/** * 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. * * The repository root is resolved per invocation from the receiving agent's * session (`workingDirectory.get(agent.session)`, T3.1) — the registration * itself carries no root, because the command runs against whatever agent * invoked it. * * 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 } } /** Session working directory of the invoking agent; process.cwd() fallback. */ function currentDirectory(workingDirectory, agent) { try { const cwd = workingDirectory?.get?.(agent?.session) if (typeof cwd === 'string' && cwd !== '') return cwd } catch { // Fall through to the process directory. } return process.cwd() } 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, workingDirectory, 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(currentDirectory(workingDirectory, agent)) 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)}`) } }, }) }