162 lines
5.7 KiB
JavaScript
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)}`)
|
|
}
|
|
},
|
|
})
|
|
}
|