.md file to compare - side-by-side diff against head-screen
head-screen
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.
For developers how this skill is built, graded, and how it runs
at a glance- the short version
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.
state/skills/head-screen/SKILL.md
present
state/lib/head-screen.ts
present
state/bin/head-screen/
not present
state/skills/head-screen/AGENTS.md
present
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.
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.
State/bin/head-screen/audit.ts, which re-reads the JSON and state/log/evals.ndjson - (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.
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.
- Loading feedback rows…
how the work flows- who makes it, who checks it
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 mathstate/bin/head-screen/ctl.ts-set,get,resetCLI forstate/log/head-screen.jsonstate/bin/head-screen/server.ts- local HTTP server and fullscreen browser pagestate/bin/head-screen/audit.ts- deterministic shape/freshness evalstate/bin/head-screen/launch.sh- starts the server, spawns the menubar (⚡), and opens Chromestate/bin/head-screen/menubar.swift+snappy-menubarbinary - Mac status-bar icon; click to focus/reopen the console- Slash command:
/snappy-face→ runsHEAD_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 freshhead-screen.json(TTL 6h)state/bin/head-screen/tick.sh+com.snappy.head-screen-tick.plist- LaunchAgent that runscompose.tsevery 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: 1mode: one ofidle,listening,thinking,working,done,blocked,errorheadline: short primary status textdetail: longer supporting texttask: current task labelttl_seconds: optional freshness window in secondsexpires_at: derived expiry timestamp ornullsource: who last wrote the stateupdated_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.0if the state parses, mode is valid, and the ttl has not expired0.5if the state parses and the shape is valid but freshness has expired0.0if 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
- NEVER read eval logs in the display. Browser page polls only
state/log/head-screen.jsonon loopback - neverevals.ndjsonor any other log. Loopback-only principle is load-bearing for local-first architecture.
- ALWAYS write state atomically. Use temp-file + rename path in
state/lib/head-screen.ts. Prevents partial reads mid-write.
- ALWAYS keep actor and auditor distinct.
ctl.tswrites;audit.tsgrades. Decoupling is mandatory per CONSTITUTION #3.
- Scope stays local. No external network, no remote state, no publishing. 127.0.0.1:3147 only.
tsxdoes NOT hot-reloadserver.ts, AND its compiled cache can stale. Running code frozen at startup. After editingserver.ts: (a) check PID viapgrep -af server.ts; (b) kill the old PID; (c) clear cache:rm -rf /var/folders/*/T/tsx-* 2>/dev/null; (d) relaunch viabash state/bin/head-screen/launch.sh.launch.shnow auto-clears cache before spawn. Symptom of stale cache:ReferenceError: Cannot access 'X' before initializationcrashes mid-dispatch.
KNOWN_COMPONENTSgate 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 inKNOWN_COMPONENTSarray at server.ts:~7302. Confirmed missing audit: Carousel, MarkdownRenderer, Timeline, QuoteCard (fixed 2026-04-29T22:10:53Z).
- EADDRINUSE: launch tolerates already-bound port. When
:3147bound by existing process,build-app.shskips spawn (graceful degrade). Diagnose:lsof -nP -iTCP:3147 -sTCP:LISTEN. Don'tkill -9blind; the running process already serves the surface. After shape commits, manually kill + restart to pick up fresh code.
- 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 viastate/log/dispatch-chat.ndjsonfor spawn errors, not matcher bugs.
- Three-leg server-matcher audit after every new shape:
- (a) server.ts: add regex matcher +
emitTriple("ShapeName", payload)call - (b) server.ts: add
"ShapeName"toKNOWN_COMPONENTSarray - (c) web/src/dispatch-card.tsx + genui-library.tsx: add
defineComponent+DISPATCH_REGISTRYentry - Verify all three legs committed before claiming shape "fully landed". ComparisonTable audit (22:05:22Z) found server emit but DISPATCH_REGISTRY key absent.
- 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.allNOT applied tosummarizeToday()- causal dependency: it builds promptForLLM that dispatch consumes. Don't add Promise.all there.
- Data-gap fallback for no-live-data shapes. LinkedInPostPreview / EmailPreview / SlackMessagePreview / TweetPreview have matchers but no queue producers. Server falls back to
Callouttone=info when queue empty. Don't add chat-surface guards - server endpoint absorbs it.
- Stale server process blocks new builds. After a shape commit, old PID still running = new matchers don't fire. Check:
pgrep -af server.tsshows process start time; if before your commit, manually kill + restart vialaunch.sh.
/dispatch/chatbody requiresintentstring, optionalmessagesarray. Format:{intent: string, threadId?: string, messageId?: string, backend?: string, model?: string, messages?: Array<{role,content}>}. Missingintent→ 400 error.messagescarries 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.
- 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)))".
- Verify shape emit via direct
/dispatch/chat, not screencapture.screencapture -D 1over 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.
- TDZ hazard: intent-detection booleans must be const-declared BEFORE the
filteredLlmTools.filterblock. Pattern: new suppression flag declared alongside regex emitter (downstream) →ReferenceError: Cannot access 'X' before initializationmid-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 beforefilteredLlmTools.filterloop entry.
- Server-side thread persistence is source of truth.
/dispatch/chatwrites assistant turn tostate/log/threads/<id>.jsonat RUN_FINISHED time (includestoolCalls). Client App.tsx sniffer POSTs to/threadswith thinner shape (text deltas only). Server/threads POSThandler merges-on-write: missingtoolCallsrestored from existing file. Keep merge guard - it's the only thing preventing client overwrites from clobbering server state.
- AOT server.mjs uses transform-only esbuild (no --bundle). build-server.sh fix 2026-05-01: removed
--bundleflag. Root cause:--bundleinlinedstate/lib/*.tsfiles; their CLI guards (if (import.meta.url === \file://${process.argv[1]}\`) matched server.mjs path and firedprocess.exit(2)at startup. Transform-only preserves each file as separate module with ownimport.meta.url, so guards eval false when imported. Node 25+ resolves.tsimports natively. Also fixed:skill-paths.js→skill-paths.tsimports. 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.jsimports to local.ts` files added.
unhandledRejectioncrashes: no process.exit() in handler. Async dispatch errors must be caught, logged, and responded - not exit. Outer try/catch at/dispatch/chathandler entry prevents unhandledRejection from killing the request loop. Confirmed root cause of random server crashes 2026-05-01T01:00:57Z.
- 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>IntentEarlyconst before filter, add shape's LLM tool tofilteredLlmToolsguard → 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.
- 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.
- 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.filterblock, 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 |
| what | command | |
|---|---|---|
| set display state | npx tsx state/bin/head-screen/ctl.ts set --mode thinking --headline "..." --detail "..." --task "..." --ttl 60 --source codex | |
| get display state | npx tsx state/bin/head-screen/ctl.ts get | |
| reset display state | npx tsx state/bin/head-screen/ctl.ts reset | |
| audit display freshness | npx 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 PID | pgrep -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 binding | lsof -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 log | state/log/dispatch-chat.ndjson (spawn errors, shape emissions, LLM context) | |
| eval leaderboard | state/log/evals.ndjson (filter: skill=="head-screen") | |
| loader writeback | state/log/loader-feedback.log | |
| thread history | state/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:
| Leg | File | Pattern | Verify |
|---|---|---|---|
| Server matcher | server.ts | const <shapeNameLc>Regex = /...pattern.../; ... if (<shapeNameLc>Regex.test(intentLC)) { ... emitTriple("<ShapeName>", {payload}); } | regex pattern matches test intent via curl; emitTriple call is present |
| KNOWN_COMPONENTS gate | server.ts | line ~7302 in KNOWN_COMPONENTS Set: add "<ShapeName>" string | array includes new shape name; no typos |
| Client renderer | web/src/dispatch-card.tsx + genui-library.tsx | defineComponent export + DISPATCH_REGISTRY entry with parser | tsc --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:
- [ ] Understand loopback-only principle: no eval-log reads in display
- [ ] Know tsx no hot-reload pattern: kill PID → clear
/var/folders/*/T/tsx-*→ relaunch - [ ] Identify collision patterns: check
!llmEmittedNames.has()guards - [ ] Verify three-leg shape audit: server regex + KNOWN_COMPONENTS + client DISPATCH_REGISTRY
- [ ] Use direct curl to verify shape emit, not screencapture
- [ ] Hoist intent-detection flags BEFORE filteredLlmTools.filter to prevent TDZ crashes
- [ ] Understand thread persistence: server JSON is truth; client POST merges, never overwrites
- [ ] Know AOT path: transform-only esbuild (no --bundle), Node 25+ resolves .ts natively
- [ ] Recognize early-intent suppression: regex fallback fires when LLM fabrication detected
- [ ] 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
LOGGEDis 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: brandedin 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
| 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 | - | - |