feat(dsh): /approve — единственный писатель approval.md с подтверждением владельца (#59)

This commit is contained in:
Andrey Epifancev 2026-10-25 01:42:51 +04:00
parent 8744575271
commit c255a24b94
2 changed files with 487 additions and 0 deletions

View File

@ -0,0 +1,264 @@
/**
* 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)}`)
}
},
})
}

223
dsh/tests/approve.test.mjs Normal file
View File

@ -0,0 +1,223 @@
import assert from 'node:assert/strict'
import { createHash } from 'node:crypto'
import { existsSync, readFileSync } from 'node:fs'
import { join } from 'node:path'
import test from 'node:test'
import { registerApproveCommand } from '../bundle/command-approve.js'
import { hashPlan, parseApproval, serializeApproval } from '../bundle/lib/gates.js'
import { cleanup, tempDir, write } from './helpers.mjs'
const PLAN_TEXT = [
'# Plan X',
'',
'**Spec:** `openspec/changes/demo/`',
'',
'## Task 1',
'',
'Do the work.',
'',
].join('\n')
const ISO = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/u
const APPROVE = 'Approve'
/** A throwaway repository with the design artifacts and one demo plan. */
function repoFixture({ builderCheck = true } = {}) {
const tmp = tempDir('dsh-approve-')
const repo = join(tmp, 'repo')
write(join(repo, 'openspec', 'changes', 'demo', 'proposal.md'), '# Proposal\n')
write(join(repo, 'openspec', 'changes', 'demo', 'design.md'), '# Design\n')
if (builderCheck) write(join(repo, 'openspec', 'changes', 'demo', 'builder-check.md'), '# Check\n')
const planPath = join(repo, 'docs', 'superpowers', 'plans', 'plan-x.md')
write(planPath, PLAN_TEXT)
return { tmp, repo, planPath }
}
function approvalPath(repo) {
return join(repo, 'openspec', 'changes', 'demo', 'approval.md')
}
/** An ask() answer batch shaped like the DSH UserQuestionService result. */
function approveAnswer(kind = 'design') {
return { answers: [{ id: `approve-${kind}`, selected: [APPROVE], custom: undefined }] }
}
/**
* Wire the command against a fake `commands.register` that captures the
* registered definition, and a scripted `userQuestions.ask`.
*/
function setup(repo, { answer, missingService = false } = {}) {
const captured = {}
const commands = {
register(definition) {
Object.assign(captured, definition)
},
}
const calls = []
let userQuestions
if (!missingService) {
userQuestions = {
async ask(request) {
calls.push(request)
return answer
},
}
}
registerApproveCommand({
commands,
userQuestions,
repoRoot: repo,
logger: { warn() {} },
})
return { captured, calls, run: (rawInput) => captured.handler({ rawInput }) }
}
function shaOf(path) {
return createHash('sha256').update(readFileSync(path)).digest('hex')
}
test('approve design refuses without builder-check.md', async () => {
const { tmp, repo } = repoFixture({ builderCheck: false })
try {
const harness = setup(repo, { answer: approveAnswer('design') })
const result = await harness.run('demo design')
assert.match(result.text, /builder-check\.md/u)
assert.equal(existsSync(approvalPath(repo)), false, 'no approval.md is written')
assert.equal(harness.calls.length, 0, 'the owner is not asked when artifacts are missing')
} finally {
cleanup(tmp)
}
})
test('approve design writes design section (by owner, at ISO)', async () => {
const { tmp, repo } = repoFixture()
try {
const existingPlan = { by: 'aep', at: '2026-10-25T11:30:00+04:00', sha256: 'a'.repeat(64) }
write(approvalPath(repo), serializeApproval({ plan: existingPlan }))
const harness = setup(repo, { answer: approveAnswer('design') })
const result = await harness.run('demo design')
assert.equal(result.kind, 'success')
const request = harness.calls[0].questions[0]
assert.equal(request.question, 'Approve demo design?')
const parsed = parseApproval(readFileSync(approvalPath(repo), 'utf8'))
assert.equal(parsed.design.by, 'owner')
assert.match(parsed.design.at, ISO)
assert.equal(Number.isNaN(Date.parse(parsed.design.at)), false)
assert.deepEqual(parsed.plan, existingPlan, 'the pre-existing plan section is untouched')
} finally {
cleanup(tmp)
}
})
test('approve design refuses without confirmation', async () => {
const { tmp, repo } = repoFixture()
try {
const harness = setup(repo, {
answer: { answers: [{ id: 'approve-design', selected: ['Cancel'] }] },
})
const result = await harness.run('demo design')
assert.match(result.text, /not confirmed/u)
assert.equal(existsSync(approvalPath(repo)), false, 'no approval.md is written')
} finally {
cleanup(tmp)
}
})
test('approve plan refuses without design section', async () => {
const { tmp, repo } = repoFixture()
try {
const harness = setup(repo, { answer: approveAnswer('plan') })
const result = await harness.run('demo plan')
assert.match(result.text, /design not approved/u)
assert.match(result.text, /\/approve demo design/u)
assert.equal(existsSync(approvalPath(repo)), false, 'no approval.md is written')
assert.equal(harness.calls.length, 0, 'the owner is not asked before the design gate passes')
} finally {
cleanup(tmp)
}
})
test('approve plan refuses when two plans reference the change', async () => {
const { tmp, repo } = repoFixture()
try {
write(join(repo, 'docs', 'superpowers', 'plans', 'plan-y.md'), PLAN_TEXT)
write(
approvalPath(repo),
serializeApproval({ design: { by: 'aep', at: '2026-10-25T10:00:00+04:00' } }),
)
const before = readFileSync(approvalPath(repo), 'utf8')
const harness = setup(repo, { answer: approveAnswer('plan') })
const result = await harness.run('demo plan')
assert.match(result.text, /plan-x\.md/u)
assert.match(result.text, /plan-y\.md/u)
assert.equal(readFileSync(approvalPath(repo), 'utf8'), before, 'approval.md is untouched')
assert.equal(harness.calls.length, 0, 'the owner is not asked when the plan is ambiguous')
} finally {
cleanup(tmp)
}
})
test('approve plan writes plan section with sha256 of the plan', async () => {
const { tmp, repo, planPath } = repoFixture()
try {
const design = { by: 'aep', at: '2026-10-25T10:00:00+04:00' }
write(approvalPath(repo), serializeApproval({ design }))
const harness = setup(repo, { answer: approveAnswer('plan') })
const result = await harness.run('demo plan')
assert.equal(result.kind, 'success')
const request = harness.calls[0].questions[0]
assert.equal(request.question, 'Approve demo plan?')
const parsed = parseApproval(readFileSync(approvalPath(repo), 'utf8'))
assert.equal(parsed.plan.by, 'owner')
assert.match(parsed.plan.at, ISO)
assert.equal(parsed.plan.sha256, shaOf(planPath))
assert.equal(parsed.plan.sha256, hashPlan(planPath))
assert.deepEqual(parsed.design, design, 'the design section is untouched')
} finally {
cleanup(tmp)
}
})
test('approve refuses when userQuestions is missing', async () => {
const { tmp, repo } = repoFixture()
try {
const harness = setup(repo, { missingService: true })
const result = await harness.run('demo design')
assert.match(result.text, /userQuestions service unavailable/u)
assert.match(result.text, /\/approve requires it/u)
assert.equal(existsSync(approvalPath(repo)), false, 'no approval.md is written')
} finally {
cleanup(tmp)
}
})
test('approve with unknown kind returns usage', async () => {
const { tmp, repo } = repoFixture()
try {
const harness = setup(repo, { answer: approveAnswer() })
const unknown = await harness.run('demo review')
assert.match(unknown.text, /Usage: \/approve <change-id> design\|plan/u)
assert.equal(unknown.kind, 'error')
const missing = await harness.run('demo')
assert.match(missing.text, /Usage: \/approve <change-id> design\|plan/u)
assert.equal(harness.calls.length, 0, 'nothing is asked')
assert.equal(existsSync(approvalPath(repo)), false, 'no approval.md is written')
} finally {
cleanup(tmp)
}
})