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

162 lines
5.7 KiB
JavaScript

/**
* Human-facing `/approve` command for DeepSeek Harness.
*
* `/approve <change-id> design|plan` is the manual front-end of the approval
* gate: it asks the owner through `userQuestions.ask` (D-12) and lets the
* shared `runApproval` (lib/approval.js) check the upstream artifacts and
* write `openspec/changes/<id>/approval.md` (D-1). The write itself lives in
* the shared module, which the model tool `request_approval` uses as well —
* both only write after the owner confirms. A missing user-questions service
* refuses instead of writing, so no model tool can approve itself.
*
* The repository root is resolved per invocation from the receiving agent's
* session (`workingDirectory.get(agent.session)`, T3.1) — the registration
* itself carries no root, because the command runs against whatever agent
* invoked it.
*
* Mirrors `command-role.js`: object-form `commands.register`, the same
* `{ kind, text }` result shape.
*/
import { resolve } from 'node:path'
import { CHANGE_ID, runApproval } from './lib/approval.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 APPROVE_LABEL = 'Approve'
const CANCEL_LABEL = 'Cancel'
/** Session working directory of the invoking agent; process.cwd() fallback. */
function currentDirectory(workingDirectory, agent) {
try {
const cwd = workingDirectory?.get?.(agent?.session)
if (typeof cwd === 'string' && cwd !== '') return cwd
} catch {
// Fall through to the process directory.
}
return process.cwd()
}
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),
)
}
/**
* A refusal produced while asking the owner (missing service, failed dialog).
* The handler turns it into the same `Refused: ...` result as `runApproval`
* refusals; any other exception is reported as an `/approve failure`.
*/
class ConfirmationRefusal extends Error {}
/**
* The `ask` implementation of this front-end: one Approve/Cancel dialog whose
* detail shows exactly what is being approved. Returns `true` only for an
* explicit Approve answer; a missing service or a failed ask throws a
* `ConfirmationRefusal` carrying the reason, so nothing is written.
*/
function ownerAsker({ userQuestions, changeId, kind, agent, signal }) {
return async (questionText, detail) => {
if (typeof userQuestions?.ask !== 'function') {
throw new ConfirmationRefusal('userQuestions service unavailable; /approve requires it')
}
const request = {
questions: [
{
id: `approve-${kind}`,
header: 'Approve',
question: questionText,
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) {
throw new ConfirmationRefusal(`userQuestions failed: ${String(error?.message ?? error)}`)
}
return isApproved(answer)
}
}
export function registerApproveCommand({ commands, userQuestions, workingDirectory, 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(currentDirectory(workingDirectory, agent))
try {
const result = await runApproval({
repoRoot: root,
changeId,
kind,
ask: ownerAsker({ userQuestions, changeId, kind, agent, signal }),
})
return result.approved
? { kind: 'success', text: result.message }
: refusal(result.message)
} catch (error) {
if (error instanceof ConfirmationRefusal) return refusal(error.message)
logger?.warn?.('[dsh-harness] /approve failed: %s', error?.message ?? error)
return refusal(`/approve failed: ${String(error?.message ?? error)}`)
}
},
})
}