OR Key
drop another .md file to compare - side-by-side diff against head-screen

head-screen

Runs the live status screen that shows what your assistant is doing.
personal 2 files 10 recent evals

What it does for you

Runs the live status screen that shows what your assistant is doing.

What it produces

A recent result, so you can see the kind of work it returns.

loading…

How to get it

These run inside the Snappy workspace. Want this working in your business? I set skills like this up with you, in one focused week.

Work with me
For developers how this skill is built, graded, and how it runs

at a glance- the short version

actorWriter that sets the state
auditorState/bin/head-screen/audit.ts, which re-reads the JSON and
eval modeauto
categorySystem
stages1
dependslog, eval

what's inside - the parts that make up a skill 3/4 present

A skill is just a few plain-text files. Only the main one is required. The rest are optional, added as the work needs them. This is what the skill is made of; how it runs is just below.

The skill
state/skills/head-screen/SKILL.md present
the skill itself, in plain text
The main file. It says what the skill is and lays out the steps in plain English.
Code
state/lib/head-screen.ts present
code the skill can run
Reusable code this skill can call when it needs to.
Scripts
state/bin/head-screen/ not present
helper scripts
Optional. Added when a skill has a few commands to run.
Loader
state/skills/head-screen/AGENTS.md present
what the AI loads on the fly
Loaded automatically the moment this skill is needed. Kept short on purpose.

how it's graded - what counts as a good run 4 criteria · 3 deterministic · 1 judge

Each row is one thing a good run has to get right. deterministic means a quick check decides, pass or fail. judge means the AI reads the result and rates it. Grading each piece on its own (instead of one overall score) shows exactly where a run fell short, so the fix is obvious.

name
kind
check
state_file_valid_and_fresh
deterministic
Running 'npx tsx state/bin/head-screen/audit.ts' exits with status code 0 and reports score 1.0.
server_is_running
deterministic
A process named 'server.ts' or 'node' listening on a local port associated with head-screen is running.
browser_display_present
deterministic
A Chrome browser window is open and displaying content from 'state/bin/head-screen/server.ts'.
no_eval_log_reads
judge
The 'state/bin/head-screen/server.ts' process does not access or read from log files related to eval, ensuring it only reads its dedicated state file.

how it runs - the shared frame every skill uses 5/5 present

Every skill runs the same way. One part does the work, a separate part checks it, and a short loader hands the AI exactly what it needs for the job. Anything this skill doesn't use shows a one-line note saying why, on purpose, not by accident.

makes the work The worker
present
Writer that sets the state the worker
Does the actual work. Whatever it produces is what gets checked next.
checks the work The reviewer
present
State/bin/head-screen/audit.ts, which re-reads the JSON and the checker
A separate checker grades the work, so the part that made it can't approve its own work.
frame
learns Self-correction
present
fixes itself learns from gaps
When a run hits a gap, the skill gets edited on the spot [FIXED] or queued for a bigger rewrite [LOGGED], so it keeps getting better.
tidies up Background fixes
present
queued for rewrite runs in the background
Bigger fixes that can't be made on the spot get queued and rewritten in the background later.
remembers Run history
present
state/log/evals.ndjson auto runs
Every run is written down here, so the next time this skill is used it already knows how the last runs went.
Critical rules the things this skill must not get wrong
  1. (a) server.ts: add regex matcher + emitTriple("ShapeName", payload) call
  2. (b) server.ts: add "ShapeName" to KNOWN_COMPONENTS array
  3. (c) web/src/dispatch-card.tsx + genui-library.tsx: add defineComponent + DISPATCH_REGISTRY entry
  4. Verify all three legs committed before claiming shape "fully landed". ComparisonTable audit (22:05:22Z) found server emit but DISPATCH_REGISTRY key absent.

what it has learned - fixes written back in over time sample

When a run hits something this skill didn't handle, the fix gets written back into the skill so it doesn't happen again. FIXED means it was corrected on the spot. LOGGED means it's queued for a bigger rewrite. Either way, the skill gets a little better and never makes the same mistake twice.

  1. Loading feedback rows…

