265 lines
8.4 KiB
JavaScript
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)}`)
|
|
}
|
|
},
|
|
})
|
|
}
|