import assert from 'node:assert/strict' import { createHash } from 'node:crypto' import { existsSync, readFileSync } from 'node:fs' import { join } from 'node:path' import test from 'node:test' import { registerApproveCommand } from '../bundle/command-approve.js' import { hashPlan, parseApproval, serializeApproval } from '../bundle/lib/gates.js' import { createApproveTool } from '../bundle/tool-approve.js' import { cleanup, tempDir, write } from './helpers.mjs' const PLAN_TEXT = [ '# Plan X', '', '**Spec:** `openspec/changes/demo/`', '', '## Task 1', '', 'Do the work.', '', ].join('\n') const ISO = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/u const APPROVE = 'Approve' /** A throwaway repository with the design artifacts and one demo plan. */ function repoFixture({ builderCheck = true, withPlan = true } = {}) { const tmp = tempDir('dsh-approve-') const repo = join(tmp, 'repo') write(join(repo, 'openspec', 'changes', 'demo', 'proposal.md'), '# Proposal\n') write(join(repo, 'openspec', 'changes', 'demo', 'design.md'), '# Design\n') if (builderCheck) write(join(repo, 'openspec', 'changes', 'demo', 'builder-check.md'), '# Check\n') let planPath = null if (withPlan) { planPath = join(repo, 'docs', 'superpowers', 'plans', 'plan-x.md') write(planPath, PLAN_TEXT) } return { tmp, repo, planPath } } function approvalPath(repo) { return join(repo, 'openspec', 'changes', 'demo', 'approval.md') } /** An ask() answer batch shaped like the DSH UserQuestionService result. */ function approveAnswer(kind = 'design') { return { answers: [{ id: `approve-${kind}`, selected: [APPROVE], custom: undefined }] } } /** * Wire the command against a fake `commands.register` that captures the * registered definition, and a scripted `userQuestions.ask`. The fake * `workingDirectory` resolves every session to the fixture repo. */ function setup(repo, { answer, missingService = false } = {}) { const captured = {} const commands = { register(definition) { Object.assign(captured, definition) }, } const calls = [] let userQuestions if (!missingService) { userQuestions = { async ask(request) { calls.push(request) return answer }, } } registerApproveCommand({ commands, userQuestions, workingDirectory: { get() { return repo } }, logger: { warn() {} }, }) return { captured, calls, run: (rawInput) => captured.handler({ rawInput }) } } function shaOf(path) { return createHash('sha256').update(readFileSync(path)).digest('hex') } test('approve design refuses without builder-check.md', async () => { const { tmp, repo } = repoFixture({ builderCheck: false }) try { const harness = setup(repo, { answer: approveAnswer('design') }) const result = await harness.run('demo design') assert.match(result.text, /builder-check\.md/u) assert.equal(existsSync(approvalPath(repo)), false, 'no approval.md is written') assert.equal(harness.calls.length, 0, 'the owner is not asked when artifacts are missing') } finally { cleanup(tmp) } }) test('approve design writes design section (by owner, at ISO)', async () => { const { tmp, repo } = repoFixture() try { const existingPlan = { by: 'aep', at: '2026-10-25T11:30:00+04:00', sha256: 'a'.repeat(64) } write(approvalPath(repo), serializeApproval({ plan: existingPlan })) const harness = setup(repo, { answer: approveAnswer('design') }) const result = await harness.run('demo design') assert.equal(result.kind, 'success') const request = harness.calls[0].questions[0] assert.equal(request.question, 'Approve demo design?') const parsed = parseApproval(readFileSync(approvalPath(repo), 'utf8')) assert.equal(parsed.design.by, 'owner') assert.match(parsed.design.at, ISO) assert.equal(Number.isNaN(Date.parse(parsed.design.at)), false) assert.deepEqual(parsed.plan, existingPlan, 'the pre-existing plan section is untouched') } finally { cleanup(tmp) } }) test('approve design refuses without confirmation', async () => { const { tmp, repo } = repoFixture() try { const harness = setup(repo, { answer: { answers: [{ id: 'approve-design', selected: ['Cancel'] }] }, }) const result = await harness.run('demo design') assert.match(result.text, /not confirmed/u) assert.equal(existsSync(approvalPath(repo)), false, 'no approval.md is written') } finally { cleanup(tmp) } }) test('approve plan refuses without design section', async () => { const { tmp, repo } = repoFixture() try { const harness = setup(repo, { answer: approveAnswer('plan') }) const result = await harness.run('demo plan') assert.match(result.text, /design not approved/u) assert.match(result.text, /\/approve demo design/u) assert.equal(existsSync(approvalPath(repo)), false, 'no approval.md is written') assert.equal(harness.calls.length, 0, 'the owner is not asked before the design gate passes') } finally { cleanup(tmp) } }) test('approve plan refuses when two plans reference the change', async () => { const { tmp, repo } = repoFixture() try { write(join(repo, 'docs', 'superpowers', 'plans', 'plan-y.md'), PLAN_TEXT) write( approvalPath(repo), serializeApproval({ design: { by: 'aep', at: '2026-10-25T10:00:00+04:00' } }), ) const before = readFileSync(approvalPath(repo), 'utf8') const harness = setup(repo, { answer: approveAnswer('plan') }) const result = await harness.run('demo plan') assert.match(result.text, /plan-x\.md/u) assert.match(result.text, /plan-y\.md/u) assert.equal(readFileSync(approvalPath(repo), 'utf8'), before, 'approval.md is untouched') assert.equal(harness.calls.length, 0, 'the owner is not asked when the plan is ambiguous') } finally { cleanup(tmp) } }) test('approve plan writes plan section with sha256 of the plan', async () => { const { tmp, repo, planPath } = repoFixture() try { const design = { by: 'aep', at: '2026-10-25T10:00:00+04:00' } write(approvalPath(repo), serializeApproval({ design })) const harness = setup(repo, { answer: approveAnswer('plan') }) const result = await harness.run('demo plan') assert.equal(result.kind, 'success') const request = harness.calls[0].questions[0] assert.equal(request.question, 'Approve demo plan?') const parsed = parseApproval(readFileSync(approvalPath(repo), 'utf8')) assert.equal(parsed.plan.by, 'owner') assert.match(parsed.plan.at, ISO) assert.equal(parsed.plan.sha256, shaOf(planPath)) assert.equal(parsed.plan.sha256, hashPlan(planPath)) assert.deepEqual(parsed.design, design, 'the design section is untouched') } finally { cleanup(tmp) } }) test('approve refuses when userQuestions is missing', async () => { const { tmp, repo } = repoFixture() try { const harness = setup(repo, { missingService: true }) const result = await harness.run('demo design') assert.match(result.text, /userQuestions service unavailable/u) assert.match(result.text, /\/approve requires it/u) assert.equal(existsSync(approvalPath(repo)), false, 'no approval.md is written') } finally { cleanup(tmp) } }) test('approve with unknown kind returns usage', async () => { const { tmp, repo } = repoFixture() try { const harness = setup(repo, { answer: approveAnswer() }) const unknown = await harness.run('demo review') assert.match(unknown.text, /Usage: \/approve design\|plan/u) assert.equal(unknown.kind, 'error') const missing = await harness.run('demo') assert.match(missing.text, /Usage: \/approve design\|plan/u) assert.equal(harness.calls.length, 0, 'nothing is asked') assert.equal(existsSync(approvalPath(repo)), false, 'no approval.md is written') } finally { cleanup(tmp) } }) // Pins for the Task 2 review findings (R-5): branches without a first test. test('approve plan refuses when no plan references the change', async () => { const { tmp, repo } = repoFixture({ withPlan: false }) try { write( approvalPath(repo), serializeApproval({ design: { by: 'aep', at: '2026-10-25T10:00:00+04:00' } }), ) const before = readFileSync(approvalPath(repo), 'utf8') const harness = setup(repo, { answer: approveAnswer('plan') }) const result = await harness.run('demo plan') assert.equal(result.kind, 'error') assert.match(result.text, /no plan for demo found/u) assert.match(result.text, /docs\/superpowers\/plans\//u) assert.equal(readFileSync(approvalPath(repo), 'utf8'), before, 'approval.md is untouched') assert.equal(harness.calls.length, 0, 'the owner is not asked without a plan') } finally { cleanup(tmp) } }) test('approve refuses an unsafe change id', async () => { const { tmp, repo } = repoFixture() try { const harness = setup(repo, { answer: approveAnswer('design') }) const result = await harness.run('../x design') assert.match(result.text, /Invalid change id "\.\.\/x"\./u) assert.match(result.text, /Usage: \/approve design\|plan/u) assert.equal(existsSync(approvalPath(repo)), false, 'no approval.md is written') assert.equal(harness.calls.length, 0, 'the owner is not asked for an invalid id') } finally { cleanup(tmp) } }) test('approve accepts the fallback answer shapes {approved}/{confirmed}/true', async () => { const { tmp, repo } = repoFixture() try { const shapes = [{ approved: true }, { confirmed: true }, true] for (const answer of shapes) { const harness = setup(repo, { answer }) const result = await harness.run('demo design') assert.equal(result.kind, 'success', `answer shape ${JSON.stringify(answer)} approves`) const parsed = parseApproval(readFileSync(approvalPath(repo), 'utf8')) assert.equal(parsed.design.by, 'owner') assert.match(parsed.design.at, ISO) } } finally { cleanup(tmp) } }) // T3.1 regression: the repo root is resolved per handler invocation from the // invocation agent's session, not fixed at registration. test('approve resolves the session cwd per invocation (T3.1)', async () => { const first = repoFixture() const second = repoFixture() try { const roots = new Map([ ['s1', first.repo], ['s2', second.repo], ]) const captured = {} const commands = { register(definition) { Object.assign(captured, definition) } } registerApproveCommand({ commands, userQuestions: { async ask() { return approveAnswer('design') } }, workingDirectory: { get(session) { return roots.get(session) } }, logger: { warn() {} }, }) const firstResult = await captured.handler({ agent: { session: 's1' }, rawInput: 'demo design' }) assert.equal(firstResult.kind, 'success') assert.equal(existsSync(approvalPath(first.repo)), true, 'approval.md written in the s1 repo') assert.equal(existsSync(approvalPath(second.repo)), false, 'the s2 repo is untouched') const secondResult = await captured.handler({ agent: { session: 's2' }, rawInput: 'demo design' }) assert.equal(secondResult.kind, 'success') assert.equal(existsSync(approvalPath(second.repo)), true, 'approval.md written in the s2 repo') } finally { cleanup(first.tmp) cleanup(second.tmp) } }) // T3.9 pin: the process.cwd() fallback of currentDirectory is part of the // T3.1 contract — a workingDirectory that throws must not break /approve. // The test changes the process directory (each test file runs in its own // process) so the write lands in an assertable place, then restores it. test('approve falls back to process.cwd() when the session cwd lookup throws (T3.9)', async () => { const { tmp, repo } = repoFixture() const previous = process.cwd() try { process.chdir(repo) const captured = {} const commands = { register(definition) { Object.assign(captured, definition) } } registerApproveCommand({ commands, userQuestions: { async ask() { return approveAnswer('design') } }, workingDirectory: { get() { throw new Error('cannot get property "session" without inject') }, }, logger: { warn() {} }, }) const result = await captured.handler({ agent: { session: 's1' }, rawInput: 'demo design' }) assert.equal(result.kind, 'success') assert.equal(existsSync(approvalPath(repo)), true, 'approval.md is written into process.cwd()') } finally { process.chdir(previous) cleanup(tmp) } }) // `request_approval` (bounded #77): the model drives the approval gate, the // owner only confirms in the userQuestions dialog, and the marker is still // written by bundle code through the same lib/approval.js as `/approve`. const FIXED_ISO = '2026-10-25T12:00:00.000Z' /** * Build the tool with fakes: `getWorkingDirectory` is the DSH service factory * (`workingDirectory.get(session)`), `getUserQuestions` resolves lazily, and * `now` is pinned so the written `at` is exact. */ function setupTool(repo, { answer, missingService = false, at = FIXED_ISO } = {}) { const calls = [] const getUserQuestions = missingService ? () => null : () => ({ async ask(request) { calls.push(request) return answer }, }) const tool = createApproveTool({ getWorkingDirectory: () => ({ get() { return repo } }), getUserQuestions, logger: { warn() {} }, now: () => new Date(at), }) return { tool, calls, run: (args, exec = {}) => tool.execute(args, exec) } } test('request_approval design: owner confirms, section is written by the harness', async () => { const { tmp, repo } = repoFixture() try { const harness = setupTool(repo, { answer: approveAnswer('design') }) assert.equal(harness.tool.name, 'request_approval') assert.match(harness.tool.description, /written by the harness only after the owner confirms/u) assert.deepEqual(harness.tool.parameters.required, ['change_id', 'kind']) assert.deepEqual(harness.tool.parameters.properties.kind.enum, ['design', 'plan']) assert.deepEqual(harness.tool.output.render({}, { approved: true, message: 'ok' }), [ { type: 'text', text: 'ok' }, ]) const result = await harness.run({ change_id: 'demo', kind: 'design' }, { agent: { session: 's1' } }) assert.equal(result.approved, true) assert.equal(result.message, `Approved demo design at ${FIXED_ISO}; wrote openspec/changes/demo/approval.md.`) const question = harness.calls[0].questions[0] assert.equal(question.question, 'Approve demo design?') assert.equal(question.header, 'Approval gate') assert.deepEqual(question.options.map((option) => option.label), ['Approve', 'Cancel']) assert.equal(harness.calls[0].agent.session, 's1', 'the receiving agent is forwarded') const parsed = parseApproval(readFileSync(approvalPath(repo), 'utf8')) assert.equal(parsed.design.by, 'owner') assert.equal(parsed.design.at, FIXED_ISO) } finally { cleanup(tmp) } }) test('request_approval design: cancel writes nothing', async () => { const { tmp, repo } = repoFixture() try { const harness = setupTool(repo, { answer: { answers: [{ id: 'approve', selected: ['Cancel'], custom: undefined }] }, }) const result = await harness.run({ change_id: 'demo', kind: 'design' }) assert.equal(result.approved, false) assert.match(result.message, /not confirmed/u) assert.equal(existsSync(approvalPath(repo)), false, 'no approval.md is written') } finally { cleanup(tmp) } }) test('request_approval refuses when the userQuestions service is missing', async () => { const { tmp, repo } = repoFixture() try { const harness = setupTool(repo, { missingService: true }) const result = await harness.run({ change_id: 'demo', kind: 'design' }) assert.equal(result.approved, false) assert.match(result.message, /userQuestions service unavailable/u) assert.equal(existsSync(approvalPath(repo)), false, 'no approval.md is written') } finally { cleanup(tmp) } }) test('request_approval refuses an unknown kind with usage', async () => { const { tmp, repo } = repoFixture() try { const harness = setupTool(repo, { answer: approveAnswer('design') }) const result = await harness.run({ change_id: 'demo', kind: 'review' }) assert.equal(result.approved, false) assert.match(result.message, /usage: request_approval/u) assert.equal(harness.calls.length, 0, 'the owner is not asked') assert.equal(existsSync(approvalPath(repo)), false, 'no approval.md is written') } finally { cleanup(tmp) } }) test('request_approval refuses an unsafe change id', async () => { const { tmp, repo } = repoFixture() try { const harness = setupTool(repo, { answer: approveAnswer('design') }) const result = await harness.run({ change_id: '../x', kind: 'design' }) assert.equal(result.approved, false) assert.match(result.message, /Invalid change id "\.\.\/x"/u) assert.equal(harness.calls.length, 0, 'the owner is not asked') assert.equal(existsSync(approvalPath(repo)), false, 'no approval.md is written') } finally { cleanup(tmp) } }) test('request_approval plan writes the sha256 of the plan', async () => { const { tmp, repo, planPath } = repoFixture() try { const design = { by: 'aep', at: '2026-10-25T10:00:00+04:00' } write(approvalPath(repo), serializeApproval({ design })) const harness = setupTool(repo, { answer: approveAnswer('plan') }) const result = await harness.run({ change_id: 'demo', kind: 'plan' }) assert.equal(result.approved, true) assert.equal( result.message, `Approved demo plan at ${FIXED_ISO} (sha256 ${shaOf(planPath)}); wrote openspec/changes/demo/approval.md.`, ) const parsed = parseApproval(readFileSync(approvalPath(repo), 'utf8')) assert.equal(parsed.plan.by, 'owner') assert.equal(parsed.plan.at, FIXED_ISO) assert.equal(parsed.plan.sha256, shaOf(planPath)) assert.deepEqual(parsed.design, design, 'the design section is untouched') } finally { cleanup(tmp) } }) test('request_approval design refuses without builder-check.md (prerequisites hold)', async () => { const { tmp, repo } = repoFixture({ builderCheck: false }) try { const harness = setupTool(repo, { answer: approveAnswer('design') }) const result = await harness.run({ change_id: 'demo', kind: 'design' }) assert.equal(result.approved, false) assert.match(result.message, /builder-check\.md/u) assert.equal(harness.calls.length, 0, 'the owner is not asked before the prerequisites pass') assert.equal(existsSync(approvalPath(repo)), false, 'no approval.md is written') } finally { cleanup(tmp) } }) test('request_approval falls back to process.cwd() when the session cwd lookup throws', async () => { const { tmp, repo } = repoFixture() const previous = process.cwd() try { process.chdir(repo) const tool = createApproveTool({ getWorkingDirectory: () => ({ get() { throw new Error('cannot get property "session" without inject') }, }), getUserQuestions: () => ({ async ask() { return approveAnswer('design') } }), logger: { warn() {} }, now: () => new Date(FIXED_ISO), }) const result = await tool.execute({ change_id: 'demo', kind: 'design' }, { agent: { session: 's1' } }) assert.equal(result.approved, true) assert.equal(existsSync(approvalPath(repo)), true, 'approval.md is written into process.cwd()') } finally { process.chdir(previous) cleanup(tmp) } }) // Refactor guard (bounded #77): `/approve` still reports a failing // userQuestions service as a refusal with the service's reason, and writes // nothing. The shared runApproval only writes after a `true` answer. test('approve reports a userQuestions failure and writes nothing', async () => { const { tmp, repo } = repoFixture() try { const captured = {} const commands = { register(definition) { Object.assign(captured, definition) } } registerApproveCommand({ commands, userQuestions: { async ask() { throw new Error('dialog closed') }, }, workingDirectory: { get() { return repo } }, logger: { warn() {} }, }) const result = await captured.handler({ rawInput: 'demo design' }) assert.equal(result.kind, 'error') assert.equal(result.text, 'Refused: userQuestions failed: dialog closed') assert.equal(existsSync(approvalPath(repo)), false, 'no approval.md is written') } finally { cleanup(tmp) } })