how the work flows- who makes it, who checks it

inputs logeval
actor Writer that sets the state
auditor State/bin/head-screen/audit.ts, which re-reads the JSON and
1 auditor
npx
npx tsx state/bin/head-screen/ctl.ts set --mode thinking --headline "Working the queue" --detail "Reviewing local state only" --task "head-s

SKILL.md- the skill, written out in plain English

head-screen

The local status face for the Mac mini case screen. It is intentionally small: one JSON state file, one loopback server, one browser window.

The display is not a dashboard. It is a physical pulse: mode, face, time, headline, detail, and task, rendered large enough to read from a desk.

Files

  • state/lib/head-screen.ts - canonical state schema, validation, atomic read/write helpers, freshness math
  • state/bin/head-screen/ctl.ts - set, get, reset CLI for state/log/head-screen.json
  • state/bin/head-screen/server.ts - local HTTP server and fullscreen browser page
  • state/bin/head-screen/audit.ts - deterministic shape/freshness eval
  • state/bin/head-screen/launch.sh - starts the server, spawns the menubar (⚡), and opens Chrome
  • state/bin/head-screen/menubar.swift + snappy-menubar binary - Mac status-bar icon; click to focus/reopen the console
  • Slash command: /snappy-face → runs HEAD_SCREEN_ONCE=1 state/bin/head-screen/launch.sh
  • state/bin/head-screen/com.snappy.head-screen.plist - LaunchAgent template (server keepalive)
  • state/bin/head-screen/compose.ts - periodic writer; reads cron/agents/recipes/health and writes a fresh head-screen.json (TTL 6h)
  • state/bin/head-screen/tick.sh + com.snappy.head-screen-tick.plist - LaunchAgent that runs compose.ts every 30 min so the state never goes stale. Install: cp state/bin/head-screen/com.snappy.head-screen-tick.plist ~/Library/LaunchAgents/ && launchctl load ~/Library/LaunchAgents/com.snappy.head-screen-tick.plist

State shape

state/log/head-screen.json contains:

  • version: 1
  • mode: one of idle, listening, thinking, working, done, blocked, error
  • headline: short primary status text
  • detail: longer supporting text
  • task: current task label
  • ttl_seconds: optional freshness window in seconds
  • expires_at: derived expiry timestamp or null
  • source: who last wrote the state
  • updated_at: canonical write timestamp

Eval

Actor: the writer that sets the state file. Auditor: state/bin/head-screen/audit.ts, which re-reads the JSON and checks canonical shape plus freshness.

Score:

  • 1.0 if the state parses, mode is valid, and the ttl has not expired
  • 0.5 if the state parses and the shape is valid but freshness has expired
  • 0.0 if the file is missing, empty, malformed, or the mode/timestamps are invalid

Why it exists

The case screen is meant to tell a human what the machine is doing without asking them to open a terminal. The browser page is the simplest local surface that can be refreshed, enlarged, and kept off the network.

The display must not infer from eval logs or any other side channel. It only reads the head-screen state file.

Commands

npx tsx state/bin/head-screen/ctl.ts set --mode thinking --headline "Working the queue" --detail "Reviewing local state only" --task "head-screen v1" --ttl 60 --source codex
npx tsx state/bin/head-screen/ctl.ts get
npx tsx state/bin/head-screen/ctl.ts reset
npx tsx state/bin/head-screen/audit.ts
state/bin/head-screen/launch.sh
HEAD_SCREEN_ONCE=1 state/bin/head-screen/launch.sh

server.ts is a long-running process. For normal use, start the display via launch.sh or the LaunchAgent template instead of foregrounding the server in an interactive Codex command. Use HEAD_SCREEN_ONCE=1 when the server is already running and you only want to open or refocus the browser window.

Rubric

criteria:
  - name: state_file_valid_and_fresh
    kind: deterministic
    check: "Running 'npx tsx state/bin/head-screen/audit.ts' exits with status code 0 and reports score 1.0."
  - name: server_is_running
    kind: deterministic
    check: "A process named 'server.ts' or 'node' listening on a local port associated with head-screen is running."
  - name: browser_display_present
    kind: deterministic
    check: "A Chrome browser window is open and displaying content from 'state/bin/head-screen/server.ts'."
  - name: no_eval_log_reads
    kind: judge
    check: "The 'state/bin/head-screen/server.ts' process does not access or read from log files related to eval, ensuring it only reads its dedicated state file."

