diff --git a/scripts/skill-apply.test.ts b/scripts/skill-apply.test.ts index 60f617298..5504188f7 100644 --- a/scripts/skill-apply.test.ts +++ b/scripts/skill-apply.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect, beforeEach } from 'vitest'; import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, existsSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import { applySkill, removeSkill, planSkill, fullyApplied, firstFailureHint, referenceProse, stepLabel, type ApplyEvent, type InputMeta, type Prompter, type PromptOpts, type StepReporter } from './skill-apply.js'; +import { applySkill, removeSkill, planSkill, fullyApplied, firstFailureHint, referenceProse, stepLabel, type ApplyEvent, type InputMeta } from './skill-apply.js'; import { parseDirectives, validate } from './skill-directives.js'; // A synthetic skill exercising the fs handlers for real (no network), plus one @@ -36,7 +36,8 @@ register demo let root: string; let skillDir: string; -const headless = (vals: Record): Prompter => ({ async ask(name) { return vals[name]; } }); +// A headless resolveInput fake: answers from a fixed map; a missing var defers. +const headless = (vals: Record) => async (name: string): Promise => vals[name]; const recordingExec = () => { const cmds: string[] = []; return { cmds, exec: (c: string) => void cmds.push(c) }; @@ -57,7 +58,7 @@ beforeEach(() => { describe('apply engine lifecycle', () => { it('applies fs directives, captures the secret, and bounces the unknown step to an agent', async () => { const { exec } = recordingExec(); - const res = await applySkill(skillDir, root, { prompter: headless({ token: 'sekret-123' }), exec }); + const res = await applySkill(skillDir, root, { resolveInput: headless({ token: 'sekret-123' }), exec }); // mutations happened expect(existsSync(join(root, 'src/sample.ts'))).toBe(true); @@ -74,23 +75,23 @@ describe('apply engine lifecycle', () => { it('is idempotent — a second apply changes nothing', async () => { const p = headless({ token: 'sekret-123' }); - await applySkill(skillDir, root, { prompter: p, exec: () => {} }); - const second = await applySkill(skillDir, root, { prompter: p, exec: () => {} }); + await applySkill(skillDir, root, { resolveInput: p, exec: () => {} }); + const second = await applySkill(skillDir, root, { resolveInput: p, exec: () => {} }); expect(second.applied).toEqual([]); // everything already applied expect(second.journal).toEqual([]); // nothing mutated expect(second.skipped.length).toBeGreaterThanOrEqual(3); }); it('removes cleanly from the journal — no hand-written REMOVE.md', async () => { - const res = await applySkill(skillDir, root, { prompter: headless({ token: 'sekret-123' }), exec: () => {} }); + const res = await applySkill(skillDir, root, { resolveInput: headless({ token: 'sekret-123' }), exec: () => {} }); await removeSkill(root, res.journal); expect(existsSync(join(root, 'src/sample.ts'))).toBe(false); expect(readFileSync(join(root, 'src/barrel.ts'), 'utf8')).not.toContain("import './sample.js';"); expect(readFileSync(join(root, '.env'), 'utf8')).not.toContain('DEMO_TOKEN'); }); - it('defers a prompt (and its consumer) when the prompter has no value — headless rebuild', async () => { - const res = await applySkill(skillDir, root, { prompter: headless({}), exec: () => {} }); + it('defers a prompt (and its consumer) when resolveInput has no value — headless rebuild', async () => { + const res = await applySkill(skillDir, root, { resolveInput: headless({}), exec: () => {} }); expect(res.deferred).toContain('token'); // prompt deferred expect(res.deferred.some((d) => /unresolved \{\{token\}\}/.test(d))).toBe(true); // env-set blocked on it expect(readFileSync(join(root, '.env'), 'utf8')).not.toContain('DEMO_TOKEN'); @@ -125,7 +126,7 @@ describe('json-merge directive', () => { }); it('pushes the object, preserving 2-space indent + trailing newline', async () => { - const res = await applySkill(jskill, jroot, { prompter: headless({}), exec: () => {} }); + const res = await applySkill(jskill, jroot, { resolveInput: headless({}), exec: () => {} }); const out = readFileSync(join(jroot, 'container/cli-tools.json'), 'utf8'); expect(out.endsWith('\n')).toBe(true); const arr = JSON.parse(out); @@ -138,8 +139,8 @@ describe('json-merge directive', () => { }); it('is idempotent — re-applying does not duplicate the element', async () => { - await applySkill(jskill, jroot, { prompter: headless({}), exec: () => {} }); - const second = await applySkill(jskill, jroot, { prompter: headless({}), exec: () => {} }); + await applySkill(jskill, jroot, { resolveInput: headless({}), exec: () => {} }); + const second = await applySkill(jskill, jroot, { resolveInput: headless({}), exec: () => {} }); expect(second.applied).toEqual([]); expect(second.skipped.length).toBe(1); const arr = JSON.parse(readFileSync(join(jroot, 'container/cli-tools.json'), 'utf8')); @@ -147,7 +148,7 @@ describe('json-merge directive', () => { }); it('removeSkill drops the element whose key matches', async () => { - const res = await applySkill(jskill, jroot, { prompter: headless({}), exec: () => {} }); + const res = await applySkill(jskill, jroot, { resolveInput: headless({}), exec: () => {} }); await removeSkill(jroot, res.journal); const arr = JSON.parse(readFileSync(join(jroot, 'container/cli-tools.json'), 'utf8')); expect(arr).toEqual([{ name: 'vercel', version: '52.2.1' }]); @@ -195,7 +196,7 @@ describe('append at:', () => { it('inserts before the `<<< marker` line, matching its indentation', async () => { writeFileSync(join(askill, 'SKILL.md'), APPEND_AT_SKILL); - await applySkill(askill, aroot, { prompter: headless({}), exec: () => {} }); + await applySkill(askill, aroot, { resolveInput: headless({}), exec: () => {} }); const out = readFileSync(join(aroot, 'setup/index.ts'), 'utf8').split('\n'); const closeIdx = out.findIndex((l) => l.includes('<<< nanoclaw:setup-steps')); expect(out[closeIdx - 1]).toBe(" codex: () => import('./codex.js'),"); // inserted just above, 2-space indent @@ -204,8 +205,8 @@ describe('append at:', () => { it('is idempotent (whole-file line check) regardless of position', async () => { writeFileSync(join(askill, 'SKILL.md'), APPEND_AT_SKILL); - await applySkill(askill, aroot, { prompter: headless({}), exec: () => {} }); - const second = await applySkill(askill, aroot, { prompter: headless({}), exec: () => {} }); + await applySkill(askill, aroot, { resolveInput: headless({}), exec: () => {} }); + const second = await applySkill(askill, aroot, { resolveInput: headless({}), exec: () => {} }); expect(second.applied).toEqual([]); const count = readFileSync(join(aroot, 'setup/index.ts'), 'utf8').split('\n').filter((l) => l.trim() === "codex: () => import('./codex.js'),").length; expect(count).toBe(1); @@ -213,14 +214,14 @@ describe('append at:', () => { it('removeSkill deletes the inserted line (position-agnostic, by trimmed line)', async () => { writeFileSync(join(askill, 'SKILL.md'), APPEND_AT_SKILL); - const res = await applySkill(askill, aroot, { prompter: headless({}), exec: () => {} }); + const res = await applySkill(askill, aroot, { resolveInput: headless({}), exec: () => {} }); await removeSkill(aroot, res.journal); expect(readFileSync(join(aroot, 'setup/index.ts'), 'utf8')).not.toContain("codex: () => import('./codex.js'),"); }); it('without at: still appends at EOF (unchanged behavior)', async () => { writeFileSync(join(askill, 'SKILL.md'), APPEND_EOF_SKILL); - await applySkill(askill, aroot, { prompter: headless({}), exec: () => {} }); + await applySkill(askill, aroot, { resolveInput: headless({}), exec: () => {} }); const lines = readFileSync(join(aroot, 'setup/index.ts'), 'utf8').split('\n').filter(Boolean); expect(lines[lines.length - 1]).toBe('// trailing line'); // at EOF, not before the marker }); @@ -259,7 +260,7 @@ describe('nc:run variable substitution', () => { it('interpolates a prompted {{var}} into run commands; var-free runs pass through unchanged', async () => { const { cmds, exec } = recordingExec(); - await applySkill(rskill, rroot, { prompter: headless({ owner_email: 'you@example.com' }), exec }); + await applySkill(rskill, rroot, { resolveInput: headless({ owner_email: 'you@example.com' }), exec }); expect(cmds).toContain( 'ncl messaging-groups create --channel-type resend --platform-id resend:you@example.com --is-group 0', ); @@ -270,7 +271,7 @@ describe('nc:run variable substitution', () => { }); it('journals the ORIGINAL command (placeholders intact) — a substituted value never lands in the journal', async () => { - const res = await applySkill(rskill, rroot, { prompter: headless({ owner_email: 'you@example.com' }), exec: () => {} }); + const res = await applySkill(rskill, rroot, { resolveInput: headless({ owner_email: 'you@example.com' }), exec: () => {} }); const ran = res.journal.filter((e) => e.op === 'ran').map((e) => 'cmd' in e ? e.cmd : ''); expect(ran).toContain( 'ncl messaging-groups create --channel-type resend --platform-id resend:{{owner_email}} --is-group 0', @@ -280,7 +281,7 @@ describe('nc:run variable substitution', () => { it('defers a wiring run when its {{var}} prompt is unanswered (degrade, not crash)', async () => { const { cmds, exec } = recordingExec(); - const res = await applySkill(rskill, rroot, { prompter: headless({}), exec }); + const res = await applySkill(rskill, rroot, { resolveInput: headless({}), exec }); expect(res.deferred.some((d) => /unresolved \{\{owner_email\}\}/.test(d))).toBe(true); expect(cmds.some((c) => c.startsWith('ncl'))).toBe(false); // no ncl ran with an unresolved value expect(cmds).toContain('pnpm run build'); // the var-free run still executes @@ -323,7 +324,7 @@ describe('nc:run capture', () => { cmds.push(c); if (c.startsWith('resolve-dm')) return 'D0SLACK123\n'; }; - await applySkill(cskill, croot, { prompter: headless({ user_id: 'U999' }), exec }); + await applySkill(cskill, croot, { resolveInput: headless({ user_id: 'U999' }), exec }); expect(cmds).toContain('resolve-dm U999'); // resolved with the prompted id expect(cmds).toContain('ncl messaging-groups create --channel-type slack --platform-id slack:D0SLACK123'); // captured value flowed downstream }); @@ -438,120 +439,26 @@ describe('nc:operator', () => { 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: () => {} }); + const res = await applySkill(oskill, oroot, { resolveInput: headless({}), exec: () => {} }); expect(res.agentTasks).toEqual([]); // operator with no sink is fine, not bounced }); -}); -// nc:operator open: deep-links the operator to the page the steps describe -// (after rendering, {{vars}} substituted); a bare `gate` flag turns the block into -// a human BARRIER — a confirm the engine waits on before the following -// side-effecting directives run. Both are pure polish: a prompter without -// open/confirm just skips them (headless/programmatic), never a crash. -const OPEN_SKILL = `# open demo - -## Name the bot -\`\`\`nc:prompt bot -Bot username? -\`\`\` - -## Open the chat -Tell the user: -\`\`\`nc:operator open:https://t.me/{{bot}} -Open @{{bot}} in Telegram and keep it on screen. -\`\`\` -`; - -const GATE_BARRIER_SKILL = `# gate barrier demo - -## Do the manual portal steps -Tell the user: -\`\`\`nc:operator gate open:https://portal.example -Finish the portal steps, then continue. -\`\`\` - -## Build the package (a side effect that must wait for the human) -\`\`\`nc:run effect:external -build-package -\`\`\` -`; - -describe('nc:operator open + gate', () => { - let oroot: string; - let oskill: string; - beforeEach(() => { - oskill = mkdtempSync(join(tmpdir(), 'nc-open-skill-')); - oroot = mkdtempSync(join(tmpdir(), 'nc-open-proj-')); - writeFileSync(join(oroot, 'package.json'), '{"name":"scratch"}'); - writeFileSync(join(oroot, '.env'), ''); - }); - - it('open: calls prompter.open with the substituted URL, after rendering the block', async () => { - writeFileSync(join(oskill, 'SKILL.md'), OPEN_SKILL); - const order: string[] = []; - const prompter: Prompter = { - async ask() { return undefined; }, - tell: () => void order.push('tell'), - open: (u) => void order.push(`open:${u}`), - }; - const res = await applySkill(oskill, oroot, { inputs: { bot: 'mybot' }, prompter, exec: () => {} }); - expect(order).toEqual(['tell', 'open:https://t.me/mybot']); // rendered, THEN opened, {{bot}} substituted - expect(res.agentTasks).toEqual([]); - }); - - it('gate calls prompter.confirm as a barrier BEFORE the next side-effecting directive runs', async () => { - writeFileSync(join(oskill, 'SKILL.md'), GATE_BARRIER_SKILL); - const events: string[] = []; - const prompter: Prompter = { - async ask() { return undefined; }, - tell: () => void events.push('tell'), - open: (u) => void events.push(`open:${u}`), - async confirm() { events.push('confirm'); return true; }, - }; - const exec = (c: string): void => void events.push(`exec:${c}`); - await applySkill(oskill, oroot, { inputs: {}, prompter, exec }); - // the barrier (confirm) fires after the block renders + opens, and BEFORE the build - expect(events).toEqual(['tell', 'open:https://portal.example', 'confirm', 'exec:build-package']); - }); - - it('degrades with a prompter lacking open/confirm — no crash, the block + side effect still run', async () => { - writeFileSync(join(oskill, 'SKILL.md'), GATE_BARRIER_SKILL); - const cmds: string[] = []; - // a minimal prompter: ask only, no tell/open/confirm - const prompter: Prompter = { async ask() { return undefined; } }; - const res = await applySkill(oskill, oroot, { inputs: {}, prompter, exec: (c) => void cmds.push(c) }); - expect(res.agentTasks).toEqual([]); // gate/open absent ⇒ skipped, not bounced - expect(cmds).toContain('build-package'); // the gated side effect still ran (no barrier to wait on) - }); - - it('an unresolved {{var}} in open: defers the block (degrade, not crash) — nothing opened or rendered', async () => { - // open references {{bot}} but no prompt/capture defines it - writeFileSync(join(oskill, 'SKILL.md'), '# o\n\nTell the user:\n```nc:operator open:https://t.me/{{bot}}\nOpen the bot.\n```\n'); - const opened: string[] = []; - const told: string[] = []; - const prompter: Prompter = { async ask() { return undefined; }, tell: (t) => void told.push(t), open: (u) => void opened.push(u) }; - const res = await applySkill(oskill, oroot, { inputs: {}, prompter, exec: () => {} }); - expect(opened).toEqual([]); // never opened — the URL var was unresolved - expect(told).toEqual([]); // and never rendered (deferred before the push/tell) + it('an unresolved {{var}} in the operator body defers the whole block — nothing collected, no event fired', async () => { + // the body references {{bot}} but no prompt/capture defines it + writeFileSync(join(oskill, 'SKILL.md'), '# o\n\nTell the user:\n```nc:operator\nOpen @{{bot}} in Telegram and keep it on screen.\n```\n'); + const events: ApplyEvent[] = []; + const res = await applySkill(oskill, oroot, { inputs: {}, exec: () => {}, onEvent: (e) => void events.push(e) }); + expect(events).toEqual([]); // never emitted — deferred before any event + expect(res.operatorMessages).toEqual([]); // and never collected half-rendered expect(res.deferred.some((d) => /unresolved \{\{bot\}\}/.test(d))).toBe(true); + expect(res.agentTasks).toEqual([]); // deferred, not bounced }); }); // Programmatic apply: pass every prompt answer via `inputs` and the whole skill -// runs through with no prompter and no human interaction. +// runs through with no resolver and no human interaction. const PROGRAMMATIC_SKILL = `# programmatic demo ## Collect @@ -584,7 +491,7 @@ describe('programmatic apply via inputs', () => { writeFileSync(join(proot, '.env'), ''); }); - it('runs the whole skill from inputs alone — no prompter, nothing deferred or bounced', async () => { + it('runs the whole skill from inputs alone — no resolver, nothing deferred or bounced', async () => { writeFileSync(join(pskill, 'SKILL.md'), PROGRAMMATIC_SKILL); const cmds: string[] = []; const exec = (c: string): string | void => { @@ -607,14 +514,14 @@ describe('programmatic apply via inputs', () => { expect(res.deferred).toContain('owner'); }); - it('inputs win over the prompter; the prompter only fills the gaps', async () => { + it('inputs win over resolveInput; resolveInput only fills the gaps', async () => { writeFileSync(join(pskill, 'SKILL.md'), '# two prompts\n\n```nc:prompt a\nA?\n```\n```nc:prompt b\nB?\n```\n```nc:env-set\nA={{a}}\nB={{b}}\n```\n'); const asked: string[] = []; - const prompter: Prompter = { async ask(n) { asked.push(n); return 'fromPrompter'; } }; - await applySkill(pskill, proot, { inputs: { a: 'fromInputs' }, prompter, exec: () => {} }); + const resolveInput = async (n: string): Promise => { asked.push(n); return 'fromResolveInput'; }; + await applySkill(pskill, proot, { inputs: { a: 'fromInputs' }, resolveInput, exec: () => {} }); const env = readFileSync(join(proot, '.env'), 'utf8'); expect(env).toContain('A=fromInputs'); // input wins - expect(env).toContain('B=fromPrompter'); // prompter filled the gap + expect(env).toContain('B=fromResolveInput'); // resolveInput filled the gap expect(asked).toEqual(['b']); // 'a' was never asked — it came from inputs }); @@ -631,16 +538,16 @@ describe('programmatic apply via inputs', () => { expect(res.skipped.some((s) => /run restart: owned by the caller/.test(s))).toBe(true); }); - it('threads a prompt validate: through to the prompter', async () => { + it('declares the prompt validate: to resolveInput via InputMeta', async () => { writeFileSync(join(pskill, 'SKILL.md'), '# v\n\n```nc:prompt token secret validate:^xoxb-\nPaste.\n```\n'); let seenValidate: string | undefined; - const prompter: Prompter = { - async ask(_name, _q, _secret, validate) { - seenValidate = validate; + await applySkill(pskill, proot, { + exec: () => {}, + resolveInput: async (_name, meta) => { + seenValidate = meta.validate; return 'xoxb-ok'; }, - }; - await applySkill(pskill, proot, { prompter, exec: () => {} }); + }); expect(seenValidate).toBe('^xoxb-'); }); @@ -713,7 +620,7 @@ describe('when: guard', () => { it('a guarded prompt with no input does not defer when its guard is unmet (the programmatic-run contract)', async () => { const { sdir, rdir } = modeScratch(); // local mode, server_url neither supplied nor answerable — must still complete. - const res = await applySkill(sdir, rdir, { inputs: { mode: 'local' }, prompter: headless({}), exec: () => {} }); + const res = await applySkill(sdir, rdir, { inputs: { mode: 'local' }, resolveInput: headless({}), exec: () => {} }); expect(res.deferred).toEqual([]); expect(fullyApplied(res)).toBe(true); }); @@ -858,7 +765,7 @@ describe('run-health gate (a bounce blocks later side effects)', () => { it('a deferred prompt does NOT block a later restart (headless rebuild stays runnable)', async () => { writeFileSync(join(gskill, 'SKILL.md'), DEFER_THEN_RESTART_SKILL); const cmds: string[] = []; - const res = await applySkill(gskill, groot, { prompter: headless({}), exec: (c) => void cmds.push(c) }); + const res = await applySkill(gskill, groot, { resolveInput: headless({}), exec: (c) => void cmds.push(c) }); // the prompt and its consumer deferred (no answer headless) — not a failure expect(res.deferred).toContain('token'); @@ -941,7 +848,7 @@ describe('nc:run effect:check (precondition gate)', () => { 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) }); + const res = await applySkill(chkSkill, chkRoot, { resolveInput: 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 @@ -965,13 +872,9 @@ describe('nc:run effect:check (precondition gate)', () => { }); }); -// 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 -// heading); an instant step (a local copy, an env write) carries null so the -// driver stays silent. The engine fires the events; this asserts shape + order + -// timing + the failure path, all with no real clack involved. -const REPORTER_SKILL = `# reporter demo +// A two-step skill for the event suite below: one effectful run (heading label) +// and one instant env-set (null label). +const EVENT_SKILL = `# event demo ## Verify the credential \`\`\`nc:run effect:fetch @@ -984,79 +887,11 @@ DEMO=ok \`\`\` `; -type RecordedEvent = - | { ev: 'start'; kind: string; label: string | null } - | { ev: 'end'; kind: string; label: string | null; ok: boolean; durationMs: number; error?: string }; - -function recordingReporter(): { events: RecordedEvent[]; reporter: StepReporter } { - const events: RecordedEvent[] = []; - return { - events, - reporter: { - stepStart: (e) => void events.push({ ev: 'start', kind: e.kind, label: e.label }), - stepEnd: (e) => - void events.push({ ev: 'end', kind: e.kind, label: e.label, ok: e.ok, durationMs: e.durationMs, error: e.error }), - }, - }; -} - -describe('apply-lifecycle reporter (driver spinners)', () => { - let rroot: string; - let rskill: string; - beforeEach(() => { - rskill = mkdtempSync(join(tmpdir(), 'nc-rep-skill-')); - rroot = mkdtempSync(join(tmpdir(), 'nc-rep-proj-')); - writeFileSync(join(rskill, 'SKILL.md'), REPORTER_SKILL); - writeFileSync(join(rroot, '.env'), ''); - writeFileSync(join(rroot, 'package.json'), '{"name":"scratch"}'); - }); - - it('fires stepStart/stepEnd in order; effectful step carries a heading label, instant step null', async () => { - const { events, reporter } = recordingReporter(); - await applySkill(rskill, rroot, { exec: () => {}, reporter }); - - // bracketed in document order: the run, then the env-set - expect(events.map((e) => `${e.ev}:${e.kind}`)).toEqual([ - 'start:run', 'end:run', 'start:env-set', 'end:env-set', - ]); - - // the effectful run: the nearest heading is its spinner label, ok + numeric ms - expect(events.find((e) => e.ev === 'start' && e.kind === 'run')?.label).toBe('Verify the credential'); - const runEnd = events.find((e): e is Extract => e.ev === 'end' && e.kind === 'run')!; - expect(runEnd.ok).toBe(true); - expect(typeof runEnd.durationMs).toBe('number'); - expect(runEnd.durationMs).toBeGreaterThanOrEqual(0); - - // the instant env-set: null label ⇒ no spin - expect(events.find((e) => e.ev === 'start' && e.kind === 'env-set')?.label).toBe(null); - }); - - it('on a failed step, fires stepEnd ok=false with the error — balanced with its start', async () => { - const { events, reporter } = recordingReporter(); - const exec = (c: string): string | void => { - if (c === 'verify-cred') throw new Error('401 bad credential'); - }; - const res = await applySkill(rskill, rroot, { exec, reporter }); - - const runEnd = events.find((e): e is Extract => e.ev === 'end' && e.kind === 'run')!; - expect(runEnd.ok).toBe(false); - expect(runEnd.error).toMatch(/401 bad credential/); - // every stepStart has a matching stepEnd (the spinner is never left hanging) - expect(events.filter((e) => e.ev === 'start')).toHaveLength(events.filter((e) => e.ev === 'end').length); - // and the failure still degraded to an agent, not a crash - expect(res.agentTasks).toHaveLength(1); - }); - - it('no reporter ⇒ silent (unchanged) — apply still completes', async () => { - const res = await applySkill(rskill, rroot, { exec: () => {} }); - expect(fullyApplied(res)).toBe(true); - }); -}); - -// The core event seam: `onEvent` replaces BOTH StepReporter and Prompter.tell. -// Step events carry the StepReporter payload plus the discriminating `type`; -// operator events carry the rendered ({{var}}-substituted) block body + the -// directive's opening-fence line. Every call is AWAITED before the engine +// The core event seam: every engine emission flows through `onEvent`. Step +// events bracket each real mutation (applyOne) — an effectful step (a run, a +// dep, a branch-fetch copy) carries a heading-derived label, an instant step +// null; operator events carry the rendered ({{var}}-substituted) block body + +// the directive's opening-fence line. Every call is AWAITED before the engine // proceeds — the ordering guarantee a consumer's gating is built on — and a // handler throw is treated like any other throw at that directive: bounce, // never crash, never a silent drop. @@ -1070,8 +905,8 @@ describe('onEvent (core event seam)', () => { writeFileSync(join(eroot, 'package.json'), '{"name":"scratch"}'); }); - it('fires step-start/step-end with the reporter payload + type; effectful label, instant null', async () => { - writeFileSync(join(eskill, 'SKILL.md'), REPORTER_SKILL); + it('fires step-start/step-end brackets in document order; effectful label, instant null', async () => { + writeFileSync(join(eskill, 'SKILL.md'), EVENT_SKILL); const events: ApplyEvent[] = []; await applySkill(eskill, eroot, { exec: () => {}, onEvent: (e) => void events.push(e) }); @@ -1079,7 +914,7 @@ describe('onEvent (core event seam)', () => { 'step-start:run', 'step-end:run', 'step-start:env-set', 'step-end:env-set', ]); const start = events[0] as Extract; - expect(start.label).toBe('Verify the credential'); // heading-derived, same as the reporter + expect(start.label).toBe('Verify the credential'); // heading-derived expect(typeof start.line).toBe('number'); const end = events[1] as Extract; expect(end.ok).toBe(true); @@ -1088,7 +923,7 @@ describe('onEvent (core event seam)', () => { }); it('closes a failed step with step-end ok=false + error — start/end always balanced', async () => { - writeFileSync(join(eskill, 'SKILL.md'), REPORTER_SKILL); + writeFileSync(join(eskill, 'SKILL.md'), EVENT_SKILL); const events: ApplyEvent[] = []; const exec = (c: string): void => { if (c === 'verify-cred') throw new Error('401 bad credential'); @@ -1130,7 +965,7 @@ describe('onEvent (core event seam)', () => { }); it('a handler throw on a step event bounces that directive (degrade, not crash)', async () => { - writeFileSync(join(eskill, 'SKILL.md'), REPORTER_SKILL); + writeFileSync(join(eskill, 'SKILL.md'), EVENT_SKILL); const res = await applySkill(eskill, eroot, { exec: () => {}, onEvent: (e) => { @@ -1159,33 +994,22 @@ describe('onEvent (core event seam)', () => { expect(res.agentTasks.some((t) => /an earlier step did not complete/.test(t.reason))).toBe(true); // …incl. the cascade }); - it('replaces the legacy reporter and prompter.tell entirely when both are supplied', async () => { - writeFileSync(join(eskill, 'SKILL.md'), '# both\n\nTell the user:\n```nc:operator\nHi there.\n```\n```nc:env-set\nA=1\n```\n'); - const events: string[] = []; - const told: string[] = []; - const reporterCalls: string[] = []; - await applySkill(eskill, eroot, { - inputs: {}, - exec: () => {}, - onEvent: (e) => void events.push(e.type), - prompter: { async ask() { return undefined; }, tell: (t) => void told.push(t) }, - reporter: { stepStart: () => void reporterCalls.push('start'), stepEnd: () => void reporterCalls.push('end') }, - }); - expect(events).toEqual(['operator', 'step-start', 'step-end']); - expect(told).toEqual([]); // legacy tell never consulted - expect(reporterCalls).toEqual([]); // legacy reporter never consulted + it('no onEvent ⇒ silent — the headless/programmatic apply still completes', async () => { + writeFileSync(join(eskill, 'SKILL.md'), EVENT_SKILL); + const res = await applySkill(eskill, eroot, { exec: () => {} }); + expect(fullyApplied(res)).toBe(true); }); }); describe('stepLabel', () => { - it('labels effectful kinds from the nearest heading, instant kinds null; label: attr overrides; step is silent', () => { + it('labels effectful kinds from the nearest heading, instant kinds null; step is silent', () => { const md = [ '# s', '', '## Install deps', '```nc:dep', 'pkg@1.0.0', '```', '', '## Copy a file', '```nc:copy', 'a -> b', '```', '', '## Pull from the branch', '```nc:copy from-branch:channels', 'x -> y', '```', '', '## Link the device', '```nc:run effect:step capture:platform_id=PLATFORM_ID', 'pair', '```', '', - '## Wire it', '```nc:run effect:wire label:Connecting', 'ncl wire', '```', + '## Wire it', '```nc:run effect:wire', 'ncl wire', '```', ].join('\n'); const ds = parseDirectives(md); const nth = (k: string, i = 0) => ds.filter((d) => d.kind === k)[i]; @@ -1193,7 +1017,7 @@ describe('stepLabel', () => { expect(stepLabel(nth('copy', 0), md)).toBe(null); // local copy = instant expect(stepLabel(nth('copy', 1), md)).toBe('Pull from the branch'); // from-branch fetch spins expect(stepLabel(nth('run', 0), md)).toBe(null); // effect:step renders its own live output - expect(stepLabel(nth('run', 1), md)).toBe('Connecting'); // label: attr overrides the heading + expect(stepLabel(nth('run', 1), md)).toBe('Wire it'); // heading-derived only — no attr override }); it('falls back to a kind/effect default when there is no heading above the fence', () => { @@ -1204,11 +1028,8 @@ describe('stepLabel', () => { // firstFailureHint surfaces the prose beside the FIRST bounced directive as the // operator's failure hint (the setup driver threads it into fail() + the Claude -// handoff). The hint defaults to the surrounding prose; an `on-fail:` attr -// on the fence narrows it to the single prose LINE that diagnoses the failure — -// and because that attr is stripped when a skill degrades to prose, the SAME -// diagnosis must already live in the prose, so a token with no matching prose -// line falls back to the full prose (prose-primary, never a leak). +// handoff). The hint IS the surrounding prose — the same text an agent reads to +// apply the step (prose-primary: a stripped fence leaves the same diagnosis). const FAIL_HINT_SKILL = `# connect demo ## Verify the credential @@ -1218,25 +1039,6 @@ check the token \`\`\` `; -const ON_FAIL_SKILL = `# connect demo - -## Connect to the service -Install the app, then paste the bot token below. -If auth.test returns invalid_auth, your token is wrong — regenerate it from the OAuth page. -\`\`\`nc:hand-verify on-fail:invalid_auth -check the token -\`\`\` -`; - -const ON_FAIL_MISS_SKILL = `# connect demo - -## Connect -Do the manual connection step in the dashboard. -\`\`\`nc:hand-verify on-fail:nonexistent_token -check -\`\`\` -`; - const NO_BOUNCE_SKILL = `# prompt only ## Collect a token @@ -1262,43 +1064,24 @@ describe('firstFailureHint', () => { const diag = firstFailureHint(res); expect(diag?.headline).toBe('Verify the credential'); // the section heading, # stripped expect(diag?.hint).toContain('the token is wrong'); // the prose beside the step - // the AgentTask carries the same hint (default = trimmed prose) - expect(res.agentTasks[0].hint).toBe(diag?.hint); - }); - - it('an on-fail: narrows the hint to the prose line that diagnoses the failure', async () => { - writeFileSync(join(fskill, 'SKILL.md'), ON_FAIL_SKILL); - const res = await applySkill(fskill, froot, { inputs: {}, exec: () => {} }); - const diag = firstFailureHint(res); - expect(diag?.headline).toBe('Connect to the service'); - // narrowed to the single diagnosing line — not the whole paragraph - expect(diag?.hint).toBe('If auth.test returns invalid_auth, your token is wrong — regenerate it from the OAuth page.'); - expect(diag?.hint).not.toContain('Install the app'); - }); - - it('falls back to the full prose when the on-fail token has no matching prose line (no leak)', async () => { - writeFileSync(join(fskill, 'SKILL.md'), ON_FAIL_MISS_SKILL); - const res = await applySkill(fskill, froot, { inputs: {}, exec: () => {} }); - const diag = firstFailureHint(res); - // the bare token never surfaces — the operator sees the prose - expect(diag?.hint).toContain('Do the manual connection step in the dashboard.'); - expect(diag?.hint).not.toContain('nonexistent_token'); + // hint ≡ prose: the diagnosis IS the bounced task's own trimmed prose + expect(diag?.hint).toBe(res.agentTasks[0].prose.trim()); }); it('returns undefined when nothing bounced (a deferred prompt is not a failure)', async () => { writeFileSync(join(fskill, 'SKILL.md'), NO_BOUNCE_SKILL); - const res = await applySkill(fskill, froot, { prompter: headless({}), exec: () => {} }); + const res = await applySkill(fskill, froot, { resolveInput: headless({}), exec: () => {} }); expect(res.deferred).toContain('token'); // deferred, not bounced expect(res.agentTasks).toEqual([]); expect(firstFailureHint(res)).toBeUndefined(); }); }); -// PromptOpts: `nc:prompt` attrs (flags/min/error/normalize). `normalize:` is -// applied DETERMINISTICALLY at engine bind — to BOTH an `inputs` value and an -// interactive answer — so they land identically (a deliberate behavior change). -// flags/min/error are carried through to the prompter (their interactive -// enforcement is asserted in skill-driver.test.ts against promptValidator). +// `nc:prompt normalize:` is applied DETERMINISTICALLY at engine bind — to +// BOTH an `inputs` value and a resolveInput answer — so they land identically. +// validate/flags/normalize are DECLARED to the consumer via InputMeta (their +// interactive enforcement is asserted in skill-driver.test.ts against +// promptValidator). const NORMALIZE_SKILL = `# normalize demo ## Collect a base URL @@ -1310,7 +1093,7 @@ PUBLIC_URL={{public_url}} \`\`\` `; -describe('nc:prompt PromptOpts (normalize at bind, opts threading)', () => { +describe('nc:prompt normalize at bind + InputMeta declaration', () => { let nroot: string; let nskill: string; beforeEach(() => { @@ -1328,28 +1111,25 @@ describe('nc:prompt PromptOpts (normalize at bind, opts threading)', () => { }); it('normalize:rstrip-slash strips a trailing slash on an interactive answer too (same bind path)', async () => { - const res = await applySkill(nskill, nroot, { prompter: headless({ public_url: 'https://x.ngrok.io/' }), exec: () => {} }); + const res = await applySkill(nskill, nroot, { resolveInput: headless({ public_url: 'https://x.ngrok.io/' }), exec: () => {} }); expect(res.vars.public_url).toBe('https://x.ngrok.io'); // identical to the inputs path expect(readFileSync(join(nroot, '.env'), 'utf8')).toContain('PUBLIC_URL=https://x.ngrok.io'); }); - it('threads validate + PromptOpts (flags/min/normalize) through to the prompter, then normalizes the answer at bind', async () => { + it('declares validate/flags/normalize to resolveInput via InputMeta, then normalizes the answer at bind', async () => { writeFileSync( join(nskill, 'SKILL.md'), - '# o\n\n```nc:prompt url validate:^https?:// flags:i min:5 normalize:rstrip-slash\nURL?\n```\n', + '# o\n\n```nc:prompt url validate:^https?:// flags:i normalize:rstrip-slash\nURL?\n```\n', ); - let seenValidate: string | undefined; - let seenOpts: PromptOpts | undefined; - const prompter: Prompter = { - async ask(_n, _q, _s, validate, opts) { - seenValidate = validate; - seenOpts = opts; - return 'HTTPS://x.io/'; // the prompter returns the raw answer; the engine normalizes + let seen: InputMeta | undefined; + const res = await applySkill(nskill, nroot, { + exec: () => {}, + resolveInput: async (_n, meta) => { + seen = meta; + return 'HTTPS://x.io/'; // the consumer returns the raw answer; the engine normalizes }, - }; - const res = await applySkill(nskill, nroot, { prompter, exec: () => {} }); - expect(seenValidate).toBe('^https?://'); - expect(seenOpts).toMatchObject({ flags: 'i', min: 5, normalize: 'rstrip-slash' }); + }); + expect(seen).toMatchObject({ validate: '^https?://', flags: 'i', normalize: 'rstrip-slash' }); // normalize applied at bind (trailing slash gone); case preserved (lower not set) expect(res.vars.url).toBe('HTTPS://x.io'); }); @@ -1365,11 +1145,9 @@ describe('nc:prompt PromptOpts (normalize at bind, opts threading)', () => { }); }); -// The core input seam: `resolveInput` replaces Prompter.ask. The engine hands it -// the prompt's DECLARED semantics (InputMeta) so a consumer can run its own -// re-ask loop (clack validate, a chat exchange); returning undefined defers, -// exactly like the legacy prompter. When present, the legacy prompter is never -// consulted; `inputs` still win over both. +// The core input seam: the engine hands `resolveInput` the prompt's DECLARED +// semantics (InputMeta) so a consumer can run its own re-ask loop (clack +// validate, a chat exchange); returning undefined defers. `inputs` win over it. describe('resolveInput (core input seam)', () => { let iroot: string; let iskill: string; @@ -1419,9 +1197,8 @@ describe('resolveInput (core input seam)', () => { expect(res.deferred).toEqual(['token']); }); - it('takes precedence over the legacy prompter; inputs still win over both', async () => { + it('is consulted only for vars `inputs` did not pre-supply — inputs win', async () => { writeFileSync(join(iskill, 'SKILL.md'), '# p\n\n```nc:prompt a\nA?\n```\n```nc:prompt b\nB?\n```\n'); - const askedLegacy: string[] = []; const resolved: string[] = []; const res = await applySkill(iskill, iroot, { inputs: { a: 'fromInputs' }, @@ -1430,17 +1207,10 @@ describe('resolveInput (core input seam)', () => { resolved.push(n); return 'fromResolveInput'; }, - prompter: { - async ask(n) { - askedLegacy.push(n); - return 'fromPrompter'; - }, - }, }); expect(res.vars.a).toBe('fromInputs'); // inputs win expect(res.vars.b).toBe('fromResolveInput'); // resolveInput fills the gap - expect(resolved).toEqual(['b']); - expect(askedLegacy).toEqual([]); // the legacy prompter is never consulted + expect(resolved).toEqual(['b']); // 'a' was never asked }); }); @@ -1539,11 +1309,6 @@ describe('validate-at-bind (inputs + resolveInput answers)', () => { expect(res.vars.h).toBe('U12345678'); }); - it('a legacy prompter answer is validated at the same bind point (transitional parity)', async () => { - const res = await applySkill(vskill, vroot, { prompter: headless({ owner_handle: 'U1' }), exec: () => {} }); - expect(res.deferred).toContain('owner_handle: invalid value (does not match validate:^U[A-Z0-9]{8,}$)'); - expect(res.vars.owner_handle).toBeUndefined(); - }); }); // referenceProse slices the author-written reference FLOOR — the engine-ignored diff --git a/scripts/skill-apply.ts b/scripts/skill-apply.ts index 95a0e6676..e562df81b 100644 --- a/scripts/skill-apply.ts +++ b/scripts/skill-apply.ts @@ -10,14 +10,14 @@ // Phases (the F2 runtime contract, minimal form): // 1. parse + validate — lint; a malformed skill never reaches apply // 2. PLAN — per directive: skip|apply|needs-input|agent — no writes -// 3. acquire inputs — resolve every `prompt` via the injected Prompter +// 3. acquire inputs — resolve every `prompt` via `inputs` / `resolveInput` // 4. mutate — copy/append/env-set, journaled + idempotent // 5. run — build/test/fetch (+ dep install) via injected exec // Remove is derived from the journal — no hand-written REMOVE.md. // -// Inputs + the Prompter make one engine serve three contexts: -// • programmatic → pass `inputs` (var→value); no prompter, runs through fully -// • setup flow → interactive prompter asks the user inline for anything left +// Inputs + `resolveInput` make one engine serve three contexts: +// • programmatic → pass `inputs` (var→value); no resolver, runs through fully +// • setup flow → an interactive `resolveInput` collects anything left // • recipe rebuild → headless: no answer for a prompt ⇒ it (and its consumers) defer // // Usage: pnpm exec tsx scripts/skill-apply.ts # plan (no writes) @@ -27,20 +27,6 @@ import { readFileSync, existsSync, writeFileSync, appendFileSync, copyFileSync, import { join, dirname } from 'node:path'; import { parseDirectives, promptVar, type Directive } from './skill-directives.js'; -// Optional per-prompt UX hints an `nc:prompt`'s attrs carry. Every field is -// trailing-optional, so an existing `async ask(name)` test fake is untouched. -// • flags regex flags for `validate` (e.g. `i` → case-insensitive match) -// • min a minimum length the interactive prompter enforces (re-asks if short) -// • error the validation message the prompter surfaces on a mismatch -// • normalize a deterministic transform applied AT BIND (see `normalizeValue`) to -// BOTH `inputs` and interactive answers: trim | rstrip-slash | lower -export interface PromptOpts { - flags?: string; - min?: number; - error?: string; - normalize?: string; -} - // What an `nc:prompt` DECLARES about the value it needs — the core seam's input // contract, passed to `resolveInput` so a consumer can run its OWN re-ask loop // (clack validate, a chat exchange). Declaration only: how the value is @@ -69,30 +55,6 @@ export type ApplyEvent = // operator: text = the rendered, {{var}}-substituted block body; // line = the directive's opening-fence line (keys driver policy maps) -// LEGACY seam — being replaced by `resolveInput` + `onEvent` (the engine prefers -// those when present). Kept only until every in-tree caller migrates. -export interface Prompter { - // Return the value, or undefined to DEFER (headless rebuild collects these). - // `validate` is an optional regex (from `nc:prompt … validate:`) the - // interactive prompter enforces, re-asking until the answer matches. `opts` - // carries the trailing-optional PromptOpts (flags/min/error — normalize is the - // engine's, applied at bind, not the prompter's). A fake may ignore both. - ask(varName: string, question: string, secret: boolean, validate?: string, opts?: PromptOpts): 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; - // Ask the operator a yes/no question (used by the driver's reuse-existing - // offer and an `nc:operator gate` barrier). Absent ⇒ no operator present, so - // the offer / barrier is skipped (headless/programmatic apply proceeds). - confirm?(message: string): Promise; - // Open a URL in the operator's browser — an `nc:operator open:` deep-link - // (the Azure portal, a Telegram bot chat, a Discord invite). Best-effort and - // trailing-optional: absent ⇒ no display (headless/programmatic), so the open - // is skipped — the URL is already in the rendered operator text for copy-paste. - open?(url: string): Promise | void; -} - // The result of a streaming `nc:run effect:step`: the spawn's exit success plus // the terminal status block's fields, which `capture:=` binds. export interface StepOutcome { @@ -100,19 +62,6 @@ export interface StepOutcome { fields: Record; } -// LEGACY apply-lifecycle hook — being replaced by `onEvent` step-start/step-end -// events (same payload, plus the discriminating `type`). The engine fires -// `stepStart` immediately before it mutates (applyOne) and `stepEnd` immediately -// after — every start has a matching end (including the failure path), so a -// driver-side spinner is never left hanging. `label` is the caption from -// `stepLabel` — null declares the step instant/cheap, or that it renders its own -// live operator-facing output (see `stepLabel`). Optional everywhere: no -// reporter ⇒ silent, the headless/programmatic apply is unchanged. -export interface StepReporter { - stepStart(e: { kind: string; line: number; label: string | null }): void; - stepEnd(e: { kind: string; line: number; label: string | null; ok: boolean; durationMs: number; error?: string }): void; -} - export type StepStatus = 'skip' | 'apply' | 'needs-input' | 'agent'; export interface PlanStep { n: number; @@ -260,11 +209,6 @@ export interface AgentTask { line: number; reason: string; prose: string; // the surrounding prose the agent reads to apply the step - // A concise failure hint surfaced to the operator (and the Claude handoff) - // when this step bounces. Defaults to the trimmed prose; an optional - // `on-fail:` attr on the fence narrows it to the single prose line that - // diagnoses the failure. See `failHint`. - hint?: string; } export interface ApplyResult { @@ -288,30 +232,25 @@ export interface ApplyResult { } export interface ApplyOptions { - // Pre-supplied answers for `prompt` vars (var name → value). Checked before the - // prompter, so a caller that has every answer needs no prompter at all and the - // whole skill runs through with no human interaction (fully programmatic apply). + // Pre-supplied answers for `prompt` vars (var name → value). Checked FIRST, so + // a caller that has every answer needs no resolver at all and the whole skill + // runs through with no human interaction (fully programmatic apply). inputs?: Record; - // The core input seam (replaces Prompter.ask): resolve a prompt var the caller - // didn't pre-supply. `meta` carries the declared semantics (question, secret, + // The core input seam: resolve a prompt var the caller didn't pre-supply. + // `meta` carries the declared semantics (question, secret, // validate/flags/normalize) so a consumer can run its OWN re-ask loop. - // Returning undefined ⇒ defer (unchanged semantics). When present, the legacy - // `prompter` is never consulted. + // Returning undefined ⇒ defer. Optional — omit it (with full `inputs`) for a + // headless run; a prompt with neither defers. resolveInput?: (name: string, meta: InputMeta) => Promise; - // The core output seam (replaces BOTH StepReporter and Prompter.tell): every - // engine emission — the step-start/step-end brackets and each rendered - // `nc:operator` block — flows through this one handler, and every call is - // AWAITED before the engine proceeds (that ordering is what lets a consumer - // gate on an operator block). A rejection is treated like any other throw at - // that directive: bounce, never crash — a consumer that throws on an operator - // event accepts the bounce consequence, including the `blocked` latch - // cascading over later side effects. When present, the legacy `reporter` and - // `prompter.tell/open/confirm` are never consulted. + // The core output seam: every engine emission — the step-start/step-end + // brackets and each rendered `nc:operator` block — flows through this one + // handler, and every call is AWAITED before the engine proceeds (that + // ordering is what lets a consumer gate on an operator block). A rejection is + // treated like any other throw at that directive: bounce, never crash — a + // consumer that throws on an operator event accepts the bounce consequence, + // including the `blocked` latch cascading over later side effects. Absent ⇒ + // silent; the headless/programmatic apply runs identically. onEvent?: (e: ApplyEvent) => void | Promise; - // LEGACY interactive prompter for any prompt not covered by `inputs` — used - // only when `resolveInput` is absent. Optional — omit it (with full `inputs`) - // for a headless run; a prompt with neither defers. - prompter?: Prompter; // dep/run/branch-fetch; injectable for tests. Returns the command's stdout so // a `run capture:` can bind it into a {{var}} (the twin of `prompt`). exec?: (cmd: string) => string | void | Promise; @@ -329,11 +268,6 @@ export interface ApplyOptions { // generic resolver (env override → first remote that has the branch → origin); // setup injects one that reuses setup/lib/channels-remote.sh for exact parity. resolveRemote?: (branch: string) => string; - // LEGACY lifecycle hook — used only when `onEvent` is absent. The setup driver - // uses it to spin on each slow apply step (stepStart/stepEnd bracket every - // applyOne). Absent ⇒ no reporting; the headless/programmatic apply runs - // identically. See `stepLabel`. - reporter?: StepReporter; } /** @@ -357,7 +291,7 @@ export function fullyApplied(res: ApplyResult): boolean { export function firstFailureHint(res: ApplyResult): { headline: string; hint: string } | undefined { const first = res.agentTasks[0]; if (!first) return undefined; - const hint = (first.hint ?? first.prose).trim(); + const hint = first.prose.trim(); // The concise headline: the nearest `#`-heading the prose carries, stripped of // its markers; failing that, the first prose line; failing that, the reason. const lines = first.prose.split('\n').map((l) => l.trim()).filter(Boolean); @@ -457,27 +391,6 @@ function proseFor(md: string, fenceLine1: number): string { return [heading, ...para].filter(Boolean).join('\n').trim(); } -// The concise failure hint surfaced to the operator when a directive bounces. -// Defaults to the surrounding prose (so a stripped fence still reads coherently: -// prose-primary, never a leak). An optional `on-fail:` attr on the fence -// narrows it to the single prose LINE that diagnoses this failure — but the attr -// is stripped along with the fence when a skill degrades to prose, so the SAME -// diagnosis must already live in the prose. We enforce that by falling back to -// the full prose when no prose line contains the token, never surfacing a bare -// token the operator would otherwise never see. -function failHint(d: Directive, prose: string): string { - const token = typeof d.attrs['on-fail'] === 'string' ? d.attrs['on-fail'] : undefined; - if (token) { - const needle = token.toLowerCase(); - const line = prose - .split('\n') - .map((l) => l.replace(/^#+\s*/, '').trim()) - .find((l) => l.length > 0 && l.toLowerCase().includes(needle)); - if (line) return line; - } - return prose.trim(); -} - // The nearest `#`-prefixed heading above a fence (the same upward scan proseFor // uses), stripped of its leading `#`s — a concise caption for a step spinner. function headingAbove(md: string, fenceLine1: number): string { @@ -500,14 +413,11 @@ const SPIN_EFFECTS = new Set(['build', 'test', 'fetch', 'wire', 'restart', 'exte * write, a json-merge), or it renders its own live operator-facing output * (`effect:step`'s QR card / pairing code) — the step event still carries * `kind` + `line`, so a consumer wanting a different render policy can derive - * its own. An explicit `label:` attr on the fence wins; otherwise - * the caption is the nearest heading above the directive (so the spinner reads - * like the section it's in), falling back to a kind/effect default. The attr - * lives on the directive fence, so it's stripped along with the fence when a - * skill degrades to prose — invisible to the agent, never narrated. + * its own. Labels are HEADING-DERIVED only: the caption is the nearest heading + * above the directive (so a consumer's progress line reads like the section + * it's in), falling back to a kind/effect default. */ export function stepLabel(d: Directive, md: string): string | null { - if (typeof d.attrs.label === 'string') return d.attrs.label; const effect = typeof d.attrs.effect === 'string' ? d.attrs.effect : undefined; const spins = d.kind === 'dep' || @@ -531,7 +441,7 @@ export function stepLabel(d: Directive, md: string): string | null { // rstrip-slash drop trailing slash(es) — a base URL with no trailing path // lower lowercase // Absent/unknown ⇒ a no-op (lint gates the known set). Doing it here, not in the -// prompter, means a programmatic `inputs` value and a typed answer land identically. +// consumer, means a programmatic `inputs` value and a typed answer land identically. // Exported so the driver's reuse-offer pre-filter (§5.4) tests an `.env` value // against the SAME normalize-then-validate the engine will apply at bind. export function normalizeValue(value: string, normalize: string | undefined): string { @@ -547,18 +457,6 @@ export function normalizeValue(value: string, normalize: string | undefined): st } } -// The PromptOpts an `nc:prompt`'s attrs carry. Stripped with the fence when a -// skill degrades to prose — invisible to the agent — so a plain re-read still -// reads as a normal question. `min` parses to a number; the rest pass through. -function promptOptsOf(d: Directive): PromptOpts { - const opts: PromptOpts = {}; - if (typeof d.attrs.flags === 'string') opts.flags = d.attrs.flags; - if (typeof d.attrs.error === 'string') opts.error = d.attrs.error; - if (typeof d.attrs.normalize === 'string') opts.normalize = d.attrs.normalize; - if (typeof d.attrs.min === 'string' && /^\d+$/.test(d.attrs.min)) opts.min = Number(d.attrs.min); - return opts; -} - // The engine-applied normalize transforms (see `normalizeValue`) — the set // InputMeta.normalize narrows to. Lint gates authorship to these; an unknown // value simply isn't declared in the meta (and normalizeValue no-ops on it). @@ -566,13 +464,14 @@ const NORMALIZE_KINDS: ReadonlySet = new Set(['trim', 'rstrip-slash', 'l // The InputMeta an `nc:prompt` declares — handed to `resolveInput` so a // consumer can run its own re-ask loop against the same semantics the engine -// enforces at bind. -function inputMetaOf(d: Directive, secret: boolean, validate: string | undefined, promptOpts: PromptOpts): InputMeta { +// enforces at bind. The attrs live on the directive fence, so they're stripped +// along with the fence when a skill degrades to prose — invisible to the agent. +function inputMetaOf(d: Directive, secret: boolean, validate: string | undefined): InputMeta { const meta: InputMeta = { question: d.body.join('\n'), secret }; if (validate !== undefined) meta.validate = validate; - if (promptOpts.flags !== undefined) meta.flags = promptOpts.flags; - if (promptOpts.normalize !== undefined && NORMALIZE_KINDS.has(promptOpts.normalize)) { - meta.normalize = promptOpts.normalize as InputMeta['normalize']; + if (typeof d.attrs.flags === 'string') meta.flags = d.attrs.flags; + if (typeof d.attrs.normalize === 'string' && NORMALIZE_KINDS.has(d.attrs.normalize)) { + meta.normalize = d.attrs.normalize as InputMeta['normalize']; } return meta; } @@ -817,14 +716,13 @@ export async function applySkill(skillDir: string, root: string, opts: ApplyOpti const SIDE_EFFECTS = new Set(['restart', 'step', 'wire']); const bounce = (d: Directive, reason: string) => { blocked = true; - const prose = proseFor(md, d.line); - res.agentTasks.push({ kind: d.kind, line: d.line, reason, prose, hint: failHint(d, prose) }); + res.agentTasks.push({ kind: d.kind, line: d.line, reason, prose: proseFor(md, d.line) }); }; for (const d of directives) { - // Tracks an in-flight reported step so the catch can always close a matching - // stepEnd (start/end stay balanced even when applyOne throws — the driver's - // spinner is never orphaned). Set only after stepStart fires. + // Tracks an in-flight step so the catch can always close a matching + // step-end (start/end stay balanced even when applyOne throws — a consumer's + // spinner is never orphaned). Set only after step-start fires. let inFlight: { label: string | null; at: number } | null = null; try { // A `when:=` guard that isn't met skips the directive entirely — @@ -839,23 +737,19 @@ export async function applySkill(skillDir: string, root: string, opts: ApplyOpti const v = promptVar(d)!; const secret = d.args.includes('secret'); const validate = typeof d.attrs.validate === 'string' ? d.attrs.validate : undefined; - const promptOpts = promptOptsOf(d); + const flags = typeof d.attrs.flags === 'string' ? d.attrs.flags : undefined; + const normalize = typeof d.attrs.normalize === 'string' ? d.attrs.normalize : undefined; // Pre-supplied inputs win OUTRIGHT (fully-programmatic apply) — an // invalid `inputs` value never falls through to a second acquisition // path (validation below rejects it loudly instead). Otherwise resolve - // via the new seam (`resolveInput`), falling back to the legacy - // prompter; still undefined ⇒ defer (headless, no answer). + // via `resolveInput`; still undefined ⇒ defer (headless, no answer). let val = opts.inputs?.[v]; - if (val === undefined) { - val = opts.resolveInput - ? await opts.resolveInput(v, inputMetaOf(d, secret, validate, promptOpts)) - : await opts.prompter?.ask(v, d.body.join(' '), secret, validate, promptOpts); - } + if (val === undefined) val = await opts.resolveInput?.(v, inputMetaOf(d, secret, validate)); if (val === undefined) { res.deferred.push(v); continue; } // normalize: binds DETERMINISTICALLY for both inputs and answers, so // an `inputs` value and a typed one land identically (a trailing slash // stripped, whitespace trimmed) — see normalizeValue. - const bound = normalizeValue(val, promptOpts.normalize); + const bound = normalizeValue(val, normalize); // Validate-at-bind: `validate:` (+ `flags:`) is DATA validation, enforced // on the NORMALIZED value no matter where it came from (normalize-then- // validate is normative: a trailing slash is stripped before an anchor @@ -866,7 +760,7 @@ export async function applySkill(skillDir: string, root: string, opts: ApplyOpti // and a pipeline passing a malformed env value fails loudly. The // interactive re-ask loop lives in the consumer's `resolveInput`; this is // the backstop for programmatic paths. - if (validate !== undefined && !new RegExp(validate, promptOpts.flags).test(bound)) { + if (validate !== undefined && !new RegExp(validate, flags).test(bound)) { res.deferred.push(`${v}: invalid value (does not match validate:${validate})`); continue; } @@ -875,35 +769,18 @@ export async function applySkill(skillDir: string, root: string, opts: ApplyOpti } if (d.kind === 'operator') { // Always collect the human-facing instructions into the result so a - // programmatic caller can relay/output them; also render live when an - // interactive prompter is present. {{vars}} render so a resolved value - // can be shown (throws → deferred if a referenced var is unset). + // programmatic caller can relay/output them. {{vars}} render so a + // resolved value can be shown (throws → deferred if a referenced var is + // unset — the whole block defers before any event fires). const text = substitute(d.body.join('\n'), vars); - // open: deep-links the operator to the page the steps describe. - // Resolve it up front so an unresolved {{var}} in the URL defers the - // whole block (consistent with the body) instead of half-rendering it. - const openTarget = typeof d.attrs.open === 'string' ? substitute(d.attrs.open, vars) : undefined; res.operatorMessages.push(text); - if (opts.onEvent) { - // The core seam: emit the rendered block and AWAIT the consumer before - // evaluating the next directive — that ordering is what lets a consumer - // gate (hold the event until the human confirms readiness). The engine - // itself never defers/bounces an operator block; a handler that throws - // opts into the standard bounce path via the outer catch (including - // the `blocked` latch over later side effects). - await opts.onEvent({ type: 'operator', line: d.line, text }); - } else { - await opts.prompter?.tell?.(text); - // After rendering, open the deep-link (best-effort; absent method ⇒ the - // URL is already in the rendered text for copy-paste — never a crash). - if (openTarget !== undefined) await opts.prompter?.open?.(openTarget); - // A bare `gate` flag turns the block into a human BARRIER: wait on a - // confirm before the following side-effecting directives run (a manifest - // build, a restart), so a manual UI step (the Azure app must exist) is - // finished first. Pure polish — a stripped fence leaves the same prose, - // and a prompter without confirm (headless/programmatic) just proceeds. - if (d.args.includes('gate')) await opts.prompter?.confirm?.('Done with the steps above? Continue when you are ready.'); - } + // The core seam: emit the rendered block and AWAIT the consumer before + // evaluating the next directive — that ordering is what lets a consumer + // gate (hold the event until the human confirms readiness). The engine + // itself never defers/bounces an operator block; a handler that throws + // opts into the standard bounce path via the outer catch (including + // the `blocked` latch over later side effects). + if (opts.onEvent) await opts.onEvent({ type: 'operator', line: d.line, text }); res.applied.push(`operator: ${(d.body[0] ?? '').slice(0, 50)}`); continue; } @@ -924,20 +801,18 @@ export async function applySkill(skillDir: string, root: string, opts: ApplyOpti 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; } - // Bracket the real mutation with step events (`onEvent` preferred, legacy - // reporter otherwise) so a consumer can render progress. `label` null is a - // step-cost/interactivity declaration (see `stepLabel`). `inFlight` is set - // only after step-start fires; the ok:true step-end clears it BEFORE its - // own (awaited) emission, so a consumer throw there never double-closes. + // Bracket the real mutation with step events so a consumer can render + // progress. `label` null is a step-cost/interactivity declaration (see + // `stepLabel`). `inFlight` is set only after step-start fires; the ok:true + // step-end clears it BEFORE its own (awaited) emission, so a consumer + // throw there never double-closes. const label = stepLabel(d, md); if (opts.onEvent) await opts.onEvent({ type: 'step-start', kind: d.kind, line: d.line, label }); - else opts.reporter?.stepStart({ kind: d.kind, line: d.line, label }); inFlight = { label, at: Date.now() }; await applyOne(d, { root, skillDir, exec, execStream: opts.execStream, resolveRemote, vars, journal: res.journal }); const durationMs = Date.now() - inFlight.at; inFlight = null; if (opts.onEvent) await opts.onEvent({ type: 'step-end', kind: d.kind, line: d.line, label, ok: true, durationMs }); - else opts.reporter?.stepEnd({ kind: d.kind, line: d.line, label, ok: true, durationMs }); res.applied.push(`${d.kind}: ${st.detail}`); } catch (e) { const msg = e instanceof Error ? e.message : String(e); @@ -946,13 +821,9 @@ export async function applySkill(skillDir: string, root: string, opts: ApplyOpti // bounce (a real failure, handled below). The failure-path close is // best-effort: a consumer that also throws here can't change the outcome — // we're already on the failure path. - if (inFlight) { + if (inFlight && opts.onEvent) { const end = { kind: d.kind, line: d.line, label: inFlight.label, ok: false, durationMs: Date.now() - inFlight.at, error: msg }; - if (opts.onEvent) { - try { await opts.onEvent({ type: 'step-end', ...end }); } catch { /* already failing — the close is best-effort */ } - } else { - opts.reporter?.stepEnd(end); - } + try { await opts.onEvent({ type: 'step-end', ...end }); } catch { /* already failing — the close is best-effort */ } } if (/unresolved \{\{/.test(msg)) res.deferred.push(msg); // blocked on a prompt input else bounce(d, `engine could not apply (${msg}) — an agent applies it from the prose`);