diff --git a/dsh/bundle/command-approve.js b/dsh/bundle/command-approve.js new file mode 100644 index 0000000..56aa63f --- /dev/null +++ b/dsh/bundle/command-approve.js @@ -0,0 +1,264 @@ +/** + * Human-facing `/approve` command for DeepSeek Harness. + * + * `/approve design|plan` is the only writer of + * `openspec/changes//approval.md` (D-1). The command checks the upstream + * artifacts, asks the owner through `userQuestions.ask` (D-12), and only then + * merges the new section into the existing file. A missing user-questions + * service refuses instead of writing, so no model tool can approve itself. + * + * Mirrors `command-role.js`: object-form `commands.register`, the same + * `{ kind, text }` result shape. + */ + +import { mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from 'node:fs' +import { dirname, join, relative, resolve } from 'node:path' +import { + APPROVAL_FILE, + CHANGE_DIR, + PLANS_DIR, + extractSpec, + hasDesignApproval, + hashPlan, + parseApproval, + resolveChangeFromSpec, + serializeApproval, +} from './lib/gates.js' + +const USAGE = [ + 'Usage: /approve design|plan', + ' design — record the owner approval of the change design', + ' plan — record the owner approval of the plan whose **Spec:** is ', +].join('\n') + +const DESIGN_ARTIFACTS = ['proposal.md', 'design.md', 'builder-check.md'] +const APPROVE_LABEL = 'Approve' +const CANCEL_LABEL = 'Cancel' +/** Change ids are directory names: keep them one safe path segment. */ +const CHANGE_ID = /^[A-Za-z0-9][A-Za-z0-9._-]*$/u + +function isFile(path) { + try { + return statSync(path).isFile() + } catch { + return false + } +} + +function readText(path) { + try { + return readFileSync(path, 'utf8') + } catch { + return null + } +} + +function refusal(text) { + return { kind: 'error', text: `Refused: ${text}` } +} + +function usage(text) { + return { kind: 'error', text: `${text}\n${USAGE}` } +} + +/** + * Read the answer of `userQuestions.ask`. + * + * The DSH UserQuestionService resolves to `{ answers: [{ id, selected }] }`; + * the plan/brief contract also allows `{ approved }` / `{ confirmed }` (and a + * bare `true`) so the command stays testable without the runtime. Anything + * else — a skipped question, a cancel, a timeout, null — is not an approval. + */ +function isApproved(answer) { + if (answer === true) return true + if (answer === null || typeof answer !== 'object') return false + + const flag = answer.approved ?? answer.confirmed + if (flag !== undefined) return Boolean(flag) + + const items = Array.isArray(answer.answers) ? answer.answers : [] + return items.some( + (item) => item !== null && typeof item === 'object' && Array.isArray(item.selected) && item.selected.includes(APPROVE_LABEL), + ) +} + +/** + * Ask the owner and return `{ ok: true }` or a refusal `{ ok: false, text }`. + * The detail shows exactly what is being approved; the answer is never + * inferred from the absence of an error. + */ +async function askOwner(userQuestions, { changeId, kind, detail, agent, signal }) { + if (typeof userQuestions?.ask !== 'function') { + return { ok: false, text: 'userQuestions service unavailable; /approve requires it' } + } + + const request = { + questions: [ + { + id: `approve-${kind}`, + header: 'Approve', + question: `Approve ${changeId} ${kind}?`, + detail, + options: [ + { + label: APPROVE_LABEL, + description: `Write the ${kind} approval to openspec/changes/${changeId}/approval.md.`, + }, + { label: CANCEL_LABEL, description: 'Write nothing.' }, + ], + }, + ], + } + if (agent !== undefined) request.agent = agent + if (signal !== undefined) request.signal = signal + + let answer + try { + answer = await userQuestions.ask(request) + } catch (error) { + return { ok: false, text: `userQuestions failed: ${String(error?.message ?? error)}` } + } + + return isApproved(answer) ? { ok: true } : { ok: false, text: 'not confirmed' } +} + +/** Merge one section into approval.md, leaving every other section as is. */ +function writeApproval(root, changeId, mutate) { + const path = join(root, CHANGE_DIR, changeId, APPROVAL_FILE) + const next = mutate(parseApproval(readText(path))) + mkdirSync(dirname(path), { recursive: true }) + writeFileSync(path, serializeApproval(next), 'utf8') + return path +} + +/** Existing plans under docs/superpowers/plans/ whose **Spec:** is ``. */ +function plansForChange(root, changeId) { + const plansDir = join(root, PLANS_DIR) + let names + try { + names = readdirSync(plansDir) + } catch { + return [] + } + + const found = [] + for (const name of [...names].sort()) { + if (!name.endsWith('.md')) continue + const path = join(plansDir, name) + if (!isFile(path)) continue + const content = readText(path) + if (content === null) continue + if (resolveChangeFromSpec(extractSpec(content)) === changeId) found.push(path) + } + return found +} + +function approvalText(root, path) { + return relative(root, path).split('\\').join('/') +} + +async function approveDesign({ root, changeId, userQuestions, agent, signal }) { + const changeDir = join(root, CHANGE_DIR, changeId) + const missing = DESIGN_ARTIFACTS.filter((name) => !isFile(join(changeDir, name))) + if (missing.length > 0) { + const listed = missing.map((name) => `openspec/changes/${changeId}/${name}`).join(', ') + return refusal(`design approval requires the change artifacts; missing: ${listed}.`) + } + + const asked = await askOwner(userQuestions, { + changeId, + kind: 'design', + detail: `Change: openspec/changes/${changeId} (proposal.md, design.md, builder-check.md).`, + agent, + signal, + }) + if (!asked.ok) return refusal(asked.text) + + const at = new Date().toISOString() + const path = writeApproval(root, changeId, (approval) => ({ + ...approval, + design: { by: 'owner', at }, + })) + return { + kind: 'success', + text: `Approved ${changeId} design at ${at}; wrote ${approvalText(root, path)}.`, + } +} + +async function approvePlan({ root, changeId, userQuestions, agent, signal }) { + const approval = parseApproval(readText(join(root, CHANGE_DIR, changeId, APPROVAL_FILE))) + if (!hasDesignApproval(approval)) { + return refusal(`design not approved; run /approve ${changeId} design`) + } + + const plans = plansForChange(root, changeId) + if (plans.length === 0) { + return refusal( + `no plan for ${changeId} found under ${PLANS_DIR.split('\\').join('/')}/ with **Spec:** openspec/changes/${changeId}.`, + ) + } + if (plans.length > 1) { + const listed = plans.map((path) => approvalText(root, path)).join(', ') + return refusal(`several plans reference ${changeId}: ${listed}. Keep exactly one.`) + } + + const [planPath] = plans + const sha256 = hashPlan(planPath) + if (sha256 === '') { + return refusal(`cannot read plan ${approvalText(root, planPath)}.`) + } + + const asked = await askOwner(userQuestions, { + changeId, + kind: 'plan', + detail: `Plan: ${approvalText(root, planPath)} (sha256 ${sha256}).`, + agent, + signal, + }) + if (!asked.ok) return refusal(asked.text) + + const at = new Date().toISOString() + const path = writeApproval(root, changeId, (current) => ({ + ...current, + plan: { by: 'owner', at, sha256 }, + })) + return { + kind: 'success', + text: `Approved ${changeId} plan at ${at} (sha256 ${sha256}); wrote ${approvalText(root, path)}.`, + } +} + +export function registerApproveCommand({ commands, userQuestions, repoRoot, logger }) { + commands.register({ + name: 'approve', + description: 'Утвердить design или plan change (единственный писатель approval.md)', + input: { hint: ' design|plan' }, + handler: async ({ agent, rawInput, signal } = {}) => { + const parts = + typeof rawInput === 'string' ? rawInput.trim().split(/\s+/u).filter((part) => part !== '') : [] + + if (parts.length !== 2) { + return usage('Missing arguments: expected design|plan.') + } + + const [changeId, kind] = parts + if (!CHANGE_ID.test(changeId)) { + return usage(`Invalid change id "${changeId}".`) + } + if (kind !== 'design' && kind !== 'plan') { + return usage(`Unknown kind "${kind}".`) + } + + const root = resolve(repoRoot || process.cwd()) + try { + if (kind === 'design') { + return await approveDesign({ root, changeId, userQuestions, agent, signal }) + } + return await approvePlan({ root, changeId, userQuestions, agent, signal }) + } catch (error) { + logger?.warn?.('[dsh-harness] /approve failed: %s', error?.message ?? error) + return refusal(`/approve failed: ${String(error?.message ?? error)}`) + } + }, + }) +} diff --git a/dsh/tests/approve.test.mjs b/dsh/tests/approve.test.mjs new file mode 100644 index 0000000..8f2016d --- /dev/null +++ b/dsh/tests/approve.test.mjs @@ -0,0 +1,223 @@ +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 { 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 } = {}) { + 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') + const 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`. + */ +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, + repoRoot: 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) + } +})