AGENTS.md- what the AI loads when this skill comes up

head-screen - loader

Per-turn rules. Full reference: state/skills/head-screen/SKILL.md. Two concerns: (1) JSON state face display for Mac mini case screen; (2) server.ts long-running HTTP server powering snappy-chat backend with 100+ shape emitters, thread persistence, and dispatch pipeline.

Critical Rules

  1. NEVER read eval logs in the display. Browser page polls only state/log/head-screen.json on loopback - never evals.ndjson or any other log. Loopback-only principle is load-bearing for local-first architecture.
  1. ALWAYS write state atomically. Use temp-file + rename path in state/lib/head-screen.ts. Prevents partial reads mid-write.
  1. ALWAYS keep actor and auditor distinct. ctl.ts writes; audit.ts grades. Decoupling is mandatory per CONSTITUTION #3.
  1. Scope stays local. No external network, no remote state, no publishing. 127.0.0.1:3147 only.
  1. tsx does NOT hot-reload server.ts, AND its compiled cache can stale. Running code frozen at startup. After editing server.ts: (a) check PID via pgrep -af server.ts; (b) kill the old PID; (c) clear cache: rm -rf /var/folders/*/T/tsx-* 2>/dev/null; (d) relaunch via bash state/bin/head-screen/launch.sh. launch.sh now auto-clears cache before spawn. Symptom of stale cache: ReferenceError: Cannot access 'X' before initialization crashes mid-dispatch.
  1. KNOWN_COMPONENTS gate must include ALL registered shapes. Missing entry = silent block filter: LLM emits [[TOOL:ShapeName]] correctly but server discards it. After adding a shape, verify it appears in KNOWN_COMPONENTS array at server.ts:~7302. Confirmed missing audit: Carousel, MarkdownRenderer, Timeline, QuoteCard (fixed 2026-04-29T22:10:53Z).
  1. EADDRINUSE: launch tolerates already-bound port. When :3147 bound by existing process, build-app.sh skips spawn (graceful degrade). Diagnose: lsof -nP -iTCP:3147 -sTCP:LISTEN. Don't kill -9 blind; the running process already serves the surface. After shape commits, manually kill + restart to pick up fresh code.
  1. Shape emitters MUST run on failure path. Matchers historically gated behind if (ok) - when backend spawn fails, gate blocks emitter. Fix: all emitters run regardless of backend success. Diagnose missing shapes via state/log/dispatch-chat.ndjson for spawn errors, not matcher bugs.
  1. Three-leg server-matcher audit after every new shape:
  • (a) server.ts: add regex matcher + emitTriple("ShapeName", payload) call
  • (b) server.ts: add "ShapeName" to KNOWN_COMPONENTS array
  • (c) web/src/dispatch-card.tsx + genui-library.tsx: add defineComponent + DISPATCH_REGISTRY entry
  • Verify all three legs committed before claiming shape "fully landed". ComparisonTable audit (22:05:22Z) found server emit but DISPATCH_REGISTRY key absent.
  1. Skills-catalog endpoint caches by mtime. Fix applied 2026-04-29T21:28:47Z: reads 159 SKILL.md files but skips re-scan if mtime unchanged. Promise.all NOT applied to summarizeToday() - causal dependency: it builds promptForLLM that dispatch consumes. Don't add Promise.all there.
  1. Data-gap fallback for no-live-data shapes. LinkedInPostPreview / EmailPreview / SlackMessagePreview / TweetPreview have matchers but no queue producers. Server falls back to Callout tone=info when queue empty. Don't add chat-surface guards - server endpoint absorbs it.
  1. Stale server process blocks new builds. After a shape commit, old PID still running = new matchers don't fire. Check: pgrep -af server.ts shows process start time; if before your commit, manually kill + restart via launch.sh.
  1. /dispatch/chat body requires intent string, optional messages array. Format: {intent: string, threadId?: string, messageId?: string, backend?: string, model?: string, messages?: Array<{role,content}>}. Missing intent → 400 error. messages carries prior conversation history (optional). Direct curl test: curl -s -X POST 127.0.0.1:3147/dispatch/chat -d '{"intent":"show me a map card","threadId":"test"}' | jq .toolCalls.
  1. Shape matcher collision guard pattern. When new shape regex overlaps existing, add !llmEmittedNames.has("ExistingShape") guard to new shape's if-block. Example: EventCard regex /calendar\s+event/ collides with CalendarEventPreview (runs earlier) → EventCard guard: !llmEmittedNames.has("CalendarEventPreview"). Test collision: node -e "const r1=/your-regex/; const r2=/existing-regex/; ['test intent'].forEach(s=>console.log(r1.test(s),r2.test(s)))".
  1. Verify shape emit via direct /dispatch/chat, not screencapture. screencapture -D 1 over SSH unreliable (intermittent TCC denials). Use: curl -s -X POST 127.0.0.1:3147/dispatch/chat -d '{"intent":"<phrase>"}' | grep -o 'toolCallName":"[^"]*' | cut -d'"' -f4. Direct endpoint confirms emission without screen recording.
  1. TDZ hazard: intent-detection booleans must be const-declared BEFORE the filteredLlmTools.filter block. Pattern: new suppression flag declared alongside regex emitter (downstream) → ReferenceError: Cannot access 'X' before initialization mid-dispatch, kills request handler after TEXT_MESSAGE_END but before TOOL_CALL_*, leaving user with (see above) placeholder text + no cards. Fix: hoist const to module scope (before filter, ~line 7900). Hoisting sites: isLinkedinPreviewIntent, kpiDispatchIntentEarly, diffViewIntentEarly, jsonViewerIntentEarly, evalLeaderboardIntentEarly, etc. Confirm in server.ts: all intent-flags declared before filteredLlmTools.filter loop entry.
  1. Server-side thread persistence is source of truth. /dispatch/chat writes assistant turn to state/log/threads/<id>.json at RUN_FINISHED time (includes toolCalls). Client App.tsx sniffer POSTs to /threads with thinner shape (text deltas only). Server /threads POST handler merges-on-write: missing toolCalls restored from existing file. Keep merge guard - it's the only thing preventing client overwrites from clobbering server state.
  1. AOT server.mjs uses transform-only esbuild (no --bundle). build-server.sh fix 2026-05-01: removed --bundle flag. Root cause: --bundle inlined state/lib/*.ts files; their CLI guards (if (import.meta.url === \file://${process.argv[1]}\`) matched server.mjs path and fired process.exit(2) at startup. Transform-only preserves each file as separate module with own import.meta.url, so guards eval false when imported. Node 25+ resolves .ts imports natively. Also fixed: skill-paths.jsskill-paths.ts imports. AOT cold start: ~1s vs 3-5s for tsx. Fallback: if server.ts edits crash, verify (a) server.mjs newer than server.ts, (b) no .js imports to local .ts` files added.
  1. unhandledRejection crashes: no process.exit() in handler. Async dispatch errors must be caught, logged, and responded - not exit. Outer try/catch at /dispatch/chat handler entry prevents unhandledRejection from killing the request loop. Confirmed root cause of random server crashes 2026-05-01T01:00:57Z.
  1. LLM fabrication suppression via early-intent flags. When a shape's data is synthetic/stale/unavailable (EvalLeaderboard, KPIBlock, StatGroup, SkillCard off-by-1 bugs, RecipeCard missing hero field), emit LLM shape via regex fallback instead of LLM native emission. Add <intentName>IntentEarly const before filter, add shape's LLM tool to filteredLlmTools guard → LLM can't hallucinate, server fallback fires with real data. Real data sources: evals.ndjson, dispatch-chat.ndjson, state/agents/*.json, git log, SKILL.md frontmatter.
  1. Shape-guide context injection: widen phrasing coverage. System prompt injects 22-of-22 shape brief keywords to LLM (not 3-of-22). LLM learns all 22 shape names before emitting. Coverage verified: MapCard, VideoPreview, ImagePreview, CommandOutput, PhaseDisclosure, TimelineCard, StatGroup, KPIBlock, ProgressList, SkillCard, and 12+ more. If new shape added, add to SYSTEM_PROMPT shape-guide section.
  1. Real data pre-fetch windows must declare regexes at module scope. Timeline (git log fetch), BrainCharacter (activeSkills count), StatGroup (KPI data), SkillCard (evals.ndjson) all require data loaded before regex matcher runs. Declare fetch + regex + payload vars at module scope before filteredLlmTools.filter block, not inline at emitter. Confirmed TDZ crashes: evalLeaderboardRegex inlined at 3 sites instead of hoisted - fix 2026-04-30T20:30:26Z inlined all 3 usage sites after hoisting failed.

Commands

| ui dashboard | state/skills/head-screen/resources/ui.openui |

whatcommand
set display statenpx tsx state/bin/head-screen/ctl.ts set --mode thinking --headline "..." --detail "..." --task "..." --ttl 60 --source codex
get display statenpx tsx state/bin/head-screen/ctl.ts get
reset display statenpx tsx state/bin/head-screen/ctl.ts reset
audit display freshnessnpx tsx state/bin/head-screen/audit.ts
launch server + display (persistent)bash state/bin/head-screen/launch.sh
reopen browser only (server alive)HEAD_SCREEN_ONCE=1 bash state/bin/head-screen/launch.sh
rebuild state from cron data (TTL 6h)npx tsx state/bin/head-screen/compose.ts
check server PIDpgrep -af server.ts
kill + safe restart (cache clear)kill <PID> && rm -rf /var/folders/*/T/tsx-* 2>/dev/null; bash state/bin/head-screen/launch.sh
diagnose port bindinglsof -nP -iTCP:3147 -sTCP:LISTEN
verify shape emit`curl -s -X POST 127.0.0.1:3147/dispatch/chat -d '{"intent":"show map card"}' \grep toolCallName`
dispatch event logstate/log/dispatch-chat.ndjson (spawn errors, shape emissions, LLM context)
eval leaderboardstate/log/evals.ndjson (filter: skill=="head-screen")
loader writebackstate/log/loader-feedback.log
thread historystate/log/threads/*.json (one file per thread ID)

Shape Matcher Checklist

When adding a new shape, ALL three legs must land in the same commit:

LegFilePatternVerify
Server matcherserver.tsconst <shapeNameLc>Regex = /...pattern.../; ... if (<shapeNameLc>Regex.test(intentLC)) { ... emitTriple("<ShapeName>", {payload}); }regex pattern matches test intent via curl; emitTriple call is present
KNOWN_COMPONENTS gateserver.tsline ~7302 in KNOWN_COMPONENTS Set: add "<ShapeName>" stringarray includes new shape name; no typos
Client rendererweb/src/dispatch-card.tsx + genui-library.tsxdefineComponent export + DISPATCH_REGISTRY entry with parsertsc --noEmit passes; shape renders in chat UI when toolCallName matches

After commit: verify via curl -s -X POST 127.0.0.1:3147/dispatch/chat -d '{"intent":"<test phrase>"}' | jq .toolCalls - should see new shape in emission stream.

Self-Test

An agent reading this should correctly:

  1. [ ] Understand loopback-only principle: no eval-log reads in display
  2. [ ] Know tsx no hot-reload pattern: kill PID → clear /var/folders/*/T/tsx-* → relaunch
  3. [ ] Identify collision patterns: check !llmEmittedNames.has() guards
  4. [ ] Verify three-leg shape audit: server regex + KNOWN_COMPONENTS + client DISPATCH_REGISTRY
  5. [ ] Use direct curl to verify shape emit, not screencapture
  6. [ ] Hoist intent-detection flags BEFORE filteredLlmTools.filter to prevent TDZ crashes
  7. [ ] Understand thread persistence: server JSON is truth; client POST merges, never overwrites
  8. [ ] Know AOT path: transform-only esbuild (no --bundle), Node 25+ resolves .ts natively
  9. [ ] Recognize early-intent suppression: regex fallback fires when LLM fabrication detected
  10. [ ] Apply shape-guide injection: widen SYSTEM_PROMPT phrasing to cover all 22 shapes

