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

145 lines
5.0 KiB
JavaScript

/**
* `request_approval` — the model-driven front-end of the approval gate.
*
* The owner no longer has to type `/approve <change-id> 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: '<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<unknown> } | 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/<id>/',
},
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))
}
},
}
}