snappy-hands skill
censusread$ npx snappy-skills install snappy-hands
$ npx snappy-skills install --all
$ npx snappy-skills update
node ~/.claude/skills/snappy-hands/api.ts census — the ready skills on this Mac. Trust it over any list in prose.It reads the daemon's GET /hands (the one census, X13 2026-09-06); if it says the census is unavailable, the
daemon is down — report that, and never guess a list from the folder yourself.
~/.claude/skills/<name>/AGENTS.md, then node ~/.claude/skills/<name>/api.ts <verb> ….snappy-telegram send "…" reaches his own chat directly; iMessage to his own number once the Mini's Automation grant exists) sends, and you report what was sent and where.needs-credential:<KEY> → name the key once in your report and stop; retired-road → do not use, say so.snappy-settings/hand-read.ts): the roomdraws them as the channel — Slack as Slack, Gmail as Gmail, FreshBooks as FreshBooks.
You write the caption; never re-describe the rows.
snappy-settings/stage.ts): api.ts send … answersstaged for approval: control <id>. That is success — the person decides once and the
decision posts it on this Mac. Never pass --now; never call the channel API around it.
(snappy-settings/stage.ts stageHandOperation): the decision runs api.ts <verb> … --now on
this Mac. Same rule: staged for approval is the success; --now is the person's word.
<!-- SKILL-INDEX-START -->
[snappy-hands Index]|root: ~/.claude/skills/snappy-hands|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 |
|---|---|---|---|
census |
— | read |
npx tsx ~/.claude/skills/snappy-hands/api.ts census |
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 -->
---
name: snappy-hands
role: The map of an agent's hands on this Mac -- run `node ~/.claude/skills/snappy-hands/api.ts census` first, then use the ready skills' api.ts with plain node; stage every send to anyone but the owner through Snappy.
loaded-by: a room's AGENTS.md ("Read ~/.claude/skills/snappy-hands/AGENTS.md first")
---
# snappy-hands -- Agent Loader
1. `node ~/.claude/skills/snappy-hands/api.ts census` — the ready skills on this Mac. Trust it over any list in prose.
It reads the daemon's `GET /hands` (the one census, X13 2026-09-06); if it says the census is unavailable, the
daemon is down — report that, and never guess a list from the folder yourself.
2. For a ready skill: read `~/.claude/skills/<name>/AGENTS.md`, then `node ~/.claude/skills/<name>/api.ts <verb> …`.
3. Never send/post/pay/delete from a skill to anyone but the owner; stage it through Snappy and report the control id. THE OWNER HIMSELF IS NOT "anyone": a message to his own channel that he asked for (`snappy-telegram send "…"` reaches his own chat directly; iMessage to his own number once the Mini's Automation grant exists) sends, and you report what was sent and where.
4. `needs-credential:<KEY>` → name the key once in your report and stop; `retired-road` → do not use, say so.
## What a hand does on its own (2026-09-06)
- A READ reports its rows to Snappy by itself (`snappy-settings/hand-read.ts`): the room
draws them as the channel — Slack as Slack, Gmail as Gmail, FreshBooks as FreshBooks.
You write the caption; never re-describe the rows.
- A SEND stages by itself (`snappy-settings/stage.ts`): `api.ts send …` answers
`staged for approval: control <id>`. That is success — the person decides once and the
decision posts it on this Mac. Never pass `--now`; never call the channel API around it.
- A hand with no press binary (LinkedIn via Typefully, FreshBooks) stages ITS OWN VERB
(`snappy-settings/stage.ts stageHandOperation`): the decision runs `api.ts <verb> … --now` on
this Mac. Same rule: `staged for approval` is the success; `--now` is the person's word.
<!-- SKILL-INDEX-START -->
[snappy-hands Index]|root: ~/.claude/skills/snappy-hands|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 |
|---|---|---|---|
| `census` | — | `read` | `npx tsx ~/.claude/skills/snappy-hands/api.ts census` |
## 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 -->
Robert, 2026-09-06 00:1x: the same skill system as this session lives in every
project the Mac mini runs; the agent's hands ARE the skills in its folder; the
app manages which are there; Snappy keeps the send door. This skill is the map.
~/.claude/skills/<name>/ — each is a folder of three files: SKILL.md (what it
is for), AGENTS.md (the operating contract, read it once per run), api.ts
(the verbs; run with plain node — never npx, bun or an install; the
room's sandbox fetches nothing).
node ~/.claude/skills/<name>/api.ts <verb> [args]
node ~/.claude/skills/snappy-hands/api.ts census
prints every hand in this Mac's collection with a verdict: ready (its
credentials are in this machine's env cache and it calls the vendor directly),
needs-credential:<KEY> (name the key in your report, once; it is the owner's
to place, never yours), or retired-road (it still goes through the banned
Xano backend — do not use it; say so). Never guess: run the census.
The census is the daemon's, not this file's (X13, 2026-09-06). `api.ts
census is a thin client of GET /hands` on the local Snappy daemon — the same
door the Skills page and the MCP read — so an agent's list and a person's screen
are the same bytes at the same revision. It used to walk the disk itself, which
made it a second reader of one number: it counted only snappy-* folders,
inferred credential keys with its own regex, and drifted. If the daemon is not
running the census SAYS SO and exits non-zero; it never falls back to a local
walk, because a quiet second answer is the defect this replaced.
Reads and drafts run directly. A SEND, POST, PAYMENT or DELETE never runs from a
skill: it is staged through Snappy (stage-send-style verbs, or the
post-connector-action door with apply:false) so the owner decides and the
receipt lands on the item. That is a protocol you follow, not a wall.
Say which skill you used and the exact command. Quote the last error line of a
failed command once; do not retry variants. A missing tool on this Mac is yours
to name precisely, not the owner's to fix.
snappy-settings/hand-read.ts → the daemon's POST /hands/read →the store's hand_reads). The room and the artifact page draw them as the channel — Gmail as
Gmail's inbox, Slack as Slack's messages, FreshBooks as its Invoices table. Caption them; never
re-describe them.
stageThroughSnappy goes through /hub/connector-action {apply:false}. Where none exists
(LinkedIn via Typefully, FreshBooks) stageHandOperation stages the hand's OWN verb
(hand_<skill>_<verb>, argv with {{field}} tokens, target, facet); the person decides in
Needs you, the decision runs api.ts <verb> … --now on that Mac, the receipt is the hand's stdout.
staged for approval: control <id> IS success. --now is the person's word, never an agent's.get-hands, and each Mac servesGET /hands/<skill>/<SKILL.md|AGENTS.md|api.ts>.
<!-- SNAPPY-NEAR-NEIGHBOURS-START -->
These hands share enough of this one's words that a model can pick the wrong
door. Each row says what the other one is for; open that one instead when its
job is the job.
| Hand | What it is for |
|---|---|
snappy-ax |
Drive any Mac app through the Accessibility tree (AXUIElement) the way the shipping tools actually do it —... |
snappy-gmail |
Gmail as the machine's own hands -- read the inbox, threads and one message straight from Gmail's REST API... |
snappy-infra |
Snappy infrastructure foundation -- Xano API surface (Slack, email, LinkedIn, FreshBooks, WhatsApp, calenda... |
snappy-jcode |
Dispatch GPT 5.6 (Luna/Sol) agents as sandboxed lane workers via the local jcode CLI, on this Mac or the Ma... |
snappy-os-operator |
Operate SnappyOS like a pro through product doors only: governed connector reads, staged writes with approv... |
snappy-telegram |
Telegram Bot API channel for Snappy: direct calls to api.telegram.org (no Xano middleware) to send text, ph... |
<!-- SNAPPY-NEAR-NEIGHBOURS-END -->
---
name: snappy-hands
description: "THE HANDS OF AN AGENT ON THIS MAC -- how an agent in a Snappy room uses the kernel skills installed on the machine it runs on (Gmail, Telegram, Notion, transcripts, video, browse, and the rest), which of them are ready here, how to run them with plain node, and the one rule for sends. Read this first in any room; it replaces looking up Snappy doors before doing work. Triggers on: hands, what can this mac do, which skills are ready, run a skill, kernel skills, the one rule for sends."
---
# snappy-hands
Robert, 2026-09-06 00:1x: the same skill system as this session lives in every
project the Mac mini runs; the agent's hands ARE the skills in its folder; the
app manages which are there; Snappy keeps the send door. This skill is the map.
## Where the hands are
`~/.claude/skills/<name>/` — each is a folder of three files: `SKILL.md` (what it
is for), `AGENTS.md` (the operating contract, read it once per run), `api.ts`
(the verbs; run with **plain `node`** — never `npx`, `bun` or an install; the
room's sandbox fetches nothing).
node ~/.claude/skills/<name>/api.ts <verb> [args]
## Which are ready on THIS Mac
node ~/.claude/skills/snappy-hands/api.ts census
prints every hand in this Mac's collection with a verdict: `ready` (its
credentials are in this machine's env cache and it calls the vendor directly),
`needs-credential:<KEY>` (name the key in your report, once; it is the owner's
to place, never yours), or `retired-road` (it still goes through the banned
Xano backend — do not use it; say so). Never guess: run the census.
**The census is the daemon's, not this file's** (X13, 2026-09-06). `api.ts
census` is a thin client of `GET /hands` on the local Snappy daemon — the same
door the Skills page and the MCP read — so an agent's list and a person's screen
are the same bytes at the same revision. It used to walk the disk itself, which
made it a second reader of one number: it counted only `snappy-*` folders,
inferred credential keys with its own regex, and drifted. If the daemon is not
running the census SAYS SO and exits non-zero; it never falls back to a local
walk, because a quiet second answer is the defect this replaced.
## The one rule
Reads and drafts run directly. A SEND, POST, PAYMENT or DELETE never runs from a
skill: it is staged through Snappy (`stage-send`-style verbs, or the
`post-connector-action` door with `apply:false`) so the owner decides and the
receipt lands on the item. That is a protocol you follow, not a wall.
## Reporting
Say which skill you used and the exact command. Quote the last error line of a
failed command once; do not retry variants. A missing tool on this Mac is yours
to name precisely, not the owner's to fix.
## What a hand does on its own (2026-09-06)
- **A read reports its rows** (`snappy-settings/hand-read.ts` → the daemon's `POST /hands/read` →
the store's `hand_reads`). The room and the artifact page draw them as the channel — Gmail as
Gmail's inbox, Slack as Slack's messages, FreshBooks as its Invoices table. Caption them; never
re-describe them.
- **A send stages** — two roads, one rule. Where a press binary exists (Gmail, Slack, Telegram)
`stageThroughSnappy` goes through `/hub/connector-action {apply:false}`. Where none exists
(LinkedIn via Typefully, FreshBooks) `stageHandOperation` stages the hand's OWN verb
(`hand_<skill>_<verb>`, `argv` with `{{field}}` tokens, `target`, `facet`); the person decides in
Needs you, the decision runs `api.ts <verb> … --now` on that Mac, the receipt is the hand's stdout.
- `staged for approval: control <id>` IS success. `--now` is the person's word, never an agent's.
- The census rides every Computer's heartbeat; the MCP serves it as `get-hands`, and each Mac serves
`GET /hands/<skill>/<SKILL.md|AGENTS.md|api.ts>`.
<!-- SNAPPY-NEAR-NEIGHBOURS-START -->
## Near neighbours
These hands share enough of this one's words that a model can pick the wrong
door. Each row says what the other one is for; open that one instead when its
job is the job.
| Hand | What it is for |
|---|---|
| `snappy-ax` | Drive any Mac app through the Accessibility tree (AXUIElement) the way the shipping tools actually do it —... |
| `snappy-gmail` | Gmail as the machine's own hands -- read the inbox, threads and one message straight from Gmail's REST API... |
| `snappy-infra` | Snappy infrastructure foundation -- Xano API surface (Slack, email, LinkedIn, FreshBooks, WhatsApp, calenda... |
| `snappy-jcode` | Dispatch GPT 5.6 (Luna/Sol) agents as sandboxed lane workers via the local jcode CLI, on this Mac or the Ma... |
| `snappy-os-operator` | Operate SnappyOS like a pro through product doors only: governed connector reads, staged writes with approv... |
| `snappy-telegram` | Telegram Bot API channel for Snappy: direct calls to api.telegram.org (no Xano middleware) to send text, ph... |
<!-- SNAPPY-NEAR-NEIGHBOURS-END -->
#!/usr/bin/env node
/**
* snappy-hands — the census of an agent's hands on this Mac, READ FROM THE ONE
* OWNER OF IT.
*
* ⟨X13, 2026-09-06⟩ THIS FILE USED TO WALK THE DISK ITSELF. That made it the
* SECOND reader of one number, and the two never agreed: this walker counted
* only `snappy-*` folders (95 of the 188 skills under the root), inferred
* credential keys with its own regex, and carried its own retired-road rule,
* while the daemon's `state/lib/hands-collection.ts` counted every skill, told
* a DECLARED requirement from an INFERRED one, and published a revision and a
* per-skill digest. An agent reading this census and a person reading the
* Skills page were looking at different collections with the same name.
*
* DUPLICATE ROADS ARE BANNED. So this is now a THIN CLIENT of `GET /hands` on
* the local daemon — the same door the Skills page and the MCP read — and it
* has no second opinion about anything. If the daemon does not answer, it says
* so and exits non-zero: an honest refusal, never a local walk that would
* quietly resurrect the second road.
*
* The daemon reaches the same base URL and bearer this kernel already uses for
* `snappy-settings/stage.ts`, so there is one credential story for both.
*/
import { fileURLToPath } from "node:url";
import { homedir } from "node:os";
import { realpathSync } from "node:fs";
import { masterKey } from "../snappy-settings/master-key.ts";
import { printRefusal, refuse, refusalTable } from "../snappy-settings/refusal-codes.ts";
import { handServices, serviceRefusal, serviceUrl } from "../snappy-settings/hand-resources.ts";
export type Verdict = "ready" | "retired-road" | `needs-credential:${string}`;
/** One row, in the daemon's own words. `missing` and `purpose` are its fields,
* not this file's reading of them. */
export interface HandRow {
readonly name: string;
readonly kind: "hand" | "instruction";
readonly verdict: Verdict;
readonly missing: readonly string[];
readonly purpose: string;
}
export interface HandsCensus {
readonly root: string;
readonly computer: string;
readonly owner: string;
readonly revision: string;
/** Hands that hold every credential they declare. */
readonly ready: number;
/** Hands — entries carrying api.ts, which the daemon can call. */
readonly total: number;
/** Every skill under the root, hands and instruction-only alike. */
readonly total_skills: number;
readonly rows: readonly HandRow[];
}
/** THE ADDRESS COMES FROM THE ONE REGISTRY ⟨lane mini-reads, 2026-09-09⟩. This
* function used to hold the port as its own literal and read three env keys
* of its own; snappy-os-operator held a fourth. One row in
* `snappy-settings/hand-resources.ts` now holds the address and every key
* that moves it, so two hands cannot reach two daemons. */
export function daemonBase(): string {
return serviceUrl("snappy-os-app");
}
export type CensusAnswer =
| { readonly ok: true; readonly census: HandsCensus }
| { readonly ok: false; readonly detail: string };
/** THE ONE CENSUS. `GET /hands` on this Mac's daemon; nothing is inferred here. */
export async function census(): Promise<CensusAnswer> {
const base = daemonBase();
const key = masterKey();
const headers: Record<string, string> = { accept: "application/json" };
if (key) headers.authorization = `Bearer ${key}`;
let res: Response;
try {
res = await fetch(`${base}/hands`, { headers, signal: AbortSignal.timeout(15_000) });
} catch (error) {
// ONE SENTENCE, NAMING THE APP, OFFERING NOTHING ELSE ⟨lane mini-reads⟩.
// "ask the person whose Mac this is" was an offer a reader cannot act on
// and a second road out of a refusal that has exactly one.
return { ok: false, detail: serviceRefusal("snappy-os-app", error).message };
}
const body = await res.json().catch(() => null) as (HandsCensus & { ok?: boolean; detail?: string }) | null;
if (!res.ok || body === null || !Array.isArray(body.rows)) {
return { ok: false, detail: `${base}/hands answered ${res.status}${body?.detail === undefined ? "" : `: ${body.detail}`}.` };
}
return { ok: true, census: body };
}
const invokedDirectly = (() => {
try { return process.argv[1] !== undefined && realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url)); } catch { return false; }
})();
/** 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.
*/
export const HAND_CONTRACT = {
skill: "snappy-hands",
description: "THE HANDS OF AN AGENT ON THIS MAC -- how an agent in a Snappy room uses the kernel skills installed on the machine it runs on (Gmail, Telegram, Notion, transcripts, video, browse, and the rest), which of them are ready here, how to run them with plain node, and the one rule for sends. Read this first in any room; it replaces looking up Snappy doors before doing work. Triggers on: hands, what can this mac do, which skills are ready, run a skill, kernel skills, the one rule for sends.",
managed: true,
requires: [] as string[],
/** WHAT THIS HAND NEEDS THAT IS NOT A CREDENTIAL ⟨lane mini-reads,
* 2026-09-09⟩. `census` is a THIN CLIENT of the app's own `GET /hands`; on
* a Mac where the app is not running there is no answer to give and no
* second road to offer. Declaring it lets a picker skip this read instead
* of choosing it and printing a refusal on the owner's glass. */
resources: handServices("snappy-os-app"),
refusals: refusalTable("unknown_verb", "missing_credential", "service_unavailable", "upstream_error"),
verbs: {
census: {
args: [], effect: "read", flags: {"json":"--json"},
class: "read", execution: "call", openWorld: true,
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: false, openWorldHint: true },
},
},
} as const;
if (invokedDirectly && process.argv[2] === "contract") {
console.log(JSON.stringify(HAND_CONTRACT, null, 2));
process.exit(0);
}
if (invokedDirectly) {
const verb = process.argv[2] ?? "census";
if (verb !== "census") {
// Exit 2 with a bare word was neither an answer nor a refusal a caller
// could read. The closed table already holds the condition.
printRefusal(refuse("unknown_verb", `unknown verb ${JSON.stringify(verb)}; contract slice verbs = [census]`));
process.exit(1);
}
void (async () => {
const answer = await census();
if (!answer.ok) {
printRefusal(refuse("service_unavailable", answer.detail));
console.error(answer.detail);
return;
}
const c = answer.census;
if (process.argv.includes("--json")) {
console.log(JSON.stringify({ host: c.computer, root: c.root, revision: c.revision, ready: c.ready, total: c.total, total_skills: c.total_skills, rows: c.rows }));
return;
}
console.log(`# hands on ${c.computer || homedir()} (${c.root} @ ${c.revision}): ${c.ready} ready of ${c.total} hands, ${c.total_skills} skills in the collection`);
for (const r of c.rows) {
if (r.kind !== "hand") continue;
console.log(`${String(r.verdict).padEnd(28)} ${r.name.padEnd(28)} ${r.purpose}`);
}
})();
}
#!/usr/bin/env node
/**
* snappy-hands — the census of an agent's hands on this Mac, READ FROM THE ONE
* OWNER OF IT.
*
* ⟨X13, 2026-09-06⟩ THIS FILE USED TO WALK THE DISK ITSELF. That made it the
* SECOND reader of one number, and the two never agreed: this walker counted
* only `snappy-*` folders (95 of the 188 skills under the root), inferred
* credential keys with its own regex, and carried its own retired-road rule,
* while the daemon's `state/lib/hands-collection.ts` counted every skill, told
* a DECLARED requirement from an INFERRED one, and published a revision and a
* per-skill digest. An agent reading this census and a person reading the
* Skills page were looking at different collections with the same name.
*
* DUPLICATE ROADS ARE BANNED. So this is now a THIN CLIENT of `GET /hands` on
* the local daemon — the same door the Skills page and the MCP read — and it
* has no second opinion about anything. If the daemon does not answer, it says
* so and exits non-zero: an honest refusal, never a local walk that would
* quietly resurrect the second road.
*
* The daemon reaches the same base URL and bearer this kernel already uses for
* `snappy-settings/stage.ts`, so there is one credential story for both.
*/
import { fileURLToPath } from "node:url";
import { homedir } from "node:os";
import { realpathSync } from "node:fs";
import { masterKey } from "../snappy-settings/master-key.ts";
import { printRefusal, refuse, refusalTable } from "../snappy-settings/refusal-codes.ts";
import { handServices, serviceRefusal, serviceUrl } from "../snappy-settings/hand-resources.ts";
export type Verdict = "ready" | "retired-road" | `needs-credential:${string}`;
/** One row, in the daemon's own words. `missing` and `purpose` are its fields,
* not this file's reading of them. */
export interface HandRow {
readonly name: string;
readonly kind: "hand" | "instruction";
readonly verdict: Verdict;
readonly missing: readonly string[];
readonly purpose: string;
}
export interface HandsCensus {
readonly root: string;
readonly computer: string;
readonly owner: string;
readonly revision: string;
/** Hands that hold every credential they declare. */
readonly ready: number;
/** Hands — entries carrying api.ts, which the daemon can call. */
readonly total: number;
/** Every skill under the root, hands and instruction-only alike. */
readonly total_skills: number;
readonly rows: readonly HandRow[];
}
/** THE ADDRESS COMES FROM THE ONE REGISTRY ⟨lane mini-reads, 2026-09-09⟩. This
* function used to hold the port as its own literal and read three env keys
* of its own; snappy-os-operator held a fourth. One row in
* `snappy-settings/hand-resources.ts` now holds the address and every key
* that moves it, so two hands cannot reach two daemons. */
export function daemonBase(): string {
return serviceUrl("snappy-os-app");
}
export type CensusAnswer =
| { readonly ok: true; readonly census: HandsCensus }
| { readonly ok: false; readonly detail: string };
/** THE ONE CENSUS. `GET /hands` on this Mac's daemon; nothing is inferred here. */
export async function census(): Promise<CensusAnswer> {
const base = daemonBase();
const key = masterKey();
const headers: Record<string, string> = { accept: "application/json" };
if (key) headers.authorization = `Bearer ${key}`;
let res: Response;
try {
res = await fetch(`${base}/hands`, { headers, signal: AbortSignal.timeout(15_000) });
} catch (error) {
// ONE SENTENCE, NAMING THE APP, OFFERING NOTHING ELSE ⟨lane mini-reads⟩.
// "ask the person whose Mac this is" was an offer a reader cannot act on
// and a second road out of a refusal that has exactly one.
return { ok: false, detail: serviceRefusal("snappy-os-app", error).message };
}
const body = await res.json().catch(() => null) as (HandsCensus & { ok?: boolean; detail?: string }) | null;
if (!res.ok || body === null || !Array.isArray(body.rows)) {
return { ok: false, detail: `${base}/hands answered ${res.status}${body?.detail === undefined ? "" : `: ${body.detail}`}.` };
}
return { ok: true, census: body };
}
const invokedDirectly = (() => {
try { return process.argv[1] !== undefined && realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url)); } catch { return false; }
})();
/** 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.
*/
export const HAND_CONTRACT = {
skill: "snappy-hands",
description: "THE HANDS OF AN AGENT ON THIS MAC -- how an agent in a Snappy room uses the kernel skills installed on the machine it runs on (Gmail, Telegram, Notion, transcripts, video, browse, and the rest), which of them are ready here, how to run them with plain node, and the one rule for sends. Read this first in any room; it replaces looking up Snappy doors before doing work. Triggers on: hands, what can this mac do, which skills are ready, run a skill, kernel skills, the one rule for sends.",
managed: true,
requires: [] as string[],
/** WHAT THIS HAND NEEDS THAT IS NOT A CREDENTIAL ⟨lane mini-reads,
* 2026-09-09⟩. `census` is a THIN CLIENT of the app's own `GET /hands`; on
* a Mac where the app is not running there is no answer to give and no
* second road to offer. Declaring it lets a picker skip this read instead
* of choosing it and printing a refusal on the owner's glass. */
resources: handServices("snappy-os-app"),
refusals: refusalTable("unknown_verb", "missing_credential", "service_unavailable", "upstream_error"),
verbs: {
census: {
args: [], effect: "read", flags: {"json":"--json"},
class: "read", execution: "call", openWorld: true,
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: false, openWorldHint: true },
},
},
} as const;
if (invokedDirectly && process.argv[2] === "contract") {
console.log(JSON.stringify(HAND_CONTRACT, null, 2));
process.exit(0);
}
if (invokedDirectly) {
const verb = process.argv[2] ?? "census";
if (verb !== "census") {
// Exit 2 with a bare word was neither an answer nor a refusal a caller
// could read. The closed table already holds the condition.
printRefusal(refuse("unknown_verb", `unknown verb ${JSON.stringify(verb)}; contract slice verbs = [census]`));
process.exit(1);
}
void (async () => {
const answer = await census();
if (!answer.ok) {
printRefusal(refuse("service_unavailable", answer.detail));
console.error(answer.detail);
return;
}
const c = answer.census;
if (process.argv.includes("--json")) {
console.log(JSON.stringify({ host: c.computer, root: c.root, revision: c.revision, ready: c.ready, total: c.total, total_skills: c.total_skills, rows: c.rows }));
return;
}
console.log(`# hands on ${c.computer || homedir()} (${c.root} @ ${c.revision}): ${c.ready} ready of ${c.total} hands, ${c.total_skills} skills in the collection`);
for (const r of c.rows) {
if (r.kind !== "hand") continue;
console.log(`${String(r.verdict).padEnd(28)} ${r.name.padEnd(28)} ${r.purpose}`);
}
})();
}
#!/usr/bin/env node
/**
* THE CONTRACT DERIVER — one pass that reads every hand's OWN CLI dispatch and
* writes back the `HAND_CONTRACT` Snappy validates its calls against.
*
* WHY IT EXISTS ⟨lane CONTRACTS, 2026-09-07⟩. The collection was "pushed all the
* way" in SHAPE — 86 skills, 86 `api.ts` — and not in TYPED EDGES: 7 of 86
* declared a contract, so `POST /hands/run` answered `contract_unreadable` for
* the other 79 and no AI, button or trigger could reach them without launching
* an agent to guess the words. The ecosystem's ~490k public skills are markdown
* with no contract at all; the typed edge is what makes a hand callable.
*
* NOT A SECOND TABLE. Every verb, argument word and flag below is READ OUT OF
* THE CODE THAT RUNS — the `switch (cmd)` cases, the `cmd === "x"` chains, each
* case's own `Usage:` line, its `const [a, b] = args` destructure, its
* `args.indexOf("--flag")`. A verb the code does not implement is never
* declared, which is the whole point of the declaration. Where the code's own
* words are ambiguous, `contract-overrides.json` carries a REVIEWED correction
* naming the file and line it was read from — never a guess, never a table that
* could drift from the dispatch.
*
* WHAT IT WILL NOT TOUCH. A skill that already exports `HAND_CONTRACT` is
* authoritative and is skipped: a hand-written contract states what was PROVEN
* against the live account (see snappy-skool's header), and no parser knows
* that. Re-running this is safe and idempotent.
*
* THE EFFECT IS A GOVERNANCE CLASS, NOT A GUESS ABOUT THE CODE. `read`, `draft`
* and `write-reversible` run now; `write`, `send`, `post`, `pay` and `delete`
* stage for the owner's decision. Two rules decide it and both err toward the
* approval: a verb is a `read` only when its name is one of the proven read
* words below, and anything else on a REMOTE skill is at least a `write`. Verbs
* on a LOCAL_ONLY skill — the ones whose whole effect is this Mac's own files,
* browser or windows — are `write-reversible` instead, because approval is for
* the irreversible (CLAUDE.md §6) and staging a video encode would be a gate
* with nothing behind it.
*
* Usage:
* node --experimental-strip-types contract-derive.ts --check # print, write nothing
* node --experimental-strip-types contract-derive.ts --write
*/
import { readFileSync, readdirSync, existsSync, writeFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { collectLocalSource, requiredEnvKeys } from "../snappy-settings/env-reads.ts";
const HERE = dirname(fileURLToPath(import.meta.url));
/** The kernel's own `skills/` by default. `--root <dir>` points it at another
* collection — the owner's `~/.claude/skills` holds a handful of hands that
* live nowhere else, and a census that skipped them would undercount the
* collection an agent actually loads. */
const rootFlag = process.argv.indexOf("--root");
const SKILLS = rootFlag >= 0 && process.argv[rootFlag + 1] !== undefined
? process.argv[rootFlag + 1]!
: join(HERE, "..");
/** The daemon's rule, in the daemon's words (state/lib/hands-collection.ts):
* a road is retired when the code REACHES the banned backend — a host, a token
* or the retired MCP. This file does not own the rule; it declares what the
* rule already reads, so the page and the contract cannot disagree. */
const REACHES_RETIRED_BACKEND =
/xano\.io|env\("[A-Z0-9_]*XANO[A-Z0-9_]*"\)|process\.env\.[A-Z0-9_]*XANO|xano-spine/u;
/** ONE ENV-READ READER ⟨lane loose-ends, 2026-09-09⟩. The regexes, the
* never-a-credential set and the `env("K", false)` ruling used to live HERE
* and, in a different shape, in snappy-tool-design (rules 35 and 36) — one
* road minting `requires`, the other grading it, and nothing keeping them
* identical. They now share `snappy-settings/env-reads.ts`, which is also
* where the reason lives: `env("K")` is a requirement, `env("K", false)` is
* not, and a bare `process.env.K` declares nothing and is not either.
*
* ONE SCOPE, TOO ⟨lane requires-scope, 2026-09-09⟩. The two roads still asked
* the question of DIFFERENT TEXT: the grader walked the hand's local imports,
* this file read the hand's own api.ts alone. `requires` means WHAT THE HAND
* NEEDS TO RUN ITS VERBS on this machine, and that is the transitive answer —
* snappy-gmail reads no `env("GOOGLE_*")` of its own, imports snappy-email
* which does, and runs no verb without those four keys. Measured before the
* join: 23 of 98 hands would have been minted SHORT of the contract they ship.
* After: 98 of 98 agree, no exceptions. `envSource` below is that walk, and
* the walk is `collectLocalSource` — not a second one. */
export type Effect = "read" | "draft" | "write-reversible" | "write" | "send" | "post" | "pay" | "delete";
/** Skills whose every effect lands on THIS Mac and nowhere else. */
const LOCAL_ONLY = new Set([
"snappy-ax", "snappy-api-sniffer", "snappy-artifact-loop", "snappy-blog", "snappy-browse",
"snappy-chain", "snappy-channel-contract", "snappy-clients", "snappy-content", "snappy-corpus",
"snappy-dispatch", "snappy-dom-cartographer", "snappy-ffmpeg", "snappy-hooks", "snappy-mine",
"snappy-nightshift", "snappy-playbook", "snappy-remotion", "snappy-review-pages",
"snappy-sensors", "snappy-session-close", "snappy-settings", "snappy-shell", "snappy-skill",
"snappy-transcripts", "snappy-video", "snappy-voice-control", "snappy-walkthrough",
// Repo-less hands that live only under the owner's ~/.claude/skills: local
// capture, local corpus reads, local classification. Nothing leaves this Mac.
"snappy-cleanshot", "snappy-mastermind-model", "snappy-data-hygiene",
]);
const READ = new Set([
"list", "get", "show", "status", "search", "read", "census", "brief", "tables", "describe",
"query", "health", "check", "profile", "me", "updates", "recent", "thread", "messages",
"channels", "events", "today", "invoices", "clients", "contacts", "comments", "video",
"repos", "prs", "issues", "page", "children", "modules", "module", "metrics", "scorecard",
"targets", "permissions", "devices", "dump", "stale", "section", "weeks", "week", "tone",
"banned", "offer", "pricing", "icp", "leads", "deploys", "logs", "routes", "sql", "snapshot",
"tree", "find", "focused", "at-point", "text", "raw", "capabilities", "doctor", "spawn-spec",
"prompt", "certs", "schema", "validate", "files", "manifest", "sources", "pending",
"scheduled", "draft-get", "token-status", "available", "bar", "board", "report",
"preflight", "trend", "fingerprint", "diff-machine", "dashboard", "catchup", "actions",
"trace", "story", "menu", "ladder", "recipes", "questions", "posts", "post", "members",
"admins", "levels", "courses", "group", "links", "notifications", "atoms", "birthdays",
"dormant", "entities", "entity", "interactions", "interaction", "sensor", "resolve",
"batch-overview", "stuck-analysis", "queue-health", "crash-patterns", "data-landing",
"completeness", "duplicates", "avatars", "edge-gaps", "history", "time-entries", "expenses",
"fetch-meetings", "fetch-document", "fetch-action-items", "filter-assignee", "pending-for",
"since", "commitments", "tool-mentions", "meetings-since", "meetings-by", "requests",
"slack-joins", "classify", "cite", "flow", "url", "logos", "auth", "blocked",
"by-attendee", "availability", "wa-chats", "wa-read", "ssl", "vercel", "score", "extract",
"verify", "envelope", "parse", "choose", "simpro", "blockers", "gates", "lesson", "pick",
]);
const SEND = new Set(["send", "notify", "wa-send", "reply", "comment", "message", "dm", "nag"]);
const POST = new Set(["post", "publish", "share", "linkedin", "slack", "telegram", "whatsapp",
"upload", "image-upload", "media-upload", "create-post", "schedule", "remind"]);
const DELETE = new Set(["delete", "trash", "remove", "delete-link", "draft-delete", "reap"]);
const PAY = new Set(["pay", "mark-paid", "charge", "refund", "invoice", "send-invoice",
"create-invoice", "log-expense"]);
const DRAFT = new Set(["draft", "draft-new", "scaffold", "propose", "dry", "generate",
"describe", "chat", "fallback", "compose", "embed", "image", "kit"]);
const TARGET_WORDS = ["to", "channel-id", "channel", "contact", "contact-id", "phone", "chat-id",
"recipient", "email", "post-urn", "post-id", "post-url"];
export interface VerbContract {
args: string[];
effect: Effect;
target?: string;
flags?: Record<string, string>;
/** THE VERB ANSWERS, THEN WAITS FOR A PERSON ⟨2026-09-09⟩. `show` on a face
* channel does not finish when the picture lands — it finishes when the
* person presses one of its doors. A runner that reads this knows the call
* has a SECOND half, and can hand the caller a task handle for the wait
* instead of an answer that looks finished while the decision is still open.
* Absent means the verb is done when its process is. */
waits?: "decision";
}
export interface Derived {
skill: string;
managed: boolean;
requires: string[];
backend?: "retired";
verbs: Record<string, VerbContract>;
}
interface Override {
drop?: string[];
backend?: "retired" | "none";
verbs?: Record<string, Partial<VerbContract> & { drop?: boolean }>;
note?: string;
}
function kebab(word: string): string {
return word.replace(/([a-z0-9])([A-Z])/gu, "$1-$2").toLowerCase().replace(/_/gu, "-");
}
/** Everything from the direct-invocation guard to the end of the file. */
function cliBlock(text: string): { block: string; guard: string; at: number } | null {
const m = /^(\s*)if\s*\(\s*(invokedDirectly|isMain|import\.meta\.url\s*===\s*`file:\/\/\$\{[^`]*\}`)\s*\)/mu.exec(text);
if (m === null) return null;
return { block: text.slice(m.index), guard: m[2]!, at: m.index };
}
function argsFromUsage(body: string, verb: string): string[] | null {
const esc = verb.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
const m = new RegExp(`[Uu]sage:[^"'\`\\n]*?\\b${esc}\\b([^"'\`\\n]*)`, "u").exec(body);
if (m === null) return null;
const out: string[] = [];
for (const t of m[1]!.matchAll(/<([a-zA-Z][\w-]*)>|\[([a-zA-Z][\w-]*)\]/gu)) {
if (t[1] !== undefined) out.push(kebab(t[1]));
else if (t[2] !== undefined) out.push(`${kebab(t[2])}?`);
}
return out;
}
function argsFromDestructure(body: string): string[] | null {
const m = /const\s*\[\s*([a-zA-Z_$][\w$,\s]*)\]\s*=\s*(?:args|rest)\b/u.exec(body);
if (m === null) return null;
return m[1]!.split(",").map((w) => w.trim()).filter((w) => w !== "" && w !== "_").map(kebab);
}
/** An index-derived word is ALWAYS optional: a case that truly requires one
* printed a `Usage:` line, which the reader above already caught. Declaring it
* required would refuse a call the hand would have answered. */
function argsFromIndex(body: string): string[] | null {
const named = new Map<number, string>();
for (const m of body.matchAll(/(?:const|let)\s+([a-zA-Z_$][\w$]*)\s*(?::[^=]+)?=\s*(?:Number\()?args\[(\d)\]/gu)) {
named.set(Number(m[2]), kebab(m[1]!));
}
let max = -1;
for (const m of body.matchAll(/\bargs\[(\d)\]/gu)) max = Math.max(max, Number(m[1]));
if (max < 0) return null;
return Array.from({ length: max + 1 }, (_, i) => `${named.get(i) ?? `arg${i + 1}`}?`);
}
function flagsOf(body: string): Record<string, string> {
const out: Record<string, string> = {};
for (const m of body.matchAll(/indexOf\(\s*["'](--[a-z][a-z0-9-]{1,30})["']/gu)) out[kebab(m[1]!.slice(2))] = m[1]!;
return out;
}
function effectOf(skill: string, verb: string, args: string[]): { effect: Effect; target?: string } {
let effect: Effect;
if (PAY.has(verb)) effect = "pay";
else if (DELETE.has(verb)) effect = "delete";
else if (SEND.has(verb) || verb.startsWith("send-")) effect = "send";
else if (POST.has(verb)) effect = "post";
else if (READ.has(verb)) effect = "read";
else if (DRAFT.has(verb)) effect = "draft";
else effect = LOCAL_ONLY.has(skill) ? "write-reversible" : "write";
// A "post" verb on a reading skill is the NOUN, not the act: `post <slug>`
// reads one post. A verb set cannot see that; the argument words can.
if (verb === "post" && args.some((a) => /(post|slug|id)/u.test(a))) effect = "read";
if (LOCAL_ONLY.has(skill) && effect === "write") effect = "write-reversible";
const target = args.map((a) => a.replace(/\?$/u, "")).find((a) => TARGET_WORDS.includes(a));
return target === undefined ? { effect } : { effect, target };
}
/**
* `text` is the hand's OWN api.ts — the verbs, the argument words, the flags and
* the retired-backend test are all read out of the code that RUNS in this file,
* and a hand does not inherit its imports' `switch` cases.
*
* `envSource` is the text the REQUIREMENT is read out of, and it is wider on
* purpose: the hand plus the local modules it imports (see the note above).
* Defaults to `text` so a caller holding only one file still gets an answer —
* the narrower one, which is why the CLI below always passes the walk.
*/
export function derive(skill: string, text: string, override: Override = {}, envSource: string = text): Derived {
const cli = cliBlock(text);
const block = cli === null ? "" : cli.block;
const verbs: Record<string, VerbContract> = {};
const dropped = new Set(override.drop ?? []);
const push = (name: string, scan: string): void => {
if (name === "contract" || name === "help" || name in verbs || dropped.has(name)) return;
let args = argsFromUsage(scan, name);
if (args === null || args.length === 0) args = argsFromDestructure(scan);
if (args === null) args = argsFromIndex(scan);
if (args === null) args = [];
const { effect, target } = effectOf(skill, name, args);
const flags = flagsOf(scan);
verbs[name] = { args, effect, ...(target === undefined ? {} : { target }), ...(Object.keys(flags).length === 0 ? {} : { flags }) };
};
// A `main(argv)` dispatcher sits ABOVE the guard, so a block that carries no
// case falls back to the whole file. Both readers are the code's own switch.
const scanIn = (source: string): void => {
const cases = [...source.matchAll(/case\s+["']([a-z][a-z0-9-]{0,39})["']\s*:/gu)];
for (let i = 0; i < cases.length; i += 1) {
const start = cases[i]!.index + cases[i]![0].length;
const end = i + 1 < cases.length ? cases[i + 1]!.index : source.length;
const body = source.slice(start, end);
const empty = /^\s*(case\s|$)/u.test(body);
push(cases[i]![1]!, empty ? source.slice(start, Math.min(source.length, start + 1500)) : body);
}
const chain = [...source.matchAll(/\bcmd\s*===\s*["']([a-z][a-z0-9-]{0,39})["']/gu)];
for (let i = 0; i < chain.length; i += 1) {
const end = i + 1 < chain.length ? chain[i + 1]!.index : source.length;
push(chain[i]![1]!, source.slice(chain[i]!.index, end));
}
};
scanIn(block);
if (Object.keys(verbs).length === 0) scanIn(text);
for (const [name, patch] of Object.entries(override.verbs ?? {})) {
if (patch.drop === true) { delete verbs[name]; continue; }
const { drop: _drop, ...fields } = patch;
verbs[name] = { args: [], effect: "write", ...verbs[name], ...fields } as VerbContract;
}
const requires = requiredEnvKeys(envSource);
const retired = override.backend === "none" ? false : override.backend === "retired" || REACHES_RETIRED_BACKEND.test(text);
const ordered: Record<string, VerbContract> = {};
for (const name of Object.keys(verbs).sort()) ordered[name] = verbs[name]!;
return {
skill,
// `managed` is the boolean that PAIRS with `requires`, so it reads the same
// text: a hand whose credential is spent inside an imported skill is still
// a hand the loader hands keys to.
managed: /from\s+["']\.\.\/snappy-settings\/load\.ts["']/u.test(envSource) && requires.length > 0,
requires,
...(retired ? { backend: "retired" as const } : {}),
verbs: ordered,
};
}
/** The literal a person reads in the file — the contract, then the `contract`
* verb, wired to the file's OWN direct-invocation guard so it fires exactly
* when the CLI below it fires and needs no import the file does not have. */
function contractSource(d: Derived, guard: string): string {
const lines: string[] = [];
lines.push("/** WHAT THIS HAND ANSWERS, and what each verb does to the world.");
lines.push(" * Derived from this file's own CLI dispatch by");
lines.push(" * `snappy-hands/contract-derive.ts` — a verb the code does not implement is");
lines.push(" * never declared here. Snappy's daemon reads it (`api.ts contract`) to");
lines.push(" * validate every call, build the argument words in order, decide whether the");
lines.push(" * act runs now or stages for the owner, and hand the child exactly the");
lines.push(" * environment keys named in `requires` — never a value, never anything else.");
if (d.backend === "retired") {
lines.push(" *");
lines.push(" * `backend: \"retired\"` — this road's backend is BANNED (the ruling of");
lines.push(" * 2026-08-30: never read it, write it, or fall back to it). The verbs are");
lines.push(" * declared so the census can count the road honestly and Snappy can refuse");
lines.push(" * it BY NAME; nothing here is callable until the road is rebuilt. */");
} else {
lines.push(" */");
}
lines.push(`export const HAND_CONTRACT = {`);
lines.push(` skill: ${JSON.stringify(d.skill)},`);
lines.push(` managed: ${d.managed},`);
lines.push(` requires: ${JSON.stringify(d.requires)} as string[],`);
if (d.backend === "retired") lines.push(` backend: "retired",`);
lines.push(` verbs: {`);
for (const [name, v] of Object.entries(d.verbs)) {
const parts = [`args: ${JSON.stringify(v.args)}`, `effect: ${JSON.stringify(v.effect)}`];
if (v.target !== undefined) parts.push(`target: ${JSON.stringify(v.target)}`);
if (v.flags !== undefined) parts.push(`flags: ${JSON.stringify(v.flags)}`);
if (v.waits !== undefined) parts.push(`waits: ${JSON.stringify(v.waits)}`);
lines.push(` ${/^[a-z][a-z0-9]*$/u.test(name) ? name : JSON.stringify(name)}: { ${parts.join(", ")} },`);
}
lines.push(` },`);
lines.push(`} as const;`);
lines.push("");
lines.push(`if (${guard} && process.argv[2] === "contract") {`);
lines.push(` console.log(JSON.stringify(HAND_CONTRACT, null, 2));`);
lines.push(` process.exit(0);`);
lines.push(`}`);
lines.push("");
return lines.join("\n");
}
const overridesPath = join(HERE, "contract-overrides.json");
const overrides: Record<string, Override> = existsSync(overridesPath)
? JSON.parse(readFileSync(overridesPath, "utf8")) as Record<string, Override>
: {};
const write = process.argv.includes("--write");
let declared = 0; let skipped = 0; let refused = 0; let retiredCount = 0;
for (const name of readdirSync(SKILLS).sort()) {
const api = join(SKILLS, name, "api.ts");
if (!existsSync(api)) continue;
const text = readFileSync(api, "utf8");
if (/export const HAND_CONTRACT/u.test(text)) { skipped += 1; continue; }
const cli = cliBlock(text);
if (cli === null) { refused += 1; console.error(`${name}: no direct-invocation guard — cannot wire a contract verb`); continue; }
const d = derive(name, text, overrides[name] ?? {}, collectLocalSource(api, new Set<string>(), SKILLS));
if (d.backend === "retired") retiredCount += 1;
declared += 1;
if (!write) {
console.log(`${name}${d.backend === "retired" ? " RETIRED" : ""}: ${Object.keys(d.verbs).length} verbs [${Object.entries(d.verbs).map(([v, c]) => `${v}:${c.effect}`).join(" ")}]`);
continue;
}
writeFileSync(api, `${text.slice(0, cli.at)}${contractSource(d, cli.guard)}\n${text.slice(cli.at)}`);
}
console.log(`${declared} contracts ${write ? "written" : "derivable"} (${retiredCount} retired roads), ${skipped} already declared, ${refused} refused`);
#!/usr/bin/env node
/**
* THE CONTRACT DERIVER — one pass that reads every hand's OWN CLI dispatch and
* writes back the `HAND_CONTRACT` Snappy validates its calls against.
*
* WHY IT EXISTS ⟨lane CONTRACTS, 2026-09-07⟩. The collection was "pushed all the
* way" in SHAPE — 86 skills, 86 `api.ts` — and not in TYPED EDGES: 7 of 86
* declared a contract, so `POST /hands/run` answered `contract_unreadable` for
* the other 79 and no AI, button or trigger could reach them without launching
* an agent to guess the words. The ecosystem's ~490k public skills are markdown
* with no contract at all; the typed edge is what makes a hand callable.
*
* NOT A SECOND TABLE. Every verb, argument word and flag below is READ OUT OF
* THE CODE THAT RUNS — the `switch (cmd)` cases, the `cmd === "x"` chains, each
* case's own `Usage:` line, its `const [a, b] = args` destructure, its
* `args.indexOf("--flag")`. A verb the code does not implement is never
* declared, which is the whole point of the declaration. Where the code's own
* words are ambiguous, `contract-overrides.json` carries a REVIEWED correction
* naming the file and line it was read from — never a guess, never a table that
* could drift from the dispatch.
*
* WHAT IT WILL NOT TOUCH. A skill that already exports `HAND_CONTRACT` is
* authoritative and is skipped: a hand-written contract states what was PROVEN
* against the live account (see snappy-skool's header), and no parser knows
* that. Re-running this is safe and idempotent.
*
* THE EFFECT IS A GOVERNANCE CLASS, NOT A GUESS ABOUT THE CODE. `read`, `draft`
* and `write-reversible` run now; `write`, `send`, `post`, `pay` and `delete`
* stage for the owner's decision. Two rules decide it and both err toward the
* approval: a verb is a `read` only when its name is one of the proven read
* words below, and anything else on a REMOTE skill is at least a `write`. Verbs
* on a LOCAL_ONLY skill — the ones whose whole effect is this Mac's own files,
* browser or windows — are `write-reversible` instead, because approval is for
* the irreversible (CLAUDE.md §6) and staging a video encode would be a gate
* with nothing behind it.
*
* Usage:
* node --experimental-strip-types contract-derive.ts --check # print, write nothing
* node --experimental-strip-types contract-derive.ts --write
*/
import { readFileSync, readdirSync, existsSync, writeFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { collectLocalSource, requiredEnvKeys } from "../snappy-settings/env-reads.ts";
const HERE = dirname(fileURLToPath(import.meta.url));
/** The kernel's own `skills/` by default. `--root <dir>` points it at another
* collection — the owner's `~/.claude/skills` holds a handful of hands that
* live nowhere else, and a census that skipped them would undercount the
* collection an agent actually loads. */
const rootFlag = process.argv.indexOf("--root");
const SKILLS = rootFlag >= 0 && process.argv[rootFlag + 1] !== undefined
? process.argv[rootFlag + 1]!
: join(HERE, "..");
/** The daemon's rule, in the daemon's words (state/lib/hands-collection.ts):
* a road is retired when the code REACHES the banned backend — a host, a token
* or the retired MCP. This file does not own the rule; it declares what the
* rule already reads, so the page and the contract cannot disagree. */
const REACHES_RETIRED_BACKEND =
/xano\.io|env\("[A-Z0-9_]*XANO[A-Z0-9_]*"\)|process\.env\.[A-Z0-9_]*XANO|xano-spine/u;
/** ONE ENV-READ READER ⟨lane loose-ends, 2026-09-09⟩. The regexes, the
* never-a-credential set and the `env("K", false)` ruling used to live HERE
* and, in a different shape, in snappy-tool-design (rules 35 and 36) — one
* road minting `requires`, the other grading it, and nothing keeping them
* identical. They now share `snappy-settings/env-reads.ts`, which is also
* where the reason lives: `env("K")` is a requirement, `env("K", false)` is
* not, and a bare `process.env.K` declares nothing and is not either.
*
* ONE SCOPE, TOO ⟨lane requires-scope, 2026-09-09⟩. The two roads still asked
* the question of DIFFERENT TEXT: the grader walked the hand's local imports,
* this file read the hand's own api.ts alone. `requires` means WHAT THE HAND
* NEEDS TO RUN ITS VERBS on this machine, and that is the transitive answer —
* snappy-gmail reads no `env("GOOGLE_*")` of its own, imports snappy-email
* which does, and runs no verb without those four keys. Measured before the
* join: 23 of 98 hands would have been minted SHORT of the contract they ship.
* After: 98 of 98 agree, no exceptions. `envSource` below is that walk, and
* the walk is `collectLocalSource` — not a second one. */
export type Effect = "read" | "draft" | "write-reversible" | "write" | "send" | "post" | "pay" | "delete";
/** Skills whose every effect lands on THIS Mac and nowhere else. */
const LOCAL_ONLY = new Set([
"snappy-ax", "snappy-api-sniffer", "snappy-artifact-loop", "snappy-blog", "snappy-browse",
"snappy-chain", "snappy-channel-contract", "snappy-clients", "snappy-content", "snappy-corpus",
"snappy-dispatch", "snappy-dom-cartographer", "snappy-ffmpeg", "snappy-hooks", "snappy-mine",
"snappy-nightshift", "snappy-playbook", "snappy-remotion", "snappy-review-pages",
"snappy-sensors", "snappy-session-close", "snappy-settings", "snappy-shell", "snappy-skill",
"snappy-transcripts", "snappy-video", "snappy-voice-control", "snappy-walkthrough",
// Repo-less hands that live only under the owner's ~/.claude/skills: local
// capture, local corpus reads, local classification. Nothing leaves this Mac.
"snappy-cleanshot", "snappy-mastermind-model", "snappy-data-hygiene",
]);
const READ = new Set([
"list", "get", "show", "status", "search", "read", "census", "brief", "tables", "describe",
"query", "health", "check", "profile", "me", "updates", "recent", "thread", "messages",
"channels", "events", "today", "invoices", "clients", "contacts", "comments", "video",
"repos", "prs", "issues", "page", "children", "modules", "module", "metrics", "scorecard",
"targets", "permissions", "devices", "dump", "stale", "section", "weeks", "week", "tone",
"banned", "offer", "pricing", "icp", "leads", "deploys", "logs", "routes", "sql", "snapshot",
"tree", "find", "focused", "at-point", "text", "raw", "capabilities", "doctor", "spawn-spec",
"prompt", "certs", "schema", "validate", "files", "manifest", "sources", "pending",
"scheduled", "draft-get", "token-status", "available", "bar", "board", "report",
"preflight", "trend", "fingerprint", "diff-machine", "dashboard", "catchup", "actions",
"trace", "story", "menu", "ladder", "recipes", "questions", "posts", "post", "members",
"admins", "levels", "courses", "group", "links", "notifications", "atoms", "birthdays",
"dormant", "entities", "entity", "interactions", "interaction", "sensor", "resolve",
"batch-overview", "stuck-analysis", "queue-health", "crash-patterns", "data-landing",
"completeness", "duplicates", "avatars", "edge-gaps", "history", "time-entries", "expenses",
"fetch-meetings", "fetch-document", "fetch-action-items", "filter-assignee", "pending-for",
"since", "commitments", "tool-mentions", "meetings-since", "meetings-by", "requests",
"slack-joins", "classify", "cite", "flow", "url", "logos", "auth", "blocked",
"by-attendee", "availability", "wa-chats", "wa-read", "ssl", "vercel", "score", "extract",
"verify", "envelope", "parse", "choose", "simpro", "blockers", "gates", "lesson", "pick",
]);
const SEND = new Set(["send", "notify", "wa-send", "reply", "comment", "message", "dm", "nag"]);
const POST = new Set(["post", "publish", "share", "linkedin", "slack", "telegram", "whatsapp",
"upload", "image-upload", "media-upload", "create-post", "schedule", "remind"]);
const DELETE = new Set(["delete", "trash", "remove", "delete-link", "draft-delete", "reap"]);
const PAY = new Set(["pay", "mark-paid", "charge", "refund", "invoice", "send-invoice",
"create-invoice", "log-expense"]);
const DRAFT = new Set(["draft", "draft-new", "scaffold", "propose", "dry", "generate",
"describe", "chat", "fallback", "compose", "embed", "image", "kit"]);
const TARGET_WORDS = ["to", "channel-id", "channel", "contact", "contact-id", "phone", "chat-id",
"recipient", "email", "post-urn", "post-id", "post-url"];
export interface VerbContract {
args: string[];
effect: Effect;
target?: string;
flags?: Record<string, string>;
/** THE VERB ANSWERS, THEN WAITS FOR A PERSON ⟨2026-09-09⟩. `show` on a face
* channel does not finish when the picture lands — it finishes when the
* person presses one of its doors. A runner that reads this knows the call
* has a SECOND half, and can hand the caller a task handle for the wait
* instead of an answer that looks finished while the decision is still open.
* Absent means the verb is done when its process is. */
waits?: "decision";
}
export interface Derived {
skill: string;
managed: boolean;
requires: string[];
backend?: "retired";
verbs: Record<string, VerbContract>;
}
interface Override {
drop?: string[];
backend?: "retired" | "none";
verbs?: Record<string, Partial<VerbContract> & { drop?: boolean }>;
note?: string;
}
function kebab(word: string): string {
return word.replace(/([a-z0-9])([A-Z])/gu, "$1-$2").toLowerCase().replace(/_/gu, "-");
}
/** Everything from the direct-invocation guard to the end of the file. */
function cliBlock(text: string): { block: string; guard: string; at: number } | null {
const m = /^(\s*)if\s*\(\s*(invokedDirectly|isMain|import\.meta\.url\s*===\s*`file:\/\/\$\{[^`]*\}`)\s*\)/mu.exec(text);
if (m === null) return null;
return { block: text.slice(m.index), guard: m[2]!, at: m.index };
}
function argsFromUsage(body: string, verb: string): string[] | null {
const esc = verb.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
const m = new RegExp(`[Uu]sage:[^"'\`\\n]*?\\b${esc}\\b([^"'\`\\n]*)`, "u").exec(body);
if (m === null) return null;
const out: string[] = [];
for (const t of m[1]!.matchAll(/<([a-zA-Z][\w-]*)>|\[([a-zA-Z][\w-]*)\]/gu)) {
if (t[1] !== undefined) out.push(kebab(t[1]));
else if (t[2] !== undefined) out.push(`${kebab(t[2])}?`);
}
return out;
}
function argsFromDestructure(body: string): string[] | null {
const m = /const\s*\[\s*([a-zA-Z_$][\w$,\s]*)\]\s*=\s*(?:args|rest)\b/u.exec(body);
if (m === null) return null;
return m[1]!.split(",").map((w) => w.trim()).filter((w) => w !== "" && w !== "_").map(kebab);
}
/** An index-derived word is ALWAYS optional: a case that truly requires one
* printed a `Usage:` line, which the reader above already caught. Declaring it
* required would refuse a call the hand would have answered. */
function argsFromIndex(body: string): string[] | null {
const named = new Map<number, string>();
for (const m of body.matchAll(/(?:const|let)\s+([a-zA-Z_$][\w$]*)\s*(?::[^=]+)?=\s*(?:Number\()?args\[(\d)\]/gu)) {
named.set(Number(m[2]), kebab(m[1]!));
}
let max = -1;
for (const m of body.matchAll(/\bargs\[(\d)\]/gu)) max = Math.max(max, Number(m[1]));
if (max < 0) return null;
return Array.from({ length: max + 1 }, (_, i) => `${named.get(i) ?? `arg${i + 1}`}?`);
}
function flagsOf(body: string): Record<string, string> {
const out: Record<string, string> = {};
for (const m of body.matchAll(/indexOf\(\s*["'](--[a-z][a-z0-9-]{1,30})["']/gu)) out[kebab(m[1]!.slice(2))] = m[1]!;
return out;
}
function effectOf(skill: string, verb: string, args: string[]): { effect: Effect; target?: string } {
let effect: Effect;
if (PAY.has(verb)) effect = "pay";
else if (DELETE.has(verb)) effect = "delete";
else if (SEND.has(verb) || verb.startsWith("send-")) effect = "send";
else if (POST.has(verb)) effect = "post";
else if (READ.has(verb)) effect = "read";
else if (DRAFT.has(verb)) effect = "draft";
else effect = LOCAL_ONLY.has(skill) ? "write-reversible" : "write";
// A "post" verb on a reading skill is the NOUN, not the act: `post <slug>`
// reads one post. A verb set cannot see that; the argument words can.
if (verb === "post" && args.some((a) => /(post|slug|id)/u.test(a))) effect = "read";
if (LOCAL_ONLY.has(skill) && effect === "write") effect = "write-reversible";
const target = args.map((a) => a.replace(/\?$/u, "")).find((a) => TARGET_WORDS.includes(a));
return target === undefined ? { effect } : { effect, target };
}
/**
* `text` is the hand's OWN api.ts — the verbs, the argument words, the flags and
* the retired-backend test are all read out of the code that RUNS in this file,
* and a hand does not inherit its imports' `switch` cases.
*
* `envSource` is the text the REQUIREMENT is read out of, and it is wider on
* purpose: the hand plus the local modules it imports (see the note above).
* Defaults to `text` so a caller holding only one file still gets an answer —
* the narrower one, which is why the CLI below always passes the walk.
*/
export function derive(skill: string, text: string, override: Override = {}, envSource: string = text): Derived {
const cli = cliBlock(text);
const block = cli === null ? "" : cli.block;
const verbs: Record<string, VerbContract> = {};
const dropped = new Set(override.drop ?? []);
const push = (name: string, scan: string): void => {
if (name === "contract" || name === "help" || name in verbs || dropped.has(name)) return;
let args = argsFromUsage(scan, name);
if (args === null || args.length === 0) args = argsFromDestructure(scan);
if (args === null) args = argsFromIndex(scan);
if (args === null) args = [];
const { effect, target } = effectOf(skill, name, args);
const flags = flagsOf(scan);
verbs[name] = { args, effect, ...(target === undefined ? {} : { target }), ...(Object.keys(flags).length === 0 ? {} : { flags }) };
};
// A `main(argv)` dispatcher sits ABOVE the guard, so a block that carries no
// case falls back to the whole file. Both readers are the code's own switch.
const scanIn = (source: string): void => {
const cases = [...source.matchAll(/case\s+["']([a-z][a-z0-9-]{0,39})["']\s*:/gu)];
for (let i = 0; i < cases.length; i += 1) {
const start = cases[i]!.index + cases[i]![0].length;
const end = i + 1 < cases.length ? cases[i + 1]!.index : source.length;
const body = source.slice(start, end);
const empty = /^\s*(case\s|$)/u.test(body);
push(cases[i]![1]!, empty ? source.slice(start, Math.min(source.length, start + 1500)) : body);
}
const chain = [...source.matchAll(/\bcmd\s*===\s*["']([a-z][a-z0-9-]{0,39})["']/gu)];
for (let i = 0; i < chain.length; i += 1) {
const end = i + 1 < chain.length ? chain[i + 1]!.index : source.length;
push(chain[i]![1]!, source.slice(chain[i]!.index, end));
}
};
scanIn(block);
if (Object.keys(verbs).length === 0) scanIn(text);
for (const [name, patch] of Object.entries(override.verbs ?? {})) {
if (patch.drop === true) { delete verbs[name]; continue; }
const { drop: _drop, ...fields } = patch;
verbs[name] = { args: [], effect: "write", ...verbs[name], ...fields } as VerbContract;
}
const requires = requiredEnvKeys(envSource);
const retired = override.backend === "none" ? false : override.backend === "retired" || REACHES_RETIRED_BACKEND.test(text);
const ordered: Record<string, VerbContract> = {};
for (const name of Object.keys(verbs).sort()) ordered[name] = verbs[name]!;
return {
skill,
// `managed` is the boolean that PAIRS with `requires`, so it reads the same
// text: a hand whose credential is spent inside an imported skill is still
// a hand the loader hands keys to.
managed: /from\s+["']\.\.\/snappy-settings\/load\.ts["']/u.test(envSource) && requires.length > 0,
requires,
...(retired ? { backend: "retired" as const } : {}),
verbs: ordered,
};
}
/** The literal a person reads in the file — the contract, then the `contract`
* verb, wired to the file's OWN direct-invocation guard so it fires exactly
* when the CLI below it fires and needs no import the file does not have. */
function contractSource(d: Derived, guard: string): string {
const lines: string[] = [];
lines.push("/** WHAT THIS HAND ANSWERS, and what each verb does to the world.");
lines.push(" * Derived from this file's own CLI dispatch by");
lines.push(" * `snappy-hands/contract-derive.ts` — a verb the code does not implement is");
lines.push(" * never declared here. Snappy's daemon reads it (`api.ts contract`) to");
lines.push(" * validate every call, build the argument words in order, decide whether the");
lines.push(" * act runs now or stages for the owner, and hand the child exactly the");
lines.push(" * environment keys named in `requires` — never a value, never anything else.");
if (d.backend === "retired") {
lines.push(" *");
lines.push(" * `backend: \"retired\"` — this road's backend is BANNED (the ruling of");
lines.push(" * 2026-08-30: never read it, write it, or fall back to it). The verbs are");
lines.push(" * declared so the census can count the road honestly and Snappy can refuse");
lines.push(" * it BY NAME; nothing here is callable until the road is rebuilt. */");
} else {
lines.push(" */");
}
lines.push(`export const HAND_CONTRACT = {`);
lines.push(` skill: ${JSON.stringify(d.skill)},`);
lines.push(` managed: ${d.managed},`);
lines.push(` requires: ${JSON.stringify(d.requires)} as string[],`);
if (d.backend === "retired") lines.push(` backend: "retired",`);
lines.push(` verbs: {`);
for (const [name, v] of Object.entries(d.verbs)) {
const parts = [`args: ${JSON.stringify(v.args)}`, `effect: ${JSON.stringify(v.effect)}`];
if (v.target !== undefined) parts.push(`target: ${JSON.stringify(v.target)}`);
if (v.flags !== undefined) parts.push(`flags: ${JSON.stringify(v.flags)}`);
if (v.waits !== undefined) parts.push(`waits: ${JSON.stringify(v.waits)}`);
lines.push(` ${/^[a-z][a-z0-9]*$/u.test(name) ? name : JSON.stringify(name)}: { ${parts.join(", ")} },`);
}
lines.push(` },`);
lines.push(`} as const;`);
lines.push("");
lines.push(`if (${guard} && process.argv[2] === "contract") {`);
lines.push(` console.log(JSON.stringify(HAND_CONTRACT, null, 2));`);
lines.push(` process.exit(0);`);
lines.push(`}`);
lines.push("");
return lines.join("\n");
}
const overridesPath = join(HERE, "contract-overrides.json");
const overrides: Record<string, Override> = existsSync(overridesPath)
? JSON.parse(readFileSync(overridesPath, "utf8")) as Record<string, Override>
: {};
const write = process.argv.includes("--write");
let declared = 0; let skipped = 0; let refused = 0; let retiredCount = 0;
for (const name of readdirSync(SKILLS).sort()) {
const api = join(SKILLS, name, "api.ts");
if (!existsSync(api)) continue;
const text = readFileSync(api, "utf8");
if (/export const HAND_CONTRACT/u.test(text)) { skipped += 1; continue; }
const cli = cliBlock(text);
if (cli === null) { refused += 1; console.error(`${name}: no direct-invocation guard — cannot wire a contract verb`); continue; }
const d = derive(name, text, overrides[name] ?? {}, collectLocalSource(api, new Set<string>(), SKILLS));
if (d.backend === "retired") retiredCount += 1;
declared += 1;
if (!write) {
console.log(`${name}${d.backend === "retired" ? " RETIRED" : ""}: ${Object.keys(d.verbs).length} verbs [${Object.entries(d.verbs).map(([v, c]) => `${v}:${c.effect}`).join(" ")}]`);
continue;
}
writeFileSync(api, `${text.slice(0, cli.at)}${contractSource(d, cli.guard)}\n${text.slice(cli.at)}`);
}
console.log(`${declared} contracts ${write ? "written" : "derivable"} (${retiredCount} retired roads), ${skipped} already declared, ${refused} refused`);
{
"_": {
"note": "REVIEWED CORRECTIONS to what contract-derive.ts reads out of each api.ts, one entry per place the code's own words were ambiguous. Every line here was read from the named case in that file \u2014 never invented. A correction that is not obvious from the code carries its reason. Keys: `verbs.<name>` patches args/effect/target/flags; `verbs.<name>.drop` removes a verb the parser saw that the dispatch does not implement; `backend` forces or clears the retired-road declaration; `drop` removes several at once."
},
"snappy-ai-models": {
"note": "`image` takes a prompt and a --size flag, not a positional WxH (case \"image\": args.filter(a => !a.startsWith(\"--\")).join(\" \")).",
"verbs": {
"image": {
"args": [
"prompt"
],
"effect": "draft",
"flags": {
"size": "--size"
}
},
"embed": {
"args": [
"text"
],
"effect": "draft"
}
}
},
"snappy-gemini": {
"note": "`image` collects the prompt from non-flag words; --model, --out and --ref are flags (api.ts:191-200).",
"verbs": {
"image": {
"args": [
"prompt"
],
"effect": "draft",
"flags": {
"model": "--model",
"out": "--out",
"ref": "--ref"
}
},
"generate": {
"args": [
"prompt"
],
"effect": "draft",
"flags": {
"model": "--model"
}
},
"describe": {
"args": [
"image",
"prompt?"
],
"effect": "read"
}
}
},
"snappy-openrouter": {
"verbs": {
"chat": {
"args": [
"prompt"
],
"effect": "draft",
"flags": {
"model": "--model"
}
},
"fallback": {
"args": [
"prompt"
],
"effect": "draft",
"flags": {
"models": "--models"
}
}
}
},
"snappy-calendar": {
"note": "create/update take one JSON word (case \"create\": createEvent(JSON.parse(args[0]))). A calendar write reaches someone else's day, so it stages.",
"verbs": {
"create": {
"args": [
"event-json"
],
"effect": "write"
},
"update": {
"args": [
"event-id",
"event-json"
],
"effect": "write"
}
}
},
"snappy-inbox-sweep": {
"note": "The three fetchers READ unread mail and Slack; send-slack and send-skool are replies to people (api.ts, cmd === chain at the tail).",
"verbs": {
"slack": {
"args": [],
"effect": "read"
},
"gmail-work": {
"args": [],
"effect": "read"
},
"gmail-personal": {
"args": [],
"effect": "read"
},
"send-slack": {
"args": [
"channel",
"text"
],
"effect": "send",
"target": "channel"
},
"send-skool": {
"args": [
"post-url",
"text"
],
"effect": "post",
"target": "post-url"
},
"sweep": {
"args": [],
"effect": "write"
},
"inbox-zero": {
"args": [],
"effect": "write"
}
}
},
"snappy-outbound": {
"note": "`pick` only resolves a contact and recommends a channel \u2014 it sends nothing (api.ts:277-283).",
"verbs": {
"pick": {
"args": [
"contact-id"
],
"effect": "read"
}
}
},
"snappy-sales": {
"note": "`log` WRITES a note against a contact; the shared read-word list would have called it a read.",
"verbs": {
"log": {
"args": [
"contact-id",
"note"
],
"effect": "write",
"target": "contact-id"
}
}
},
"snappy-dom-cartographer": {
"note": "`eval` runs arbitrary JavaScript in a live page; that is actuation, never a read.",
"verbs": {
"eval": {
"effect": "write-reversible"
}
}
},
"snappy-krisp": {
"verbs": {
"fetch-meetings": {
"args": [
"after-date?",
"limit?"
],
"effect": "read"
}
}
},
"snappy-remotion": {
"verbs": {
"add": {
"args": [
"name",
"description"
],
"effect": "write-reversible"
}
}
},
"snappy-ffmpeg": {
"note": "`concat` is variadic (concat(args, out)); two paths are the floor.",
"verbs": {
"concat": {
"args": [
"path-1",
"path-2"
],
"effect": "write-reversible"
}
}
},
"snappy-hands": {
"note": "One verb, and it is not a switch: `const verb = process.argv[2] ?? \"census\"` (api.ts tail).",
"verbs": {
"census": {
"args": [],
"effect": "read",
"flags": {
"json": "--json"
}
}
}
},
"snappy-session-close": {
"note": "A `verb ===` chain, not a switch; the `case` labels the parser found belong to an unrelated inner switch over evidence kinds.",
"drop": [
"path",
"file-line",
"sha",
"command",
"file_line"
],
"verbs": {
"reconcile": {
"args": [],
"effect": "write-reversible",
"flags": {
"dry-run": "--dry-run",
"json": "--json"
}
},
"handoff": {
"args": [],
"effect": "write-reversible",
"flags": {
"out": "--out"
}
}
}
},
"snappy-walkthrough": {
"note": "main() dispatches run|capture|annotate|gates|lesson over a path (api.ts:552-606); the other cases belong to inner switches over step kinds and annotation shapes.",
"drop": [
"macos-window",
"browser",
"file-render",
"terminal-exec",
"box",
"arrow",
"callout",
"pin"
],
"verbs": {
"run": {
"args": [
"recipe"
],
"effect": "write-reversible"
},
"capture": {
"args": [
"recipe"
],
"effect": "write-reversible"
},
"annotate": {
"args": [
"out-dir"
],
"effect": "write-reversible"
},
"gates": {
"args": [
"out-dir"
],
"effect": "read"
},
"lesson": {
"args": [
"out-dir"
],
"effect": "read"
}
}
},
"snappy-artifact-loop": {
"verbs": {
"channels": {
"args": [],
"effect": "read"
},
"choose": {
"args": [
"opts-json?"
],
"effect": "read"
},
"envelope": {
"args": [
"kind",
"payload-json",
"page-key?"
],
"effect": "read"
},
"parse": {
"args": [
"text?"
],
"effect": "read"
},
"kit": {
"args": [
"kind",
"page-key"
],
"effect": "read"
}
}
},
"snappy-skill": {
"verbs": {
"scaffold": {
"args": [
"name"
],
"effect": "write-reversible"
}
}
},
"snappy-sync": {
"note": "`share` uploads a local file and hands back a public URL \u2014 that is a post, not a local write.",
"verbs": {
"share": {
"args": [
"file?"
],
"effect": "post",
"flags": {
"latest-screenshot": "--latest-screenshot",
"region": "--region"
}
}
}
},
"snappy-desktop": {
"note": "`run` drives the desktop from a natural-language instruction; it can do anything a person can, so it stages.",
"verbs": {
"screenshot": {
"args": [],
"effect": "write-reversible"
},
"run": {
"args": [
"instruction"
],
"effect": "write"
}
}
},
"snappy-client-ray": {
"verbs": {
"nag": {
"args": [],
"effect": "send"
}
}
},
"snappy-inbound": {
"note": "NOT a retired road. Its only Xano word is prose inside an `info:` string telling a reader that subscriber polling is unimplemented (api.ts:36) \u2014 the code reaches nothing. The rule is that a road is retired when the CODE reaches the banned backend, never when the word appears.",
"backend": "none"
},
"snappy-maintenance": {
"note": "Retired: `xano-main` is one of its uptime probe targets (api.ts:31), and probing the banned host is still reaching it. `all` runs every check and reports.",
"backend": "retired",
"verbs": {
"all": {
"args": [],
"effect": "read"
}
}
},
"snappy-xano-dashboard": {
"note": "Retired: every verb drives https://xano.snappy.ai through a stored browser session (api.ts:20, 33). The tightened host regex does not see that URL, so the declaration says it.",
"backend": "retired"
},
"snappy-update": {
"note": "`commits <owner/repo> [days]` READS recent commits (api.ts:70-74).",
"verbs": {
"commits": {
"args": [
"repo",
"days?"
],
"effect": "read"
}
}
},
"snappy-whatsapp": {
"note": "`media` sends an image or video to a person, exactly as `send` sends words.",
"verbs": {
"media": {
"args": [
"url",
"caption?"
],
"effect": "send"
}
}
},
"snappy-report-publish": {
"verbs": {
"verify": {
"args": [
"url",
"out?"
],
"effect": "read"
},
"publish": {
"args": [
"hub-dir",
"project"
],
"effect": "post"
},
"gen-batch": {
"args": [
"prompts-file",
"out-dir"
],
"effect": "draft"
},
"style": {
"args": [],
"effect": "read"
},
"update-index": {
"args": [
"hub-dir",
"slug",
"title",
"description",
"date"
],
"effect": "write-reversible"
}
}
},
"snappy-nightshift": {
"note": "`blocked` parks a local record (parkBlocked, api.ts:342). `reap` removes worktrees on this Mac only with --apply. `log` reads the run log.",
"verbs": {
"blocked": {
"args": [
"id",
"failed",
"t1",
"t2",
"unblock"
],
"effect": "write-reversible"
},
"reap": {
"args": [],
"effect": "delete",
"flags": {
"apply": "--apply"
}
},
"log": {
"args": [],
"effect": "read"
}
}
},
"snappy-dashboard": {
"note": "A scaffolded stub whose CLI has no verbs; it declares none so the census reads it honestly."
},
"snappy-swarm": {
"note": "A scaffolded stub whose CLI has no verbs; it declares none so the census reads it honestly."
},
"snappy-client-template": {
"note": "A template with no verbs; it declares none."
},
"snappy-chain": {
"note": "A flag-only CLI (--dry, --llm) with no named verb; it declares none rather than inventing one."
},
"snappy-dispatch": {
"note": "A flag-only CLI whose one argument is a prompt; it declares no named verb."
},
"snappy-shell": {
"note": "A REPL with flag arguments and no named verb; it declares none."
},
"snappy-os-operator": {
"note": "The operator control plane. `approve`/`deny`/`stage`/`grant-*`/`script-*` are the owner's own decisions and stage; the listings and health are reads.",
"verbs": {
"health": {
"args": [],
"effect": "read"
},
"deploy-truth": {
"args": [],
"effect": "read"
},
"runs": {
"args": [
"script-id?"
],
"effect": "read"
},
"run": {
"args": [
"id"
],
"effect": "read"
},
"approvals": {
"args": [
"status?"
],
"effect": "read"
},
"session": {
"args": [],
"effect": "write-reversible"
}
}
},
"snappy-mastermind-model": {
"note": "A local corpus reader: every verb prints a slice of transcripts held on this Mac.",
"verbs": {
"ladders": {
"args": [],
"effect": "read"
},
"math": {
"args": [],
"effect": "read"
},
"funnels": {
"args": [],
"effect": "read"
},
"sales": {
"args": [],
"effect": "read"
},
"team": {
"args": [],
"effect": "read"
},
"topics": {
"args": [],
"effect": "read"
},
"transcripts": {
"args": [],
"effect": "read"
},
"read": {
"args": [
"slug"
],
"effect": "read"
},
"transcript": {
"args": [
"file"
],
"effect": "read"
},
"grep": {
"args": [
"term"
],
"effect": "read"
}
}
},
"snappy-telegram": {
"note": "show sends the face and then waits on the owner's thumb; the press comes back through decisions. Declared so a re-derive keeps it.",
"verbs": {
"show": {
"waits": "decision"
}
}
}
}
{
"_": {
"note": "REVIEWED CORRECTIONS to what contract-derive.ts reads out of each api.ts, one entry per place the code's own words were ambiguous. Every line here was read from the named case in that file \u2014 never invented. A correction that is not obvious from the code carries its reason. Keys: `verbs.<name>` patches args/effect/target/flags; `verbs.<name>.drop` removes a verb the parser saw that the dispatch does not implement; `backend` forces or clears the retired-road declaration; `drop` removes several at once."
},
"snappy-ai-models": {
"note": "`image` takes a prompt and a --size flag, not a positional WxH (case \"image\": args.filter(a => !a.startsWith(\"--\")).join(\" \")).",
"verbs": {
"image": {
"args": [
"prompt"
],
"effect": "draft",
"flags": {
"size": "--size"
}
},
"embed": {
"args": [
"text"
],
"effect": "draft"
}
}
},
"snappy-gemini": {
"note": "`image` collects the prompt from non-flag words; --model, --out and --ref are flags (api.ts:191-200).",
"verbs": {
"image": {
"args": [
"prompt"
],
"effect": "draft",
"flags": {
"model": "--model",
"out": "--out",
"ref": "--ref"
}
},
"generate": {
"args": [
"prompt"
],
"effect": "draft",
"flags": {
"model": "--model"
}
},
"describe": {
"args": [
"image",
"prompt?"
],
"effect": "read"
}
}
},
"snappy-openrouter": {
"verbs": {
"chat": {
"args": [
"prompt"
],
"effect": "draft",
"flags": {
"model": "--model"
}
},
"fallback": {
"args": [
"prompt"
],
"effect": "draft",
"flags": {
"models": "--models"
}
}
}
},
"snappy-calendar": {
"note": "create/update take one JSON word (case \"create\": createEvent(JSON.parse(args[0]))). A calendar write reaches someone else's day, so it stages.",
"verbs": {
"create": {
"args": [
"event-json"
],
"effect": "write"
},
"update": {
"args": [
"event-id",
"event-json"
],
"effect": "write"
}
}
},
"snappy-inbox-sweep": {
"note": "The three fetchers READ unread mail and Slack; send-slack and send-skool are replies to people (api.ts, cmd === chain at the tail).",
"verbs": {
"slack": {
"args": [],
"effect": "read"
},
"gmail-work": {
"args": [],
"effect": "read"
},
"gmail-personal": {
"args": [],
"effect": "read"
},
"send-slack": {
"args": [
"channel",
"text"
],
"effect": "send",
"target": "channel"
},
"send-skool": {
"args": [
"post-url",
"text"
],
"effect": "post",
"target": "post-url"
},
"sweep": {
"args": [],
"effect": "write"
},
"inbox-zero": {
"args": [],
"effect": "write"
}
}
},
"snappy-outbound": {
"note": "`pick` only resolves a contact and recommends a channel \u2014 it sends nothing (api.ts:277-283).",
"verbs": {
"pick": {
"args": [
"contact-id"
],
"effect": "read"
}
}
},
"snappy-sales": {
"note": "`log` WRITES a note against a contact; the shared read-word list would have called it a read.",
"verbs": {
"log": {
"args": [
"contact-id",
"note"
],
"effect": "write",
"target": "contact-id"
}
}
},
"snappy-dom-cartographer": {
"note": "`eval` runs arbitrary JavaScript in a live page; that is actuation, never a read.",
"verbs": {
"eval": {
"effect": "write-reversible"
}
}
},
"snappy-krisp": {
"verbs": {
"fetch-meetings": {
"args": [
"after-date?",
"limit?"
],
"effect": "read"
}
}
},
"snappy-remotion": {
"verbs": {
"add": {
"args": [
"name",
"description"
],
"effect": "write-reversible"
}
}
},
"snappy-ffmpeg": {
"note": "`concat` is variadic (concat(args, out)); two paths are the floor.",
"verbs": {
"concat": {
"args": [
"path-1",
"path-2"
],
"effect": "write-reversible"
}
}
},
"snappy-hands": {
"note": "One verb, and it is not a switch: `const verb = process.argv[2] ?? \"census\"` (api.ts tail).",
"verbs": {
"census": {
"args": [],
"effect": "read",
"flags": {
"json": "--json"
}
}
}
},
"snappy-session-close": {
"note": "A `verb ===` chain, not a switch; the `case` labels the parser found belong to an unrelated inner switch over evidence kinds.",
"drop": [
"path",
"file-line",
"sha",
"command",
"file_line"
],
"verbs": {
"reconcile": {
"args": [],
"effect": "write-reversible",
"flags": {
"dry-run": "--dry-run",
"json": "--json"
}
},
"handoff": {
"args": [],
"effect": "write-reversible",
"flags": {
"out": "--out"
}
}
}
},
"snappy-walkthrough": {
"note": "main() dispatches run|capture|annotate|gates|lesson over a path (api.ts:552-606); the other cases belong to inner switches over step kinds and annotation shapes.",
"drop": [
"macos-window",
"browser",
"file-render",
"terminal-exec",
"box",
"arrow",
"callout",
"pin"
],
"verbs": {
"run": {
"args": [
"recipe"
],
"effect": "write-reversible"
},
"capture": {
"args": [
"recipe"
],
"effect": "write-reversible"
},
"annotate": {
"args": [
"out-dir"
],
"effect": "write-reversible"
},
"gates": {
"args": [
"out-dir"
],
"effect": "read"
},
"lesson": {
"args": [
"out-dir"
],
"effect": "read"
}
}
},
"snappy-artifact-loop": {
"verbs": {
"channels": {
"args": [],
"effect": "read"
},
"choose": {
"args": [
"opts-json?"
],
"effect": "read"
},
"envelope": {
"args": [
"kind",
"payload-json",
"page-key?"
],
"effect": "read"
},
"parse": {
"args": [
"text?"
],
"effect": "read"
},
"kit": {
"args": [
"kind",
"page-key"
],
"effect": "read"
}
}
},
"snappy-skill": {
"verbs": {
"scaffold": {
"args": [
"name"
],
"effect": "write-reversible"
}
}
},
"snappy-sync": {
"note": "`share` uploads a local file and hands back a public URL \u2014 that is a post, not a local write.",
"verbs": {
"share": {
"args": [
"file?"
],
"effect": "post",
"flags": {
"latest-screenshot": "--latest-screenshot",
"region": "--region"
}
}
}
},
"snappy-desktop": {
"note": "`run` drives the desktop from a natural-language instruction; it can do anything a person can, so it stages.",
"verbs": {
"screenshot": {
"args": [],
"effect": "write-reversible"
},
"run": {
"args": [
"instruction"
],
"effect": "write"
}
}
},
"snappy-client-ray": {
"verbs": {
"nag": {
"args": [],
"effect": "send"
}
}
},
"snappy-inbound": {
"note": "NOT a retired road. Its only Xano word is prose inside an `info:` string telling a reader that subscriber polling is unimplemented (api.ts:36) \u2014 the code reaches nothing. The rule is that a road is retired when the CODE reaches the banned backend, never when the word appears.",
"backend": "none"
},
"snappy-maintenance": {
"note": "Retired: `xano-main` is one of its uptime probe targets (api.ts:31), and probing the banned host is still reaching it. `all` runs every check and reports.",
"backend": "retired",
"verbs": {
"all": {
"args": [],
"effect": "read"
}
}
},
"snappy-xano-dashboard": {
"note": "Retired: every verb drives https://xano.snappy.ai through a stored browser session (api.ts:20, 33). The tightened host regex does not see that URL, so the declaration says it.",
"backend": "retired"
},
"snappy-update": {
"note": "`commits <owner/repo> [days]` READS recent commits (api.ts:70-74).",
"verbs": {
"commits": {
"args": [
"repo",
"days?"
],
"effect": "read"
}
}
},
"snappy-whatsapp": {
"note": "`media` sends an image or video to a person, exactly as `send` sends words.",
"verbs": {
"media": {
"args": [
"url",
"caption?"
],
"effect": "send"
}
}
},
"snappy-report-publish": {
"verbs": {
"verify": {
"args": [
"url",
"out?"
],
"effect": "read"
},
"publish": {
"args": [
"hub-dir",
"project"
],
"effect": "post"
},
"gen-batch": {
"args": [
"prompts-file",
"out-dir"
],
"effect": "draft"
},
"style": {
"args": [],
"effect": "read"
},
"update-index": {
"args": [
"hub-dir",
"slug",
"title",
"description",
"date"
],
"effect": "write-reversible"
}
}
},
"snappy-nightshift": {
"note": "`blocked` parks a local record (parkBlocked, api.ts:342). `reap` removes worktrees on this Mac only with --apply. `log` reads the run log.",
"verbs": {
"blocked": {
"args": [
"id",
"failed",
"t1",
"t2",
"unblock"
],
"effect": "write-reversible"
},
"reap": {
"args": [],
"effect": "delete",
"flags": {
"apply": "--apply"
}
},
"log": {
"args": [],
"effect": "read"
}
}
},
"snappy-dashboard": {
"note": "A scaffolded stub whose CLI has no verbs; it declares none so the census reads it honestly."
},
"snappy-swarm": {
"note": "A scaffolded stub whose CLI has no verbs; it declares none so the census reads it honestly."
},
"snappy-client-template": {
"note": "A template with no verbs; it declares none."
},
"snappy-chain": {
"note": "A flag-only CLI (--dry, --llm) with no named verb; it declares none rather than inventing one."
},
"snappy-dispatch": {
"note": "A flag-only CLI whose one argument is a prompt; it declares no named verb."
},
"snappy-shell": {
"note": "A REPL with flag arguments and no named verb; it declares none."
},
"snappy-os-operator": {
"note": "The operator control plane. `approve`/`deny`/`stage`/`grant-*`/`script-*` are the owner's own decisions and stage; the listings and health are reads.",
"verbs": {
"health": {
"args": [],
"effect": "read"
},
"deploy-truth": {
"args": [],
"effect": "read"
},
"runs": {
"args": [
"script-id?"
],
"effect": "read"
},
"run": {
"args": [
"id"
],
"effect": "read"
},
"approvals": {
"args": [
"status?"
],
"effect": "read"
},
"session": {
"args": [],
"effect": "write-reversible"
}
}
},
"snappy-mastermind-model": {
"note": "A local corpus reader: every verb prints a slice of transcripts held on this Mac.",
"verbs": {
"ladders": {
"args": [],
"effect": "read"
},
"math": {
"args": [],
"effect": "read"
},
"funnels": {
"args": [],
"effect": "read"
},
"sales": {
"args": [],
"effect": "read"
},
"team": {
"args": [],
"effect": "read"
},
"topics": {
"args": [],
"effect": "read"
},
"transcripts": {
"args": [],
"effect": "read"
},
"read": {
"args": [
"slug"
],
"effect": "read"
},
"transcript": {
"args": [
"file"
],
"effect": "read"
},
"grep": {
"args": [
"term"
],
"effect": "read"
}
}
},
"snappy-telegram": {
"note": "show sends the face and then waits on the owner's thumb; the press comes back through decisions. Declared so a re-derive keeps it.",
"verbs": {
"show": {
"waits": "decision"
}
}
}
}
import { test } from "node:test";
import assert from "node:assert/strict";
import { HAND_CONTRACT } from "./api.ts";
import { REFUSAL_CODES, type RefusalCode } from "../snappy-settings/refusal-codes.ts";
/** RULE 33 COVERAGE, AGAINST THE ONE CLOSED TABLE ⟨lane r30, 2026-09-09⟩.
* This file used to restate a refusal row's own properties — that it has a
* `contract_slice`, that it has a `fix`, that it leaks no token — once per
* hand, 48 times, over rows that all come from the SAME object. Forty-eight
* copies of one check is the duplicate road the closed table exists to end:
* `snappy-settings/refusal-codes.test.ts` runs those checks ONCE over every
* row, and the second test below proves this hand carries THE ROW ITSELF and
* not a copy — an identity a drifted duplicate cannot fake. Before this, each
* hand hand-wrote its own row, and the wording had already drifted: the
* inline `unknown_verb` said "Call one of the verbs named in
* HAND_CONTRACT.verbs" while the closed table says "Call one of the verbs the
* contract declares; the refusal lists them."
*
* DECLARED stays a literal list, deliberately. It is this hand's coverage
* manifest and it is what rule 33's lint reads out of the test SOURCE to ask
* "did a person look at this code" — deriving it from Object.keys would make
* the test pass for a hand with no refusals at all. `satisfies readonly
* RefusalCode[]` makes the compiler refuse a name the one table does not
* have. It NAMES codes; it no longer DEFINES them. */
const DECLARED = [
"unknown_verb",
"missing_credential",
// ⟨lane mini-reads, 2026-09-09⟩ `census` PRINTED this whenever the Snappy OS
// app was not running — the way it failed from the mini — and the table
// never named it, so no caller could enumerate it.
"service_unavailable",
"upstream_error",
] as const satisfies readonly RefusalCode[];
test("snappy-hands: the refusal table declares exactly the codes this test names", () => {
assert.deepEqual(Object.keys(HAND_CONTRACT.refusals ?? {}).sort(), [...DECLARED].sort());
});
test("snappy-hands: every declared refusal is the one closed table's own row, never a copy", () => {
const declared = Object.entries(HAND_CONTRACT.refusals ?? {});
assert.ok(declared.length > 0, "HAND_CONTRACT.refusals is empty");
for (const [code, row] of declared) {
assert.ok(code in REFUSAL_CODES, `${code} is not a row of snappy-settings/refusal-codes.ts`);
assert.equal(row, REFUSAL_CODES[code as RefusalCode], `${code} is a copy of the closed table's row, not the row itself`);
}
});
/**
* THE READ THAT NEEDS THE OLD APP SAYS SO, AND OFFERS NOTHING ELSE
* ⟨lane mini-reads, 2026-09-09⟩.
*
* RED BEFORE: `census` was the verb the owner's bar picked, and on a Mac where
* the Snappy OS app is not running it printed
* "…did not answer … start it, or ask the person whose Mac this is" — a second
* road out of a refusal that has exactly one — while the contract said nothing
* about the dependency at all, so a picker had no way to choose a different
* read. The contract now names the app.
*/
test("snappy-hands: census names the Snappy OS app as the thing it needs", () => {
const resources = (HAND_CONTRACT as { resources?: Record<string, { kind: string; name: string }> }).resources ?? {};
assert.deepEqual(Object.keys(resources), ["snappy-os-app"]);
assert.equal(resources["snappy-os-app"].kind, "service");
assert.equal(resources["snappy-os-app"].name, "the Snappy OS app");
});
test("snappy-hands: the census refusal is one sentence about the app, with no second road", async () => {
const { census } = await import("./api.ts");
const before = process.env.SNAPPY_OS_BASE;
process.env.SNAPPY_OS_BASE = "http://127.0.0.1:1";
try {
const answer = await census();
assert.equal(answer.ok, false);
if (answer.ok) return;
assert.ok(answer.detail.includes("the Snappy OS app"), answer.detail);
assert.ok(!/ask the person/i.test(answer.detail), `the refusal still offers a second road: ${answer.detail}`);
} finally {
if (before === undefined) delete process.env.SNAPPY_OS_BASE;
else process.env.SNAPPY_OS_BASE = before;
}
});
import { test } from "node:test";
import assert from "node:assert/strict";
import { HAND_CONTRACT } from "./api.ts";
import { REFUSAL_CODES, type RefusalCode } from "../snappy-settings/refusal-codes.ts";
/** RULE 33 COVERAGE, AGAINST THE ONE CLOSED TABLE ⟨lane r30, 2026-09-09⟩.
* This file used to restate a refusal row's own properties — that it has a
* `contract_slice`, that it has a `fix`, that it leaks no token — once per
* hand, 48 times, over rows that all come from the SAME object. Forty-eight
* copies of one check is the duplicate road the closed table exists to end:
* `snappy-settings/refusal-codes.test.ts` runs those checks ONCE over every
* row, and the second test below proves this hand carries THE ROW ITSELF and
* not a copy — an identity a drifted duplicate cannot fake. Before this, each
* hand hand-wrote its own row, and the wording had already drifted: the
* inline `unknown_verb` said "Call one of the verbs named in
* HAND_CONTRACT.verbs" while the closed table says "Call one of the verbs the
* contract declares; the refusal lists them."
*
* DECLARED stays a literal list, deliberately. It is this hand's coverage
* manifest and it is what rule 33's lint reads out of the test SOURCE to ask
* "did a person look at this code" — deriving it from Object.keys would make
* the test pass for a hand with no refusals at all. `satisfies readonly
* RefusalCode[]` makes the compiler refuse a name the one table does not
* have. It NAMES codes; it no longer DEFINES them. */
const DECLARED = [
"unknown_verb",
"missing_credential",
// ⟨lane mini-reads, 2026-09-09⟩ `census` PRINTED this whenever the Snappy OS
// app was not running — the way it failed from the mini — and the table
// never named it, so no caller could enumerate it.
"service_unavailable",
"upstream_error",
] as const satisfies readonly RefusalCode[];
test("snappy-hands: the refusal table declares exactly the codes this test names", () => {
assert.deepEqual(Object.keys(HAND_CONTRACT.refusals ?? {}).sort(), [...DECLARED].sort());
});
test("snappy-hands: every declared refusal is the one closed table's own row, never a copy", () => {
const declared = Object.entries(HAND_CONTRACT.refusals ?? {});
assert.ok(declared.length > 0, "HAND_CONTRACT.refusals is empty");
for (const [code, row] of declared) {
assert.ok(code in REFUSAL_CODES, `${code} is not a row of snappy-settings/refusal-codes.ts`);
assert.equal(row, REFUSAL_CODES[code as RefusalCode], `${code} is a copy of the closed table's row, not the row itself`);
}
});
/**
* THE READ THAT NEEDS THE OLD APP SAYS SO, AND OFFERS NOTHING ELSE
* ⟨lane mini-reads, 2026-09-09⟩.
*
* RED BEFORE: `census` was the verb the owner's bar picked, and on a Mac where
* the Snappy OS app is not running it printed
* "…did not answer … start it, or ask the person whose Mac this is" — a second
* road out of a refusal that has exactly one — while the contract said nothing
* about the dependency at all, so a picker had no way to choose a different
* read. The contract now names the app.
*/
test("snappy-hands: census names the Snappy OS app as the thing it needs", () => {
const resources = (HAND_CONTRACT as { resources?: Record<string, { kind: string; name: string }> }).resources ?? {};
assert.deepEqual(Object.keys(resources), ["snappy-os-app"]);
assert.equal(resources["snappy-os-app"].kind, "service");
assert.equal(resources["snappy-os-app"].name, "the Snappy OS app");
});
test("snappy-hands: the census refusal is one sentence about the app, with no second road", async () => {
const { census } = await import("./api.ts");
const before = process.env.SNAPPY_OS_BASE;
process.env.SNAPPY_OS_BASE = "http://127.0.0.1:1";
try {
const answer = await census();
assert.equal(answer.ok, false);
if (answer.ok) return;
assert.ok(answer.detail.includes("the Snappy OS app"), answer.detail);
assert.ok(!/ask the person/i.test(answer.detail), `the refusal still offers a second road: ${answer.detail}`);
} finally {
if (before === undefined) delete process.env.SNAPPY_OS_BASE;
else process.env.SNAPPY_OS_BASE = before;
}
});
{
"name": "snappy-hands",
"lockfileVersion": 3,
"requires": true,
"packages": {}
}
{
"name": "snappy-hands",
"lockfileVersion": 3,
"requires": true,
"packages": {}
}
{ "type": "module", "private": true }
{ "type": "module", "private": true }