<!-- kernel-ok: historical attribution for the PID-loader pattern. -->

Self-correcting loader (PID feedback)

This is the rule that makes loaders learn. Ported from snappy-kernel, where it demonstrably worked: the snappy-dom-cartographer skill mapped Skool admin UI once, every later ship-agent inherited the full map for free.

Before you finish, do two things:

1. Fix gaps (P - proportional). MANDATORY. If this loader didn't cover your case - if you had to read another file, run an undocumented command, work around a wrong selector, or discover a quirk - you MUST attempt an Edit to this AGENTS.md before you log.

  • Default action: edit this file. Surgical. Add the missing line, table

row, or code example where it belongs. No restructuring.

  • Only LOGGED is allowed when: the fix needs >10 lines, spans multiple

files, or requires a structural rewrite. In that case the state/regen/drain.sh queue picks it up asynchronously.

  • "I didn't have time" / "it's minor" / "the next agent will figure it out"

are NOT valid reasons. Minor gaps compound - that's the whole point of this loop.

  • Concrete example of a valid P-fix: discover agent-browser screenshot

takes path positionally not as --output <path>. One line in a table. Edit it in. Do not just log it.

  • The goal: the next agent never has to leave the loader.

2. Log the result.

echo "[$(date -u +%FT%TZ)] head-screen: <what was missing or fixed> [FIXED|LOGGED] action_kind=<kind>" >> state/log/loader-feedback.log
  • FIXED = you patched this loader inline (P-fix).
  • LOGGED = too large for inline; the PostToolUse enqueue + Stop-hook drain

