145 lines
5.0 KiB
JavaScript
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))
|
|
}
|
|
},
|
|
}
|
|
}
|