feat(skills): nc:operator — delineate operator-facing content from agent prose

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: <steps>" — 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 <noreply@anthropic.com>
This commit is contained in:
gavrielc
2026-06-27 16:37:12 +03:00
parent 3c14310928
commit e33fa970f5
5 changed files with 85 additions and 36 deletions
+29
View File
@@ -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
});
});
+16 -1
View File
@@ -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<string | undefined>;
// 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> | 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; }
+10
View File
@@ -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');
+9 -1
View File
@@ -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 <var> [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:<file> key:<field> 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:<var>` bound from a command's output.