203 lines
6.8 KiB
JavaScript

/**
* 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<boolean>`,
* 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 `<id>`, 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/<slug>/` 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 `<changeId> <kind>` 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 })
}