will rewrite the loader from scratch on next session-end.

  • action_kind: shape-ok | skill-ran | loader-rewritten | pattern-elevated

Do not skip this. Every agent run must leave the system better than it found it. The loader is the setpoint; you are the sensor; the gap is the error signal; closing the gap is the correction.

OpenUI Resource

  • Skill-owned OpenUI Lang resource: state/skills/head-screen/resources/ui.openui. Read it before rendering or editing this skill's generated component surface.
  • Treat this resource as a first-class artifact of the skill, not a generic chat response. Improve it when the skill's user-facing output needs to become richer.
  • System resources compose OpenUI primitives and inherit SnappyChat tokens. Use ui_contract: branded in SKILL.md only for deliberate platform or client visuals.

api.ts- the code it can call

#!/usr/bin/env npx tsx
/**
 * state/lib/head-screen.ts -- Canonical state helpers for the local
 * head-screen display.
 */

import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "fs";
import { dirname, join } from "path";
import { fileURLToPath } from "url";

const HERE = dirname(fileURLToPath(import.meta.url));
const LOG_DIR = join(HERE, "..", "log");

export const HEAD_SCREEN_PATH = join(LOG_DIR, "head-screen.json");
export const HEAD_SCREEN_EVENTS_PATH = join(LOG_DIR, "head-screen-events.ndjson");

