mirror of
https://github.com/qwibitai/nanoclaw.git
synced 2026-07-06 18:52:03 +08:00
refactor(skills): core deletion of the old seam — Prompter/PromptOpts/StepReporter and the presentation attrs leave the engine
The engine now declares and emits only: inputs/resolveInput acquire,
onEvent renders. Removed the legacy Prompter (ask/tell/confirm/open),
PromptOpts, StepReporter, ApplyOptions.prompter/reporter, and engine
handling of open:/gate (driver policy now owns both), label: (labels
are heading-derived only), on-fail:+AgentTask.hint (hint is always the
prose — firstFailureHint reads it directly), and the min:/error:
plumbing (flags/normalize live on InputMeta). Engine tests for the
removed syntax deleted; semantic siblings kept (heading labels,
prose-hint default, operator-body {{var}} deferral — plus a new
no-event-fired assertion for the deferred block).
Spec: docs/skill-engine-seam.md §8 step 3, §9.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+93
-328
@@ -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<string, string>): Prompter => ({ async ask(name) { return vals[name]; } });
|
||||
// A headless resolveInput fake: answers from a fixed map; a missing var defers.
|
||||
const headless = (vals: Record<string, string>) => async (name: string): Promise<string | undefined> => 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:<marker>', () => {
|
||||
|
||||
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:<marker>', () => {
|
||||
|
||||
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:<marker>', () => {
|
||||
|
||||
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:<url> 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:<url> 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:<url> 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<string> => { 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:<re> through to the prompter', async () => {
|
||||
it('declares the prompt validate:<re> 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<RecordedEvent, { ev: 'end' }> => 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<RecordedEvent, { ev: 'end' }> => 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<ApplyEvent, { type: 'step-start' }>;
|
||||
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<ApplyEvent, { type: 'step-end' }>;
|
||||
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:<token>` 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:<token> 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:<how>` 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:<how>` 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
|
||||
|
||||
+57
-186
@@ -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 <skillDir> # 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:<re>`) 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<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;
|
||||
// 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<boolean>;
|
||||
// Open a URL in the operator's browser — an `nc:operator open:<url>` 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> | void;
|
||||
}
|
||||
|
||||
// The result of a streaming `nc:run effect:step`: the spawn's exit success plus
|
||||
// the terminal status block's fields, which `capture:<var>=<FIELD>` binds.
|
||||
export interface StepOutcome {
|
||||
@@ -100,19 +62,6 @@ export interface StepOutcome {
|
||||
fields: Record<string, string>;
|
||||
}
|
||||
|
||||
// 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:<token>` 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<string, string>;
|
||||
// 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<string | undefined>;
|
||||
// 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<void>;
|
||||
// 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:<var>` can bind it into a {{var}} (the twin of `prompt`).
|
||||
exec?: (cmd: string) => string | void | Promise<string | void>;
|
||||
@@ -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:<token>` 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:<word>` 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<string> = 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:<var>=<value>` 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:<how> 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:<url> 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`);
|
||||
|
||||
Reference in New Issue
Block a user