From e33fa970f5752dcb229647afa5ef66d1b2b8cd3d Mon Sep 17 00:00:00 2001 From: gavrielc Date: Sat, 27 Jun 2026 16:37:12 +0300 Subject: [PATCH] =?UTF-8?q?feat(skills):=20nc:operator=20=E2=80=94=20delin?= =?UTF-8?q?eate=20operator-facing=20content=20from=20agent=20prose?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The SKILL.md is addressed to the coding agent (the interpreter), so its prose is agent-facing by default. The parts meant for the HUMAN operator — clicking through the Slack UI — were undifferentiated prose, so an agent couldn't tell "relay this to the user" from "do this yourself", and the engine couldn't render them as human steps. nc:operator is the output twin of nc:prompt (prompt: ask the operator for input; operator: give the operator instructions). Lead it with agent-facing prose like "Tell the user:" so the agent relays it. The engine renders the body via the Prompter's new optional tell() (a clack note in setup, a channel message when an agent relays; absent in a headless rebuild → skipped). {{vars}} substitute in, so a resolved value can be shown to the operator. The content model is now: agent prose (unmarked, the degrade floor) / execution directives / operator I/O (prompt in, operator out). add-slack's app-creation and event-delivery walkthroughs are now nc:operator blocks; strip the fences and each reads "Tell the user: " — natural, never narrating the engine. Dry-run on the real skill: the two operator blocks route to tell() (the human), the ncl commands to exec() (the agent) — cleanly separated. 600 tests pass. Co-Authored-By: Claude Opus 4.8 --- .claude/skills/add-slack/SKILL.md | 55 ++++++++++++------------------- scripts/skill-apply.test.ts | 29 ++++++++++++++++ scripts/skill-apply.ts | 17 +++++++++- scripts/skill-directives.test.ts | 10 ++++++ scripts/skill-directives.ts | 10 +++++- 5 files changed, 85 insertions(+), 36 deletions(-) diff --git a/.claude/skills/add-slack/SKILL.md b/.claude/skills/add-slack/SKILL.md index 4108c7a47..0d1974189 100644 --- a/.claude/skills/add-slack/SKILL.md +++ b/.claude/skills/add-slack/SKILL.md @@ -62,36 +62,20 @@ delivery against a real workspace is verified manually once the service runs. ## Credentials -Slack app setup is human and interactive — these steps are prose, not directives -(no parser can click through the Slack UI). A recipe rebuild produces a -compiling, registered adapter that cannot receive a message until they're done. +Walk the operator through creating the Slack app, then collect the two secrets it +hands back. The adapter is already installed and registered — it just can't +receive a message until this is done. Tell the user: -### Create the Slack app +```nc:operator +Create the Slack app: +1. Go to api.slack.com/apps → Create New App → From scratch. Name it (e.g. "NanoClaw") and pick your workspace. +2. OAuth & Permissions → add these Bot Token Scopes: chat:write, im:write, channels:history, groups:history, im:history, channels:read, groups:read, users:read, reactions:write, files:read, files:write. +3. App Home → enable the Messages Tab, and check "Allow users to send Slash commands and messages from the messages tab." +4. Install to Workspace, then copy the Bot User OAuth Token (starts with xoxb-). +5. Basic Information → copy the Signing Secret. +``` -1. Go to [api.slack.com/apps](https://api.slack.com/apps) → **Create New App** → **From scratch**. -2. Name it (e.g. "NanoClaw") and select your workspace. -3. **OAuth & Permissions** → add Bot Token Scopes: `chat:write`, `im:write`, `channels:history`, `groups:history`, `im:history`, `channels:read`, `groups:read`, `users:read`, `reactions:write`, `files:read`, `files:write`. -4. **Install to Workspace**, then copy the **Bot User OAuth Token** (`xoxb-…`). -5. **Basic Information** → copy the **Signing Secret**. - -### Enable DMs - -6. **App Home** → enable the **Messages Tab**. -7. Check **"Allow users to send Slash commands and messages from the messages tab."** - -### Event Subscriptions & Interactivity - -8. **Event Subscriptions** → **Enable Events**. Set the **Request URL** to your public `https://your-domain/webhook/slack` (see Webhook server); Slack sends a challenge that must pass before you can save. -9. Under **Subscribe to bot events**, add `message.channels`, `message.groups`, `message.im`, `app_mention`. **Save Changes**. -10. **Interactivity & Shortcuts** → toggle **Interactivity** on, set the same Request URL, **Save Changes**, then **reinstall** the app when Slack prompts. - -### Store the credentials - -Capture the two values, then write them. `prompt` only *asks* and binds the -answer to a name; a separate directive consumes it — so the same prompts could -feed `ncl` or the OneCLI vault instead of `.env` by swapping only the consumer. -Here they go to `.env` (set-if-absent — a value you've already filled in is -never overwritten) and sync to the container: +Collect the two secrets and store them (the bridge reads them from `.env`): ```nc:prompt bot_token secret Paste the Bot User OAuth Token — OAuth & Permissions, starts with `xoxb-`. @@ -106,13 +90,16 @@ SLACK_SIGNING_SECRET={{signing_secret}} ```nc:env-sync ``` -### Webhook server +The bridge serves the webhook on port 3000 at `/webhook/slack` automatically; to +receive replies, that port must be reachable from the internet and registered +with Slack. Tell the user: -The Chat SDK bridge automatically starts a shared webhook server on port 3000 -(`WEBHOOK_PORT` to change it), handling `/webhook/slack`. This port must be -publicly reachable for Slack to deliver events. Running locally, expose it with -ngrok (`ngrok http 3000`), a Cloudflare Tunnel, or a reverse proxy on a VPS — -the resulting public URL is the base for the Request URL above. +```nc:operator +Set up event delivery (needs a public HTTPS URL for port 3000 — ngrok, a Cloudflare Tunnel, or a reverse proxy on a VPS): +1. Event Subscriptions → Enable Events. Set the Request URL to https:///webhook/slack and wait for the challenge to pass. +2. Subscribe to bot events: message.channels, message.groups, message.im, app_mention. Save Changes. +3. Interactivity & Shortcuts → toggle Interactivity on, set the same Request URL, Save Changes, then reinstall the app when Slack prompts. +``` ## Connect yourself diff --git a/scripts/skill-apply.test.ts b/scripts/skill-apply.test.ts index bd8d52b45..99b9d1d1a 100644 --- a/scripts/skill-apply.test.ts +++ b/scripts/skill-apply.test.ts @@ -332,3 +332,32 @@ describe('nc:run capture', () => { expect(validate(parseDirectives(CAPTURE_SKILL))).toEqual([]); }); }); + +// operator: the parts addressed to the human (UI steps), delineated so the agent +// relays them and the engine renders them — the output twin of prompt. +describe('nc:operator', () => { + let oroot: string; + let oskill: string; + beforeEach(() => { + oskill = mkdtempSync(join(tmpdir(), 'nc-skill-')); + oroot = mkdtempSync(join(tmpdir(), 'nc-proj-')); + writeFileSync(join(oroot, 'package.json'), '{"name":"scratch"}'); + }); + + it('relays the operator body to prompter.tell, substituting {{vars}}', async () => { + writeFileSync( + join(oskill, 'SKILL.md'), + '# op demo\n\n```nc:prompt who\nName?\n```\nTell the user:\n```nc:operator\nHello {{who}} — go click the button.\n```\n', + ); + const told: string[] = []; + const prompter: Prompter = { async ask() { return 'world'; }, tell: (t) => void told.push(t) }; + await applySkill(oskill, oroot, { prompter, exec: () => {} }); + expect(told).toEqual(['Hello world — go click the button.']); + }); + + it('is a no-op when no operator sink is present (headless rebuild) — not a crash, not an agent bounce', async () => { + writeFileSync(join(oskill, 'SKILL.md'), '# op demo\n\nTell the user:\n```nc:operator\nDo a manual thing.\n```\n'); + const res = await applySkill(oskill, oroot, { prompter: headless({}), exec: () => {} }); + expect(res.agentTasks).toEqual([]); // operator with no sink is fine, not bounced + }); +}); diff --git a/scripts/skill-apply.ts b/scripts/skill-apply.ts index 160b4d904..059a04543 100644 --- a/scripts/skill-apply.ts +++ b/scripts/skill-apply.ts @@ -4,7 +4,8 @@ // accelerator it delegates to. Anything the engine can't do bounces back to the // AGENT (which reads the same prose and applies it, the way skills work today) — // never to the human, and never as a hard abort. The human is in the loop only -// for `prompt` inputs and inherently-human prose (e.g. clicking through Slack). +// for `prompt` inputs and `operator` instructions — the parts addressed to the +// human (e.g. clicking through the Slack UI), which the agent relays. // // Phases (the F2 runtime contract, minimal form): // 1. parse + validate — lint; a malformed skill never reaches apply @@ -28,6 +29,10 @@ import { parseDirectives, promptVar, type Directive } from './skill-directives.j export interface Prompter { // Return the value, or undefined to DEFER (headless rebuild collects these). ask(varName: string, question: string, secret: boolean): Promise; + // Show an `nc:operator` block to the human operator (a clack note in setup, a + // channel message when a coding agent relays). Absent ⇒ no operator present + // (headless rebuild), so the instructions are simply skipped. + tell?(text: string): Promise | void; } export type StepStatus = 'skip' | 'apply' | 'needs-input' | 'agent'; @@ -127,6 +132,8 @@ function selfStatus(d: Directive, root: string): { status: StepStatus; detail: s } case 'prompt': return { status: 'needs-input', detail: '' }; + case 'operator': + return { status: 'apply', detail: `show operator: ${(d.body[0] ?? '').slice(0, 50)}…` }; default: return { status: 'agent', detail: `no deterministic handler for nc:${d.kind} — an agent applies it from the prose` }; } @@ -372,6 +379,14 @@ export async function applySkill(skillDir: string, root: string, opts: ApplyOpti else vars.set(v, { value: val, secret: d.args.includes('secret') }); continue; } + if (d.kind === 'operator') { + // Relay the human-facing instructions. No operator present (headless + // rebuild) ⇒ nothing to show. {{vars}} render so a resolved value can be + // shown to the operator (throws → deferred if a referenced var is unset). + await opts.prompter.tell?.(substitute(d.body.join('\n'), vars)); + res.applied.push(`operator: ${(d.body[0] ?? '').slice(0, 50)}`); + continue; + } const st = selfStatus(d, root); if (st.status === 'agent') { bounce(d, 'no deterministic handler'); continue; } if (st.status === 'skip') { res.skipped.push(`${d.kind}: ${st.detail}`); continue; } diff --git a/scripts/skill-directives.test.ts b/scripts/skill-directives.test.ts index 8e5f43b2b..5c59cefd3 100644 --- a/scripts/skill-directives.test.ts +++ b/scripts/skill-directives.test.ts @@ -15,10 +15,12 @@ describe('skill-directives parser, on the converted add-slack', () => { 'dep', // step 3: pinned package 'run', // step 4: build 'run', // step 4: test + 'operator', // credentials: create-app walkthrough (addressed to the operator) 'prompt', // credentials: capture bot token 'prompt', // credentials: capture signing secret 'env-set', // credentials: write captured values to .env 'env-sync', // credentials: sync to container + 'operator', // credentials: event-delivery walkthrough 'prompt', // wire: owner member id 'prompt', // wire: target agent folder 'run', // wire: validate token (auth.test) @@ -27,6 +29,14 @@ describe('skill-directives parser, on the converted add-slack', () => { ]); }); + it('delineates the human UI steps as nc:operator (not agent prose or a run)', () => { + const ops = directives.filter((d) => d.kind === 'operator'); + expect(ops).toHaveLength(2); + expect(ops[0].body.join('\n')).toMatch(/Create the Slack app/); + expect(ops[0].body.join('\n')).toMatch(/Bot Token Scopes/); + expect(ops[1].body.join('\n')).toMatch(/Event Subscriptions/); + }); + it('reads copy as a branch fetch with both files', () => { const copy = directives.find((d) => d.kind === 'copy')!; expect(copy.attrs['from-branch']).toBe('channels'); diff --git a/scripts/skill-directives.ts b/scripts/skill-directives.ts index 7014f6a5e..63b945f7d 100644 --- a/scripts/skill-directives.ts +++ b/scripts/skill-directives.ts @@ -27,6 +27,11 @@ // command's stdout into {{var}} (twin of prompt) — e.g. resolve an id // from an API and feed it to a later directive. // prompt [secret] body: the question → binds {{var}} skip if satisfied +// operator body: instructions for the human operator output-only +// The SKILL.md is addressed to the coding agent; `operator` delineates the +// parts meant for the HUMAN (e.g. clicking through the Slack UI). Lead it +// with agent-facing prose like "Tell the user:" so the agent relays it; +// the engine renders the body to the operator ({{vars}} substituted in). // env-set body: `KEY=value` ({{var}} allowed) set-if-absent // env-sync (no body) `.env` → data/env/env idempotent copy // json-merge into: key: body: a JSON object push-if-absent @@ -58,7 +63,7 @@ export interface Problem { const FENCE = /^```(\S.*)?$/; const EXACT_SEMVER = /^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/; const VAR_REF = /\{\{\s*([A-Za-z_][A-Za-z0-9_]*)\s*\}\}/g; -const KNOWN = new Set(['copy', 'append', 'dep', 'run', 'prompt', 'env-set', 'env-sync', 'json-merge']); +const KNOWN = new Set(['copy', 'append', 'dep', 'run', 'prompt', 'operator', 'env-set', 'env-sync', 'json-merge']); const PROMPT_FLAGS = new Set(['secret']); export function parseDirectives(markdown: string): Directive[] { @@ -183,6 +188,9 @@ export function validate(directives: Directive[], ctx?: { chatVersion?: string } if (!promptVar(d)) flag(d, 'prompt requires a variable name, e.g. `nc:prompt token`'); if (d.body.length === 0) flag(d, 'prompt requires a question in its body'); break; + case 'operator': + if (d.body.length === 0) flag(d, 'operator requires instructions for the human in its body'); + break; } // A consumer can only reference a variable an earlier prompt captured, or an // earlier `run capture:` bound from a command's output.