export const HEAD_SCREEN_MODES = [
  "idle",
  "listening",
  "thinking",
  "working",
  "done",
  "blocked",
  "error",
] as const;

export type HeadScreenMode = (typeof HEAD_SCREEN_MODES)[number];

export type HeadScreenState = {
  version: 1 | 2;
  mode: HeadScreenMode;
  headline: string;
  detail: string;
  task: string;
  ttl_seconds: number | null;
  expires_at: string | null;
  source: string;
  updated_at: string;
  // version 2 optional extensions written by bin/head-screen/compose.ts; readers that
  // only need the v1 core can ignore these safely.
  [extra: string]: unknown;
};

type UnknownRecord = Record<string, unknown>;

export function headScreenDir(): string {
  return LOG_DIR;
}

export function isHeadScreenMode(mode: unknown): mode is HeadScreenMode {
  return typeof mode === "string" && (HEAD_SCREEN_MODES as readonly string[]).includes(mode);
}

function cleanText(value: unknown, fallback = ""): string {
  if (typeof value !== "string") return fallback;
  return value.replace(/\r\n/g, "\n").trim();
}

function parseTimestamp(value: unknown, field: string): string {
  if (typeof value !== "string" || !value.trim()) throw new Error(`head-screen: missing ${field}`);
  const parsed = Date.parse(value);
  if (Number.isNaN(parsed)) throw new Error(`head-screen: invalid ${field}`);
  return new Date(parsed).toISOString();
}

