/** * `request_approval` — the model-driven front-end of the approval gate. * * The owner no longer has to type `/approve design|plan`: the model * calls this tool, the owner only confirms in the userQuestions dialog, and the * approval marker is still written by bundle code (`lib/approval.js`), never by * the model. The gates keep their invariant: `approval.md` is written only by * the harness after an explicit owner confirmation. * * The definition is hand-built plain JSON — no `@deepseek-ai/*` imports (the * bundle is installed from a local directory, so DSH internals are not * resolvable). `parameters` is object-rooted raw JSON Schema; DSH performs no * argument validation, so `execute` validates the args itself and always * returns the lossless `{ approved, message }` shape. * * `userQuestions` is resolved lazily (injected as `getUserQuestions`) and never * declared in `inject`: a missing service must refuse this tool, not park the * whole bundle. Likewise `getWorkingDirectory` is a service factory, because * the repo root depends on the receiving agent's session. */ import { resolve } from 'node:path' import { CHANGE_ID, KINDS, runApproval } from './lib/approval.js' const APPROVE_LABEL = 'Approve' const CANCEL_LABEL = 'Cancel' const USAGE = "usage: request_approval { change_id: '', kind: 'design' | 'plan' }" /** Session working directory of the receiving agent; process.cwd() fallback. */ function repoRootOf(getWorkingDirectory, agent) { try { const workingDirectory = getWorkingDirectory?.() const cwd = workingDirectory?.get?.(agent?.session) if (typeof cwd === 'string' && cwd !== '') return resolve(cwd) } catch { // Fall through to the process directory. } return process.cwd() } function refusal(message) { return { approved: false, message } } /** * Build the tool definition. * * @param {{ * getWorkingDirectory?: () => { get(session: unknown): string } | undefined, * getUserQuestions?: () => { ask(request: unknown): Promise } | null, * logger?: { warn?: Function }, * now?: () => Date, * }} deps */ export function createApproveTool({ getWorkingDirectory, getUserQuestions, logger, now = () => new Date() } = {}) { return { name: 'request_approval', description: 'Ask the owner to approve an OpenSpec change design or an implementation plan. The approval marker is written by the harness only after the owner confirms.', parameters: { type: 'object', properties: { change_id: { type: 'string', description: 'Change id under openspec/changes//', }, kind: { type: 'string', enum: ['design', 'plan'], description: 'What is being approved', }, }, required: ['change_id', 'kind'], additionalProperties: false, }, output: { schema: { type: 'object', properties: { approved: { type: 'boolean' }, message: { type: 'string' }, }, required: ['approved', 'message'], additionalProperties: false, }, render(args, value) { return [{ type: 'text', text: value.message }] }, }, async execute(args, exec = {}) { try { const changeId = typeof args?.change_id === 'string' ? args.change_id.trim() : '' const kind = args?.kind if (changeId === '') { return refusal(`Missing change_id. ${USAGE}`) } if (!CHANGE_ID.test(changeId)) { return refusal(`Invalid change id "${changeId}". ${USAGE}`) } if (!KINDS.includes(kind)) { return refusal(`Unknown kind "${String(kind)}". ${USAGE}`) } const userQuestions = getUserQuestions?.() ?? null if (typeof userQuestions?.ask !== 'function') { return refusal( 'userQuestions service unavailable; request_approval requires the owner dialog (use /approve).', ) } // The ask implementation of this front-end: mirrors the /approve // dialog, but a `selected[0] === 'Approve'` answer is the only yes. const ask = async (questionText) => { const answer = await userQuestions.ask({ questions: [ { id: 'approve', question: questionText, header: 'Approval gate', options: [{ label: APPROVE_LABEL }, { label: CANCEL_LABEL }], }, ], ...(exec.agent ? { agent: exec.agent } : {}), signal: exec.signal, }) return answer?.answers?.[0]?.selected?.[0] === APPROVE_LABEL } return await runApproval({ repoRoot: repoRootOf(getWorkingDirectory, exec.agent), changeId, kind, ask, now, }) } catch (error) { logger?.warn?.('[dsh-harness] request_approval failed: %s', error?.message ?? error) return refusal(String(error?.message ?? error)) } }, } }