feat(dsh): /approve — единственный писатель approval.md с подтверждением владельца (#59)
This commit is contained in:
parent
8744575271
commit
c255a24b94
264
dsh/bundle/command-approve.js
Normal file
264
dsh/bundle/command-approve.js
Normal 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
223
dsh/tests/approve.test.mjs
Normal 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)
|
||||
}
|
||||
})
|
||||
Loading…
x
Reference in New Issue
Block a user