diff --git a/.claude/skills/add-imessage/SKILL.md b/.claude/skills/add-imessage/SKILL.md index 263c2cd22..72e59d7ca 100644 --- a/.claude/skills/add-imessage/SKILL.md +++ b/.claude/skills/add-imessage/SKILL.md @@ -89,6 +89,15 @@ How should iMessage run — `local` (this Mac, needs Full Disk Access) or `remot Requirements: macOS, with Full Disk Access granted to the Node binary. Without it the adapter can't read `chat.db` and inbound messages never arrive. +Local mode only works on a Mac — it reads this machine's iMessage `chat.db` +directly, and there is no such database off macOS. On any other OS, stop here and +use remote (Photon) mode instead; otherwise you'd write a local config that can +never receive a message: + +```nc:run effect:check when:mode=local +[ "$(uname)" = Darwin ] +``` + The Node binary path is buried deep (e.g. `~/.nvm/versions/node/v22.x.x/bin/node`), so open its folder in Finder to make the drag-and-drop target obvious. Harmless off a desktop (SSH/headless) — it just no-ops: diff --git a/.claude/skills/add-signal/SKILL.md b/.claude/skills/add-signal/SKILL.md index a7b575583..de859f8e1 100644 --- a/.claude/skills/add-signal/SKILL.md +++ b/.claude/skills/add-signal/SKILL.md @@ -84,7 +84,17 @@ Signal account is verified manually once the service runs. This is the whole credential step. signal-cli opens a device-link handshake, prints a `sgnl://linkdevice…` URL, and renders it as a scannable QR. You scan it once from the phone that already runs Signal; that phone's number becomes the -account NanoClaw sends and receives as — no number is registered. Tell the user: +account NanoClaw sends and receives as — no number is registered. + +The device-link runs signal-cli, so it must be reachable first — on `PATH`, or at +`$SIGNAL_CLI_PATH`. If step 1's install didn't land, the link step has nothing to +drive; confirm it's present before linking (re-run step 1 if this fails): + +```nc:run effect:check +command -v signal-cli >/dev/null 2>&1 || [ -x "$SIGNAL_CLI_PATH" ] +``` + +Tell the user: ```nc:operator Link NanoClaw to your Signal account: diff --git a/.claude/skills/add-teams/SKILL.md b/.claude/skills/add-teams/SKILL.md index 033b07db0..7c4c889d4 100644 --- a/.claude/skills/add-teams/SKILL.md +++ b/.claude/skills/add-teams/SKILL.md @@ -176,6 +176,14 @@ Enable the Microsoft Teams channel on the bot: ### Build the Teams app package +The manifest bakes in the Application (client) ID, so the Azure app registration +above must be done first — building with a blank `app_id` produces a package that +no bot can claim. Confirm it's set before generating the zip: + +```nc:run effect:check +[ -n "{{app_id}}" ] +``` + Generate the zip you'll sideload into Teams (manifest + icons, written to `data/teams/teams-app-package.zip`). Re-running regenerates a fresh zip, so this is safe to repeat. diff --git a/.claude/skills/add-whatsapp/SKILL.md b/.claude/skills/add-whatsapp/SKILL.md index a2fa8d8ed..b171931a2 100644 --- a/.claude/skills/add-whatsapp/SKILL.md +++ b/.claude/skills/add-whatsapp/SKILL.md @@ -121,6 +121,14 @@ pnpm exec tsx setup/index.ts --step whatsapp-auth -- --method pairing-code --pho If the handshake fails (`logged_out` or a timeout), the code expired — clear `store/auth/` and run the step again for a fresh one. See Troubleshooting. +A successful link reports the number back as `bot_phone`. If it came back empty, +the device never confirmed (an expired QR or pairing code), so don't restart or +wire against a blank number — clear `store/auth/` and re-run the link step first: + +```nc:run effect:check +[ -n "{{bot_phone}}" ] +``` + ## Restart Restart the service so it loads the WhatsApp adapter and picks up the diff --git a/scripts/skill-apply.test.ts b/scripts/skill-apply.test.ts index 1425765e2..3e61912d4 100644 --- a/scripts/skill-apply.test.ts +++ b/scripts/skill-apply.test.ts @@ -683,6 +683,100 @@ describe('run-health gate (a bounce blocks later side effects)', () => { }); }); +// effect:check runs the body as a shell PREDICATE — a precondition gate that +// mutates NOTHING (no journal, no capture). A non-zero exit bounces to an agent +// AND latches `blocked`, so a following dangerous side effect (a restart) is +// gated. An unresolved {{var}} defers (a headless rebuild before the value is +// collected). A zero exit is a silent pass. +const CHECK_GATE_SKILL = `# check gate demo + +## Require macOS for local mode +\`\`\`nc:run effect:check +[ "$(uname)" = Darwin ] +\`\`\` + +## Restart the service +\`\`\`nc:run effect:restart +bash restart.sh +\`\`\` +`; + +const CHECK_VAR_SKILL = `# check var demo + +## Collect the linked number +\`\`\`nc:prompt bot_phone +The linked number. +\`\`\` + +## Guard the captured value before using it +\`\`\`nc:run effect:check +[ -n "{{bot_phone}}" ] +\`\`\` +`; + +const CHECK_PASS_SKILL = `# check pass demo + +## A precondition that passes +\`\`\`nc:run effect:check +true +\`\`\` +`; + +describe('nc:run effect:check (precondition gate)', () => { + let chkSkill: string; + let chkRoot: string; + beforeEach(() => { + chkSkill = mkdtempSync(join(tmpdir(), 'nc-check-skill-')); + chkRoot = mkdtempSync(join(tmpdir(), 'nc-check-proj-')); + writeFileSync(join(chkRoot, 'package.json'), '{"name":"scratch"}'); + writeFileSync(join(chkRoot, '.env'), ''); + }); + + it('a non-zero check bounces to an agent and gates a following effect:restart', async () => { + writeFileSync(join(chkSkill, 'SKILL.md'), CHECK_GATE_SKILL); + const cmds: string[] = []; + const exec = (c: string): string | void => { + cmds.push(c); + if (c.startsWith('[')) throw new Error('exit 1'); // predicate failed (non-zero) + }; + const res = await applySkill(chkSkill, chkRoot, { inputs: {}, exec }); + + expect(cmds).toContain('[ "$(uname)" = Darwin ]'); // the predicate actually ran + expect(cmds).not.toContain('bash restart.sh'); // restart never executed — gated by the failed check + // two agent tasks: the failed check itself + the gated restart + expect(res.agentTasks).toHaveLength(2); + expect(res.agentTasks[0].kind).toBe('run'); + expect(res.agentTasks.some((t) => /an earlier step did not complete/.test(t.reason))).toBe(true); + expect(res.journal).toEqual([]); // a check mutates nothing + }); + + it('an unresolved {{var}} in a check defers (headless rebuild) — not a bounce', async () => { + writeFileSync(join(chkSkill, 'SKILL.md'), CHECK_VAR_SKILL); + const cmds: string[] = []; + const res = await applySkill(chkSkill, chkRoot, { prompter: headless({}), exec: (c) => void cmds.push(c) }); + + expect(res.deferred).toContain('bot_phone'); // the prompt deferred (no headless answer) + expect(res.deferred.some((d) => /unresolved \{\{bot_phone\}\}/.test(d))).toBe(true); // the check deferred on it + expect(res.agentTasks).toEqual([]); // a deferred check is NOT a failure — no bounce + expect(cmds.some((c) => c.startsWith('['))).toBe(false); // the predicate never ran (var unresolved) + }); + + it('a zero-exit check is a no-op — no journal entry, no bounce, no defer', async () => { + writeFileSync(join(chkSkill, 'SKILL.md'), CHECK_PASS_SKILL); + const cmds: string[] = []; + const res = await applySkill(chkSkill, chkRoot, { inputs: {}, exec: (c) => void cmds.push(c) }); + + expect(cmds).toContain('true'); // the predicate ran + expect(res.journal).toEqual([]); // mutated nothing — no 'ran' entry + expect(res.agentTasks).toEqual([]); + expect(res.deferred).toEqual([]); + }); + + it('lint accepts a check that guards an earlier-defined var', () => { + expect(validate(parseDirectives(CHECK_VAR_SKILL))).toEqual([]); + }); +}); + // The apply-lifecycle reporter brackets each real mutation (applyOne) with // stepStart/stepEnd so the setup driver can spin on the slow ones. An effectful // step (a run, a dep, a branch-fetch copy) carries a human label (the nearest diff --git a/scripts/skill-apply.ts b/scripts/skill-apply.ts index 2dd0226b4..90f925747 100644 --- a/scripts/skill-apply.ts +++ b/scripts/skill-apply.ts @@ -418,6 +418,19 @@ async function applyOne( // API (e.g. Slack conversations.open → the DM channel id) and feed it to a // later directive, so a flow that validates/resolves stays pure directives. const capture = typeof d.attrs.capture === 'string' ? d.attrs.capture : undefined; + // effect:check runs the body as a shell PREDICATE — a precondition gate + // that mutates NOTHING. It pushes no journal entry and binds no capture: a + // zero exit is a silent pass; a non-zero exit throws → the outer catch + // bounces it to an agent (which reads the prose and decides); an unresolved + // {{var}} throws from substitute first → deferred (like any other run, e.g. + // a headless rebuild before the value is collected). Because a bounce here + // latches `blocked`, a failed precondition gates the dangerous side effects + // (a restart, a pairing/QR step, a wire) that follow — a broken local + // config or an un-registered app never reaches a doomed restart/QR. + if (d.attrs.effect === 'check') { + for (const cmd of d.body) await exec(substitute(cmd, vars)); + break; + } // effect:step runs a long-running, operator-interactive step (a pairing // code, a QR device-link) through the streaming exec and binds the terminal // status block's named fields via capture:=[,…] — the structured, diff --git a/scripts/skill-directives.ts b/scripts/skill-directives.ts index 7a66640d0..93df2d5d3 100644 --- a/scripts/skill-directives.ts +++ b/scripts/skill-directives.ts @@ -20,7 +20,7 @@ // copy [from-branch:] body: `PATH` (src==dst) or `SRC -> DST` overwrite // append to: [at:] body: line(s) to add skip if present // dep [manager:pnpm] body: `pkg@` line(s) reinstall no-op -// run [effect:build|test|fetch|external|wire|restart|step] [capture:] re-runnable +// run [effect:build|test|fetch|external|wire|restart|step|check] [capture:] re-runnable // body: shell command(s). {{vars}} are substituted in. effect:wire runs // `ncl …` to wire collected input (no undo — the rows it creates are user // runtime data, not reversed on skill remove). effect:restart restarts the @@ -34,6 +34,11 @@ // `capture:=[,=…]` binds the terminal block's // named fields into {{vars}} (multi-valued, structured twin of stdout // capture). Degrades to an agent when no streaming exec is wired. +// effect:check runs the body as a shell PREDICATE (a precondition gate): +// it mutates nothing (no journal, no capture). A zero exit passes silently; +// a non-zero exit bounces to an agent (degrade, not crash) and, via the +// run-health gate, blocks the dangerous side effects that follow it (a +// restart, a pairing/QR step, a wire). An unresolved {{var}} defers. // prompt [secret] [validate:] body: the question → binds {{var}} skip if satisfied // validate: is a regex the interactive prompter enforces (e.g. // validate:^xoxb- to require a Slack bot token); `inputs` bypass it.