function parseTtlSeconds(value: unknown): number | null {
  if (value === undefined || value === null || value === "") return null;
  if (typeof value === "number" && Number.isInteger(value) && value >= 0) return value;
  throw new Error("head-screen: ttl_seconds must be a non-negative integer or null");
}

function computeExpiresAt(updatedAt: string, ttlSeconds: number | null): string | null {
  if (ttlSeconds === null) return null;
  return new Date(Date.parse(updatedAt) + ttlSeconds * 1000).toISOString();
}

function assertKnownMode(mode: unknown): HeadScreenMode {
  if (!isHeadScreenMode(mode)) throw new Error(`head-screen: invalid mode "${String(mode)}"`);
  return mode;
}

function assertShape(raw: unknown): UnknownRecord {
  if (!raw || typeof raw !== "object" || Array.isArray(raw)) throw new Error("head-screen: state is not an object");
  return raw as UnknownRecord;
}

export function defaultHeadScreenState(now = new Date(), source = "head-screen"): HeadScreenState {
  const updated_at = now.toISOString();
  return {
    version: 1,
    mode: "idle",
    headline: "",
    detail: "",
    task: "",
    ttl_seconds: null,
    expires_at: null,
    source,
    updated_at,
  };
}

export function parseHeadScreenState(raw: unknown): HeadScreenState {
  const obj = assertShape(raw);
  const updated_at = parseTimestamp(obj.updated_at, "updated_at");
  const ttl_seconds = parseTtlSeconds(obj.ttl_seconds);
  const expires_at = obj.expires_at === undefined || obj.expires_at === null || obj.expires_at === ""
    ? computeExpiresAt(updated_at, ttl_seconds)
    : parseTimestamp(obj.expires_at, "expires_at");
  const expectedExpires = computeExpiresAt(updated_at, ttl_seconds);
  if (ttl_seconds === null && expires_at !== null) throw new Error("head-screen: expires_at must be null when ttl_seconds is null");
  if (ttl_seconds !== null && expires_at !== expectedExpires) throw new Error("head-screen: expires_at does not match ttl_seconds");
  const versionRaw = obj.version === undefined ? 1 : obj.version;
  const version = (versionRaw === 1 || versionRaw === 2) ? (versionRaw as 1 | 2) : (() => { throw new Error("head-screen: unsupported version"); })();
  const base: HeadScreenState = {
    version,
    mode: assertKnownMode(obj.mode),
    headline: cleanText(obj.headline),
    detail: cleanText(obj.detail),
    task: cleanText(obj.task),
    ttl_seconds,
    expires_at,
    source: cleanText(obj.source, "head-screen") || "head-screen",
    updated_at,
  };
  // Preserve v2 extensions verbatim (lanes, roster, signals, recipes, scene, now, recent, need…).
  if (version === 2) {
    for (const k of Object.keys(obj)) {
      if (!(k in base)) base[k] = obj[k];
    }
  }
  return base;
}

export function validateHeadScreenState(state: HeadScreenState): void {
  if (state.version !== 1 && state.version !== 2) throw new Error("head-screen: unsupported version");
  assertKnownMode(state.mode);
  if (!cleanText(state.source)) throw new Error("head-screen: source is required");
  parseTimestamp(state.updated_at, "updated_at");
  const ttl_seconds = parseTtlSeconds(state.ttl_seconds);
  if (ttl_seconds !== state.ttl_seconds) throw new Error("head-screen: ttl_seconds is invalid");
  const expectedExpires = computeExpiresAt(state.updated_at, ttl_seconds);
  if (expectedExpires !== state.expires_at) throw new Error("head-screen: expires_at mismatch");
}

