Files
nanoclaw/scripts/skill-apply.test.ts
T
gavrielc 76c1f13ca3 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>
2026-07-04 16:42:00 +03:00

1411 lines
62 KiB
TypeScript

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 } from './skill-apply.js';
import { parseDirectives, validate } from './skill-directives.js';
// A synthetic skill exercising the fs handlers for real (no network), plus one
// directive the engine can't handle — to prove it bounces to an agent, not abort.
const SKILL = `# demo skill
## Copy the file
\`\`\`nc:copy
resources/sample.ts -> src/sample.ts
\`\`\`
## Register it
\`\`\`nc:append to:src/barrel.ts
import './sample.js';
\`\`\`
## Capture and store a secret
\`\`\`nc:prompt token secret
Paste the demo token.
\`\`\`
\`\`\`nc:env-set
DEMO_TOKEN={{token}}
\`\`\`
## A step the engine can't do deterministically
Hand-edit the scheduler to register the demo hook.
\`\`\`nc:patch-scheduler
register demo
\`\`\`
`;
let root: string;
let skillDir: string;
// 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) };
};
beforeEach(() => {
skillDir = mkdtempSync(join(tmpdir(), 'nc-skill-'));
root = mkdtempSync(join(tmpdir(), 'nc-proj-'));
mkdirSync(join(skillDir, 'resources'), { recursive: true });
writeFileSync(join(skillDir, 'SKILL.md'), SKILL);
writeFileSync(join(skillDir, 'resources/sample.ts'), 'export const sample = true;\n');
mkdirSync(join(root, 'src'), { recursive: true });
writeFileSync(join(root, 'src/barrel.ts'), '// channel barrel\n');
writeFileSync(join(root, '.env'), '');
writeFileSync(join(root, 'package.json'), '{"name":"scratch"}');
});
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, { resolveInput: headless({ token: 'sekret-123' }), exec });
// mutations happened
expect(existsSync(join(root, 'src/sample.ts'))).toBe(true);
expect(readFileSync(join(root, 'src/barrel.ts'), 'utf8')).toContain("import './sample.js';");
expect(readFileSync(join(root, '.env'), 'utf8')).toContain('DEMO_TOKEN=sekret-123');
// the unknown directive went to an agent — with prose — not the human, not an abort
expect(res.agentTasks).toHaveLength(1);
expect(res.agentTasks[0].kind).toBe('patch-scheduler');
expect(res.agentTasks[0].prose).toContain('Hand-edit the scheduler');
expect(res.deferred).toEqual([]);
expect(res.journal.length).toBeGreaterThanOrEqual(3); // wrote + appended + set-env
});
it('is idempotent — a second apply changes nothing', async () => {
const p = headless({ token: 'sekret-123' });
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, { 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 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');
});
it('plan marks the unknown step ↳agent and the prompt ? needs-input before any write', () => {
const { steps, agentSteps, needsInput } = planSkill(skillDir, root);
expect(agentSteps).toBe(1);
expect(needsInput).toContain('token');
expect(existsSync(join(root, 'src/sample.ts'))).toBe(false); // planning mutated nothing
});
});
// json-merge: push a body object into an array-of-objects JSON file, keyed.
const JSON_MERGE_SKILL = `# json-merge demo
## Register the CLI tool
\`\`\`nc:json-merge into:container/cli-tools.json key:name
{ "name": "@openai/codex", "version": "0.138.0" }
\`\`\`
`;
describe('json-merge directive', () => {
let jroot: string;
let jskill: string;
beforeEach(() => {
jskill = mkdtempSync(join(tmpdir(), 'nc-skill-'));
jroot = mkdtempSync(join(tmpdir(), 'nc-proj-'));
writeFileSync(join(jskill, 'SKILL.md'), JSON_MERGE_SKILL);
mkdirSync(join(jroot, 'container'), { recursive: true });
writeFileSync(join(jroot, 'container/cli-tools.json'), '[\n { "name": "vercel", "version": "52.2.1" }\n]\n');
});
it('pushes the object, preserving 2-space indent + trailing newline', async () => {
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);
expect(arr).toEqual([
{ name: 'vercel', version: '52.2.1' },
{ name: '@openai/codex', version: '0.138.0' },
]);
expect(out).toBe(JSON.stringify(arr, null, 2) + '\n'); // 2-space indent
expect(res.journal.some((e) => e.op === 'json-merge')).toBe(true);
});
it('is idempotent — re-applying does not duplicate the element', async () => {
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'));
expect(arr.filter((e: { name: string }) => e.name === '@openai/codex')).toHaveLength(1);
});
it('removeSkill drops the element whose key matches', async () => {
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' }]);
});
it('plan marks it →apply when absent, ✓skip when present', () => {
const before = planSkill(jskill, jroot);
expect(before.steps[0].status).toBe('apply');
// simulate already-merged
writeFileSync(
join(jroot, 'container/cli-tools.json'),
JSON.stringify([{ name: '@openai/codex', version: '0.138.0' }], null, 2) + '\n',
);
const after = planSkill(jskill, jroot);
expect(after.steps[0].status).toBe('skip');
});
});
// append at:<marker>: insert before a dormant region's closing line.
const MARKER_FILE = ['const STEPS = {', " auth: () => import('./auth.js'),", ' // >>> nanoclaw:setup-steps', ' // <<< nanoclaw:setup-steps', '};', ''].join('\n');
const APPEND_AT_SKILL = `# append-at demo
## Register a setup step
\`\`\`nc:append to:setup/index.ts at:nanoclaw:setup-steps
codex: () => import('./codex.js'),
\`\`\`
`;
const APPEND_EOF_SKILL = `# append-eof demo
## Register at EOF
\`\`\`nc:append to:setup/index.ts
// trailing line
\`\`\`
`;
describe('append at:<marker>', () => {
let aroot: string;
let askill: string;
beforeEach(() => {
askill = mkdtempSync(join(tmpdir(), 'nc-skill-'));
aroot = mkdtempSync(join(tmpdir(), 'nc-proj-'));
mkdirSync(join(aroot, 'setup'), { recursive: true });
writeFileSync(join(aroot, 'setup/index.ts'), MARKER_FILE);
});
it('inserts before the `<<< marker` line, matching its indentation', async () => {
writeFileSync(join(askill, 'SKILL.md'), APPEND_AT_SKILL);
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
expect(out[closeIdx - 2]).toContain('>>> nanoclaw:setup-steps'); // open marker untouched
});
it('is idempotent (whole-file line check) regardless of position', async () => {
writeFileSync(join(askill, 'SKILL.md'), APPEND_AT_SKILL);
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);
});
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, { 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, { 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
});
});
// nc:run substitutes prompted {{vars}} — this is what lets wiring be "collect
// input + call ncl", with no nc:wire directive.
const RUN_WIRE_SKILL = `# run-substitute demo
## Collect input
\`\`\`nc:prompt owner_email
Your email.
\`\`\`
## Wire via ncl
\`\`\`nc:run effect:wire
ncl messaging-groups create --channel-type resend --platform-id resend:{{owner_email}} --is-group 0
ncl messaging-groups send --channel-type resend --platform-id resend:{{owner_email}} --text "hello"
\`\`\`
## A var-free build run
\`\`\`nc:run effect:build
pnpm run build
\`\`\`
`;
describe('nc:run variable substitution', () => {
let rroot: string;
let rskill: string;
beforeEach(() => {
rskill = mkdtempSync(join(tmpdir(), 'nc-skill-'));
rroot = mkdtempSync(join(tmpdir(), 'nc-proj-'));
writeFileSync(join(rskill, 'SKILL.md'), RUN_WIRE_SKILL);
writeFileSync(join(rroot, 'package.json'), '{"name":"scratch"}');
});
it('interpolates a prompted {{var}} into run commands; var-free runs pass through unchanged', async () => {
const { cmds, exec } = recordingExec();
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',
);
expect(cmds).toContain(
'ncl messaging-groups send --channel-type resend --platform-id resend:you@example.com --text "hello"',
);
expect(cmds).toContain('pnpm run build');
});
it('journals the ORIGINAL command (placeholders intact) — a substituted value never lands in the journal', async () => {
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',
);
expect(JSON.stringify(res.journal)).not.toContain('you@example.com');
});
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, { 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
});
});
// capture: a run binds its stdout into a {{var}}, the twin of prompt. This is
// what lets a flow resolve a value from an API (Slack conversations.open) and
// feed it downstream — so even slack.ts's bespoke steps are pure directives.
const CAPTURE_SKILL = `# capture demo
## Collect
\`\`\`nc:prompt user_id
Your member id.
\`\`\`
## Resolve an id from a command, then wire with it
\`\`\`nc:run capture:dm_channel effect:fetch
resolve-dm {{user_id}}
\`\`\`
\`\`\`nc:run effect:wire
ncl messaging-groups create --channel-type slack --platform-id slack:{{dm_channel}}
\`\`\`
`;
describe('nc:run capture', () => {
let croot: string;
let cskill: string;
beforeEach(() => {
cskill = mkdtempSync(join(tmpdir(), 'nc-skill-'));
croot = mkdtempSync(join(tmpdir(), 'nc-proj-'));
writeFileSync(join(cskill, 'SKILL.md'), CAPTURE_SKILL);
writeFileSync(join(croot, 'package.json'), '{"name":"scratch"}');
});
it('binds a command stdout (trimmed) into {{var}} and substitutes it downstream', async () => {
const cmds: string[] = [];
// exec returns stdout for the resolve command (simulating `… | jq -r .channel.id`).
const exec = (c: string): string | void => {
cmds.push(c);
if (c.startsWith('resolve-dm')) return 'D0SLACK123\n';
};
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
});
it('lint accepts {{dm_channel}} as defined by the earlier capture', () => {
expect(validate(parseDirectives(CAPTURE_SKILL))).toEqual([]);
});
});
// Multi-field JSON capture: a `capture:a=.x,b=.owner.id` on an effect:fetch parses
// the command's stdout as JSON and binds each var to its jq-style dot-path — so ONE
// API call (Discord's /oauth2/applications/@me) resolves several values at once.
// A single `capture:var` (no =) still binds stdout as-is. validate:<re> shape-guards
// a captured value; a mismatch bounces to an agent (a command's output has no human
// to re-prompt). effect:step's terminal-block field capture (distinguished by
// effect) is untouched — see the effect:step describe above.
const MULTI_CAPTURE_SKILL = `# multi-field capture demo
## Derive three values from one call
\`\`\`nc:run capture:application_id=.id,public_key=.verify_key,owner_handle=.owner.id effect:fetch
curl -sf https://example/app
\`\`\`
## Store the derived values
\`\`\`nc:env-set
APP_ID={{application_id}}
PUB_KEY={{public_key}}
\`\`\`
`;
const CAPTURE_VALIDATE_SKILL = `# capture validate demo
## Resolve an id that must be numeric
\`\`\`nc:run capture:app_id=.id effect:fetch validate:^\\d+$
curl -sf https://example/app
\`\`\`
## Use it
\`\`\`nc:env-set
APP_ID={{app_id}}
\`\`\`
`;
describe('nc:run multi-field JSON capture + validate', () => {
let mroot: string;
let mskill: string;
beforeEach(() => {
mskill = mkdtempSync(join(tmpdir(), 'nc-multi-skill-'));
mroot = mkdtempSync(join(tmpdir(), 'nc-multi-proj-'));
writeFileSync(join(mroot, 'package.json'), '{"name":"scratch"}');
writeFileSync(join(mroot, '.env'), '');
});
it('binds three vars from one JSON stdout via dot-paths (incl. a nested .owner.id) and feeds them downstream', async () => {
writeFileSync(join(mskill, 'SKILL.md'), MULTI_CAPTURE_SKILL);
const json = JSON.stringify({ id: '111111111111111111', verify_key: 'abc123', owner: { id: '999999999999999999' } });
const res = await applySkill(mskill, mroot, { inputs: {}, exec: () => json + '\n' });
expect(fullyApplied(res)).toBe(true);
expect(res.vars.application_id).toBe('111111111111111111');
expect(res.vars.public_key).toBe('abc123');
expect(res.vars.owner_handle).toBe('999999999999999999'); // nested dot-path resolved
const env = readFileSync(join(mroot, '.env'), 'utf8');
expect(env).toContain('APP_ID=111111111111111111'); // flowed into env-set
expect(env).toContain('PUB_KEY=abc123');
});
it('lint registers each capture:<var>=<dot-path> var as defined for the downstream env-set', () => {
expect(validate(parseDirectives(MULTI_CAPTURE_SKILL))).toEqual([]);
});
it('single capture:<var> (no =) still binds stdout as-is — unchanged', async () => {
writeFileSync(join(mskill, 'SKILL.md'), '# single\n\n```nc:run capture:dm effect:fetch\nresolve\n```\n```nc:env-set\nDM={{dm}}\n```\n');
const res = await applySkill(mskill, mroot, { inputs: {}, exec: () => 'D123\n' });
expect(res.vars.dm).toBe('D123');
expect(readFileSync(join(mroot, '.env'), 'utf8')).toContain('DM=D123');
});
it('a validate mismatch on a captured value bounces to an agent — never binds the var', async () => {
writeFileSync(join(mskill, 'SKILL.md'), CAPTURE_VALIDATE_SKILL);
const res = await applySkill(mskill, mroot, { inputs: {}, exec: () => JSON.stringify({ id: 'not-a-number' }) });
expect(res.agentTasks).toHaveLength(1); // bounce, not re-ask
expect(res.agentTasks[0].kind).toBe('run');
expect(res.vars.app_id).toBeUndefined(); // validate failed before binding
// the downstream env-set then defers on the unresolved {{app_id}}
expect(res.deferred.some((d) => /unresolved \{\{app_id\}\}/.test(d))).toBe(true);
expect(readFileSync(join(mroot, '.env'), 'utf8')).not.toContain('APP_ID=');
});
it('a validate match binds the captured value and applies clean', async () => {
writeFileSync(join(mskill, 'SKILL.md'), CAPTURE_VALIDATE_SKILL);
const res = await applySkill(mskill, mroot, { inputs: {}, exec: () => JSON.stringify({ id: '42' }) });
expect(fullyApplied(res)).toBe(true);
expect(res.vars.app_id).toBe('42');
});
it('unparseable JSON stdout for a multi-field capture bounces (degrade, not crash)', async () => {
writeFileSync(join(mskill, 'SKILL.md'), MULTI_CAPTURE_SKILL);
const res = await applySkill(mskill, mroot, { inputs: {}, exec: () => 'not json at all' });
expect(res.agentTasks).toHaveLength(1);
expect(res.vars.application_id).toBeUndefined();
});
});
// operator: the parts addressed to the human (UI steps), delineated so the agent
// relays them and the engine renders them — the output twin of prompt.
describe('nc:operator', () => {
let oroot: string;
let oskill: string;
beforeEach(() => {
oskill = mkdtempSync(join(tmpdir(), 'nc-skill-'));
oroot = mkdtempSync(join(tmpdir(), 'nc-proj-'));
writeFileSync(join(oroot, 'package.json'), '{"name":"scratch"}');
});
it('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, { resolveInput: headless({}), exec: () => {} });
expect(res.agentTasks).toEqual([]); // operator with no sink is fine, not bounced
});
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 resolver and no human interaction.
const PROGRAMMATIC_SKILL = `# programmatic demo
## Collect
\`\`\`nc:prompt owner
Your name.
\`\`\`
## A human step (collected, not blocking)
Tell the user:
\`\`\`nc:operator
Go create the thing, {{owner}}.
\`\`\`
## Resolve from a command, then wire
\`\`\`nc:run capture:thing_id effect:fetch
resolve-thing {{owner}}
\`\`\`
\`\`\`nc:run effect:wire
ncl wire --owner {{owner}} --thing {{thing_id}}
\`\`\`
`;
describe('programmatic apply via inputs', () => {
let proot: string;
let pskill: string;
beforeEach(() => {
pskill = mkdtempSync(join(tmpdir(), 'nc-skill-'));
proot = mkdtempSync(join(tmpdir(), 'nc-proj-'));
writeFileSync(join(proot, 'package.json'), '{"name":"scratch"}');
writeFileSync(join(proot, '.env'), '');
});
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 => {
cmds.push(c);
if (c.startsWith('resolve-thing')) return 'T-42\n';
};
const res = await applySkill(pskill, proot, { inputs: { owner: 'ada' }, exec });
expect(fullyApplied(res)).toBe(true);
expect(res.deferred).toEqual([]);
expect(res.agentTasks).toEqual([]);
expect(cmds).toContain('resolve-thing ada'); // prompt input flowed through
expect(cmds).toContain('ncl wire --owner ada --thing T-42'); // captured value flowed through
expect(res.operatorMessages).toEqual(['Go create the thing, ada.']); // human step collected for relay
});
it('reports a missing input as deferred — fullyApplied is false, not a crash', async () => {
writeFileSync(join(pskill, 'SKILL.md'), PROGRAMMATIC_SKILL);
const res = await applySkill(pskill, proot, { inputs: {}, exec: () => {} });
expect(fullyApplied(res)).toBe(false);
expect(res.deferred).toContain('owner');
});
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 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=fromResolveInput'); // resolveInput filled the gap
expect(asked).toEqual(['b']); // 'a' was never asked — it came from inputs
});
it('skipEffects skips a run the caller owns (effect:restart) but runs the rest', async () => {
writeFileSync(
join(pskill, 'SKILL.md'),
'# restart demo\n\n```nc:run effect:build\npnpm run build\n```\n```nc:run effect:restart\nbash setup/lib/restart.sh\n```\n```nc:run effect:wire\nncl wire\n```\n',
);
const cmds: string[] = [];
const res = await applySkill(pskill, proot, { inputs: {}, skipEffects: ['restart'], exec: (c) => void cmds.push(c) });
expect(cmds).toContain('pnpm run build');
expect(cmds).toContain('ncl wire');
expect(cmds).not.toContain('bash setup/lib/restart.sh'); // restart owned by the caller → skipped
expect(res.skipped.some((s) => /run restart: owned by the caller/.test(s))).toBe(true);
});
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;
await applySkill(pskill, proot, {
exec: () => {},
resolveInput: async (_name, meta) => {
seenValidate = meta.validate;
return 'xoxb-ok';
},
});
expect(seenValidate).toBe('^xoxb-');
});
it('exposes resolved non-secret vars (prompt answers + captures) but never secrets', async () => {
writeFileSync(
join(pskill, 'SKILL.md'),
'# vars demo\n\n```nc:prompt token secret\nT?\n```\n```nc:prompt handle\nH?\n```\n```nc:run capture:addr\nresolve {{handle}}\n```\n',
);
const res = await applySkill(pskill, proot, { inputs: { token: 'SEKRET', handle: 'U9' }, exec: () => 'x:U9\n' });
expect(res.vars.handle).toBe('U9'); // plain prompt answer exposed
expect(res.vars.addr).toBe('x:U9'); // capture output exposed (a caller reads this)
expect(res.vars.token).toBeUndefined(); // secret prompt NOT exposed
});
});
// when: guards let one skill carry mutually-exclusive branches (a local vs
// remote install mode) in document order — the unmet branch is skipped, and a
// guarded prompt is skipped (not deferred) so the programmatic run still completes.
const MODE_SKILL = `# mode demo
## Pick a mode
\`\`\`nc:prompt mode
Pick local or remote.
\`\`\`
## Remote needs a server
\`\`\`nc:prompt server_url when:mode=remote
Photon server URL.
\`\`\`
\`\`\`nc:env-set when:mode=remote
IMESSAGE_SERVER_URL={{server_url}}
\`\`\`
## Local needs nothing extra
\`\`\`nc:env-set when:mode=local
IMESSAGE_LOCAL=true
\`\`\`
`;
function modeScratch(): { sdir: string; rdir: string } {
const sdir = mkdtempSync(join(tmpdir(), 'nc-when-skill-'));
const rdir = mkdtempSync(join(tmpdir(), 'nc-when-proj-'));
writeFileSync(join(sdir, 'SKILL.md'), MODE_SKILL);
writeFileSync(join(rdir, '.env'), '');
writeFileSync(join(rdir, 'package.json'), '{"name":"scratch"}');
return { sdir, rdir };
}
describe('when: guard', () => {
it('local mode: the remote-guarded prompt + env-set are skipped, not deferred — fully programmatic', async () => {
const { sdir, rdir } = modeScratch();
const res = await applySkill(sdir, rdir, { inputs: { mode: 'local' }, exec: () => {} });
expect(fullyApplied(res)).toBe(true);
expect(res.deferred).toEqual([]); // server_url was skipped by the guard, NOT deferred
const env = readFileSync(join(rdir, '.env'), 'utf8');
expect(env).toContain('IMESSAGE_LOCAL=true');
expect(env).not.toContain('IMESSAGE_SERVER_URL');
expect(res.skipped.some((s) => /when mode=remote/.test(s))).toBe(true);
});
it('remote mode: the remote branch applies, the local-only env-set is skipped', async () => {
const { sdir, rdir } = modeScratch();
const res = await applySkill(sdir, rdir, { inputs: { mode: 'remote', server_url: 'https://photon.example' }, exec: () => {} });
expect(fullyApplied(res)).toBe(true);
const env = readFileSync(join(rdir, '.env'), 'utf8');
expect(env).toContain('IMESSAGE_SERVER_URL=https://photon.example');
expect(env).not.toContain('IMESSAGE_LOCAL');
});
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' }, resolveInput: headless({}), exec: () => {} });
expect(res.deferred).toEqual([]);
expect(fullyApplied(res)).toBe(true);
});
});
// effect:step runs a long-running, operator-interactive step (a pairing code, a
// QR device-link) through the injected streaming exec and binds the terminal
// block's named fields via capture:<var>=<FIELD>,… — the structured twin of
// stdout capture. With no streaming exec it degrades to an agent.
const STEP_SKILL = `# step demo
## Link the device
\`\`\`nc:run effect:step capture:platform_id=PLATFORM_ID,owner_handle=ADMIN_ID
pnpm exec tsx setup/index.ts --step pair-demo
\`\`\`
## Use what the step resolved
\`\`\`nc:env-set
DEMO_PLATFORM={{platform_id}}
\`\`\`
`;
function stepScratch(): { sdir: string; rdir: string } {
const sdir = mkdtempSync(join(tmpdir(), 'nc-step-skill-'));
const rdir = mkdtempSync(join(tmpdir(), 'nc-step-proj-'));
writeFileSync(join(sdir, 'SKILL.md'), STEP_SKILL);
writeFileSync(join(rdir, '.env'), '');
writeFileSync(join(rdir, 'package.json'), '{"name":"scratch"}');
return { sdir, rdir };
}
describe('nc:run effect:step (streaming, multi-field capture)', () => {
it('binds the terminal block fields into vars and substitutes them downstream', async () => {
const { sdir, rdir } = stepScratch();
const seen: string[] = [];
const execStream = async (cmd: string) => {
seen.push(cmd);
return { ok: true, fields: { STATUS: 'success', PLATFORM_ID: 'telegram:12345', ADMIN_ID: '67890' } };
};
const res = await applySkill(sdir, rdir, { exec: () => {}, execStream });
expect(fullyApplied(res)).toBe(true);
expect(seen).toEqual(['pnpm exec tsx setup/index.ts --step pair-demo']);
expect(res.vars.platform_id).toBe('telegram:12345'); // both fields captured…
expect(res.vars.owner_handle).toBe('67890');
expect(readFileSync(join(rdir, '.env'), 'utf8')).toContain('DEMO_PLATFORM=telegram:12345'); // …and consumed downstream
});
it('degrades to an agent when no streaming exec is wired (not a crash)', async () => {
const { sdir, rdir } = stepScratch();
const res = await applySkill(sdir, rdir, { exec: () => {} }); // no execStream
expect(res.agentTasks).toHaveLength(1);
expect(res.agentTasks[0].kind).toBe('run');
expect(res.deferred.some((d) => /platform_id/.test(d))).toBe(true); // downstream env-set then defers
});
it('a failed step bounces to an agent rather than capturing empty values', async () => {
const { sdir, rdir } = stepScratch();
const res = await applySkill(sdir, rdir, { exec: () => {}, execStream: async () => ({ ok: false, fields: {} }) });
expect(res.agentTasks).toHaveLength(1);
expect(res.vars.platform_id).toBeUndefined();
});
});
// Run-health gate: once any directive bounces (a real failure, not a deferred
// prompt), the dangerous side effects — a live restart, an interactive
// pairing/QR step, a wire — must not fire on their own. They bounce too, so the
// agent finishes them from the prose after fixing the upstream failure. This is
// what stops a doomed QR / a pointless restart after a bad credential.
const GATE_SKILL = `# gate demo
## Validate the credential first
\`\`\`nc:run capture:who effect:fetch
verify-cred
\`\`\`
## Restart the service
\`\`\`nc:run effect:restart
bash restart.sh
\`\`\`
## Link the device (interactive)
\`\`\`nc:run effect:step capture:platform_id=PLATFORM_ID
pnpm exec tsx setup/index.ts --step pair
\`\`\`
`;
// A deferred prompt is NOT a failure: the headless rebuild leaves it (and its
// {{var}} consumer) unresolved, but a later restart must still be runnable.
const DEFER_THEN_RESTART_SKILL = `# defer then restart demo
## Collect a token
\`\`\`nc:prompt token secret
Paste it.
\`\`\`
\`\`\`nc:env-set
TOK={{token}}
\`\`\`
## Restart the service
\`\`\`nc:run effect:restart
bash restart.sh
\`\`\`
`;
describe('run-health gate (a bounce blocks later side effects)', () => {
let groot: string;
let gskill: string;
beforeEach(() => {
gskill = mkdtempSync(join(tmpdir(), 'nc-gate-skill-'));
groot = mkdtempSync(join(tmpdir(), 'nc-gate-proj-'));
writeFileSync(join(groot, 'package.json'), '{"name":"scratch"}');
writeFileSync(join(groot, '.env'), '');
});
it('a failed effect:fetch blocks the later restart and step — they bounce, never execute', async () => {
writeFileSync(join(gskill, 'SKILL.md'), GATE_SKILL);
const cmds: string[] = [];
const streamed: string[] = [];
const exec = (c: string): string | void => {
cmds.push(c);
if (c === 'verify-cred') throw new Error('401 bad credential'); // bad cred → bounce
};
const execStream = async (c: string) => {
streamed.push(c);
return { ok: true, fields: { PLATFORM_ID: 'x' } };
};
const res = await applySkill(gskill, groot, { inputs: {}, exec, execStream });
// the fetch actually ran and threw — that's the first bounce
expect(cmds).toContain('verify-cred');
// the restart never executed (no live restart on a bad credential)…
expect(cmds).not.toContain('bash restart.sh');
// …and the interactive step never spawned (no doomed QR/pairing)
expect(streamed).toEqual([]);
// three agent tasks: the failed fetch + the two gated side effects
expect(res.agentTasks).toHaveLength(3);
const gated = res.agentTasks.filter((t) => /an earlier step did not complete/.test(t.reason));
expect(gated).toHaveLength(2); // restart + step, both bounced by the gate
});
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, { 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');
expect(res.deferred.some((d) => /unresolved \{\{token\}\}/.test(d))).toBe(true);
expect(readFileSync(join(groot, '.env'), 'utf8')).not.toContain('TOK=');
// the restart still runs, and nothing bounced
expect(cmds).toContain('bash restart.sh');
expect(res.agentTasks).toEqual([]);
});
});
// effect:check runs the body as a shell PREDICATE — a precondition gate that
// mutates NOTHING (no journal, no capture). A non-zero exit bounces to an agent
// AND latches `blocked`, so a following dangerous side effect (a restart) is
// gated. An unresolved {{var}} defers (a headless rebuild before the value is
// collected). A zero exit is a silent pass.
const CHECK_GATE_SKILL = `# check gate demo
## Require macOS for local mode
\`\`\`nc:run effect:check
[ "$(uname)" = Darwin ]
\`\`\`
## Restart the service
\`\`\`nc:run effect:restart
bash restart.sh
\`\`\`
`;
const CHECK_VAR_SKILL = `# check var demo
## Collect the linked number
\`\`\`nc:prompt bot_phone
The linked number.
\`\`\`
## Guard the captured value before using it
\`\`\`nc:run effect:check
[ -n "{{bot_phone}}" ]
\`\`\`
`;
const CHECK_PASS_SKILL = `# check pass demo
## A precondition that passes
\`\`\`nc:run effect:check
true
\`\`\`
`;
describe('nc:run effect:check (precondition gate)', () => {
let chkSkill: string;
let chkRoot: string;
beforeEach(() => {
chkSkill = mkdtempSync(join(tmpdir(), 'nc-check-skill-'));
chkRoot = mkdtempSync(join(tmpdir(), 'nc-check-proj-'));
writeFileSync(join(chkRoot, 'package.json'), '{"name":"scratch"}');
writeFileSync(join(chkRoot, '.env'), '');
});
it('a non-zero check bounces to an agent and gates a following effect:restart', async () => {
writeFileSync(join(chkSkill, 'SKILL.md'), CHECK_GATE_SKILL);
const cmds: string[] = [];
const exec = (c: string): string | void => {
cmds.push(c);
if (c.startsWith('[')) throw new Error('exit 1'); // predicate failed (non-zero)
};
const res = await applySkill(chkSkill, chkRoot, { inputs: {}, exec });
expect(cmds).toContain('[ "$(uname)" = Darwin ]'); // the predicate actually ran
expect(cmds).not.toContain('bash restart.sh'); // restart never executed — gated by the failed check
// two agent tasks: the failed check itself + the gated restart
expect(res.agentTasks).toHaveLength(2);
expect(res.agentTasks[0].kind).toBe('run');
expect(res.agentTasks.some((t) => /an earlier step did not complete/.test(t.reason))).toBe(true);
expect(res.journal).toEqual([]); // a check mutates nothing
});
it('an unresolved {{var}} in a check defers (headless rebuild) — not a bounce', async () => {
writeFileSync(join(chkSkill, 'SKILL.md'), CHECK_VAR_SKILL);
const cmds: string[] = [];
const res = await applySkill(chkSkill, chkRoot, { 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
expect(res.agentTasks).toEqual([]); // a deferred check is NOT a failure — no bounce
expect(cmds.some((c) => c.startsWith('['))).toBe(false); // the predicate never ran (var unresolved)
});
it('a zero-exit check is a no-op — no journal entry, no bounce, no defer', async () => {
writeFileSync(join(chkSkill, 'SKILL.md'), CHECK_PASS_SKILL);
const cmds: string[] = [];
const res = await applySkill(chkSkill, chkRoot, { inputs: {}, exec: (c) => void cmds.push(c) });
expect(cmds).toContain('true'); // the predicate ran
expect(res.journal).toEqual([]); // mutated nothing — no 'ran' entry
expect(res.agentTasks).toEqual([]);
expect(res.deferred).toEqual([]);
});
it('lint accepts a check that guards an earlier-defined var', () => {
expect(validate(parseDirectives(CHECK_VAR_SKILL))).toEqual([]);
});
});
// 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
verify-cred
\`\`\`
## Store it
\`\`\`nc:env-set
DEMO=ok
\`\`\`
`;
// 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.
describe('onEvent (core event seam)', () => {
let eroot: string;
let eskill: string;
beforeEach(() => {
eskill = mkdtempSync(join(tmpdir(), 'nc-ev-skill-'));
eroot = mkdtempSync(join(tmpdir(), 'nc-ev-proj-'));
writeFileSync(join(eroot, '.env'), '');
writeFileSync(join(eroot, 'package.json'), '{"name":"scratch"}');
});
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) });
expect(events.map((e) => `${e.type}:${'kind' in e ? e.kind : ''}`)).toEqual([
'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
expect(typeof start.line).toBe('number');
const end = events[1] as Extract<ApplyEvent, { type: 'step-end' }>;
expect(end.ok).toBe(true);
expect(end.durationMs).toBeGreaterThanOrEqual(0);
expect((events[2] as Extract<ApplyEvent, { type: 'step-start' }>).label).toBe(null); // instant env-set
});
it('closes a failed step with step-end ok=false + error — start/end always balanced', async () => {
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');
};
const res = await applySkill(eskill, eroot, { exec, onEvent: (e) => void events.push(e) });
const end = events.find((e): e is Extract<ApplyEvent, { type: 'step-end' }> => e.type === 'step-end' && e.kind === 'run')!;
expect(end.ok).toBe(false);
expect(end.error).toMatch(/401 bad credential/);
expect(events.filter((e) => e.type === 'step-start')).toHaveLength(events.filter((e) => e.type === 'step-end').length);
expect(res.agentTasks).toHaveLength(1); // degraded to an agent, not a crash
});
it('emits an operator event with the rendered text + fence line, after collecting operatorMessages', async () => {
const md = '# op\n\n```nc:prompt who\nName?\n```\nTell the user:\n```nc:operator\nHello {{who}} — go click the button.\n```\n';
writeFileSync(join(eskill, 'SKILL.md'), md);
const opLine = parseDirectives(md).find((d) => d.kind === 'operator')!.line;
const events: ApplyEvent[] = [];
const res = await applySkill(eskill, eroot, { inputs: { who: 'world' }, exec: () => {}, onEvent: (e) => void events.push(e) });
const op = events.find((e): e is Extract<ApplyEvent, { type: 'operator' }> => e.type === 'operator')!;
expect(op.text).toBe('Hello world — go click the button.'); // {{var}} substituted
expect(op.line).toBe(opLine); // keyed on the opening-fence line (driver policy maps)
expect(res.operatorMessages).toEqual(['Hello world — go click the button.']); // still collected in the result
});
it('awaits each onEvent before evaluating the next directive (async handler ordering)', async () => {
writeFileSync(
join(eskill, 'SKILL.md'),
'# order\n\nTell the user:\n```nc:operator\nDo the manual thing first.\n```\n\n## Build\n```nc:run effect:build\npnpm run build\n```\n',
);
const seq: string[] = [];
const onEvent = async (e: ApplyEvent): Promise<void> => {
await new Promise((r) => setTimeout(r, 5)); // if the engine did not await, exec would land first
seq.push(`event:${e.type}`);
};
await applySkill(eskill, eroot, { inputs: {}, exec: (c) => void seq.push(`exec:${c}`), onEvent });
expect(seq).toEqual(['event:operator', 'event:step-start', 'exec:pnpm run build', 'event:step-end']);
});
it('a handler throw on a step event bounces that directive (degrade, not crash)', async () => {
writeFileSync(join(eskill, 'SKILL.md'), EVENT_SKILL);
const res = await applySkill(eskill, eroot, {
exec: () => {},
onEvent: (e) => {
if (e.type === 'step-start' && e.kind === 'run') throw new Error('consumer exploded');
},
});
expect(res.agentTasks.some((t) => t.kind === 'run' && /consumer exploded/.test(t.reason))).toBe(true);
});
it('a handler throw on an OPERATOR event bounces too — and the blocked latch gates a later side effect', async () => {
writeFileSync(
join(eskill, 'SKILL.md'),
'# op throw\n\nTell the user:\n```nc:operator\nDo the thing.\n```\n\n## Restart\n```nc:run effect:restart\nbash restart.sh\n```\n',
);
const cmds: string[] = [];
const res = await applySkill(eskill, eroot, {
inputs: {},
exec: (c) => void cmds.push(c),
onEvent: (e) => {
if (e.type === 'operator') throw new Error('relay failed');
},
});
expect(res.operatorMessages).toEqual(['Do the thing.']); // collected before the emit (unchanged)
expect(res.agentTasks.some((t) => t.kind === 'operator' && /relay failed/.test(t.reason))).toBe(true);
expect(cmds).not.toContain('bash restart.sh'); // the consumer accepted the bounce consequence…
expect(res.agentTasks.some((t) => /an earlier step did not complete/.test(t.reason))).toBe(true); // …incl. the cascade
});
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; 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', 'ncl wire', '```',
].join('\n');
const ds = parseDirectives(md);
const nth = (k: string, i = 0) => ds.filter((d) => d.kind === k)[i];
expect(stepLabel(nth('dep'), md)).toBe('Install deps'); // heading-derived
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('Wire it'); // heading-derived only — no attr override
});
it('falls back to a kind/effect default when there is no heading above the fence', () => {
const ds = parseDirectives('```nc:run effect:build\npnpm run build\n```\n');
expect(stepLabel(ds[0], '```nc:run effect:build\npnpm run build\n```\n')).toBe('Building');
});
});
// 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 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
The bot token must be valid. If auth.test fails, the token is wrong or the app isn't installed in the workspace.
\`\`\`nc:hand-verify
check the token
\`\`\`
`;
const NO_BOUNCE_SKILL = `# prompt only
## Collect a token
\`\`\`nc:prompt token secret
Paste it.
\`\`\`
`;
describe('firstFailureHint', () => {
let froot: string;
let fskill: string;
beforeEach(() => {
fskill = mkdtempSync(join(tmpdir(), 'nc-fh-skill-'));
froot = mkdtempSync(join(tmpdir(), 'nc-fh-proj-'));
writeFileSync(join(froot, 'package.json'), '{"name":"scratch"}');
writeFileSync(join(froot, '.env'), '');
});
it('returns the heading as a headline and the bounced step prose as the hint', async () => {
writeFileSync(join(fskill, 'SKILL.md'), FAIL_HINT_SKILL);
const res = await applySkill(fskill, froot, { inputs: {}, exec: () => {} });
expect(res.agentTasks).toHaveLength(1); // the unknown directive bounced
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
// 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, { resolveInput: headless({}), exec: () => {} });
expect(res.deferred).toContain('token'); // deferred, not bounced
expect(res.agentTasks).toEqual([]);
expect(firstFailureHint(res)).toBeUndefined();
});
});
// `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
\`\`\`nc:prompt public_url normalize:rstrip-slash
Paste your base URL.
\`\`\`
\`\`\`nc:env-set
PUBLIC_URL={{public_url}}
\`\`\`
`;
describe('nc:prompt normalize at bind + InputMeta declaration', () => {
let nroot: string;
let nskill: string;
beforeEach(() => {
nskill = mkdtempSync(join(tmpdir(), 'nc-opts-skill-'));
nroot = mkdtempSync(join(tmpdir(), 'nc-opts-proj-'));
writeFileSync(join(nskill, 'SKILL.md'), NORMALIZE_SKILL);
writeFileSync(join(nroot, '.env'), '');
writeFileSync(join(nroot, 'package.json'), '{"name":"scratch"}');
});
it('normalize:rstrip-slash strips a trailing slash on an inputs value (bound + consumed downstream)', async () => {
const res = await applySkill(nskill, nroot, { inputs: { public_url: 'https://x.ngrok.io/' }, exec: () => {} });
expect(res.vars.public_url).toBe('https://x.ngrok.io'); // slash stripped at bind
expect(readFileSync(join(nroot, '.env'), 'utf8')).toContain('PUBLIC_URL=https://x.ngrok.io');
});
it('normalize:rstrip-slash strips a trailing slash on an interactive answer too (same bind path)', async () => {
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('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 normalize:rstrip-slash\nURL?\n```\n',
);
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
},
});
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');
});
it('normalize:lower and trim also bind deterministically', async () => {
writeFileSync(
join(nskill, 'SKILL.md'),
'# n\n\n```nc:prompt a normalize:lower\nA?\n```\n```nc:prompt b normalize:trim\nB?\n```\n',
);
const res = await applySkill(nskill, nroot, { inputs: { a: 'MixedCASE', b: ' spaced ' }, exec: () => {} });
expect(res.vars.a).toBe('mixedcase');
expect(res.vars.b).toBe('spaced');
});
});
// 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;
beforeEach(() => {
iskill = mkdtempSync(join(tmpdir(), 'nc-ri-skill-'));
iroot = mkdtempSync(join(tmpdir(), 'nc-ri-proj-'));
writeFileSync(join(iroot, '.env'), '');
writeFileSync(join(iroot, 'package.json'), '{"name":"scratch"}');
});
it('receives the declared InputMeta — question verbatim, secret, validate, flags, normalize', async () => {
writeFileSync(
join(iskill, 'SKILL.md'),
'# m\n\n```nc:prompt url secret validate:^https?:// flags:i normalize:rstrip-slash\nPaste the URL.\nIt must be the public base.\n```\n',
);
let seenName: string | undefined;
let seen: InputMeta | undefined;
const res = await applySkill(iskill, iroot, {
exec: () => {},
resolveInput: async (name, meta) => {
seenName = name;
seen = meta;
return 'HTTPS://x.io/';
},
});
expect(seenName).toBe('url');
expect(seen).toEqual({
question: 'Paste the URL.\nIt must be the public base.', // the body verbatim (multi-line intact)
secret: true,
validate: '^https?://',
flags: 'i',
normalize: 'rstrip-slash',
});
expect(res.deferred).toEqual([]); // the answer passed validate (flags honored) after normalize
expect(res.vars.url).toBeUndefined(); // secret — never exposed
});
it('binds the answer through normalize at the same bind point as inputs', async () => {
writeFileSync(join(iskill, 'SKILL.md'), '# n\n\n```nc:prompt base normalize:rstrip-slash\nBase URL?\n```\n');
const res = await applySkill(iskill, iroot, { exec: () => {}, resolveInput: async () => 'https://x.io/' });
expect(res.vars.base).toBe('https://x.io');
});
it('undefined ⇒ defer, recorded as the bare var name (unchanged semantics)', async () => {
writeFileSync(join(iskill, 'SKILL.md'), '# d\n\n```nc:prompt token secret\nPaste it.\n```\n');
const res = await applySkill(iskill, iroot, { exec: () => {}, resolveInput: async () => undefined });
expect(res.deferred).toEqual(['token']);
});
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 resolved: string[] = [];
const res = await applySkill(iskill, iroot, {
inputs: { a: 'fromInputs' },
exec: () => {},
resolveInput: async (n) => {
resolved.push(n);
return 'fromResolveInput';
},
});
expect(res.vars.a).toBe('fromInputs'); // inputs win
expect(res.vars.b).toBe('fromResolveInput'); // resolveInput fills the gap
expect(resolved).toEqual(['b']); // 'a' was never asked
});
});
// Validate-at-bind: `validate:` (+ `flags:`) is DATA validation the ENGINE
// enforces on the NORMALIZED value at the single bind point — for `inputs` and
// resolveInput answers alike. A mismatch leaves the var UNBOUND and records
// `<var>: invalid value (does not match validate:<re>)` in `deferred` — not an
// agentTask, not a throw — so downstream consumers defer exactly as if the
// value were never supplied, and a pipeline passing a malformed env value fails
// loudly via fullyApplied=false. The value itself never lands in the entry (a
// secret can't leak). run-capture validate is unchanged (it throws → bounces).
const VALIDATE_BIND_SKILL = `# vb demo
## Collect the member id
\`\`\`nc:prompt owner_handle validate:^U[A-Z0-9]{8,}$
Your member id (starts with U).
\`\`\`
\`\`\`nc:env-set
OWNER={{owner_handle}}
\`\`\`
`;
describe('validate-at-bind (inputs + resolveInput answers)', () => {
let vroot: string;
let vskill: string;
beforeEach(() => {
vskill = mkdtempSync(join(tmpdir(), 'nc-vb-skill-'));
vroot = mkdtempSync(join(tmpdir(), 'nc-vb-proj-'));
writeFileSync(join(vskill, 'SKILL.md'), VALIDATE_BIND_SKILL);
writeFileSync(join(vroot, '.env'), '');
writeFileSync(join(vroot, 'package.json'), '{"name":"scratch"}');
});
it('rejects an invalid inputs value: var unbound, exact deferred entry, downstream defers, fullyApplied false', async () => {
const res = await applySkill(vskill, vroot, { inputs: { 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(); // the var stayed unbound
expect(res.deferred.some((d) => /unresolved \{\{owner_handle\}\}/.test(d))).toBe(true); // env-set deferred on it
expect(res.agentTasks).toEqual([]); // deferred, never bounced (a re-run with a fixed value completes)
expect(fullyApplied(res)).toBe(false); // the loud pipeline failure
expect(readFileSync(join(vroot, '.env'), 'utf8')).not.toContain('OWNER=');
});
it('a valid inputs value binds exactly as before', async () => {
const res = await applySkill(vskill, vroot, { inputs: { owner_handle: 'U12345678' }, exec: () => {} });
expect(fullyApplied(res)).toBe(true);
expect(res.vars.owner_handle).toBe('U12345678');
expect(readFileSync(join(vroot, '.env'), 'utf8')).toContain('OWNER=U12345678');
});
it('rejects an invalid resolveInput answer the same way (the programmatic backstop)', async () => {
const res = await applySkill(vskill, vroot, { exec: () => {}, resolveInput: async () => 'not-a-handle' });
expect(res.deferred).toContain('owner_handle: invalid value (does not match validate:^U[A-Z0-9]{8,}$)');
expect(res.vars.owner_handle).toBeUndefined();
expect(fullyApplied(res)).toBe(false);
});
it('validates the NORMALIZED value — normalize-then-validate is the order (teams public_url authoring)', async () => {
writeFileSync(
join(vskill, 'SKILL.md'),
'# nv\n\n```nc:prompt base_url validate:^https://[a-z.]+$ normalize:rstrip-slash\nBase URL?\n```\n',
);
// the RAW value ends with a slash and would fail the anchored regex; the
// normalized one passes — proving normalize runs first.
const res = await applySkill(vskill, vroot, { inputs: { base_url: 'https://x.io/' }, exec: () => {} });
expect(res.deferred).toEqual([]);
expect(res.vars.base_url).toBe('https://x.io');
});
it('an invalid inputs value does NOT fall through to resolveInput — inputs win outright, loudly', async () => {
const resolved: string[] = [];
const res = await applySkill(vskill, vroot, {
inputs: { owner_handle: 'bad' },
exec: () => {},
resolveInput: async (n) => {
resolved.push(n);
return 'U12345678';
},
});
expect(resolved).toEqual([]); // never a surprise second acquisition path
expect(res.deferred).toContain('owner_handle: invalid value (does not match validate:^U[A-Z0-9]{8,}$)');
expect(res.vars.owner_handle).toBeUndefined();
});
it('a secret value never appears in the deferred entry — only the var name + regex source', async () => {
writeFileSync(join(vskill, 'SKILL.md'), '# s\n\n```nc:prompt token secret validate:^xoxb-\nPaste the bot token.\n```\n');
const res = await applySkill(vskill, vroot, { inputs: { token: 'SUPER-SECRET-VALUE' }, exec: () => {} });
expect(res.deferred).toContain('token: invalid value (does not match validate:^xoxb-)');
expect(JSON.stringify(res)).not.toContain('SUPER-SECRET-VALUE');
});
it('honors flags: at bind (case-insensitive match passes)', async () => {
writeFileSync(join(vskill, 'SKILL.md'), '# f\n\n```nc:prompt h validate:^u[a-z0-9]{8,}$ flags:i\nHandle?\n```\n');
const res = await applySkill(vskill, vroot, { inputs: { h: 'U12345678' }, exec: () => {} });
expect(res.deferred).toEqual([]);
expect(res.vars.h).toBe('U12345678');
});
});
// referenceProse slices the author-written reference FLOOR — the engine-ignored
// `## Alternatives`, `## Optional configuration`, `## Troubleshooting` sections —
// out of the raw markdown, keeping their ### subsections and plain bash/json
// fences but dropping any stray nc: directive fence. Keyed on the author headings
// (never the resolved {{var}} map), so a {{secret}} placeholder can never leak in.
const REFERENCE_SKILL = `# demo
Intro prose that is NOT reference floor.
## Apply
### 1. Build it
\`\`\`nc:run effect:build
pnpm run build
\`\`\`
## Alternatives
### Use a dedicated number
Register a number instead of linking.
\`\`\`bash
signal-cli -a +1 register
\`\`\`
## Optional configuration
Tune the daemon.
\`\`\`bash
SIGNAL_TCP_HOST=127.0.0.1
\`\`\`
## Channel Info
- type: demo
## Troubleshooting
### Bot not responding
Check the logs.
\`\`\`bash
grep demo logs/nanoclaw.log
\`\`\`
`;
describe('referenceProse (reference-floor slice)', () => {
it('slices the three reference headings (with ### subsections + plain fences) in document order', () => {
const ref = referenceProse(REFERENCE_SKILL);
expect(ref).toContain('## Alternatives');
expect(ref).toContain('Register a number instead of linking.');
expect(ref).toContain('signal-cli -a +1 register'); // a plain bash fence is kept
expect(ref).toContain('## Optional configuration');
expect(ref).toContain('SIGNAL_TCP_HOST=127.0.0.1');
expect(ref).toContain('## Troubleshooting');
expect(ref).toContain('### Bot not responding'); // a ### subsection is kept
expect(ref).toContain('grep demo logs/nanoclaw.log');
// non-reference sections are never included
expect(ref).not.toContain('## Apply');
expect(ref).not.toContain('## Channel Info');
expect(ref).not.toContain('Intro prose');
// document order: Alternatives, then Optional configuration, then Troubleshooting
expect(ref.indexOf('## Alternatives')).toBeLessThan(ref.indexOf('## Optional configuration'));
expect(ref.indexOf('## Optional configuration')).toBeLessThan(ref.indexOf('## Troubleshooting'));
});
it('drops nc: directive fences and never resolves a {{secret}} placeholder (no leak)', () => {
// A Troubleshooting section that (pathologically) carries an nc: fence and a
// {{token}} placeholder: the nc: block is dropped wholesale, while the literal
// placeholder stays literal — referenceProse keys on raw author text, not vars.
const md = [
'# s', '',
'## Apply', '```nc:prompt token secret', 'Paste it.', '```', '',
'## Troubleshooting', 'If it fails, confirm {{token}} was written.',
'```nc:run effect:restart', 'bash restart.sh', '```',
'```bash', 'grep TOK .env', '```', '',
].join('\n');
const ref = referenceProse(md);
expect(ref).toContain('## Troubleshooting');
expect(ref).toContain('grep TOK .env'); // the plain fence survives
expect(ref).not.toContain('bash restart.sh'); // the nc: fence body is dropped…
expect(ref).not.toContain('nc:run'); // …along with its opening fence line
expect(ref).toContain('{{token}}'); // the placeholder is never resolved to a secret value
});
it('returns empty for a skill with no reference sections', () => {
expect(referenceProse('# only apply\n\n## Apply\n```nc:run effect:build\npnpm run build\n```\n')).toBe('');
});
it('is carried on ApplyResult.referenceProse through a real apply', async () => {
const sdir = mkdtempSync(join(tmpdir(), 'nc-ref-skill-'));
const rdir = mkdtempSync(join(tmpdir(), 'nc-ref-proj-'));
writeFileSync(join(rdir, 'package.json'), '{"name":"scratch"}');
writeFileSync(join(rdir, '.env'), '');
writeFileSync(join(sdir, 'SKILL.md'), REFERENCE_SKILL);
const res = await applySkill(sdir, rdir, { inputs: {}, exec: () => {} });
expect(res.referenceProse).toContain('## Troubleshooting');
expect(res.referenceProse).toContain('## Alternatives');
expect(res.referenceProse).not.toContain('## Apply');
});
});