snappy-session-close skill
handoffwrite-reversiblereconcilewrite-reversible$ npx snappy-skills install snappy-session-close
$ npx snappy-skills install --all
$ npx snappy-skills update
Close a session in two verbs. Read SKILL.md for the doctrine, the sources
and the worked example; this is the short form.
At the END of any working session on a repo set — the owner says "update the
docs / fold in the learnings / write the handoff", or the session is about to
lose its context. Never mid-task.
Part 1 reconcile: enumerate the agent-facing docs, classify by citation,
extract every volatile claim (path, count, version, command, status, SHA),
MEASURE each with the cheapest probe, delete what is wrong, edit the wrong
line in place, archive the uncited dated, move detail to the doc that owns
it, fold the session's learnings in as rules WITH reasons, keep the root doc
at or under its starting length, commit per file with a claim→probe→verdict
certificate. Part 2 handoff: write `docs/prompts/FRESH-SESSION-BRIEF-<next
date>.md` for a zero-memory reader — state (measured), order (with
already-landed flags), decisions (dated), needs-owner, meta-tactics,
monitors (pointer to snappy-watchtower), traps (dated), where things live
(probed), laws (pointers), how to verify. Never invent a fact a probe did not
confirm; never append a correction paragraph; never push on its own.
bashnpx tsx ~/.claude/skills/snappy-session-close/api.ts reconcile --dry-run # claim table for the default repos
npx tsx ~/.claude/skills/snappy-session-close/api.ts handoff --out docs/prompts/FRESH-SESSION-BRIEF-YYYYMMDD.md
Both print what they measured; the judgment (what to delete, what to fold in,
what the next order is) stays with the model, from the session's sources
(the HANDOFF §9 lines, the LEDGER, the memory dir, research/*<date>*.md).
premises: docs enumerated; every volatile claim has a probe or an (unverified <date>) mark
action: reconcile → per-file commits pushed; handoff → the brief committed
trace: claim → probe → verdict → action; the brief's §10 probes
evidence: fresh read of each doc after commit; git log -1 per repo
conclusion: PASS only if no live doc carries an unprobed claim
Show produced work with snappy-faces: call draw for image channels or lang for MCP Apps.
<!-- SKILL-INDEX-START -->
[snappy-session-close Index]|root: ~/.claude/skills/snappy-session-close|IMPORTANT: Prefer these files over pre-training assumptions for this domain. Read the relevant file when the AGENTS.md summary is insufficient.|root:{SKILL.md}
<!-- SKILL-INDEX-END -->
Nothing in the collection names this skill.
<!-- SNAPPY-CONTRACT-VERBS-START -->
Generated from api.ts HAND_CONTRACT. Do not hand-edit this block.
| Verb | Contract arguments | Effect | First call |
|---|---|---|---|
handoff |
— | write-reversible |
npx tsx ~/.claude/skills/snappy-session-close/api.ts handoff |
reconcile |
— | write-reversible |
npx tsx ~/.claude/skills/snappy-session-close/api.ts reconcile |
When an answer carries face_hint, show it with one snappy_present(<answer>) call.
See /snappy-faces for face selection. Human-facing images must crop to the
element, render at 2x on Retina, and fill the destination channel instead of
placing a small card in a full-page screenshot.
<!-- SNAPPY-CONTRACT-VERBS-END -->
# snappy-session-close (agent loader)
Close a session in two verbs. Read `SKILL.md` for the doctrine, the sources
and the worked example; this is the short form.
## When to invoke
At the END of any working session on a repo set — the owner says "update the
docs / fold in the learnings / write the handoff", or the session is about to
lose its context. Never mid-task.
## The contract, in one paragraph
Part 1 `reconcile`: enumerate the agent-facing docs, classify by citation,
extract every volatile claim (path, count, version, command, status, SHA),
MEASURE each with the cheapest probe, delete what is wrong, edit the wrong
line in place, archive the uncited dated, move detail to the doc that owns
it, fold the session's learnings in as rules WITH reasons, keep the root doc
at or under its starting length, commit per file with a claim→probe→verdict
certificate. Part 2 `handoff`: write `docs/prompts/FRESH-SESSION-BRIEF-<next
date>.md` for a zero-memory reader — state (measured), order (with
already-landed flags), decisions (dated), needs-owner, meta-tactics,
monitors (pointer to `snappy-watchtower`), traps (dated), where things live
(probed), laws (pointers), how to verify. Never invent a fact a probe did not
confirm; never append a correction paragraph; never push on its own.
## The two commands
```bash
npx tsx ~/.claude/skills/snappy-session-close/api.ts reconcile --dry-run # claim table for the default repos
npx tsx ~/.claude/skills/snappy-session-close/api.ts handoff --out docs/prompts/FRESH-SESSION-BRIEF-YYYYMMDD.md
```
Both print what they measured; the judgment (what to delete, what to fold in,
what the next order is) stays with the model, from the session's sources
(the HANDOFF §9 lines, the LEDGER, the memory dir, `research/*<date>*.md`).
## Certificate
```
premises: docs enumerated; every volatile claim has a probe or an (unverified <date>) mark
action: reconcile → per-file commits pushed; handoff → the brief committed
trace: claim → probe → verdict → action; the brief's §10 probes
evidence: fresh read of each doc after commit; git log -1 per repo
conclusion: PASS only if no live doc carries an unprobed claim
```
Show produced work with `snappy-faces`: call `draw` for image channels or `lang` for MCP Apps.
<!-- SKILL-INDEX-START -->
[snappy-session-close Index]|root: ~/.claude/skills/snappy-session-close|IMPORTANT: Prefer these files over pre-training assumptions for this domain. Read the relevant file when the AGENTS.md summary is insufficient.|root:{SKILL.md}
<!-- SKILL-INDEX-END -->
## Used by
Nothing in the collection names this skill.
<!-- SNAPPY-CONTRACT-VERBS-START -->
## Contract verbs
Generated from `api.ts` `HAND_CONTRACT`. Do not hand-edit this block.
| Verb | Contract arguments | Effect | First call |
|---|---|---|---|
| `handoff` | — | `write-reversible` | `npx tsx ~/.claude/skills/snappy-session-close/api.ts handoff` |
| `reconcile` | — | `write-reversible` | `npx tsx ~/.claude/skills/snappy-session-close/api.ts reconcile` |
## Show the result
When an answer carries `face_hint`, show it with one `snappy_present(<answer>)` call.
See `/snappy-faces` for face selection. Human-facing images must crop to the
element, render at 2x on Retina, and fill the destination channel instead of
placing a small card in a full-page screenshot.
<!-- SNAPPY-CONTRACT-VERBS-END -->
Triggers: snappy-session-close, session-close, end of session, update the docs, fold in learnings, write the handoff, fresh-session brief.
Owner ask (Robert, 2026-09-03 ~08:00, verbatim in substance): "update the docs
for the three repos … take out any crap that's inaccurate, fold in your
learnings … based on best practices learned from online. It's a two-part
skill: the first part is that; the second part creates a handoff for the next
session. It always assumes the next session will be a totally fresh session.
So it includes all the hard-won lessons, all the tips, techniques, stuff like
the monitors that were set up, what's the most effective tactic available for
doing whatever task is being continued, and all the stuff about the project.
Standardize it so it's easier."
Two verbs. Each runs alone. Part 1 without Part 2 leaves the docs right and
the next session blind; Part 2 without Part 1 writes a brief that points at
docs that lie. Run both.
snappy-nightshift owns HOW a night runs (lanes, batched installs, theorchestrator loop). This skill owns how a session ENDS. It reads nightshift's
ledger; it never re-implements the loop.
snappy-watchtower owns the monitors themselves and the probes that lie.This skill's §6 of the handoff NAMES which monitors to arm and points at
watchtower for the commands; it never restates them.
probe did not confirm, and never appends a "correction" paragraph.
A doc that carries one wrong count teaches the reader to distrust all of it
(Snappy's CLAUDE.md Xano paragraph was wrong four times, each time by
repeating instead of measuring; it cost a lane-run on 09-01 and a paragraph
of self-narration until 09-03).
Appended corrections leave both versions standing; the reader finds the wrong
one first.
(HARNESS-MASTER) > the live LEDGER > RUN-BRIEFING > CLAUDE.md > anything
dated. Where a doc and the code disagree, the doc changes.
more reliably; every sentence a model reads competes with the task
(the app repo's reference_prompting_research_202608 memory records the
measured finding: instruction-following degrades with prompt length, and
the highest-leverage edit is removal).
Two copies drift silently and are discovered by a person, late (Snappy's
DUPLICATE ROADS ARE BANNED ruling, 2026-08-31, applies to docs too).
CLAUDE.md): keep CLAUDE.md concise and human-readable; put project-specific
workflows, commands and gotchas in it; it is read at the start of every
session, so length is paid every time.
repo, plain markdown, commands and conventions the agent must follow —
generic across tools; Snappy uses it as the SDK retrieval index.
"effective context engineering for AI agents", 2025): the smallest set of
high-signal tokens; write durable state to files outside the context window
and re-read them — the handoff IS that file for a session boundary.
dominant optimization"; measured, not asserted.
guides (2026): state files with explicit "what is done / what is next /
how to verify" sections outperform narrative logs for a fresh worker.
reconcile#Input: a repo list (default: Snappy's three), the session's sources (the
HANDOFF §9 lines, the LEDGER, the memory dir, research/*<date>*.md).
CLAUDE.md, AGENTS.md,README.md, and every root *.md. Classify each: cited-by-code
(grep the name across *.ts *.mjs *.json *.sh), cited-by-doc (only other
docs name it), uncited-dated (nothing names it and it carries a date or
a snapshot). Uncited-dated → docs/archive/<name>-<date>-<what>.md with
one line in the index saying what it was.
counts, versions, commands, gate names, status words ("done", "live",
"installed", "deployed"), heads/SHAs, dates.
ls /test -e for paths; grep -n for file:line; npm ls /
require('<pkg>/package.json').version per WORKSPACE (a monorepo has more
than one node_modules — Snappy's app root and web workspace differ); the
repo's own gates for counts (npm run gate:xano-frontier prints its pin);
curl the live endpoints for heads and versions (/deploy-truth, the
worker's /.well-known/snappy-surface.json); git log -1 per repo. A
claim with no probe is marked (unverified <date>) — never left bare.
replace with the live one or delete the sentence. Narrative history of
the doc's own mistakes → collapse to the ruling, the measured facts, and
ONE sentence of lesson. Detail that belongs elsewhere → move it (traps →
TRAPS.md; asks → the LEDGER; SDK facts → AGENTS.md). Root doc length: at
or under where it started.
belong: standing laws → the root doc's pinned section; traps → a dated
TRAPS section, one line each, tell → fix; new paths → "where things
live" (each probed); SDK facts → AGENTS.md; the repo's role → its own
CLAUDE.md (a mirror/writer repo's CLAUDE.md stays SHORT: what it is, the
one command, the one gate, the protocol, a pointer to the doctrine repo).
claim → probe → verdict → actionin the commit message body or a docs/reconcile-<date>.md, committed WITH
the docs. Commit per file (git commit --only <path>), push.
The worked example (2026-09-03, app repo): CLAUDE.md 320 → 304 lines — the
Xano paragraph collapsed from 82 lines to 20 (ruling + gate:xano-frontier
"379 live reach sites in 139 files, convex 0" + one lesson), the deleted
goal-mockups-v2/ path removed, four laws added to the pinned core, seven
new paths in "where things live" each ls-probed; AGENTS.md's versions
re-measured per workspace (the hosted adapters live in the built bundle,
not in any node_modules), three dead paths replaced; RUN-BRIEFING rewritten
to the measured now; TRAPS §2026-09-03 with 24 dated traps. Twin: BOOT.md
archived (an 08-13 build brief cited by nothing), CLAUDE.md told that the
parity gate covers convex-tests. Worker: the three generators of
src/generated/, the night's door invariants, the worktree setup.
handoff#Output: docs/prompts/FRESH-SESSION-BRIEF-<YYYYMMDD>.md (the date is the
day the NEXT session runs), plus one line in the memory index if a law was
learned. Written for a reader with ZERO memory of this session. Sections,
in this order, each measured at write time:
what is proven on glass WITH the run ids / receipts to reopen.
ALREADY-LANDED flags so nothing is redone.
task this project has (orchestration shape, the twin protocol, the
install lanes, driving doors as the user, red-first tests, the cap).
which NOT to arm, with the measured reason.
session distrusts it correctly.
Also a §0 "read in this order" and a header naming the repos and branches.
The brief that started the 2026-09-03 run (FRESH-SESSION-BRIEF-20260903.md)
and the one that closed it (…-20260904.md) are the reference pair: the
first carried a NOT-DONE REGISTER and "the skills built for this work" —
both survive as §2 and §0.
reconcile({repos, sources, dryRun}) — enumerates docs, classifies by
citation, extracts volatile-claim candidates by regex class, runs the cheap
probes (path exists, file:line resolves, optional URL 200), and emits the
claim table as markdown. The judgment steps (what to delete, how to fold a
learning in, what a count means) are the model's, and the SKILL says so.
handoff({repos, sources, out}) — renders the ten-section skeleton with the
STATE block filled by probes (git heads per repo, /deploy-truth, the
worker surface) and the section bodies left as fenced TODO blocks the model
fills from the sources.
npx tsx ~/.claude/skills/snappy-session-close/api.ts reconcile --dry-run
npx tsx ~/.claude/skills/snappy-session-close/api.ts handoff --out docs/prompts/FRESH-SESSION-BRIEF-<date>.md
premises: every doc in the repo list enumerated; every volatile claim probed
action: reconcile → per-file commits; handoff → the brief at docs/prompts/
trace: the claim table (claim → probe → verdict → action); the brief's §10
evidence: a FRESH read of each doc after commit; `git log -1` per repo; the brief's own probes re-run
conclusion: PASS only if no claim in any live doc is left unprobed or unmarked
Skills whose description overlaps this one enough that a reader could pick the
wrong door. Each row is that skill's own first sentence about itself, so the
choice is made on its words, not on a summary written here.
| Skill | Reach for it instead when |
|---|---|
snappy-agent-host |
Run the REAL Claude Code, Codex, and Gemini CLIs through ACP via the skills MCP, with durable… |
snappy-ax |
Drive any Mac app through the Accessibility tree (AXUIElement) the way the shipping tools act… |
snappy-client-total |
Jordan Cameron's mortgage adviser CRM for New Zealand -- the largest and most active client e… |
snappy-dom-cartographer |
Master DOM mapping agent for the Snappy swarm. |
snappy-github |
Centralized GitHub operations across all Snappy client repos via the gh CLI -- pull request… |
snappy-infra |
Snappy infrastructure foundation -- Xano API surface (Slack, email, LinkedIn, FreshBooks, Wha… |
snappy-os-operator |
Operate SnappyOS like a pro through product doors only: governed connector reads, staged writ… |
snappy-pipeline |
Read-only QA agent for Orbiter enrichment pipeline data quality auditing. |
snappy-telegram |
Telegram Bot API channel for Snappy: direct calls to api.telegram.org (no Xano middleware) to… |
snappy-voice-control |
Voice control on macOS, extracted from two shipping open-source agents (fazm by mediar-ai; Ag… |
---
name: snappy-session-close
instruction-only: true
description: "Close a working session in two verbs: RECONCILE the agent-facing docs of a repo set (CLAUDE.md, AGENTS.md, README, the repo's named live docs) against MEASURED truth — delete what is wrong, edit the wrong line in place, archive the uncited dated, fold in the session's learnings with their reasons — then HANDOFF: write the fresh-session brief a zero-memory successor starts from (state of the world measured, the order with already-landed flags, dated decisions, needs-owner, meta-tactics, monitors, traps, where things live, laws, how to verify). Built 2026-09-03 from the overnight run that landed 20 lanes; defaults to Snappy's three repos (app, Convex twin, MCP worker) but takes any repo list. Triggers: snappy-session-close, session-close, end of session, close the session, update the docs, fold in learnings, write the handoff, fresh-session brief."
---
# snappy-session-close — reconcile the docs, then hand off to a stranger
Triggers: snappy-session-close, session-close, end of session, update the docs, fold in learnings, write the handoff, fresh-session brief.
Owner ask (Robert, 2026-09-03 ~08:00, verbatim in substance): "update the docs
for the three repos … take out any crap that's inaccurate, fold in your
learnings … based on best practices learned from online. It's a two-part
skill: the first part is that; the second part creates a handoff for the next
session. It always assumes the next session will be a totally fresh session.
So it includes all the hard-won lessons, all the tips, techniques, stuff like
the monitors that were set up, what's the most effective tactic available for
doing whatever task is being continued, and all the stuff about the project.
Standardize it so it's easier."
Two verbs. Each runs alone. Part 1 without Part 2 leaves the docs right and
the next session blind; Part 2 without Part 1 writes a brief that points at
docs that lie. Run both.
## 0. What this skill is NOT (so it does not duplicate a road)
- `snappy-nightshift` owns HOW a night runs (lanes, batched installs, the
orchestrator loop). This skill owns how a session ENDS. It reads nightshift's
ledger; it never re-implements the loop.
- `snappy-watchtower` owns the monitors themselves and the probes that lie.
This skill's §6 of the handoff NAMES which monitors to arm and points at
watchtower for the commands; it never restates them.
- It never auto-publishes, never pushes on its own, never invents a fact a
probe did not confirm, and never appends a "correction" paragraph.
## 1. The doctrine (why the rules are what they are)
- **Every rule carries its reason; volatile facts are accurate or deleted.**
A doc that carries one wrong count teaches the reader to distrust all of it
(Snappy's CLAUDE.md Xano paragraph was wrong four times, each time by
repeating instead of measuring; it cost a lane-run on 09-01 and a paragraph
of self-narration until 09-03).
- **A correction is an edit to the wrong line, never an appended paragraph.**
Appended corrections leave both versions standing; the reader finds the wrong
one first.
- **Code decides.** Authority order for Snappy: code > derived docs
(HARNESS-MASTER) > the live LEDGER > RUN-BRIEFING > CLAUDE.md > anything
dated. Where a doc and the code disagree, the doc changes.
- **Deletion is the dominant optimization.** Shorter instructions are followed
more reliably; every sentence a model reads competes with the task
(the app repo's `reference_prompting_research_202608` memory records the
measured finding: instruction-following degrades with prompt length, and
the highest-leverage edit is removal).
- **One representation.** A fact lives in ONE doc; the others point at it.
Two copies drift silently and are discovered by a person, late (Snappy's
DUPLICATE ROADS ARE BANNED ruling, 2026-08-31, applies to docs too).
### Sources consulted (2026-09-03)
- Anthropic, Claude Code memory docs (code.claude.com/docs, "memory" /
CLAUDE.md): keep CLAUDE.md concise and human-readable; put project-specific
workflows, commands and gotchas in it; it is read at the start of every
session, so length is paid every time.
- The AGENTS.md convention (agents.md, 2025–26): one agent-facing file per
repo, plain markdown, commands and conventions the agent must follow —
generic across tools; Snappy uses it as the SDK retrieval index.
- Anthropic's context-engineering guidance (anthropic.com/engineering,
"effective context engineering for AI agents", 2025): the smallest set of
high-signal tokens; write durable state to files outside the context window
and re-read them — the handoff IS that file for a session boundary.
- The prompting-research memory in the app repo (2026-08): "deletion is the
dominant optimization"; measured, not asserted.
- OpenAI's agent-continuity practice as documented in its Codex/agents
guides (2026): state files with explicit "what is done / what is next /
how to verify" sections outperform narrative logs for a fresh worker.
## 2. PART 1 — `reconcile`
Input: a repo list (default: Snappy's three), the session's sources (the
HANDOFF §9 lines, the LEDGER, the memory dir, `research/*<date>*.md`).
1. **Enumerate** the agent-facing docs per repo: `CLAUDE.md`, `AGENTS.md`,
`README.md`, and every root `*.md`. Classify each: cited-by-code
(grep the name across `*.ts *.mjs *.json *.sh`), cited-by-doc (only other
docs name it), uncited-dated (nothing names it and it carries a date or
a snapshot). Uncited-dated → `docs/archive/<name>-<date>-<what>.md` with
one line in the index saying what it was.
2. **Extract volatile claims** from every live doc: paths, file:line refs,
counts, versions, commands, gate names, status words ("done", "live",
"installed", "deployed"), heads/SHAs, dates.
3. **Measure each claim** with the cheapest probe that settles it: `ls` /
`test -e` for paths; `grep -n` for file:line; `npm ls` /
`require('<pkg>/package.json').version` per WORKSPACE (a monorepo has more
than one node_modules — Snappy's app root and web workspace differ); the
repo's own gates for counts (`npm run gate:xano-frontier` prints its pin);
`curl` the live endpoints for heads and versions (`/deploy-truth`, the
worker's `/.well-known/snappy-surface.json`); `git log -1` per repo. A
claim with no probe is marked `(unverified <date>)` — never left bare.
4. **Delete / edit / move.** Wrong → delete or edit in place. Dead path →
replace with the live one or delete the sentence. Narrative history of
the doc's own mistakes → collapse to the ruling, the measured facts, and
ONE sentence of lesson. Detail that belongs elsewhere → move it (traps →
TRAPS.md; asks → the LEDGER; SDK facts → AGENTS.md). Root doc length: at
or under where it started.
5. **Fold in the session's learnings** as rules WITH reasons, where they
belong: standing laws → the root doc's pinned section; traps → a dated
TRAPS section, one line each, tell → fix; new paths → "where things
live" (each probed); SDK facts → AGENTS.md; the repo's role → its own
CLAUDE.md (a mirror/writer repo's CLAUDE.md stays SHORT: what it is, the
one command, the one gate, the protocol, a pointer to the doctrine repo).
6. **Certificate** (spec §11): a table `claim → probe → verdict → action`
in the commit message body or a `docs/reconcile-<date>.md`, committed WITH
the docs. Commit per file (`git commit --only <path>`), push.
The worked example (2026-09-03, app repo): CLAUDE.md 320 → 304 lines — the
Xano paragraph collapsed from 82 lines to 20 (ruling + `gate:xano-frontier`
"379 live reach sites in 139 files, convex 0" + one lesson), the deleted
`goal-mockups-v2/` path removed, four laws added to the pinned core, seven
new paths in "where things live" each `ls`-probed; AGENTS.md's versions
re-measured per workspace (the hosted adapters live in the built bundle,
not in any node_modules), three dead paths replaced; RUN-BRIEFING rewritten
to the measured now; TRAPS §2026-09-03 with 24 dated traps. Twin: BOOT.md
archived (an 08-13 build brief cited by nothing), CLAUDE.md told that the
parity gate covers convex-tests. Worker: the three generators of
`src/generated/`, the night's door invariants, the worktree setup.
## 3. PART 2 — `handoff`
Output: `docs/prompts/FRESH-SESSION-BRIEF-<YYYYMMDD>.md` (the date is the
day the NEXT session runs), plus one line in the memory index if a law was
learned. Written for a reader with ZERO memory of this session. Sections,
in this order, each measured at write time:
1. **STATE OF THE WORLD NOW** — serving heads, versions, what is live where,
what is proven on glass WITH the run ids / receipts to reopen.
2. **THE ORDER / THE QUEUE** — priority-ordered next work, with
ALREADY-LANDED flags so nothing is redone.
3. **DECISIONS** the owner made — each with date and reason; never re-ask.
4. **NEEDS THE OWNER** — one line each, the exact command or ruling wanted.
5. **THE META-TACTICS** — the most effective known way to do each kind of
task this project has (orchestration shape, the twin protocol, the
install lanes, driving doors as the user, red-first tests, the cap).
6. **MONITORS** — which to arm (pointer to watchtower for the commands) and
which NOT to arm, with the measured reason.
7. **HARD-WON TRAPS** from this session — dated, one line, tell → fix.
8. **WHERE THINGS LIVE** — paths verified to exist at write time.
9. **THE LAWS** that outrank everything — pointers, not copies.
10. **HOW TO VERIFY THIS BRIEF** — each volatile claim's probe, so the next
session distrusts it correctly.
Also a §0 "read in this order" and a header naming the repos and branches.
The brief that started the 2026-09-03 run (`FRESH-SESSION-BRIEF-20260903.md`)
and the one that closed it (`…-20260904.md`) are the reference pair: the
first carried a NOT-DONE REGISTER and "the skills built for this work" —
both survive as §2 and §0.
## 4. The api.ts surface
`reconcile({repos, sources, dryRun})` — enumerates docs, classifies by
citation, extracts volatile-claim candidates by regex class, runs the cheap
probes (path exists, file:line resolves, optional URL 200), and emits the
claim table as markdown. The judgment steps (what to delete, how to fold a
learning in, what a count means) are the model's, and the SKILL says so.
`handoff({repos, sources, out})` — renders the ten-section skeleton with the
STATE block filled by probes (git heads per repo, `/deploy-truth`, the
worker surface) and the section bodies left as fenced TODO blocks the model
fills from the sources.
`npx tsx ~/.claude/skills/snappy-session-close/api.ts reconcile --dry-run`
`npx tsx ~/.claude/skills/snappy-session-close/api.ts handoff --out docs/prompts/FRESH-SESSION-BRIEF-<date>.md`
## 5. Certificate
```
premises: every doc in the repo list enumerated; every volatile claim probed
action: reconcile → per-file commits; handoff → the brief at docs/prompts/
trace: the claim table (claim → probe → verdict → action); the brief's §10
evidence: a FRESH read of each doc after commit; `git log -1` per repo; the brief's own probes re-run
conclusion: PASS only if no claim in any live doc is left unprobed or unmarked
```
## Near neighbours
Skills whose description overlaps this one enough that a reader could pick the
wrong door. Each row is that skill's own first sentence about itself, so the
choice is made on its words, not on a summary written here.
| Skill | Reach for it instead when |
|---|---|
| `snappy-agent-host` | Run the REAL Claude Code, Codex, and Gemini CLIs through ACP via the skills MCP, with durable… |
| `snappy-ax` | Drive any Mac app through the Accessibility tree (AXUIElement) the way the shipping tools act… |
| `snappy-client-total` | Jordan Cameron's mortgage adviser CRM for New Zealand -- the largest and most active client e… |
| `snappy-dom-cartographer` | Master DOM mapping agent for the Snappy swarm. |
| `snappy-github` | Centralized GitHub operations across all Snappy client repos via the `gh` CLI -- pull request… |
| `snappy-infra` | Snappy infrastructure foundation -- Xano API surface (Slack, email, LinkedIn, FreshBooks, Wha… |
| `snappy-os-operator` | Operate SnappyOS like a pro through product doors only: governed connector reads, staged writ… |
| `snappy-pipeline` | Read-only QA agent for Orbiter enrichment pipeline data quality auditing. |
| `snappy-telegram` | Telegram Bot API channel for Snappy: direct calls to api.telegram.org (no Xano middleware) to… |
| `snappy-voice-control` | Voice control on macOS, extracted from two shipping open-source agents (fazm by mediar-ai; Ag… |
/**
* snappy-session-close/api.ts — the typed interface.
*
* Two verbs: `reconcile` (measure every volatile claim in a repo set's
* agent-facing docs) and `handoff` (render the fresh-session brief skeleton
* with the STATE block filled by probes). The mechanical half lives here; the
* judgment half (what to delete, how to fold a learning in, what the next
* order is) is the model's, and SKILL.md says so.
*
* Every probe is cheap and read-only: fs.existsSync, a grep-shaped scan,
* `git log -1`, an optional GET. Nothing here writes to a repo.
*/
import { realpathSync } from "node:fs";
import { existsSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
import { basename, isAbsolute, join, resolve } from "node:path";
import { execFileSync } from "node:child_process";
import { env } from "../snappy-settings/load.ts";
import { annotationsForClass } from "../snappy-settings/tool-annotations.ts";
import { refusalTable } from "../snappy-settings/refusal-codes.ts";
import { serviceUrl } from "../snappy-settings/hand-resources.ts";
/** An authed probe (a private endpoint) reads its bearer from the env cache;
* every default probe here is public and needs none. */
export function bearerFor(key: string): string | null {
try { return env(key) || null; } catch { return null; }
}
/* ── types ────────────────────────────────────────────────────────────── */
export interface RepoSpec {
readonly root: string;
readonly label: string;
/** Live docs to reconcile beyond the root *.md census (relative paths). */
readonly liveDocs?: readonly string[];
/** Workspace roots a doc may name paths relative to (a monorepo's `convex/`
* means `apps/snappy-os/web/convex/`); tried after the repo root. */
readonly workspaces?: readonly string[];
}
export interface Sources {
readonly handoff?: string;
readonly ledger?: string;
readonly memoryDir?: string;
readonly researchGlob?: string;
}
export type ClaimKind = "path" | "file_line" | "count" | "version" | "command" | "status" | "sha" | "date" | "url";
export interface Claim {
readonly doc: string;
readonly line: number;
readonly kind: ClaimKind;
readonly text: string;
}
export type Verdict = "confirmed" | "wrong" | "unverified";
export interface ProbedClaim extends Claim {
readonly probe: string;
readonly verdict: Verdict;
readonly note?: string;
}
export interface DocCensusRow {
readonly repo: string;
readonly doc: string;
readonly lines: number;
readonly citedByCode: number;
readonly citedByDoc: number;
readonly dated: boolean;
readonly classification: "cited-by-code" | "cited-by-doc" | "uncited-dated" | "uncited";
}
export interface ReconcileReport {
readonly census: readonly DocCensusRow[];
readonly claims: readonly ProbedClaim[];
readonly markdown: string;
}
export interface StateProbe {
readonly repo: string;
readonly head: string;
readonly branch: string;
readonly dirty: number;
}
export interface HandoffInput {
readonly repos?: readonly RepoSpec[];
readonly sources?: Sources;
readonly out: string;
/** Extra live endpoints to fold into the STATE block, label → URL. */
readonly endpoints?: Readonly<Record<string, string>>;
}
/* ── defaults: Snappy's three repos ───────────────────────────────────── */
export const SNAPPY_REPOS: readonly RepoSpec[] = [
{
root: "/Users/robertboulos/Projects/snappy-os-app",
label: "app",
liveDocs: ["RUN-BRIEFING.md", "HARNESS-MASTER.md", "docs/TRAPS.md", "docs/LEDGER-2026-08-28.md"],
workspaces: ["apps/snappy-os/web", "apps/snappy-os/web/convex-tests", "apps/snappy-os/web/src", "state/lib", "state/bin/head-screen/routes", "apps/snappy-os"],
},
{ root: "/Users/robertboulos/Projects/snappy-os-app-convex", label: "convex-twin", workspaces: ["apps/snappy-os/web", "apps/snappy-os/web/convex-tests"] },
{ root: "/Users/robertboulos/Projects/mcp-servers/snappy-os-mcp", label: "mcp-worker", liveDocs: ["THE-LAWS.md"] },
];
export const SNAPPY_ENDPOINTS: Readonly<Record<string, string>> = {
// THE ADDRESS COMES FROM THE ONE REGISTRY ⟨lane mini-reads, 2026-09-09⟩.
"daemon /deploy-truth": `${serviceUrl("snappy-os-app")}/deploy-truth`,
"worker surface": "https://snappy-os-mcp.robertjboulos.workers.dev/.well-known/snappy-surface.json",
};
export const HANDOFF_SECTIONS: readonly string[] = [
"0. Read, in this order (15 minutes, then never re-read; measure instead)",
"1. STATE OF THE WORLD (measured; re-measure per §10)",
"2. THE ORDER / THE QUEUE (priority order; ALREADY-LANDED flags so nothing is redone)",
"3. DECISIONS THE OWNER MADE (each with date and reason; never re-ask)",
"4. NEEDS THE OWNER (one line each; the exact ask)",
"5. THE META-TACTICS (the most effective known way for each kind of task here)",
"6. MONITORS (arm at session start; and the ones NOT to arm)",
"7. HARD-WON TRAPS (dated; one line each — tell → fix)",
"8. WHERE THINGS LIVE (verified to exist at write time)",
"9. THE LAWS THAT OUTRANK EVERYTHING (pointers; the long form is in memory and CLAUDE.md)",
"10. HOW TO VERIFY THIS BRIEF (each volatile claim's probe)",
];
/* ── helpers ──────────────────────────────────────────────────────────── */
const CODE_EXT = new Set([".ts", ".tsx", ".mjs", ".js", ".json", ".sh", ".swift"]);
const SKIP_DIRS = new Set(["node_modules", ".git", ".build", "dist", ".claude", ".worktrees", "docs/archive"]);
function walk(root: string, depth = 0, out: string[] = []): string[] {
if (depth > 6) return out;
let entries: string[];
try { entries = readdirSync(root); } catch { return out; }
for (const name of entries) {
if (SKIP_DIRS.has(name)) continue;
const full = join(root, name);
let st; try { st = statSync(full); } catch { continue; }
if (st.isDirectory()) walk(full, depth + 1, out);
else if (st.isFile() && st.size < 2_000_000) out.push(full);
}
return out;
}
function countMentions(files: readonly string[], needle: string, self: string): number {
let n = 0;
for (const f of files) {
if (f === self) continue;
try { if (readFileSync(f, "utf8").includes(needle)) n += 1; } catch { /* unreadable */ }
}
return n;
}
function git(root: string, args: readonly string[]): string {
try { return execFileSync("git", ["-C", root, ...args], { encoding: "utf8", timeout: 20_000, stdio: ["ignore", "pipe", "ignore"] }).trim(); }
catch (e) { return `git failed: ${(e as Error).message.split("\n")[0]}`; }
}
const DATE_RE = /\b20\d\d-\d\d-\d\d\b|\b20\d\d(0[1-9]|1[0-2])(0[1-9]|[12]\d|3[01])\b/;
/* ── PART 1: reconcile ───────────────────────────────────────────────── */
export function docCensus(repo: RepoSpec): DocCensusRow[] {
const files = walk(repo.root);
const codeFiles = files.filter((f) => CODE_EXT.has(f.slice(f.lastIndexOf("."))));
const mdFiles = files.filter((f) => f.endsWith(".md"));
const rootDocs = readdirSync(repo.root).filter((n) => n.endsWith(".md")).map((n) => join(repo.root, n));
const live = (repo.liveDocs ?? []).map((p) => join(repo.root, p)).filter(existsSync);
const docs = Array.from(new Set([...rootDocs, ...live]));
return docs.map((doc) => {
const name = basename(doc);
const text = readFileSync(doc, "utf8");
const citedByCode = countMentions(codeFiles, name, doc);
const citedByDoc = countMentions(mdFiles, name, doc);
const dated = DATE_RE.test(name) || /^# .*\b20\d\d-\d\d-\d\d\b/m.test(text.slice(0, 400));
const classification: DocCensusRow["classification"] =
citedByCode > 0 ? "cited-by-code" : citedByDoc > 0 ? "cited-by-doc" : dated ? "uncited-dated" : "uncited";
return { repo: repo.label, doc: doc.replace(repo.root + "/", ""), lines: text.split("\n").length, citedByCode, citedByDoc, dated, classification };
});
}
const CLAIM_PATTERNS: ReadonlyArray<readonly [ClaimKind, RegExp]> = [
["file_line", /`([\w./@-]+\.(?:ts|tsx|swift|mjs|css|json|sh|md)):(\d+)(?:-\d+)?`/g],
["path", /`((?:\/Users\/|~\/|apps\/|state\/|src\/|docs\/|scripts\/|convex\/|research\/)[\w./@-]+)`/g],
["url", /\bhttps?:\/\/[\w./:%#?=&-]+/g],
["sha", /\b(?!\d{7,12}\b)[0-9a-f]{7,12}\b(?=[\s,.;:)`])/g],
["version", /`?@?[\w/.-]+@\^?\d+\.\d+\.\d+`?/g],
["command", /`(npm run [\w:-]+|npx [^`]{3,80}|bash [^`]{3,80}|git [^`]{3,60})`/g],
["count", /\b\d{2,}\s+(?:doors|tools|files|lines|tests|connectors|deployments|definitions|rows|commands|sites)\b/g],
["status", /\b(?:CLOSED|LANDED|LIVE|INSTALLED|DEPLOYED|PROVEN|OPEN|BLOCKED)\b/g],
["date", /\b2026-\d\d-\d\d\b/g],
];
export function extractClaims(docPath: string, repoRoot: string): Claim[] {
const text = readFileSync(docPath, "utf8");
const out: Claim[] = [];
const rel = docPath.replace(repoRoot + "/", "");
text.split("\n").forEach((line, i) => {
for (const [kind, re] of CLAIM_PATTERNS) {
re.lastIndex = 0;
let m: RegExpExecArray | null;
while ((m = re.exec(line)) !== null) {
out.push({ doc: rel, line: i + 1, kind, text: m[0].replace(/`/g, "") });
if (m[0].length === 0) re.lastIndex += 1;
}
}
});
return out;
}
export function probeClaim(claim: Claim, repoRoot: string, workspaces: readonly string[] = []): ProbedClaim {
const abs = (p: string): string => {
if (isAbsolute(p)) return p;
if (p.startsWith("~/")) return p.replace("~", process.env.HOME ?? "");
const direct = resolve(repoRoot, p);
if (existsSync(direct)) return direct;
for (const w of workspaces) { const c = resolve(repoRoot, w, p); if (existsSync(c)) return c; }
// a bare basename (e.g. `evals.test.ts:610`) — find it once under the workspaces
if (!p.includes("/")) for (const w of workspaces) { const c = resolve(repoRoot, w, p); if (existsSync(c)) return c; }
return direct;
};
switch (claim.kind) {
case "path": {
const p = abs(claim.text);
return { ...claim, probe: `test -e ${p}`, verdict: existsSync(p) ? "confirmed" : "wrong" };
}
case "file_line": {
const [file, lineStr] = claim.text.split(":");
const p = abs(file);
if (!existsSync(p)) return { ...claim, probe: `test -e ${p}`, verdict: "wrong", note: "file absent" };
const n = Number(lineStr);
const lines = readFileSync(p, "utf8").split("\n").length;
return { ...claim, probe: `wc -l ${p}`, verdict: n <= lines ? "confirmed" : "wrong", note: n <= lines ? undefined : `only ${lines} lines` };
}
case "sha": {
const r = git(repoRoot, ["cat-file", "-t", claim.text]);
return { ...claim, probe: `git cat-file -t ${claim.text}`, verdict: r === "commit" ? "confirmed" : "unverified", note: r === "commit" ? undefined : "not a commit in this repo (may be another repo's)" };
}
case "command": {
const m = /^npm run ([\w:-]+)/.exec(claim.text);
if (m) {
try {
const pkg = JSON.parse(readFileSync(join(repoRoot, "package.json"), "utf8")) as { scripts?: Record<string, string> };
const has = Boolean(pkg.scripts?.[m[1]]);
return { ...claim, probe: `package.json scripts.${m[1]}`, verdict: has ? "confirmed" : "wrong" };
} catch { return { ...claim, probe: "package.json", verdict: "unverified" }; }
}
return { ...claim, probe: "(model judges)", verdict: "unverified" };
}
default:
return { ...claim, probe: "(model judges: version/count/status/date/url need context)", verdict: "unverified" };
}
}
export function reconcile(input: { repos?: readonly RepoSpec[]; sources?: Sources; dryRun?: boolean } = {}): ReconcileReport {
const repos = input.repos ?? SNAPPY_REPOS;
const census: DocCensusRow[] = [];
const claims: ProbedClaim[] = [];
for (const repo of repos) {
const rows = docCensus(repo);
census.push(...rows);
for (const row of rows) {
if (row.classification === "uncited-dated") continue; // archive candidate; nothing to probe
const doc = join(repo.root, row.doc);
for (const c of extractClaims(doc, repo.root)) claims.push(probeClaim(c, repo.root, repo.workspaces ?? []));
}
}
const wrong = claims.filter((c) => c.verdict === "wrong");
const md = [
`# reconcile — ${new Date().toISOString()}`,
"",
"## Doc census (classification decides archive candidates)",
"",
"| repo | doc | lines | code cites | doc cites | class |",
"|---|---|---|---|---|---|",
...census.map((r) => `| ${r.repo} | ${r.doc} | ${r.lines} | ${r.citedByCode} | ${r.citedByDoc} | ${r.classification} |`),
"",
`## Claims: ${claims.length} extracted, ${wrong.length} WRONG, ${claims.filter((c) => c.verdict === "confirmed").length} confirmed, ${claims.filter((c) => c.verdict === "unverified").length} for the model to judge`,
"",
"| doc:line | kind | claim | probe | verdict | note |",
"|---|---|---|---|---|---|",
...wrong.map((c) => `| ${c.doc}:${c.line} | ${c.kind} | ${c.text} | ${c.probe} | **${c.verdict}** | ${c.note ?? ""} |`),
"",
"Unverified claims are listed only in JSON output; the model judges them from the session's sources.",
].join("\n");
return { census, claims, markdown: md };
}
/* ── PART 2: handoff ─────────────────────────────────────────────────── */
export function probeState(repos: readonly RepoSpec[]): StateProbe[] {
return repos.map((r) => ({
repo: r.label,
head: git(r.root, ["log", "-1", "--format=%h %ci %s"]).slice(0, 100),
branch: git(r.root, ["rev-parse", "--abbrev-ref", "HEAD"]),
dirty: git(r.root, ["status", "--short"]).split("\n").filter((l) => l.trim() !== "" && !l.startsWith("??")).length,
}));
}
async function probeEndpoint(url: string): Promise<string> {
try {
const res = await fetch(url, { signal: AbortSignal.timeout(8000) });
const body = await res.text();
return `${res.status} ${body.slice(0, 240).replace(/\s+/g, " ")}`;
} catch (e) { return `unreachable: ${(e as Error).message}`; }
}
export async function handoff(input: HandoffInput): Promise<{ out: string; skeleton: string }> {
const repos = input.repos ?? SNAPPY_REPOS;
const endpoints = input.endpoints ?? SNAPPY_ENDPOINTS;
const state = probeState(repos);
const live = await Promise.all(Object.entries(endpoints).map(async ([label, url]) => `- ${label}: ${await probeEndpoint(url)}`));
const today = new Date().toISOString().slice(0, 10);
const lines: string[] = [
`# FRESH SESSION BRIEF (written ${today} by the session that is closing; for a reader with ZERO memory of it)`,
"",
"You have NO memory of the session that wrote this. Every fact below was measured when written; §10 says how to re-measure each one. Distrust it correctly.",
"",
"Repos: " + repos.map((r) => `\`${r.root}\` (${r.label})`).join(", "),
"",
];
for (const s of HANDOFF_SECTIONS) {
lines.push(`## ${s}`, "");
if (s.startsWith("1.")) {
lines.push(...state.map((p) => `- ${p.repo}: branch \`${p.branch}\`, head ${p.head}, dirty ${p.dirty}`), ...live, "", "<!-- TODO(model): what is live where, what is proven on glass, with run ids/receipts to reopen -->", "");
} else if (s.startsWith("10.")) {
lines.push(...repos.map((r) => `- ${r.label} head: \`git -C ${r.root} log -1\``), ...Object.entries(endpoints).map(([l, u]) => `- ${l}: \`curl -s ${u}\``), "", "<!-- TODO(model): one probe per volatile claim above -->", "");
} else {
lines.push("<!-- TODO(model): fill from the session's sources (HANDOFF §9, LEDGER, memory dir, research/*<date>*.md) -->", "");
}
}
const skeleton = lines.join("\n");
writeFileSync(input.out, skeleton);
return { out: input.out, skeleton };
}
/* ── CLI ─────────────────────────────────────────────────────────────── */
const HELP = `snappy-session-close
reconcile [--dry-run] [--json] measure every volatile claim in the default repos' agent-facing docs; print the claim table
handoff --out <path> render the fresh-session brief skeleton with the STATE block filled by probes
The judgment (what to delete, what to fold in, the next order) is the model's — see SKILL.md.`;
// ⟨lane CONTRACTS, 2026-09-07⟩ realpathSync IS REQUIRED HERE. Every skill under
// ~/.claude/skills is a SYMLINK into the kernel repo, so `process.argv[1]` is the
// link and `import.meta.url` is its target: without resolving one to the other the
// guard is FALSE under the collection root and this file's whole CLI — including
// `contract` — silently answers nothing. Spec §2 rule 4 says so; five files had
// drifted from it. Measured: `api.ts list` printed nothing through the symlink.
/** WHAT THIS HAND ANSWERS, and what each verb does to the world.
* Derived from this file's own CLI dispatch by
* `snappy-hands/contract-derive.ts` — a verb the code does not implement is
* never declared here. Snappy's daemon reads it (`api.ts contract`) to
* validate every call, build the argument words in order, decide whether the
* act runs now or stages for the owner, and hand the child exactly the
* environment keys named in `requires` — never a value, never anything else.
*/
/** THE HOST-FACING FACTS ⟨lane CONTRACTS N–Z, 2026-09-09⟩. `class` is the
* closed effect set snappy-tool-design rule 18 grades; `annotations` are
* DERIVED from it by the ONE derivation in
* `snappy-settings/tool-annotations.ts`, never written per verb, so a class
* and its published hints cannot disagree; `refusals` projects the ONE closed
* table in `snappy-settings/refusal-codes.ts`; `requires` is exactly the
* credential keys this file's own executable reads name, and nothing else. */
export const HAND_CONTRACT = {
skill: "snappy-session-close",
description: "Close a working session in two verbs: RECONCILE the agent-facing docs of a repo set (CLAUDE.md, AGENTS.md, README, the repo's named live docs) against MEASURED truth — delete what is wrong, edit the wrong line in place, archive the uncited dated, fold in the session's learnings with their reasons — then HANDOFF: write the fresh-session brief a zero-memory successor starts from (state of the world measured, the order with already-landed flags, dated decisions, needs-owner, meta-tactics, monitors, traps, where things live, laws, how to verify). Built 2026-09-03 from the overnight run that landed 20 lanes; defaults to Snappy's three repos (app, Convex twin, MCP worker) but takes any repo list. Triggers: snappy-session-close, session-close, end of session, close the session, update the docs, fold in learnings, write the handoff, fresh-session brief.",
managed: false,
requires: [] as string[],
refusals: refusalTable("unknown_verb"),
verbs: {
handoff: {
args: [], effect: "write-reversible", class: "additive-write", openWorld: true,
annotations: annotationsForClass("additive-write", { openWorld: true }),
flags: {"out":"--out"},
},
reconcile: {
args: [], effect: "write-reversible", class: "additive-write", openWorld: true,
annotations: annotationsForClass("additive-write", { openWorld: true }),
flags: {"dry-run":"--dry-run","json":"--json"},
},
},
} as const;
if (import.meta.url === `file://${realpathSync(process.argv[1])}` && process.argv[2] === "contract") {
console.log(JSON.stringify(HAND_CONTRACT, null, 2));
process.exit(0);
}
if (import.meta.url === `file://${realpathSync(process.argv[1])}`) {
const [verb, ...rest] = process.argv.slice(2);
const flag = (name: string) => { const i = rest.indexOf(name); return i >= 0 ? rest[i + 1] : undefined; };
if (verb === "reconcile") {
const report = reconcile({ dryRun: rest.includes("--dry-run") });
process.stdout.write(rest.includes("--json") ? JSON.stringify(report, null, 2) : report.markdown + "\n");
} else if (verb === "handoff") {
const out = flag("--out");
if (!out) { process.stderr.write("handoff needs --out <path>\n"); process.exit(2); }
handoff({ out }).then((r) => process.stdout.write(`wrote ${r.out} (${r.skeleton.split("\n").length} lines)\n`));
} else {
process.stdout.write(HELP + "\n");
process.exit(verb === undefined || verb === "--help" ? 0 : 2);
}
}
/**
* snappy-session-close/api.ts — the typed interface.
*
* Two verbs: `reconcile` (measure every volatile claim in a repo set's
* agent-facing docs) and `handoff` (render the fresh-session brief skeleton
* with the STATE block filled by probes). The mechanical half lives here; the
* judgment half (what to delete, how to fold a learning in, what the next
* order is) is the model's, and SKILL.md says so.
*
* Every probe is cheap and read-only: fs.existsSync, a grep-shaped scan,
* `git log -1`, an optional GET. Nothing here writes to a repo.
*/
import { realpathSync } from "node:fs";
import { existsSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
import { basename, isAbsolute, join, resolve } from "node:path";
import { execFileSync } from "node:child_process";
import { env } from "../snappy-settings/load.ts";
import { annotationsForClass } from "../snappy-settings/tool-annotations.ts";
import { refusalTable } from "../snappy-settings/refusal-codes.ts";
import { serviceUrl } from "../snappy-settings/hand-resources.ts";
/** An authed probe (a private endpoint) reads its bearer from the env cache;
* every default probe here is public and needs none. */
export function bearerFor(key: string): string | null {
try { return env(key) || null; } catch { return null; }
}
/* ── types ────────────────────────────────────────────────────────────── */
export interface RepoSpec {
readonly root: string;
readonly label: string;
/** Live docs to reconcile beyond the root *.md census (relative paths). */
readonly liveDocs?: readonly string[];
/** Workspace roots a doc may name paths relative to (a monorepo's `convex/`
* means `apps/snappy-os/web/convex/`); tried after the repo root. */
readonly workspaces?: readonly string[];
}
export interface Sources {
readonly handoff?: string;
readonly ledger?: string;
readonly memoryDir?: string;
readonly researchGlob?: string;
}
export type ClaimKind = "path" | "file_line" | "count" | "version" | "command" | "status" | "sha" | "date" | "url";
export interface Claim {
readonly doc: string;
readonly line: number;
readonly kind: ClaimKind;
readonly text: string;
}
export type Verdict = "confirmed" | "wrong" | "unverified";
export interface ProbedClaim extends Claim {
readonly probe: string;
readonly verdict: Verdict;
readonly note?: string;
}
export interface DocCensusRow {
readonly repo: string;
readonly doc: string;
readonly lines: number;
readonly citedByCode: number;
readonly citedByDoc: number;
readonly dated: boolean;
readonly classification: "cited-by-code" | "cited-by-doc" | "uncited-dated" | "uncited";
}
export interface ReconcileReport {
readonly census: readonly DocCensusRow[];
readonly claims: readonly ProbedClaim[];
readonly markdown: string;
}
export interface StateProbe {
readonly repo: string;
readonly head: string;
readonly branch: string;
readonly dirty: number;
}
export interface HandoffInput {
readonly repos?: readonly RepoSpec[];
readonly sources?: Sources;
readonly out: string;
/** Extra live endpoints to fold into the STATE block, label → URL. */
readonly endpoints?: Readonly<Record<string, string>>;
}
/* ── defaults: Snappy's three repos ───────────────────────────────────── */
export const SNAPPY_REPOS: readonly RepoSpec[] = [
{
root: "/Users/robertboulos/Projects/snappy-os-app",
label: "app",
liveDocs: ["RUN-BRIEFING.md", "HARNESS-MASTER.md", "docs/TRAPS.md", "docs/LEDGER-2026-08-28.md"],
workspaces: ["apps/snappy-os/web", "apps/snappy-os/web/convex-tests", "apps/snappy-os/web/src", "state/lib", "state/bin/head-screen/routes", "apps/snappy-os"],
},
{ root: "/Users/robertboulos/Projects/snappy-os-app-convex", label: "convex-twin", workspaces: ["apps/snappy-os/web", "apps/snappy-os/web/convex-tests"] },
{ root: "/Users/robertboulos/Projects/mcp-servers/snappy-os-mcp", label: "mcp-worker", liveDocs: ["THE-LAWS.md"] },
];
export const SNAPPY_ENDPOINTS: Readonly<Record<string, string>> = {
// THE ADDRESS COMES FROM THE ONE REGISTRY ⟨lane mini-reads, 2026-09-09⟩.
"daemon /deploy-truth": `${serviceUrl("snappy-os-app")}/deploy-truth`,
"worker surface": "https://snappy-os-mcp.robertjboulos.workers.dev/.well-known/snappy-surface.json",
};
export const HANDOFF_SECTIONS: readonly string[] = [
"0. Read, in this order (15 minutes, then never re-read; measure instead)",
"1. STATE OF THE WORLD (measured; re-measure per §10)",
"2. THE ORDER / THE QUEUE (priority order; ALREADY-LANDED flags so nothing is redone)",
"3. DECISIONS THE OWNER MADE (each with date and reason; never re-ask)",
"4. NEEDS THE OWNER (one line each; the exact ask)",
"5. THE META-TACTICS (the most effective known way for each kind of task here)",
"6. MONITORS (arm at session start; and the ones NOT to arm)",
"7. HARD-WON TRAPS (dated; one line each — tell → fix)",
"8. WHERE THINGS LIVE (verified to exist at write time)",
"9. THE LAWS THAT OUTRANK EVERYTHING (pointers; the long form is in memory and CLAUDE.md)",
"10. HOW TO VERIFY THIS BRIEF (each volatile claim's probe)",
];
/* ── helpers ──────────────────────────────────────────────────────────── */
const CODE_EXT = new Set([".ts", ".tsx", ".mjs", ".js", ".json", ".sh", ".swift"]);
const SKIP_DIRS = new Set(["node_modules", ".git", ".build", "dist", ".claude", ".worktrees", "docs/archive"]);
function walk(root: string, depth = 0, out: string[] = []): string[] {
if (depth > 6) return out;
let entries: string[];
try { entries = readdirSync(root); } catch { return out; }
for (const name of entries) {
if (SKIP_DIRS.has(name)) continue;
const full = join(root, name);
let st; try { st = statSync(full); } catch { continue; }
if (st.isDirectory()) walk(full, depth + 1, out);
else if (st.isFile() && st.size < 2_000_000) out.push(full);
}
return out;
}
function countMentions(files: readonly string[], needle: string, self: string): number {
let n = 0;
for (const f of files) {
if (f === self) continue;
try { if (readFileSync(f, "utf8").includes(needle)) n += 1; } catch { /* unreadable */ }
}
return n;
}
function git(root: string, args: readonly string[]): string {
try { return execFileSync("git", ["-C", root, ...args], { encoding: "utf8", timeout: 20_000, stdio: ["ignore", "pipe", "ignore"] }).trim(); }
catch (e) { return `git failed: ${(e as Error).message.split("\n")[0]}`; }
}
const DATE_RE = /\b20\d\d-\d\d-\d\d\b|\b20\d\d(0[1-9]|1[0-2])(0[1-9]|[12]\d|3[01])\b/;
/* ── PART 1: reconcile ───────────────────────────────────────────────── */
export function docCensus(repo: RepoSpec): DocCensusRow[] {
const files = walk(repo.root);
const codeFiles = files.filter((f) => CODE_EXT.has(f.slice(f.lastIndexOf("."))));
const mdFiles = files.filter((f) => f.endsWith(".md"));
const rootDocs = readdirSync(repo.root).filter((n) => n.endsWith(".md")).map((n) => join(repo.root, n));
const live = (repo.liveDocs ?? []).map((p) => join(repo.root, p)).filter(existsSync);
const docs = Array.from(new Set([...rootDocs, ...live]));
return docs.map((doc) => {
const name = basename(doc);
const text = readFileSync(doc, "utf8");
const citedByCode = countMentions(codeFiles, name, doc);
const citedByDoc = countMentions(mdFiles, name, doc);
const dated = DATE_RE.test(name) || /^# .*\b20\d\d-\d\d-\d\d\b/m.test(text.slice(0, 400));
const classification: DocCensusRow["classification"] =
citedByCode > 0 ? "cited-by-code" : citedByDoc > 0 ? "cited-by-doc" : dated ? "uncited-dated" : "uncited";
return { repo: repo.label, doc: doc.replace(repo.root + "/", ""), lines: text.split("\n").length, citedByCode, citedByDoc, dated, classification };
});
}
const CLAIM_PATTERNS: ReadonlyArray<readonly [ClaimKind, RegExp]> = [
["file_line", /`([\w./@-]+\.(?:ts|tsx|swift|mjs|css|json|sh|md)):(\d+)(?:-\d+)?`/g],
["path", /`((?:\/Users\/|~\/|apps\/|state\/|src\/|docs\/|scripts\/|convex\/|research\/)[\w./@-]+)`/g],
["url", /\bhttps?:\/\/[\w./:%#?=&-]+/g],
["sha", /\b(?!\d{7,12}\b)[0-9a-f]{7,12}\b(?=[\s,.;:)`])/g],
["version", /`?@?[\w/.-]+@\^?\d+\.\d+\.\d+`?/g],
["command", /`(npm run [\w:-]+|npx [^`]{3,80}|bash [^`]{3,80}|git [^`]{3,60})`/g],
["count", /\b\d{2,}\s+(?:doors|tools|files|lines|tests|connectors|deployments|definitions|rows|commands|sites)\b/g],
["status", /\b(?:CLOSED|LANDED|LIVE|INSTALLED|DEPLOYED|PROVEN|OPEN|BLOCKED)\b/g],
["date", /\b2026-\d\d-\d\d\b/g],
];
export function extractClaims(docPath: string, repoRoot: string): Claim[] {
const text = readFileSync(docPath, "utf8");
const out: Claim[] = [];
const rel = docPath.replace(repoRoot + "/", "");
text.split("\n").forEach((line, i) => {
for (const [kind, re] of CLAIM_PATTERNS) {
re.lastIndex = 0;
let m: RegExpExecArray | null;
while ((m = re.exec(line)) !== null) {
out.push({ doc: rel, line: i + 1, kind, text: m[0].replace(/`/g, "") });
if (m[0].length === 0) re.lastIndex += 1;
}
}
});
return out;
}
export function probeClaim(claim: Claim, repoRoot: string, workspaces: readonly string[] = []): ProbedClaim {
const abs = (p: string): string => {
if (isAbsolute(p)) return p;
if (p.startsWith("~/")) return p.replace("~", process.env.HOME ?? "");
const direct = resolve(repoRoot, p);
if (existsSync(direct)) return direct;
for (const w of workspaces) { const c = resolve(repoRoot, w, p); if (existsSync(c)) return c; }
// a bare basename (e.g. `evals.test.ts:610`) — find it once under the workspaces
if (!p.includes("/")) for (const w of workspaces) { const c = resolve(repoRoot, w, p); if (existsSync(c)) return c; }
return direct;
};
switch (claim.kind) {
case "path": {
const p = abs(claim.text);
return { ...claim, probe: `test -e ${p}`, verdict: existsSync(p) ? "confirmed" : "wrong" };
}
case "file_line": {
const [file, lineStr] = claim.text.split(":");
const p = abs(file);
if (!existsSync(p)) return { ...claim, probe: `test -e ${p}`, verdict: "wrong", note: "file absent" };
const n = Number(lineStr);
const lines = readFileSync(p, "utf8").split("\n").length;
return { ...claim, probe: `wc -l ${p}`, verdict: n <= lines ? "confirmed" : "wrong", note: n <= lines ? undefined : `only ${lines} lines` };
}
case "sha": {
const r = git(repoRoot, ["cat-file", "-t", claim.text]);
return { ...claim, probe: `git cat-file -t ${claim.text}`, verdict: r === "commit" ? "confirmed" : "unverified", note: r === "commit" ? undefined : "not a commit in this repo (may be another repo's)" };
}
case "command": {
const m = /^npm run ([\w:-]+)/.exec(claim.text);
if (m) {
try {
const pkg = JSON.parse(readFileSync(join(repoRoot, "package.json"), "utf8")) as { scripts?: Record<string, string> };
const has = Boolean(pkg.scripts?.[m[1]]);
return { ...claim, probe: `package.json scripts.${m[1]}`, verdict: has ? "confirmed" : "wrong" };
} catch { return { ...claim, probe: "package.json", verdict: "unverified" }; }
}
return { ...claim, probe: "(model judges)", verdict: "unverified" };
}
default:
return { ...claim, probe: "(model judges: version/count/status/date/url need context)", verdict: "unverified" };
}
}
export function reconcile(input: { repos?: readonly RepoSpec[]; sources?: Sources; dryRun?: boolean } = {}): ReconcileReport {
const repos = input.repos ?? SNAPPY_REPOS;
const census: DocCensusRow[] = [];
const claims: ProbedClaim[] = [];
for (const repo of repos) {
const rows = docCensus(repo);
census.push(...rows);
for (const row of rows) {
if (row.classification === "uncited-dated") continue; // archive candidate; nothing to probe
const doc = join(repo.root, row.doc);
for (const c of extractClaims(doc, repo.root)) claims.push(probeClaim(c, repo.root, repo.workspaces ?? []));
}
}
const wrong = claims.filter((c) => c.verdict === "wrong");
const md = [
`# reconcile — ${new Date().toISOString()}`,
"",
"## Doc census (classification decides archive candidates)",
"",
"| repo | doc | lines | code cites | doc cites | class |",
"|---|---|---|---|---|---|",
...census.map((r) => `| ${r.repo} | ${r.doc} | ${r.lines} | ${r.citedByCode} | ${r.citedByDoc} | ${r.classification} |`),
"",
`## Claims: ${claims.length} extracted, ${wrong.length} WRONG, ${claims.filter((c) => c.verdict === "confirmed").length} confirmed, ${claims.filter((c) => c.verdict === "unverified").length} for the model to judge`,
"",
"| doc:line | kind | claim | probe | verdict | note |",
"|---|---|---|---|---|---|",
...wrong.map((c) => `| ${c.doc}:${c.line} | ${c.kind} | ${c.text} | ${c.probe} | **${c.verdict}** | ${c.note ?? ""} |`),
"",
"Unverified claims are listed only in JSON output; the model judges them from the session's sources.",
].join("\n");
return { census, claims, markdown: md };
}
/* ── PART 2: handoff ─────────────────────────────────────────────────── */
export function probeState(repos: readonly RepoSpec[]): StateProbe[] {
return repos.map((r) => ({
repo: r.label,
head: git(r.root, ["log", "-1", "--format=%h %ci %s"]).slice(0, 100),
branch: git(r.root, ["rev-parse", "--abbrev-ref", "HEAD"]),
dirty: git(r.root, ["status", "--short"]).split("\n").filter((l) => l.trim() !== "" && !l.startsWith("??")).length,
}));
}
async function probeEndpoint(url: string): Promise<string> {
try {
const res = await fetch(url, { signal: AbortSignal.timeout(8000) });
const body = await res.text();
return `${res.status} ${body.slice(0, 240).replace(/\s+/g, " ")}`;
} catch (e) { return `unreachable: ${(e as Error).message}`; }
}
export async function handoff(input: HandoffInput): Promise<{ out: string; skeleton: string }> {
const repos = input.repos ?? SNAPPY_REPOS;
const endpoints = input.endpoints ?? SNAPPY_ENDPOINTS;
const state = probeState(repos);
const live = await Promise.all(Object.entries(endpoints).map(async ([label, url]) => `- ${label}: ${await probeEndpoint(url)}`));
const today = new Date().toISOString().slice(0, 10);
const lines: string[] = [
`# FRESH SESSION BRIEF (written ${today} by the session that is closing; for a reader with ZERO memory of it)`,
"",
"You have NO memory of the session that wrote this. Every fact below was measured when written; §10 says how to re-measure each one. Distrust it correctly.",
"",
"Repos: " + repos.map((r) => `\`${r.root}\` (${r.label})`).join(", "),
"",
];
for (const s of HANDOFF_SECTIONS) {
lines.push(`## ${s}`, "");
if (s.startsWith("1.")) {
lines.push(...state.map((p) => `- ${p.repo}: branch \`${p.branch}\`, head ${p.head}, dirty ${p.dirty}`), ...live, "", "<!-- TODO(model): what is live where, what is proven on glass, with run ids/receipts to reopen -->", "");
} else if (s.startsWith("10.")) {
lines.push(...repos.map((r) => `- ${r.label} head: \`git -C ${r.root} log -1\``), ...Object.entries(endpoints).map(([l, u]) => `- ${l}: \`curl -s ${u}\``), "", "<!-- TODO(model): one probe per volatile claim above -->", "");
} else {
lines.push("<!-- TODO(model): fill from the session's sources (HANDOFF §9, LEDGER, memory dir, research/*<date>*.md) -->", "");
}
}
const skeleton = lines.join("\n");
writeFileSync(input.out, skeleton);
return { out: input.out, skeleton };
}
/* ── CLI ─────────────────────────────────────────────────────────────── */
const HELP = `snappy-session-close
reconcile [--dry-run] [--json] measure every volatile claim in the default repos' agent-facing docs; print the claim table
handoff --out <path> render the fresh-session brief skeleton with the STATE block filled by probes
The judgment (what to delete, what to fold in, the next order) is the model's — see SKILL.md.`;
// ⟨lane CONTRACTS, 2026-09-07⟩ realpathSync IS REQUIRED HERE. Every skill under
// ~/.claude/skills is a SYMLINK into the kernel repo, so `process.argv[1]` is the
// link and `import.meta.url` is its target: without resolving one to the other the
// guard is FALSE under the collection root and this file's whole CLI — including
// `contract` — silently answers nothing. Spec §2 rule 4 says so; five files had
// drifted from it. Measured: `api.ts list` printed nothing through the symlink.
/** WHAT THIS HAND ANSWERS, and what each verb does to the world.
* Derived from this file's own CLI dispatch by
* `snappy-hands/contract-derive.ts` — a verb the code does not implement is
* never declared here. Snappy's daemon reads it (`api.ts contract`) to
* validate every call, build the argument words in order, decide whether the
* act runs now or stages for the owner, and hand the child exactly the
* environment keys named in `requires` — never a value, never anything else.
*/
/** THE HOST-FACING FACTS ⟨lane CONTRACTS N–Z, 2026-09-09⟩. `class` is the
* closed effect set snappy-tool-design rule 18 grades; `annotations` are
* DERIVED from it by the ONE derivation in
* `snappy-settings/tool-annotations.ts`, never written per verb, so a class
* and its published hints cannot disagree; `refusals` projects the ONE closed
* table in `snappy-settings/refusal-codes.ts`; `requires` is exactly the
* credential keys this file's own executable reads name, and nothing else. */
export const HAND_CONTRACT = {
skill: "snappy-session-close",
description: "Close a working session in two verbs: RECONCILE the agent-facing docs of a repo set (CLAUDE.md, AGENTS.md, README, the repo's named live docs) against MEASURED truth — delete what is wrong, edit the wrong line in place, archive the uncited dated, fold in the session's learnings with their reasons — then HANDOFF: write the fresh-session brief a zero-memory successor starts from (state of the world measured, the order with already-landed flags, dated decisions, needs-owner, meta-tactics, monitors, traps, where things live, laws, how to verify). Built 2026-09-03 from the overnight run that landed 20 lanes; defaults to Snappy's three repos (app, Convex twin, MCP worker) but takes any repo list. Triggers: snappy-session-close, session-close, end of session, close the session, update the docs, fold in learnings, write the handoff, fresh-session brief.",
managed: false,
requires: [] as string[],
refusals: refusalTable("unknown_verb"),
verbs: {
handoff: {
args: [], effect: "write-reversible", class: "additive-write", openWorld: true,
annotations: annotationsForClass("additive-write", { openWorld: true }),
flags: {"out":"--out"},
},
reconcile: {
args: [], effect: "write-reversible", class: "additive-write", openWorld: true,
annotations: annotationsForClass("additive-write", { openWorld: true }),
flags: {"dry-run":"--dry-run","json":"--json"},
},
},
} as const;
if (import.meta.url === `file://${realpathSync(process.argv[1])}` && process.argv[2] === "contract") {
console.log(JSON.stringify(HAND_CONTRACT, null, 2));
process.exit(0);
}
if (import.meta.url === `file://${realpathSync(process.argv[1])}`) {
const [verb, ...rest] = process.argv.slice(2);
const flag = (name: string) => { const i = rest.indexOf(name); return i >= 0 ? rest[i + 1] : undefined; };
if (verb === "reconcile") {
const report = reconcile({ dryRun: rest.includes("--dry-run") });
process.stdout.write(rest.includes("--json") ? JSON.stringify(report, null, 2) : report.markdown + "\n");
} else if (verb === "handoff") {
const out = flag("--out");
if (!out) { process.stderr.write("handoff needs --out <path>\n"); process.exit(2); }
handoff({ out }).then((r) => process.stdout.write(`wrote ${r.out} (${r.skeleton.split("\n").length} lines)\n`));
} else {
process.stdout.write(HELP + "\n");
process.exit(verb === undefined || verb === "--help" ? 0 : 2);
}
}
{
"skill": "snappy-session-close",
"declared": "2026-09-03",
"gauges": [
{ "name": "unprobed_claims", "unit": "count", "target": 0, "how": "reconcile --json | claims where verdict=unverified and no (unverified <date>) mark in the doc" },
{ "name": "wrong_claims_after_reconcile", "unit": "count", "target": 0, "how": "reconcile --json | claims where verdict=wrong, re-run after the commits" },
{ "name": "root_doc_length_delta", "unit": "lines", "target": "<= 0", "how": "wc -l CLAUDE.md before vs after; deletion is the dominant optimization" },
{ "name": "handoff_sections_present", "unit": "count", "target": 11, "how": "grep -c '^## ' docs/prompts/FRESH-SESSION-BRIEF-<date>.md" }
],
"smoke": [
{ "name": "help", "cmd": "npx tsx ~/.claude/skills/snappy-session-close/api.ts --help", "expect_exit": 0 },
{ "name": "reconcile-dry", "cmd": "npx tsx ~/.claude/skills/snappy-session-close/api.ts reconcile --dry-run", "expect_exit": 0 },
{ "name": "handoff-skeleton", "cmd": "npx tsx ~/.claude/skills/snappy-session-close/api.ts handoff --out /tmp/snappy-session-close-smoke.md", "expect_exit": 0 }
]
}
{
"skill": "snappy-session-close",
"declared": "2026-09-03",
"gauges": [
{ "name": "unprobed_claims", "unit": "count", "target": 0, "how": "reconcile --json | claims where verdict=unverified and no (unverified <date>) mark in the doc" },
{ "name": "wrong_claims_after_reconcile", "unit": "count", "target": 0, "how": "reconcile --json | claims where verdict=wrong, re-run after the commits" },
{ "name": "root_doc_length_delta", "unit": "lines", "target": "<= 0", "how": "wc -l CLAUDE.md before vs after; deletion is the dominant optimization" },
{ "name": "handoff_sections_present", "unit": "count", "target": 11, "how": "grep -c '^## ' docs/prompts/FRESH-SESSION-BRIEF-<date>.md" }
],
"smoke": [
{ "name": "help", "cmd": "npx tsx ~/.claude/skills/snappy-session-close/api.ts --help", "expect_exit": 0 },
{ "name": "reconcile-dry", "cmd": "npx tsx ~/.claude/skills/snappy-session-close/api.ts reconcile --dry-run", "expect_exit": 0 },
{ "name": "handoff-skeleton", "cmd": "npx tsx ~/.claude/skills/snappy-session-close/api.ts handoff --out /tmp/snappy-session-close-smoke.md", "expect_exit": 0 }
]
}
/**
* COVERAGE FOR SNAPPY-SESSION-CLOSE'S DECLARED REFUSAL CODES
* (snappy-tool-design rule 33: "refusal codes form one closed table and each
* row has coverage").
*
* Two things are graded here, and the second is the one that matters. The
* first is that the hand's table is a PROJECTION of the collection's one
* closed table in snappy-settings/refusal-codes.ts — same object, not a copy
* that can drift. The second is that every declared code is GROUNDED: the
* evidence that justified declaring it is re-checked here, because a refusal
* code with no path that emits it is a branch the reader waits for and never
* sees, and a table of those passes a lint while teaching a lie.
*
* The code list is spelled out rather than read from the contract: a test that
* iterates the thing it grades passes for an empty table.
*/
import { strict as assert } from "node:assert";
import { test } from "node:test";
import { readFileSync } from "node:fs";
import { join, dirname } from "node:path";
import { fileURLToPath } from "node:url";
import { HAND_CONTRACT } from "./api.ts";
import { REFUSAL_CODES } from "../snappy-settings/refusal-codes.ts";
const SOURCE = readFileSync(join(dirname(fileURLToPath(import.meta.url)), "api.ts"), "utf8");
/** Every refusal code snappy-session-close declares. */
const DECLARED = [
"unknown_verb",
] as const;
test("snappy-session-close declares exactly these refusal codes", () => {
assert.deepEqual(Object.keys(HAND_CONTRACT.refusals).sort(), [...DECLARED].sort());
});
test("every declared code is the SAME row as the one closed table's, never a copy", () => {
const table = HAND_CONTRACT.refusals as Record<string, unknown>;
for (const code of DECLARED) {
assert.equal(table[code], REFUSAL_CODES[code], `${code} is not the shared row`);
}
});
test("unknown_verb is grounded: the contract closes the verb set, so a word outside it is refusable", () => {
assert.ok(Object.keys(HAND_CONTRACT.verbs).length >= 0);
assert.ok(!Object.keys(HAND_CONTRACT.verbs).includes("no-such-verb"));
});
/**
* COVERAGE FOR SNAPPY-SESSION-CLOSE'S DECLARED REFUSAL CODES
* (snappy-tool-design rule 33: "refusal codes form one closed table and each
* row has coverage").
*
* Two things are graded here, and the second is the one that matters. The
* first is that the hand's table is a PROJECTION of the collection's one
* closed table in snappy-settings/refusal-codes.ts — same object, not a copy
* that can drift. The second is that every declared code is GROUNDED: the
* evidence that justified declaring it is re-checked here, because a refusal
* code with no path that emits it is a branch the reader waits for and never
* sees, and a table of those passes a lint while teaching a lie.
*
* The code list is spelled out rather than read from the contract: a test that
* iterates the thing it grades passes for an empty table.
*/
import { strict as assert } from "node:assert";
import { test } from "node:test";
import { readFileSync } from "node:fs";
import { join, dirname } from "node:path";
import { fileURLToPath } from "node:url";
import { HAND_CONTRACT } from "./api.ts";
import { REFUSAL_CODES } from "../snappy-settings/refusal-codes.ts";
const SOURCE = readFileSync(join(dirname(fileURLToPath(import.meta.url)), "api.ts"), "utf8");
/** Every refusal code snappy-session-close declares. */
const DECLARED = [
"unknown_verb",
] as const;
test("snappy-session-close declares exactly these refusal codes", () => {
assert.deepEqual(Object.keys(HAND_CONTRACT.refusals).sort(), [...DECLARED].sort());
});
test("every declared code is the SAME row as the one closed table's, never a copy", () => {
const table = HAND_CONTRACT.refusals as Record<string, unknown>;
for (const code of DECLARED) {
assert.equal(table[code], REFUSAL_CODES[code], `${code} is not the shared row`);
}
});
test("unknown_verb is grounded: the contract closes the verb set, so a word outside it is refusable", () => {
assert.ok(Object.keys(HAND_CONTRACT.verbs).length >= 0);
assert.ok(!Object.keys(HAND_CONTRACT.verbs).includes("no-such-verb"));
});