snappy-specwatch skill
check skill?write-reversibledrift skill?readfix skillwrite-reversiblescheduleread$ npx snappy-skills install snappy-specwatch
$ npx snappy-skills install --all
$ npx snappy-skills update
Half of every hand's contract belongs to somebody else. This hand reads the
vendor documents the hands pin, says what moved, and starts a fix run for a
hand that has fallen behind.
drift is a read and touches no network. check fetches and rewrites the
corpus. Two verbs, never one with a mode flag.
A hand declares HAND_CONTRACT.spec (snappy-settings/spec-read.ts owns the
type and the reader): openapi (OpenAPI 3.x or Swagger 2.0 JSON), discovery
(Google), docs (prose only), mini (no vendor API — the mini fills it), or
none (unmapped on purpose). The last three carry a one-sentence reason,
because "nobody publishes one" and "nobody has looked" must not look alike.
No credential ever reaches a vendor spec. The one credential this hand holds is
the LOCAL runner's bearer, read from a 600-mode file, used only against
127.0.0.1 to start a fix run.
<!-- SKILL-INDEX-START -->
[snappy-specwatch Index]|root: ~/.claude/skills/snappy-specwatch|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 -->
<!-- SNAPPY-CONTRACT-VERBS-START -->
Generated from api.ts HAND_CONTRACT. Do not hand-edit this block.
| Verb | Contract arguments | Effect | First call |
|---|---|---|---|
check |
skill? |
write-reversible |
npx tsx ~/.claude/skills/snappy-specwatch/api.ts check |
drift |
skill? |
read |
npx tsx ~/.claude/skills/snappy-specwatch/api.ts drift |
fix |
skill |
write-reversible |
npx tsx ~/.claude/skills/snappy-specwatch/api.ts fix <skill> |
schedule |
— | read |
npx tsx ~/.claude/skills/snappy-specwatch/api.ts schedule |
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-specwatch — loader
Half of every hand's contract belongs to somebody else. This hand reads the
vendor documents the hands pin, says what moved, and starts a fix run for a
hand that has fallen behind.
`drift` is a read and touches no network. `check` fetches and rewrites the
corpus. Two verbs, never one with a mode flag.
A hand declares `HAND_CONTRACT.spec` (`snappy-settings/spec-read.ts` owns the
type and the reader): `openapi` (OpenAPI 3.x or Swagger 2.0 JSON), `discovery`
(Google), `docs` (prose only), `mini` (no vendor API — the mini fills it), or
`none` (unmapped on purpose). The last three carry a one-sentence reason,
because "nobody publishes one" and "nobody has looked" must not look alike.
No credential ever reaches a vendor spec. The one credential this hand holds is
the LOCAL runner's bearer, read from a 600-mode file, used only against
127.0.0.1 to start a fix run.
<!-- SKILL-INDEX-START -->
[snappy-specwatch Index]|root: ~/.claude/skills/snappy-specwatch|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 -->
<!-- SNAPPY-CONTRACT-VERBS-START -->
## Contract verbs
Generated from `api.ts` `HAND_CONTRACT`. Do not hand-edit this block.
| Verb | Contract arguments | Effect | First call |
|---|---|---|---|
| `check` | `skill?` | `write-reversible` | `npx tsx ~/.claude/skills/snappy-specwatch/api.ts check` |
| `drift` | `skill?` | `read` | `npx tsx ~/.claude/skills/snappy-specwatch/api.ts drift` |
| `fix` | `skill` | `write-reversible` | `npx tsx ~/.claude/skills/snappy-specwatch/api.ts fix <skill>` |
| `schedule` | — | `read` | `npx tsx ~/.claude/skills/snappy-specwatch/api.ts schedule` |
## 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 -->
Half of every hand's contract belongs to somebody else, and that half changes
without a commit here. This hand is the noticing.
bashnpx tsx ~/.claude/skills/snappy-specwatch/api.ts drift # the corpus as it stands, no network
npx tsx ~/.claude/skills/snappy-specwatch/api.ts check # re-read every pinned spec
npx tsx ~/.claude/skills/snappy-specwatch/api.ts check --fix # …and start a fix run for each drift
npx tsx ~/.claude/skills/snappy-specwatch/api.ts fix snappy-image # one hand, now
npx tsx ~/.claude/skills/snappy-specwatch/api.ts schedule # print the launchd agent; installs nothing
drift is a read and touches no network. check is the write: it fetches and
rewrites snappy-tool-design/tool-design-specs.json. They are two verbs
because one verb with a mode flag would be a read that quietly reaches the
world.
HAND_CONTRACT.spec, whose shape and reader live together in
snappy-settings/spec-read.ts:
| kind | means | how rule 61 checks it |
|---|---|---|
openapi |
an OpenAPI 3.x or Swagger 2.0 JSON document | operations still exist; enums the hand hard-codes have not been outgrown |
discovery |
a Google Discovery document | the same, walked through nested resources |
docs |
the vendor publishes prose only | a url, a reason, and how long since a person looked |
mini |
there is no vendor API at all — iMessage, WhatsApp outside the business API, Libretto. The mini fills the gap | the hand's own recorded read replaying (watch-me-once), never a vendor document |
none |
unmapped on purpose | the reason, in one sentence |
A hand with no spec at all DEFERs. That is deliberate: 97 of 98 hands name
none today, and a rule that failed all of them on day one would be 97 rows of
debt with a receipt.
snappy-image — OpenAI's spec (openapi 3.1.0, info.version 2.3.0, 289operations) had grown gpt-image-2.5-sunburst, gpt-image-2.5-flare and
their 2026-09-08 snapshots, plus quality values xhigh and max. This
hand still teaches gpt-image-1, gpt-image-2 and dall-e-3.
snappy-gmail — Google's Discovery document (revision 20260907, 79operations); all seven declared operations present; format has raw, which
this hand never asks for.
snappy-slack — Slack publishes Swagger 2.0, not OpenAPI 3.x(info.version 1.7.0, 174 operations). All nine declared operations present.
It declares no enums, so this hand can only be checked for removals — an
honest, weaker check, said out loud rather than dressed up.
A digest is not a version. Three fetches of Gmail's Discovery document
seconds apart returned three different sha256 digests while revision held at
20260907: the bytes are assembled per request. Comparing digests would have
printed "the document moved" on every run, and a signal that fires every time
is a signal nobody reads. So the moved-check compares the vendor's own version
and keeps the digest only as a tie-break.
A sentence that hides its headline gets ignored. Sorted plainly, the first
red run opened with dall-e-2 and truncated gpt-image-2.5-sunburst into "and
4 more" — the two names the owner typed the word "image" for. Missing values
are now ranked by how much of their name they share with something the hand
already offers, so the next generation of a capability it already has leads.
| skill | why |
|---|---|
snappy-tool-design |
owns rule 61 and the one contract loader; readSpecs is the one fetch |
snappy-settings |
spec-read.ts is the one reader and the home of the HandSpec type |
snappy-skill |
HandContract.spec is declared there; the scaffold template carries it empty |
--- name: snappy-specwatch instruction-only: false reports_to: tool head: false description: "Watches the vendor documents the hands sit on -- OpenAPI, Swagger and Google Discovery -- reads each pinned spec once a day with no credential, writes what it found into the collection's spec corpus, and for a hand that has fallen behind a capability the vendor now publishes starts a fix run through the same road Make it better takes. Use when a vendor ships something a skill does not offer yet, when a verb's vendor operation may have been removed, or to see which hands pin a spec at all. NOT the grader itself (see snappy-tool-design, which owns rule 61). NOT the scaffolder (see snappy-skill). Triggers on: snappy-specwatch, spec drift, vendor spec, openapi drift, the vendor added, check the specs, gpt image, api changed." --- # snappy-specwatch Half of every hand's contract belongs to somebody else, and that half changes without a commit here. This hand is the noticing. ## Use it ```bash npx tsx ~/.claude/skills/snappy-specwatch/api.ts drift # the corpus as it stands, no network npx tsx ~/.claude/skills/snappy-specwatch/api.ts check # re-read every pinned spec npx tsx ~/.claude/skills/snappy-specwatch/api.ts check --fix # …and start a fix run for each drift npx tsx ~/.claude/skills/snappy-specwatch/api.ts fix snappy-image # one hand, now npx tsx ~/.claude/skills/snappy-specwatch/api.ts schedule # print the launchd agent; installs nothing ``` `drift` is a read and touches no network. `check` is the write: it fetches and rewrites `snappy-tool-design/tool-design-specs.json`. They are two verbs because one verb with a mode flag would be a read that quietly reaches the world. ## What a hand declares `HAND_CONTRACT.spec`, whose shape and reader live together in `snappy-settings/spec-read.ts`: | kind | means | how rule 61 checks it | |---|---|---| | `openapi` | an OpenAPI 3.x **or Swagger 2.0** JSON document | operations still exist; enums the hand hard-codes have not been outgrown | | `discovery` | a Google Discovery document | the same, walked through nested `resources` | | `docs` | the vendor publishes prose only | a url, a reason, and how long since a person looked | | `mini` | there is no vendor API at all — iMessage, WhatsApp outside the business API, Libretto. The mini fills the gap | the hand's own recorded read replaying (watch-me-once), never a vendor document | | `none` | unmapped on purpose | the reason, in one sentence | A hand with no `spec` at all DEFERs. That is deliberate: 97 of 98 hands name none today, and a rule that failed all of them on day one would be 97 rows of debt with a receipt. ## Measured 2026-09-09, on the first run - `snappy-image` — OpenAI's spec (openapi 3.1.0, info.version 2.3.0, 289 operations) had grown `gpt-image-2.5-sunburst`, `gpt-image-2.5-flare` and their `2026-09-08` snapshots, plus `quality` values `xhigh` and `max`. This hand still teaches `gpt-image-1`, `gpt-image-2` and `dall-e-3`. - `snappy-gmail` — Google's Discovery document (revision 20260907, 79 operations); all seven declared operations present; `format` has `raw`, which this hand never asks for. - `snappy-slack` — Slack publishes **Swagger 2.0**, not OpenAPI 3.x (info.version 1.7.0, 174 operations). All nine declared operations present. It declares no enums, so this hand can only be checked for removals — an honest, weaker check, said out loud rather than dressed up. ## The two traps this hand was built around **A digest is not a version.** Three fetches of Gmail's Discovery document seconds apart returned three different sha256 digests while `revision` held at `20260907`: the bytes are assembled per request. Comparing digests would have printed "the document moved" on every run, and a signal that fires every time is a signal nobody reads. So the moved-check compares the vendor's own version and keeps the digest only as a tie-break. **A sentence that hides its headline gets ignored.** Sorted plainly, the first red run opened with `dall-e-2` and truncated `gpt-image-2.5-sunburst` into "and 4 more" — the two names the owner typed the word "image" for. Missing values are now ranked by how much of their name they share with something the hand already offers, so the next generation of a capability it already has leads. ## Related | skill | why | |---|---| | `snappy-tool-design` | owns rule 61 and the one contract loader; `readSpecs` is the one fetch | | `snappy-settings` | `spec-read.ts` is the one reader and the home of the `HandSpec` type | | `snappy-skill` | `HandContract.spec` is declared there; the scaffold template carries it empty |
#!/usr/bin/env npx tsx
/**
* snappy-specwatch — THE WORLD CHANGES, AND SOMETHING HERE NOTICES.
*
* ⟨owner, 2026-09-09 14:0x⟩ "I type 'image' because GPT Image 2.5 came out; the
* system spins up to the capability instead of putting the question back to
* me." And at 16:5x: "the OpenAPI spec could be lined up against api.ts, and if
* there's a difference it's programmatically noticed and then updated and then
* downstream of that whatever else."
*
* This hand is the noticing. Once a day it reads every hand's pinned vendor
* document, writes what it found into the collection's spec corpus, and — for
* a hand that has fallen behind a capability the vendor now publishes — STARTS
* A FIX RUN. It does not fix anything itself.
*
* ── FOUR ROADS THIS HAND DOES NOT BUILD ⟨CLAUDE.md §4⟩ ───────────────────────
*
* 1. IT DOES NOT READ A SPEC ITS OWN WAY. `snappy-settings/spec-read.ts` is the
* collection's one spec reader and `snappy-tool-design`'s `readSpecs` is the
* one call that fetches and writes the corpus. This hand calls that. A
* second fetcher would be two readings of one document that drift in
* silence, which is the whole failure this hand exists to catch — committed
* one level up.
*
* 2. IT DOES NOT GRADE. `snappy-tool-design lint` owns rule 61 and every other
* rule. This hand reads the lint's own answer for the hand it is watching.
*
* 3. IT RUNS NO AGENT. `POST /fix-request { start: true }` on the local runner
* is the road "Make it better" already takes: a real coding agent in a fresh
* worktree of the kernel, briefed with the failing checks in the same words
* the card shows, bounded at two runs per body by the runner. Starting a
* second kind of agent from here would put two claimants on one Mac's
* coding capacity with no shared ceiling.
*
* 4. IT INSTALLS NO SCHEDULE OF ITS OWN. `schedule` PRINTS the launchd agent
* and names the one command that loads it; nothing here writes into
* `~/Library/LaunchAgents` or the crontab. The runner's `GET /schedules` is
* the one place a person sees what runs on its own and switches it off, and
* a job that installed itself behind that page would be the thing the owner
* named on 2026-09-09 12:0x: "enough of things running in the dark."
*
* ── WHAT IT WILL NOT DO ─────────────────────────────────────────────────────
*
* NO CREDENTIAL EVER REACHES A VENDOR SPEC. A public document answers 200 to
* nobody in particular; a document that wants a login is declared `kind:
* "docs"` on its hand, with the reason, and skipped. This hand holds one
* credential and it is the LOCAL runner's bearer, read from a 600-mode file,
* used only against the skills runner on this Mac (its address is the registry's, snappy-settings/hand-resources.ts).
*/
import { existsSync, readFileSync, realpathSync } from "node:fs";
import { homedir } from "node:os";
import { dirname, join } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import { env } from "../snappy-settings/load.ts";
import { refusalTable, RefusedError, isRefusedError, printRefusal } from "../snappy-settings/refusal-codes.ts";
import { driftOf, driftSentence, readSpecFile, specFor, vendorOf } from "../snappy-tool-design/spec-corpus.ts";
import { lintSkill, loadContract, readSpecs } from "../snappy-tool-design/api.ts";
import { serviceUrl } from "../snappy-settings/hand-resources.ts";
/** THIS CLONE'S OWN COLLECTION, resolved off this file rather than off
* `~/.claude/skills`. A lane runs in a worktree whose hands differ from the
* installed ones, and a watch that graded the installed copy would report
* drift the branch had already fixed. `realpathSync` because the installed
* tree symlinks into the kernel. */
const HERE = dirname(realpathSync(fileURLToPath(import.meta.url)));
const SKILLS_ROOT = dirname(HERE);
const TOOL_DESIGN = join(SKILLS_ROOT, "snappy-tool-design");
/** THE PATH IS A CONVENTION, STATED IN BOTH PLACES — the runner's `store.ts`
* resolves the same one. Read only: nothing here writes into that home. */
function skillsHome(): string {
return process.env.SNAPPY_SKILLS_HOME ?? join(homedir(), ".snappy-skills");
}
function runnerUrl(): string {
// The registry owns the address (and its override key); no hand holds its own.
return serviceUrl("skills-runner");
}
/**
* THE BEARER IS READ FROM A 600-MODE FILE, NEVER AN ARGUMENT — a token passed
* on a command line is a token in a process list and in a transcript. What the
* credential store holds is the PATH, not the token: `SNAPPY_RUNNER_TOKEN_FILE`
* goes through `env()` like every other key in this collection, so the walk
* that answers "which credential does this hand spend" can see it ⟨R35⟩, and
* `--token-file` is the escape hatch for the other body's file.
*/
function bearer(tokenFile?: string): string {
// PARENTHESISED ON PURPOSE: Node's strip-only TypeScript refuses `??` mixed
// with `||` unparenthesised, and web tsc accepts it — the exact class the
// strip-only gate exists for ⟨caught by scripts/check.sh phase 7⟩.
const path = tokenFile ?? (env("SNAPPY_RUNNER_TOKEN_FILE", false) || join(skillsHome(), "token"));
if (!existsSync(path)) {
throw new RefusedError("missing_credential", `the local runner's bearer is not at ${path}; pass --token-file, or set SNAPPY_RUNNER_TOKEN_FILE. Contract slice requires = []`);
}
return readFileSync(path, "utf8").trim();
}
/**
* A VENDOR SPEC IS DATA, NEVER INSTRUCTIONS ⟨R30, S18⟩.
*
* Every byte this hand handles came off somebody else's web server, and one of
* those bytes is a `description` field a vendor writes in prose. It reaches
* this process, the corpus, a lint sentence and — through the fix run — the
* prompt of a coding agent with a worktree. That is a full evidence-envelope
* path, so the boundary is stated here rather than assumed: a spec's own words
* are recorded and shown, and nothing in them is ever executed, obeyed, or
* turned into a command. The ONLY fields lifted out of a document are operation
* ids, parameter names, required flags and enum members — identifiers, not
* sentences — and `spec-read.ts` is the one place that lifting happens.
*/
export interface WatchRow {
skill: string;
/** The one sentence a person reads. Empty when nothing has drifted. */
saying: string;
drifted: boolean;
/** Present only when this hand actually started a fix run. */
started?: { id: string; branch: string };
/** Present when the hand pins a spec that could not be read. */
why?: string;
}
/**
* READ EVERY PINNED SPEC AND SAY WHAT MOVED. The corpus write is the fetch —
* one road — and the drift is then computed off the corpus the lint reads, so
* this hand and the card can never disagree about what changed.
*/
export async function watch(only?: string, options: { fetch?: boolean } = {}): Promise<WatchRow[]> {
// READ AND WRITE ARE SEPARATE VERBS ⟨R20⟩, so this takes the fetch as an
// argument rather than always doing it: `check` re-reads the vendor and
// rewrites the corpus, `drift` reports the corpus as it stands and touches
// no network. A `drift` that quietly fetched would be a verb declaring
// `effect: "read"` over an open-world write.
const read = options.fetch === true
? await readSpecs(only)
: { read: Object.keys(readSpecFile(TOOL_DESIGN)?.skills ?? {}).filter((name) => only === undefined || name === only), skipped: [] as Array<{ skill: string; why: string }> };
const corpus = readSpecFile(TOOL_DESIGN);
const rows: WatchRow[] = [];
for (const row of read.skipped) if (!/^kind "/u.test(row.why)) rows.push({ skill: row.skill, saying: "", drifted: false, why: row.why });
for (const skill of read.read) {
const contract = await loadContract(skill);
const projection = specFor(corpus, skill);
if (!contract.spec || !projection) continue;
const dir = join(SKILLS_ROOT, skill);
// The hand's own declared surface, exactly as rule 61 reads it.
const surface = ["api.ts", "SKILL.md", "AGENTS.md"].map((file) => readIf(join(dir, file))).join("\n") + JSON.stringify(contract);
const drift = driftOf(contract.spec, projection, surface);
const drifted = drift.gone.length > 0 || drift.enums.length > 0;
rows.push({ skill, drifted, saying: drifted ? driftSentence(vendorOf(contract.spec.url ?? ""), drift) : "" });
}
return rows;
}
function readIf(path: string): string {
try { return readFileSync(path, "utf8"); } catch { return ""; }
}
/**
* START THE FIX, through the road "Make it better" already takes.
*
* The ask is the DRIFT SENTENCE and nothing else: the runner puts it verbatim
* at the top of the agent's prompt, above the failing checks it reads out of
* the worktree itself. So the agent is told "OpenAI added gpt-image-2.5-flare
* — this hand still offers gpt-image-1", which is a job, rather than "R61
* FAIL", which is an id.
*/
export async function startFix(skill: string, ask: string, tokenFile?: string): Promise<{ id: string; branch: string }> {
const answer = await fetch(`${runnerUrl()}/fix-request`, {
method: "POST",
headers: { "content-type": "application/json", authorization: `Bearer ${bearer(tokenFile)}` },
body: JSON.stringify({ skill, words: ask, start: true }),
});
const body = await answer.json() as { ok?: boolean; run?: { id: string; branch: string }; refusal?: { code: string; message: string } };
if (!answer.ok || body.ok === false) {
throw new RefusedError("upstream_error", `${runnerUrl()}/fix-request refused: ${body.refusal?.code ?? answer.status} ${body.refusal?.message ?? ""}`.trim());
}
if (!body.run) throw new RefusedError("upstream_error", `${runnerUrl()}/fix-request filed the note but started nothing; the door answers { ok, run } when it starts`);
return { id: body.run.id, branch: body.run.branch };
}
/** THE LAUNCHD AGENT, PRINTED AND NEVER INSTALLED. The label prefix is what
* puts the row under the runner's "Runs on its own" with its off switch. */
export function schedulePlist(hour = 7): string {
const api = join(SKILLS_ROOT, "snappy-specwatch", "api.ts");
return `<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key><string>com.snappy.specwatch</string>
<key>ProgramArguments</key>
<array><string>/usr/bin/env</string><string>npx</string><string>tsx</string><string>${api}</string><string>check</string><string>--fix</string></array>
<key>StartCalendarInterval</key><dict><key>Hour</key><integer>${hour}</integer><key>Minute</key><integer>0</integer></dict>
<key>RunAtLoad</key><false/>
</dict>
</plist>
`;
}
export const HAND_CONTRACT = {
skill: "snappy-specwatch",
description: "Watches the vendor documents the hands sit on -- OpenAPI, Swagger and Google Discovery -- reads each pinned spec once a day with no credential, writes what it found into the collection's spec corpus, and for a hand that has fallen behind a capability the vendor now publishes starts a fix run through the same road Make it better takes. Use when a vendor ships something a skill does not offer yet, when a verb's vendor operation may have been removed, or to see which hands pin a spec at all. NOT the grader itself (see snappy-tool-design, which owns rule 61). NOT the scaffolder (see snappy-skill). Triggers on: snappy-specwatch, spec drift, vendor spec, openapi drift, the vendor added, check the specs, gpt image, api changed.",
kind: "tool",
managed: false,
/**
* NOTHING IS REQUIRED, and that is the honest reading ⟨R36⟩. `drift`, `check`
* and `schedule` need no credential at all — a public spec is fetched by
* nobody in particular. Only `fix` wants the local runner's bearer, and it
* resolves through `env("SNAPPY_RUNNER_TOKEN_FILE", false)` with the runner's
* own default path behind it, so an optional read never becomes a demand a
* hand makes of every caller.
*/
requires: [] as string[],
refusals: refusalTable("unknown_verb", "missing_argument", "missing_credential", "upstream_error"),
/** THIS HAND HAS NO VENDOR OF ITS OWN — it is the one that reads everyone
* else's. Declared rather than omitted so R61 can tell it apart from a hand
* nobody has looked at yet. */
spec: {
kind: "none",
reason: "This hand calls no vendor API; its only network calls are the public spec documents other hands pin, and the local runner (whose address the registry holds).",
},
verbs: {
check: {
args: ["skill?"], effect: "write-reversible", class: "additive-write", execution: "call", idempotent: true, openWorld: true,
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
flags: { fix: "--fix", json: "--json", "token-file": "--token-file" },
inputSchema: { properties: { skill: { type: "string", description: "One snappy-* hand to re-read; omit for every hand that pins a spec" } } },
},
drift: {
args: ["skill?"], effect: "read", class: "read", execution: "call", openWorld: false,
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: false, openWorldHint: false },
flags: { json: "--json" },
inputSchema: { properties: { skill: { type: "string", description: "One snappy-* hand whose last reading is reported; omit for every hand in the corpus" } } },
},
/** STARTING A CODING AGENT IS NOT A SEND, A SPEND OR A DELETE. It opens a
* worktree and pushes a branch, which a person reverts by deleting the
* branch — approval is for the irreversible ⟨CLAUDE.md, 2026-08-27⟩. */
fix: {
args: ["skill"], effect: "write-reversible", class: "additive-write", execution: "call", openWorld: true,
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
flags: { "token-file": "--token-file" },
inputSchema: { properties: { skill: { type: "string", description: "The hand whose measured drift becomes the ask of a fix run" } } },
},
schedule: {
args: [] as const, effect: "read", class: "read", execution: "call", openWorld: false,
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: false, openWorldHint: false },
flags: { hour: "--hour" },
inputSchema: { properties: { hour: { type: "integer", description: "Local hour the daily read would run", default: 7, maximum: 23 } } },
},
},
} as const;
async function main(): Promise<void> {
const [, , command, ...args] = process.argv;
const json = args.includes("--json");
const flag = (name: string) => { const at = args.indexOf(name); return at >= 0 ? args[at + 1] : undefined; };
const skill = args.find((arg, index) => !arg.startsWith("--") && !["--token-file", "--hour"].includes(args[index - 1] ?? ""));
try {
if (command === "contract") { console.log(JSON.stringify(HAND_CONTRACT, null, 2)); return; }
if (!command || command === "help") {
console.log("Usage:");
console.log(" npx tsx api.ts check [<skill>] [--fix] [--json] [--token-file FILE]");
console.log(" npx tsx api.ts drift [<skill>] [--json]");
console.log(" npx tsx api.ts fix <skill> [--token-file FILE]");
console.log(" npx tsx api.ts schedule [--hour 7]");
return;
}
if (command === "check" || command === "drift") {
const rows = await watch(skill, { fetch: command === "check" });
if (command === "check" && args.includes("--fix")) {
for (const row of rows.filter((candidate) => candidate.drifted)) {
try { row.started = await startFix(row.skill, row.saying, flag("--token-file")); }
catch (error) { row.why = error instanceof Error ? error.message : String(error); }
}
}
if (json) { console.log(JSON.stringify({ rows }, null, 2)); return; }
for (const row of rows) {
if (row.why) console.log(`${row.skill}: ${row.why}`);
else if (!row.drifted) console.log(`${row.skill}: nothing moved`);
else console.log(`${row.skill}: ${row.saying}${row.started ? ` — started ${row.started.branch}` : ""}`);
}
if (!rows.length) console.log("no hand pins a machine-readable spec yet; add HAND_CONTRACT.spec to one");
return;
}
if (command === "fix") {
if (!skill) throw new RefusedError("missing_argument", "fix needs <skill>; contract slice fix.args = [skill]");
const [row] = await watch(skill, { fetch: true });
if (!row?.drifted) { console.log(`${skill}: nothing has moved, so there is nothing to brief a fix run with`); return; }
const started = await startFix(skill, row.saying, flag("--token-file"));
console.log(`started ${started.branch} (${started.id}): ${row.saying}`);
return;
}
if (command === "schedule") { console.log(schedulePlist(Number(flag("--hour") ?? 7))); return; }
// ⟨R61 is graded by the lint, not here⟩ — `lintSkill` is imported so a
// caller can ask for the one hand's whole grade in the same process.
if (command === "lint") { console.log(JSON.stringify(await lintSkill(skill ?? "snappy-specwatch"), null, 2)); return; }
throw new RefusedError("unknown_verb", `unknown verb ${JSON.stringify(command)}; contract slice verbs = [check, drift, fix, schedule]`);
} catch (error) {
if (isRefusedError(error)) { printRefusal(error.refusal); console.error(error.message); process.exitCode = 1; return; }
console.error(error instanceof Error ? error.message : String(error));
process.exitCode = 1;
}
}
if (import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href) void main();
#!/usr/bin/env npx tsx
/**
* snappy-specwatch — THE WORLD CHANGES, AND SOMETHING HERE NOTICES.
*
* ⟨owner, 2026-09-09 14:0x⟩ "I type 'image' because GPT Image 2.5 came out; the
* system spins up to the capability instead of putting the question back to
* me." And at 16:5x: "the OpenAPI spec could be lined up against api.ts, and if
* there's a difference it's programmatically noticed and then updated and then
* downstream of that whatever else."
*
* This hand is the noticing. Once a day it reads every hand's pinned vendor
* document, writes what it found into the collection's spec corpus, and — for
* a hand that has fallen behind a capability the vendor now publishes — STARTS
* A FIX RUN. It does not fix anything itself.
*
* ── FOUR ROADS THIS HAND DOES NOT BUILD ⟨CLAUDE.md §4⟩ ───────────────────────
*
* 1. IT DOES NOT READ A SPEC ITS OWN WAY. `snappy-settings/spec-read.ts` is the
* collection's one spec reader and `snappy-tool-design`'s `readSpecs` is the
* one call that fetches and writes the corpus. This hand calls that. A
* second fetcher would be two readings of one document that drift in
* silence, which is the whole failure this hand exists to catch — committed
* one level up.
*
* 2. IT DOES NOT GRADE. `snappy-tool-design lint` owns rule 61 and every other
* rule. This hand reads the lint's own answer for the hand it is watching.
*
* 3. IT RUNS NO AGENT. `POST /fix-request { start: true }` on the local runner
* is the road "Make it better" already takes: a real coding agent in a fresh
* worktree of the kernel, briefed with the failing checks in the same words
* the card shows, bounded at two runs per body by the runner. Starting a
* second kind of agent from here would put two claimants on one Mac's
* coding capacity with no shared ceiling.
*
* 4. IT INSTALLS NO SCHEDULE OF ITS OWN. `schedule` PRINTS the launchd agent
* and names the one command that loads it; nothing here writes into
* `~/Library/LaunchAgents` or the crontab. The runner's `GET /schedules` is
* the one place a person sees what runs on its own and switches it off, and
* a job that installed itself behind that page would be the thing the owner
* named on 2026-09-09 12:0x: "enough of things running in the dark."
*
* ── WHAT IT WILL NOT DO ─────────────────────────────────────────────────────
*
* NO CREDENTIAL EVER REACHES A VENDOR SPEC. A public document answers 200 to
* nobody in particular; a document that wants a login is declared `kind:
* "docs"` on its hand, with the reason, and skipped. This hand holds one
* credential and it is the LOCAL runner's bearer, read from a 600-mode file,
* used only against the skills runner on this Mac (its address is the registry's, snappy-settings/hand-resources.ts).
*/
import { existsSync, readFileSync, realpathSync } from "node:fs";
import { homedir } from "node:os";
import { dirname, join } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import { env } from "../snappy-settings/load.ts";
import { refusalTable, RefusedError, isRefusedError, printRefusal } from "../snappy-settings/refusal-codes.ts";
import { driftOf, driftSentence, readSpecFile, specFor, vendorOf } from "../snappy-tool-design/spec-corpus.ts";
import { lintSkill, loadContract, readSpecs } from "../snappy-tool-design/api.ts";
import { serviceUrl } from "../snappy-settings/hand-resources.ts";
/** THIS CLONE'S OWN COLLECTION, resolved off this file rather than off
* `~/.claude/skills`. A lane runs in a worktree whose hands differ from the
* installed ones, and a watch that graded the installed copy would report
* drift the branch had already fixed. `realpathSync` because the installed
* tree symlinks into the kernel. */
const HERE = dirname(realpathSync(fileURLToPath(import.meta.url)));
const SKILLS_ROOT = dirname(HERE);
const TOOL_DESIGN = join(SKILLS_ROOT, "snappy-tool-design");
/** THE PATH IS A CONVENTION, STATED IN BOTH PLACES — the runner's `store.ts`
* resolves the same one. Read only: nothing here writes into that home. */
function skillsHome(): string {
return process.env.SNAPPY_SKILLS_HOME ?? join(homedir(), ".snappy-skills");
}
function runnerUrl(): string {
// The registry owns the address (and its override key); no hand holds its own.
return serviceUrl("skills-runner");
}
/**
* THE BEARER IS READ FROM A 600-MODE FILE, NEVER AN ARGUMENT — a token passed
* on a command line is a token in a process list and in a transcript. What the
* credential store holds is the PATH, not the token: `SNAPPY_RUNNER_TOKEN_FILE`
* goes through `env()` like every other key in this collection, so the walk
* that answers "which credential does this hand spend" can see it ⟨R35⟩, and
* `--token-file` is the escape hatch for the other body's file.
*/
function bearer(tokenFile?: string): string {
// PARENTHESISED ON PURPOSE: Node's strip-only TypeScript refuses `??` mixed
// with `||` unparenthesised, and web tsc accepts it — the exact class the
// strip-only gate exists for ⟨caught by scripts/check.sh phase 7⟩.
const path = tokenFile ?? (env("SNAPPY_RUNNER_TOKEN_FILE", false) || join(skillsHome(), "token"));
if (!existsSync(path)) {
throw new RefusedError("missing_credential", `the local runner's bearer is not at ${path}; pass --token-file, or set SNAPPY_RUNNER_TOKEN_FILE. Contract slice requires = []`);
}
return readFileSync(path, "utf8").trim();
}
/**
* A VENDOR SPEC IS DATA, NEVER INSTRUCTIONS ⟨R30, S18⟩.
*
* Every byte this hand handles came off somebody else's web server, and one of
* those bytes is a `description` field a vendor writes in prose. It reaches
* this process, the corpus, a lint sentence and — through the fix run — the
* prompt of a coding agent with a worktree. That is a full evidence-envelope
* path, so the boundary is stated here rather than assumed: a spec's own words
* are recorded and shown, and nothing in them is ever executed, obeyed, or
* turned into a command. The ONLY fields lifted out of a document are operation
* ids, parameter names, required flags and enum members — identifiers, not
* sentences — and `spec-read.ts` is the one place that lifting happens.
*/
export interface WatchRow {
skill: string;
/** The one sentence a person reads. Empty when nothing has drifted. */
saying: string;
drifted: boolean;
/** Present only when this hand actually started a fix run. */
started?: { id: string; branch: string };
/** Present when the hand pins a spec that could not be read. */
why?: string;
}
/**
* READ EVERY PINNED SPEC AND SAY WHAT MOVED. The corpus write is the fetch —
* one road — and the drift is then computed off the corpus the lint reads, so
* this hand and the card can never disagree about what changed.
*/
export async function watch(only?: string, options: { fetch?: boolean } = {}): Promise<WatchRow[]> {
// READ AND WRITE ARE SEPARATE VERBS ⟨R20⟩, so this takes the fetch as an
// argument rather than always doing it: `check` re-reads the vendor and
// rewrites the corpus, `drift` reports the corpus as it stands and touches
// no network. A `drift` that quietly fetched would be a verb declaring
// `effect: "read"` over an open-world write.
const read = options.fetch === true
? await readSpecs(only)
: { read: Object.keys(readSpecFile(TOOL_DESIGN)?.skills ?? {}).filter((name) => only === undefined || name === only), skipped: [] as Array<{ skill: string; why: string }> };
const corpus = readSpecFile(TOOL_DESIGN);
const rows: WatchRow[] = [];
for (const row of read.skipped) if (!/^kind "/u.test(row.why)) rows.push({ skill: row.skill, saying: "", drifted: false, why: row.why });
for (const skill of read.read) {
const contract = await loadContract(skill);
const projection = specFor(corpus, skill);
if (!contract.spec || !projection) continue;
const dir = join(SKILLS_ROOT, skill);
// The hand's own declared surface, exactly as rule 61 reads it.
const surface = ["api.ts", "SKILL.md", "AGENTS.md"].map((file) => readIf(join(dir, file))).join("\n") + JSON.stringify(contract);
const drift = driftOf(contract.spec, projection, surface);
const drifted = drift.gone.length > 0 || drift.enums.length > 0;
rows.push({ skill, drifted, saying: drifted ? driftSentence(vendorOf(contract.spec.url ?? ""), drift) : "" });
}
return rows;
}
function readIf(path: string): string {
try { return readFileSync(path, "utf8"); } catch { return ""; }
}
/**
* START THE FIX, through the road "Make it better" already takes.
*
* The ask is the DRIFT SENTENCE and nothing else: the runner puts it verbatim
* at the top of the agent's prompt, above the failing checks it reads out of
* the worktree itself. So the agent is told "OpenAI added gpt-image-2.5-flare
* — this hand still offers gpt-image-1", which is a job, rather than "R61
* FAIL", which is an id.
*/
export async function startFix(skill: string, ask: string, tokenFile?: string): Promise<{ id: string; branch: string }> {
const answer = await fetch(`${runnerUrl()}/fix-request`, {
method: "POST",
headers: { "content-type": "application/json", authorization: `Bearer ${bearer(tokenFile)}` },
body: JSON.stringify({ skill, words: ask, start: true }),
});
const body = await answer.json() as { ok?: boolean; run?: { id: string; branch: string }; refusal?: { code: string; message: string } };
if (!answer.ok || body.ok === false) {
throw new RefusedError("upstream_error", `${runnerUrl()}/fix-request refused: ${body.refusal?.code ?? answer.status} ${body.refusal?.message ?? ""}`.trim());
}
if (!body.run) throw new RefusedError("upstream_error", `${runnerUrl()}/fix-request filed the note but started nothing; the door answers { ok, run } when it starts`);
return { id: body.run.id, branch: body.run.branch };
}
/** THE LAUNCHD AGENT, PRINTED AND NEVER INSTALLED. The label prefix is what
* puts the row under the runner's "Runs on its own" with its off switch. */
export function schedulePlist(hour = 7): string {
const api = join(SKILLS_ROOT, "snappy-specwatch", "api.ts");
return `<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key><string>com.snappy.specwatch</string>
<key>ProgramArguments</key>
<array><string>/usr/bin/env</string><string>npx</string><string>tsx</string><string>${api}</string><string>check</string><string>--fix</string></array>
<key>StartCalendarInterval</key><dict><key>Hour</key><integer>${hour}</integer><key>Minute</key><integer>0</integer></dict>
<key>RunAtLoad</key><false/>
</dict>
</plist>
`;
}
export const HAND_CONTRACT = {
skill: "snappy-specwatch",
description: "Watches the vendor documents the hands sit on -- OpenAPI, Swagger and Google Discovery -- reads each pinned spec once a day with no credential, writes what it found into the collection's spec corpus, and for a hand that has fallen behind a capability the vendor now publishes starts a fix run through the same road Make it better takes. Use when a vendor ships something a skill does not offer yet, when a verb's vendor operation may have been removed, or to see which hands pin a spec at all. NOT the grader itself (see snappy-tool-design, which owns rule 61). NOT the scaffolder (see snappy-skill). Triggers on: snappy-specwatch, spec drift, vendor spec, openapi drift, the vendor added, check the specs, gpt image, api changed.",
kind: "tool",
managed: false,
/**
* NOTHING IS REQUIRED, and that is the honest reading ⟨R36⟩. `drift`, `check`
* and `schedule` need no credential at all — a public spec is fetched by
* nobody in particular. Only `fix` wants the local runner's bearer, and it
* resolves through `env("SNAPPY_RUNNER_TOKEN_FILE", false)` with the runner's
* own default path behind it, so an optional read never becomes a demand a
* hand makes of every caller.
*/
requires: [] as string[],
refusals: refusalTable("unknown_verb", "missing_argument", "missing_credential", "upstream_error"),
/** THIS HAND HAS NO VENDOR OF ITS OWN — it is the one that reads everyone
* else's. Declared rather than omitted so R61 can tell it apart from a hand
* nobody has looked at yet. */
spec: {
kind: "none",
reason: "This hand calls no vendor API; its only network calls are the public spec documents other hands pin, and the local runner (whose address the registry holds).",
},
verbs: {
check: {
args: ["skill?"], effect: "write-reversible", class: "additive-write", execution: "call", idempotent: true, openWorld: true,
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
flags: { fix: "--fix", json: "--json", "token-file": "--token-file" },
inputSchema: { properties: { skill: { type: "string", description: "One snappy-* hand to re-read; omit for every hand that pins a spec" } } },
},
drift: {
args: ["skill?"], effect: "read", class: "read", execution: "call", openWorld: false,
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: false, openWorldHint: false },
flags: { json: "--json" },
inputSchema: { properties: { skill: { type: "string", description: "One snappy-* hand whose last reading is reported; omit for every hand in the corpus" } } },
},
/** STARTING A CODING AGENT IS NOT A SEND, A SPEND OR A DELETE. It opens a
* worktree and pushes a branch, which a person reverts by deleting the
* branch — approval is for the irreversible ⟨CLAUDE.md, 2026-08-27⟩. */
fix: {
args: ["skill"], effect: "write-reversible", class: "additive-write", execution: "call", openWorld: true,
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
flags: { "token-file": "--token-file" },
inputSchema: { properties: { skill: { type: "string", description: "The hand whose measured drift becomes the ask of a fix run" } } },
},
schedule: {
args: [] as const, effect: "read", class: "read", execution: "call", openWorld: false,
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: false, openWorldHint: false },
flags: { hour: "--hour" },
inputSchema: { properties: { hour: { type: "integer", description: "Local hour the daily read would run", default: 7, maximum: 23 } } },
},
},
} as const;
async function main(): Promise<void> {
const [, , command, ...args] = process.argv;
const json = args.includes("--json");
const flag = (name: string) => { const at = args.indexOf(name); return at >= 0 ? args[at + 1] : undefined; };
const skill = args.find((arg, index) => !arg.startsWith("--") && !["--token-file", "--hour"].includes(args[index - 1] ?? ""));
try {
if (command === "contract") { console.log(JSON.stringify(HAND_CONTRACT, null, 2)); return; }
if (!command || command === "help") {
console.log("Usage:");
console.log(" npx tsx api.ts check [<skill>] [--fix] [--json] [--token-file FILE]");
console.log(" npx tsx api.ts drift [<skill>] [--json]");
console.log(" npx tsx api.ts fix <skill> [--token-file FILE]");
console.log(" npx tsx api.ts schedule [--hour 7]");
return;
}
if (command === "check" || command === "drift") {
const rows = await watch(skill, { fetch: command === "check" });
if (command === "check" && args.includes("--fix")) {
for (const row of rows.filter((candidate) => candidate.drifted)) {
try { row.started = await startFix(row.skill, row.saying, flag("--token-file")); }
catch (error) { row.why = error instanceof Error ? error.message : String(error); }
}
}
if (json) { console.log(JSON.stringify({ rows }, null, 2)); return; }
for (const row of rows) {
if (row.why) console.log(`${row.skill}: ${row.why}`);
else if (!row.drifted) console.log(`${row.skill}: nothing moved`);
else console.log(`${row.skill}: ${row.saying}${row.started ? ` — started ${row.started.branch}` : ""}`);
}
if (!rows.length) console.log("no hand pins a machine-readable spec yet; add HAND_CONTRACT.spec to one");
return;
}
if (command === "fix") {
if (!skill) throw new RefusedError("missing_argument", "fix needs <skill>; contract slice fix.args = [skill]");
const [row] = await watch(skill, { fetch: true });
if (!row?.drifted) { console.log(`${skill}: nothing has moved, so there is nothing to brief a fix run with`); return; }
const started = await startFix(skill, row.saying, flag("--token-file"));
console.log(`started ${started.branch} (${started.id}): ${row.saying}`);
return;
}
if (command === "schedule") { console.log(schedulePlist(Number(flag("--hour") ?? 7))); return; }
// ⟨R61 is graded by the lint, not here⟩ — `lintSkill` is imported so a
// caller can ask for the one hand's whole grade in the same process.
if (command === "lint") { console.log(JSON.stringify(await lintSkill(skill ?? "snappy-specwatch"), null, 2)); return; }
throw new RefusedError("unknown_verb", `unknown verb ${JSON.stringify(command)}; contract slice verbs = [check, drift, fix, schedule]`);
} catch (error) {
if (isRefusedError(error)) { printRefusal(error.refusal); console.error(error.message); process.exitCode = 1; return; }
console.error(error instanceof Error ? error.message : String(error));
process.exitCode = 1;
}
}
if (import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href) void main();