import assert from 'node:assert/strict' import { createHash } from 'node:crypto' import { join } from 'node:path' import test from 'node:test' import { apply } from '../bundle/index.js' import { collectSelfCheck } from '../bundle/lib/selfcheck.js' import { checkSddWrite } from '../bundle/lib/gates.js' import { registerCheckCommand } from '../bundle/command-check.js' import { cleanup, git, makeRepo, write } from './helpers.mjs' const FEATURES = ['guards', 'bootstrap', 'commands', 'workingDirectory'] /** A ctx-like with every service the bundle depends on. */ function fullCtx() { return { tools: { guard() {} }, on() {}, inject() {}, workingDirectory: { get() {} }, } } test('collectSelfCheck ok on full ctx-like', () => { const report = collectSelfCheck(fullCtx()) assert.deepEqual( report.map((entry) => entry.feature), FEATURES, ) for (const entry of report) { assert.equal(entry.ok, true, `${entry.feature} ok`) assert.equal(typeof entry.detail, 'string', `${entry.feature} detail is a string`) assert.ok(entry.detail.length > 0, `${entry.feature} detail is non-empty`) } }) test('collectSelfCheck flags missing tools.guard', () => { const ctx = fullCtx() ctx.tools = {} const report = collectSelfCheck(ctx) const guards = report.find((entry) => entry.feature === 'guards') assert.equal(guards.ok, false) assert.match(guards.detail, /tools\.guard/) for (const entry of report.filter((entry) => entry.feature !== 'guards')) { assert.equal(entry.ok, true, `${entry.feature} stays ok`) } }) test('collectSelfCheck flags missing on()', () => { const ctx = fullCtx() ctx.on = undefined const report = collectSelfCheck(ctx) const bootstrap = report.find((entry) => entry.feature === 'bootstrap') assert.equal(bootstrap.ok, false) for (const entry of report.filter((entry) => entry.feature !== 'bootstrap')) { assert.equal(entry.ok, true, `${entry.feature} stays ok`) } }) test('collectSelfCheck skips features disabled by config', () => { const report = collectSelfCheck(fullCtx(), { guards: false, bootstrap: false, roleCommand: false, checkCommand: false, }) assert.deepEqual( report.map((entry) => entry.feature), ['workingDirectory'], ) }) test('collectSelfCheck does not flag a disabled feature that is also missing', () => { const ctx = fullCtx() ctx.tools = {} ctx.on = undefined const report = collectSelfCheck(ctx, { guards: false, bootstrap: false }) assert.deepEqual( report.map((entry) => entry.feature), ['commands', 'workingDirectory'], ) assert.ok(report.every((entry) => entry.ok), 'nothing not-ok in the report') const noInject = fullCtx() noInject.inject = undefined const commandsOff = collectSelfCheck(noInject, { roleCommand: false, checkCommand: false }) assert.ok(!commandsOff.some((entry) => entry.feature === 'commands'), 'commands skipped') assert.ok(commandsOff.every((entry) => entry.ok), 'missing inject not flagged') const roleOnly = collectSelfCheck(noInject, { roleCommand: false }) assert.ok(roleOnly.some((entry) => entry.feature === 'commands'), 'commands still checked') assert.equal( roleOnly.find((entry) => entry.feature === 'commands').ok, false, 'checkCommand alone still needs ctx.inject', ) }) test('registerCheckCommand builds report text', async () => { let captured const commands = { register(def) { captured = def }, } registerCheckCommand({ commands, collect: () => collectSelfCheck(fullCtx()) }) assert.equal(captured.name, 'dsh-harness-check') const okResult = await captured.handler({}) assert.match(okResult.text, /guards: ok/) assert.match(okResult.text, /all ok/) const brokenCtx = fullCtx() brokenCtx.tools = {} registerCheckCommand({ commands, collect: () => collectSelfCheck(brokenCtx) }) const badResult = await captured.handler({}) assert.match(badResult.text, /guards: not-ok/) assert.match(badResult.text, /problem/) }) /** Logger fake that interpolates `%s` and records error lines. */ function recordingLogger(errors) { return { info() {}, warn() {}, error(fmt, ...args) { const line = fmt.replace(/%s/g, () => String(args.shift() ?? '')) errors.push(line) }, } } test('apply does not throw when ctx.on and ctx.inject are missing', () => { const calls = { guard: 0 } const errors = [] const ctx = { tools: { guard() { calls.guard += 1 } }, workingDirectory: { get() {} }, logger: recordingLogger(errors), } assert.doesNotThrow(() => apply(ctx)) assert.equal(calls.guard, 1, 'guards feature stays wired') assert.ok( errors.some((line) => line.includes('self-check: bootstrap not-ok')), 'bootstrap not-ok is logged', ) assert.ok( errors.some((line) => line.includes('self-check: commands not-ok')), 'commands not-ok is logged', ) }) test('apply wires guards, bootstrap, and commands with a full ctx', () => { const calls = { guard: 0, on: 0, inject: 0 } const ctx = { tools: { guard() { calls.guard += 1 } }, on() { calls.on += 1 }, inject() { calls.inject += 1 }, workingDirectory: { get() {} }, logger: { info() {}, warn() {}, error() {} }, } assert.doesNotThrow(() => apply(ctx)) assert.equal(calls.guard, 1, 'guards wired') assert.equal(calls.on, 1, 'bootstrap wired') assert.equal(calls.inject, 2, 'commands wired (role/check + /approve)') }) // --------------------------------------------------------------------------- // Approval gates (Task 3): apply() wires the A/B/C gates after the hooks. // --------------------------------------------------------------------------- const PLAN_TEXT = [ '# Plan X', '', '**Spec:** `openspec/changes/demo/`', '', '## Task 1', '', 'Do the work.', '', ].join('\n') /** * A throwaway repository on a feature branch (the hooks must pass) with the * change artifacts and one demo plan. `design` writes a design-approved * approval.md; `planSha` additionally writes the plan section (default: the * real sha256 of the fixture plan). */ function gateFixture({ design = false, planSha } = {}) { const repo = makeRepo() git(repo, ['checkout', '-b', 'feat/x']) write(join(repo, 'openspec', 'changes', 'demo', 'proposal.md'), '# Proposal\n') write(join(repo, 'openspec', 'changes', 'demo', 'design.md'), '# Design\n') const planPath = join(repo, 'docs', 'superpowers', 'plans', 'plan-x.md') write(planPath, PLAN_TEXT) if (design || planSha !== undefined) { const sections = [] if (design) { sections.push('design:\n by: owner\n at: 2026-10-25T10:00:00.000Z') } if (planSha !== undefined) { sections.push(`plan:\n by: owner\n at: 2026-10-25T11:00:00.000Z\n sha256: ${planSha}`) } write(join(repo, 'openspec', 'changes', 'demo', 'approval.md'), `${sections.join('\n\n')}\n`) } return { repo, planPath } } function planShaOf(planText) { return createHash('sha256').update(planText).digest('hex') } /** apply() against a fake ctx: captured guard + inject, session cwd = repo. */ function gateHarness(repo, config = {}) { const guardFns = [] const injected = [] const ctx = { tools: { guard(fn) { guardFns.push(fn) } }, on() {}, inject(names, callback) { injected.push({ names, callback }) }, workingDirectory: { get() { return repo } }, get() { return undefined }, logger: { info() {}, warn() {}, error() {} }, } apply(ctx, config) return { guard: (exec) => guardFns[0]({ agent: { session: 's1' }, ...exec }), injected, } } function call(name, args) { return { name, arguments: args } } test('apply: guard denies plan write without design approval', () => { const { repo } = gateFixture() try { const harness = gateHarness(repo) const reason = harness.guard(call('write', { file_path: join(repo, 'docs', 'superpowers', 'plans', 'plan-new.md'), content: PLAN_TEXT, })) assert.match(reason ?? '', /BLOCKED \[approval-gates\]: design not approved; run \/approve demo design/) } finally { cleanup(repo) } }) test('apply: guard allows plan write after approval.md with design', () => { const { repo } = gateFixture({ design: true }) try { const harness = gateHarness(repo) const reason = harness.guard(call('write', { file_path: join(repo, 'docs', 'superpowers', 'plans', 'plan-new.md'), content: PLAN_TEXT, })) assert.equal(reason, undefined) } finally { cleanup(repo) } }) test('apply: guard denies subagent dispatch referencing unapproved plan', () => { const { repo } = gateFixture() try { const harness = gateHarness(repo) const reason = harness.guard(call('subagent', { prompt: 'Implement docs/superpowers/plans/plan-x.md Task 1.', })) assert.match(reason ?? '', /BLOCKED \[approval-gates\]: plan not approved; run \/approve demo plan/) } finally { cleanup(repo) } }) test('apply: guard allows dispatch after plan approval (sha match)', () => { const { repo } = gateFixture({ design: true, planSha: planShaOf(PLAN_TEXT) }) try { const harness = gateHarness(repo) const reason = harness.guard(call('subagent', { prompt: 'Implement docs/superpowers/plans/plan-x.md Task 1.', })) assert.equal(reason, undefined) } finally { cleanup(repo) } }) test('apply: bash heredoc into approval.md denied', () => { const { repo } = gateFixture() try { const harness = gateHarness(repo) const reason = harness.guard(call('bash', { command: "cat > openspec/changes/demo/approval.md <<'EOF'\nby: me\nEOF\n", })) assert.match(reason ?? '', /BLOCKED \[approval-gates\]: approval\.md is written only by \/approve/) } finally { cleanup(repo) } }) test('apply: bash write under .superpowers/sdd// denied without approval', () => { const { repo } = gateFixture() try { const harness = gateHarness(repo) const reason = harness.guard(call('bash', { command: 'mkdir -p .superpowers/sdd/plan-x && printf "x" > .superpowers/sdd/plan-x/progress.md', })) assert.match(reason ?? '', /BLOCKED \[approval-gates\]: plan not approved; run \/approve demo plan/) } finally { cleanup(repo) } }) test('apply: bash write under .superpowers/sdd// allowed with approval', () => { const { repo } = gateFixture({ design: true, planSha: planShaOf(PLAN_TEXT) }) try { const harness = gateHarness(repo) const reason = harness.guard(call('bash', { command: 'mkdir -p .superpowers/sdd/plan-x && printf "x" > .superpowers/sdd/plan-x/progress.md', })) assert.equal(reason, undefined) } finally { cleanup(repo) } }) test('gates: checkSddWrite sha-mismatch deny', () => { const { repo } = gateFixture({ design: true, planSha: 'a'.repeat(64) }) try { const result = checkSddWrite({ filePath: join(repo, '.superpowers', 'sdd', 'plan-x', 'progress.md'), repoRoot: repo, }) assert.equal(result.deny, 'plan changed after approval; re-approve') } finally { cleanup(repo) } }) test('gates: bare .superpowers/sdd/ dir ungated', () => { const { repo } = gateFixture() try { const result = checkSddWrite({ filePath: join(repo, '.superpowers', 'sdd', 'plan-x'), repoRoot: repo, }) assert.equal(result.deny, undefined) } finally { cleanup(repo) } }) test('apply: approvalGates:false disables all gates', () => { const { repo } = gateFixture() try { const harness = gateHarness(repo, { approvalGates: false }) assert.equal( harness.guard(call('write', { file_path: join(repo, 'docs', 'superpowers', 'plans', 'plan-new.md'), content: PLAN_TEXT, })), undefined, ) assert.equal( harness.guard(call('subagent', { prompt: 'Implement docs/superpowers/plans/plan-x.md Task 1.', })), undefined, ) assert.equal( harness.guard(call('bash', { command: "cat > openspec/changes/demo/approval.md <<'EOF'\nby: me\nEOF\n", })), undefined, ) } finally { cleanup(repo) } }) test('apply: /approve registered when commands present; absent userQuestions still registers', () => { const { repo } = gateFixture({ design: true }) try { const harness = gateHarness(repo) assert.equal(harness.injected.length, 2, 'role/check and /approve inject blocks ran') // A cordis-like command context: declared services resolve, any other key // (e.g. `agent`) throws — the wiring must not read it (T3.1). const registered = [] const commandCtx = new Proxy( { commands: { register(definition) { registered.push(definition) } } }, { get(target, prop) { if (prop in target) return target[prop] throw new Error(`unknown service "${String(prop)}"`) }, }, ) for (const { callback } of harness.injected) { assert.doesNotThrow(() => callback(commandCtx), 'the inject callback must not touch ctx.agent') } const approve = registered.find((definition) => definition.name === 'approve') assert.ok(approve, '/approve is registered') assert.equal(typeof approve.handler, 'function') assert.equal(approve.input.hint, ' design|plan') } finally { cleanup(repo) } }) test('apply: /approve registers on approvalGates alone (role/check commands off)', () => { const { repo } = gateFixture({ design: true }) try { const harness = gateHarness(repo, { roleCommand: false, checkCommand: false }) assert.equal(harness.injected.length, 1, 'only the /approve inject block runs') const registered = [] harness.injected[0].callback({ commands: { register(definition) { registered.push(definition) } }, }) assert.ok( registered.some((definition) => definition.name === 'approve'), '/approve is registered without role/check commands', ) assert.ok( !registered.some((definition) => definition.name === 'role'), '/role is not registered', ) } finally { cleanup(repo) } }) test('apply: approvalGates:false leaves /approve unregistered', () => { const { repo } = gateFixture() try { const harness = gateHarness(repo, { approvalGates: false }) const registered = [] for (const { callback } of harness.injected) { callback({ commands: { register(definition) { registered.push(definition) } } }) } assert.ok( !registered.some((definition) => definition.name === 'approve'), '/approve is not registered when approvalGates is off', ) } finally { cleanup(repo) } }) test('apply: bash targets prefer approval/plan denies before sdd denies', () => { const { repo } = gateFixture() try { const harness = gateHarness(repo) const reason = harness.guard(call('bash', { command: 'printf "y" > .superpowers/sdd/plan-x/progress.md && printf "x" > openspec/changes/demo/approval.md', })) assert.match(reason ?? '', /BLOCKED \[approval-gates\]: approval\.md is written only by \/approve/) } finally { cleanup(repo) } })