/** * Shared approval logic of the two front-ends: the `/approve` command and the * `request_approval` model tool. * * `runApproval` owns everything except the question itself: the upstream * artifact checks (design needs proposal/design/builder-check, plan needs an * approved design and exactly one plan whose **Spec:** names the change) and * the section write (`parseApproval` -> merge -> `serializeApproval`). The * confirmation is injected as `ask(questionText, detail) -> Promise`, * so no model-facing caller can write `approval.md` without the owner: the * command asks through `userQuestions.ask`, the tool does the same on behalf * of the model, and this module never writes without a `true` answer. * * `now` is injected for deterministic tests; it returns a Date, so the written * `at` is a UTC ISO timestamp exactly as before the extraction. * * No DSH runtime is imported here (only Node builtins and `./gates.js`). */ import { mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from 'node:fs' import { dirname, join, relative } from 'node:path' import { APPROVAL_FILE, CHANGE_DIR, PLANS_DIR, candidateRoots, extractSpec, findChangeRoot, findRepoRoot, hasDesignApproval, hashPlan, parseApproval, resolveChangeFromSpec, serializeApproval, } from './gates.js' /** Design approval requires all three change artifacts, as before. */ export const DESIGN_ARTIFACTS = ['proposal.md', 'design.md', 'builder-check.md'] /** Change ids are directory names: keep them one safe path segment. */ export const CHANGE_ID = /^[A-Za-z0-9][A-Za-z0-9._-]*$/u export const KINDS = ['design', 'plan'] 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 { approved: false, message: text } } /** 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 whose **Spec:** is ``, across all root candidates (the * session repository, then its linked worktrees; names sorted inside a root). */ function plansForChange(root, changeId) { const found = [] for (const candidate of candidateRoots(root)) { const plansDir = join(candidate, PLANS_DIR) let names try { names = readdirSync(plansDir) } catch { continue } 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('/') } /** Ask the injected confirmation; anything but `true` is not an approval. */ async function confirmed(ask, questionText, detail) { const answer = await ask(questionText, detail) return answer === true } async function approveDesign({ root, changeId, ask, now }) { const changeRoot = findChangeRoot(root, changeId) const missing = changeRoot === null ? [...DESIGN_ARTIFACTS] : DESIGN_ARTIFACTS.filter((name) => !isFile(join(changeRoot, CHANGE_DIR, changeId, name))) if (changeRoot === null || missing.length > 0) { const listed = missing.map((name) => `openspec/changes/${changeId}/${name}`).join(', ') return refusal(`design approval requires the change artifacts; missing: ${listed}.`) } const detail = `Change: openspec/changes/${changeId} (proposal.md, design.md, builder-check.md).` if (!(await confirmed(ask, `Approve ${changeId} design?`, detail))) { return refusal('not confirmed') } const at = new Date(now()).toISOString() const path = writeApproval(changeRoot, changeId, (approval) => ({ ...approval, design: { by: 'owner', at }, })) return { approved: true, message: `Approved ${changeId} design at ${at}; wrote ${approvalText(changeRoot, path)}.`, } } async function approvePlan({ root, changeId, ask, now }) { // The design marker lives with the change: in the session root or a worktree. const changeRoot = findChangeRoot(root, changeId) ?? root const approval = parseApproval(readText(join(changeRoot, CHANGE_DIR, changeId, APPROVAL_FILE))) if (!hasDesignApproval(approval)) { return refusal(`design not approved; run /approve ${changeId} design`) } // Plans may live in different roots; the listing is rooted at the session // root, so a worktree plan shows its `.claude/worktrees//` prefix. 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 detail = `Plan: ${approvalText(root, planPath)} (sha256 ${sha256}).` if (!(await confirmed(ask, `Approve ${changeId} plan?`, detail))) { return refusal('not confirmed') } const at = new Date(now()).toISOString() const path = writeApproval(changeRoot, changeId, (current) => ({ ...current, plan: { by: 'owner', at, sha256 }, })) return { approved: true, message: `Approved ${changeId} plan at ${at} (sha256 ${sha256}); wrote ${approvalText(changeRoot, path)}.`, } } /** * Approve ` ` and write the marker. * * Returns `{ approved, message }`; every refusal is `approved: false` with a * human-readable reason. The change id and kind are re-validated here (not * only in the front-ends): the id becomes a path segment. */ export async function runApproval({ repoRoot, changeId, kind, ask, now = () => new Date() }) { if (typeof changeId !== 'string' || !CHANGE_ID.test(changeId)) { return refusal(`Invalid change id "${String(changeId)}".`) } if (!KINDS.includes(kind)) { return refusal(`Unknown kind "${String(kind)}"; expected design or plan.`) } if (typeof ask !== 'function') { return refusal('no confirmation function; the approval requires the owner') } const root = findRepoRoot(repoRoot) if (kind === 'design') return approveDesign({ root, changeId, ask, now }) return approvePlan({ root, changeId, ask, now }) }