claude-harness/dsh/bundle/command-approve.js

265 lines
8.4 KiB
JavaScript

/**
* Human-facing `/approve` command for DeepSeek Harness.
*
* `/approve <change-id> design|plan` is the only writer of
* `openspec/changes/<id>/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 <change-id> design|plan',
' design — record the owner approval of the change design',
' plan — record the owner approval of the plan whose **Spec:** is <change-id>',
].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 `<id>`. */
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: '<change-id> 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 <change-id> 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)}`)
}
},
})
}