export function readHeadScreenState(): HeadScreenState {
  if (!existsSync(HEAD_SCREEN_PATH)) {
    const state = defaultHeadScreenState();
    validateHeadScreenState(state);
    return state;
  }
  const raw = readFileSync(HEAD_SCREEN_PATH, "utf8");
  if (!raw.trim()) throw new Error("head-screen: state file is empty");
  const state = parseHeadScreenState(JSON.parse(raw));
  validateHeadScreenState(state);
  return state;
}

export function writeHeadScreenState(next: HeadScreenState, atomic = true): HeadScreenState {
  validateHeadScreenState(next);
  if (!existsSync(LOG_DIR)) mkdirSync(LOG_DIR, { recursive: true });
  const body = JSON.stringify(next, null, 2) + "\n";
  if (atomic) {
    const tmp = `${HEAD_SCREEN_PATH}.${process.pid}.${Date.now()}.tmp`;
    writeFileSync(tmp, body, "utf8");
    renameSync(tmp, HEAD_SCREEN_PATH);
  } else {
    writeFileSync(HEAD_SCREEN_PATH, body, "utf8");
  }
  return next;
}

export function nextHeadScreenState(
  current: HeadScreenState,
  patch: Partial<Pick<HeadScreenState, "mode" | "headline" | "detail" | "task" | "ttl_seconds" | "source">> & {
    updated_at?: string;
  },
): HeadScreenState {
  const updated_at = patch.updated_at ? parseTimestamp(patch.updated_at, "updated_at") : new Date().toISOString();
  const ttl_seconds = patch.ttl_seconds === undefined ? current.ttl_seconds : parseTtlSeconds(patch.ttl_seconds);
  const next: HeadScreenState = {
    version: 1,
    mode: assertKnownMode(patch.mode ?? current.mode),
    headline: cleanText(patch.headline ?? current.headline),
    detail: cleanText(patch.detail ?? current.detail),
    task: cleanText(patch.task ?? current.task),
    ttl_seconds,
    expires_at: computeExpiresAt(updated_at, ttl_seconds),
    source: cleanText(patch.source ?? current.source, "head-screen") || "head-screen",
    updated_at,
  };
  validateHeadScreenState(next);
  return next;
}

export function headScreenFreshness(state: HeadScreenState, now = Date.now()): {
  fresh: boolean;
  age_ms: number;
  remaining_ms: number | null;
  stale_reason: string | null;
} {
  const updatedMs = Date.parse(state.updated_at);
  const age_ms = Number.isNaN(updatedMs) ? 0 : Math.max(0, now - updatedMs);
  if (state.expires_at) {
    const expiresMs = Date.parse(state.expires_at);
    const remaining_ms = Number.isNaN(expiresMs) ? null : expiresMs - now;
    return {
      fresh: remaining_ms === null ? false : remaining_ms >= 0,
      age_ms,
      remaining_ms,
      stale_reason: remaining_ms !== null && remaining_ms < 0 ? "ttl-expired" : null,
    };
  }
  return { fresh: true, age_ms, remaining_ms: null, stale_reason: null };
}

scripts- helper scripts it can run

prose-only skill - 2 inline code blocks live in SKILL.md above (no state/bin/ sidecar yet).

how we check it- the checks, plus the last 10 runs

rubric auto no rubric declared
recent mean 0.81 · 10 runs actor/auditor: unverifiable
deps log eval
timestamp verb score primary_issue artifact
2026-04-30 07:30Z - 0.60 - -
2026-04-29 06:40Z - 1.00 - -
2026-04-29 03:55Z - 1.00 - -
2026-04-26 23:47Z - 0.50 - -
2026-04-25 04:11Z - 1.00 - -
2026-04-24 06:02Z - 0.00 - -
2026-04-23 00:21Z - 1.00 - -
2026-04-23 00:20Z - 1.00 - -
2026-04-23 00:15Z - 1.00 - -
2026-04-23 00:15Z - 1.00 - -