203 lines
6.8 KiB
JavaScript
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 })
|
|
}
|