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