@try-works/dsh-recursive-mode 0.4.4 → 0.4.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +15 -4
- package/lib/errors.d.ts +45 -0
- package/lib/goals-projection.d.ts +34 -4
- package/lib/index.js +783 -51
- package/lib/recursive_ask.tool.d.ts +137 -0
- package/lib/recursive_closeout.tool.d.ts +10 -0
- package/lib/recursive_init.tool.d.ts +23 -0
- package/lib/recursive_phase.tool.d.ts +8 -0
- package/lib/recursive_scratch.tool.d.ts +10 -0
- package/lib/recursive_worktree.tool.d.ts +16 -0
- package/lib/run-id.d.ts +62 -0
- package/lib/run-start.d.ts +55 -0
- package/lib/runtime.d.ts +137 -31
- package/package.json +1 -1
- package/scripts/test-recursive-mode-smoke.ts +5 -1
- package/src/errors.ts +45 -0
- package/src/goals-projection.ts +48 -7
- package/src/index.ts +9 -0
- package/src/recursive_ask.tool.ts +368 -18
- package/src/recursive_closeout.tool.ts +53 -35
- package/src/recursive_init.tool.ts +35 -4
- package/src/recursive_phase.tool.ts +22 -2
- package/src/recursive_scratch.tool.ts +17 -1
- package/src/recursive_worktree.tool.ts +27 -2
- package/src/run-id.ts +100 -0
- package/src/run-start.ts +129 -0
- package/src/runtime.ts +157 -14
package/src/errors.ts
CHANGED
|
@@ -65,12 +65,30 @@ export const TOOL_ERRORS = {
|
|
|
65
65
|
problem: 'this gate has no default artifact, so one must be named',
|
|
66
66
|
next: 'pass artifact: <file> so the answer has somewhere durable to land',
|
|
67
67
|
},
|
|
68
|
+
RELAY_ONLY_FOR_RUN_START: {
|
|
69
|
+
code: 'RM1150',
|
|
70
|
+
klass: 'input',
|
|
71
|
+
problem: 'relay applies only to the run-start gate, which is the only gate that asks the user-questions channel',
|
|
72
|
+
next: 'drop relay for this gate and answer it with one of its own labels, or call recursive_ask with gate: run-start when the decision is whether to start the run',
|
|
73
|
+
},
|
|
68
74
|
MISSING_RUN_ID: {
|
|
69
75
|
code: 'RM1101',
|
|
70
76
|
klass: 'input',
|
|
71
77
|
problem: 'runId is required',
|
|
72
78
|
next: 'call recursive_status with no runId to see the latest run id in this workspace',
|
|
73
79
|
},
|
|
80
|
+
/**
|
|
81
|
+
* A run id is the NAME of the run directory and is joined onto the run layer
|
|
82
|
+
* as one path segment, so a path-shaped id is refused before any directory is
|
|
83
|
+
* created. See `run-id.ts` for the rule and for why it is not the runtime that
|
|
84
|
+
* learns to accept a path.
|
|
85
|
+
*/
|
|
86
|
+
BAD_RUN_ID: {
|
|
87
|
+
code: 'RM1107',
|
|
88
|
+
klass: 'input',
|
|
89
|
+
problem: 'runId is not a single directory name',
|
|
90
|
+
next: 'pass the run directory name such as 03-something, then call recursive_init again',
|
|
91
|
+
},
|
|
74
92
|
MISSING_ARTIFACT: {
|
|
75
93
|
code: 'RM1102',
|
|
76
94
|
klass: 'input',
|
|
@@ -147,6 +165,33 @@ export const TOOL_ERRORS = {
|
|
|
147
165
|
|
|
148
166
|
/* 5xxx — the runtime refused an operation it understands. */
|
|
149
167
|
|
|
168
|
+
RUN_START_NO_CHANNEL: {
|
|
169
|
+
code: 'RM5502',
|
|
170
|
+
klass: 'runtime',
|
|
171
|
+
problem: 'the run-start gate needs an answer, and this composition mounts no user-questions channel to ask one directly',
|
|
172
|
+
next: 'call recursive_ask with gate: run-start and no answer to surface the question, then retry with answer: ' + '"Start run"',
|
|
173
|
+
},
|
|
174
|
+
/**
|
|
175
|
+
* ⚠ RM5503 USED TO LIE. Its text asserted "so no person was asked" for EVERY cause, because the caller
|
|
176
|
+
* that produced it had already thrown the cause away — and a live session showed the cost: the gate
|
|
177
|
+
* failed 22.9 s into the call with a reason nobody could see, and its `Next:` clause prescribed the very
|
|
178
|
+
* call that had just failed, so no route to start a run remained. The problem statement now claims only
|
|
179
|
+
* what the gate knows (no decision came back), and the cause travels in the `detail` the caller
|
|
180
|
+
* supplies. `RUN_START_ANSWER_UNUSABLE` carries the one case this entry must NOT cover: a person was
|
|
181
|
+
* reached and their answer was not an approval.
|
|
182
|
+
*/
|
|
183
|
+
RUN_START_UNANSWERED: {
|
|
184
|
+
code: 'RM5503',
|
|
185
|
+
klass: 'runtime',
|
|
186
|
+
problem: 'the run-start question reached no decision: the mounted user-questions channel failed before a person answered it',
|
|
187
|
+
next: 'read the cause named in the detail, fix it and call recursive_ask again, or - when this composition cannot deliver the question at all - call recursive_ask with answer: ' + '"Start run"' + ' and relay=true to record the person\'s explicit approval as a relayed one',
|
|
188
|
+
},
|
|
189
|
+
RUN_START_ANSWER_UNUSABLE: {
|
|
190
|
+
code: 'RM5504',
|
|
191
|
+
klass: 'runtime',
|
|
192
|
+
problem: 'a person was asked to start this run and their answer was not one of the labels the run-start gate offered',
|
|
193
|
+
next: 'call recursive_ask again and have the person choose exactly "Start run" or "Hold"; a skipped or custom answer is not an approval, and no relayed answer can replace a decision the person made',
|
|
194
|
+
},
|
|
150
195
|
RUNTIME_REFUSED: {
|
|
151
196
|
code: 'RM5501',
|
|
152
197
|
klass: 'runtime',
|
package/src/goals-projection.ts
CHANGED
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
* completed goal may be replaced, per the service contract).
|
|
16
16
|
*/
|
|
17
17
|
import type { RunState } from './lifecycle.ts'
|
|
18
|
+
import { RUN_START_NOT_APPROVED } from './run-start.ts'
|
|
18
19
|
|
|
19
20
|
/** Native goal phase (mirrors @deepseek-ai/dsh-goal GoalPhase). */
|
|
20
21
|
export type GoalPhase = 'active' | 'paused' | 'blocked' | 'complete'
|
|
@@ -97,8 +98,31 @@ function mutatePhase(service: GoalServiceLike, agent: AgentHandle, ref: GoalRefL
|
|
|
97
98
|
* Sync a run's durable goal to the requested phase. Safe: never touches a goal
|
|
98
99
|
* whose objective is not this run's marker, and never re-creates over a
|
|
99
100
|
* non-complete foreign goal.
|
|
101
|
+
*
|
|
102
|
+
* ⚠ `approved` IS THE PHASE-0 GATE, and it defaults to the SAFE direction. A goal is not a label:
|
|
103
|
+
* `create` returns an ARMED view and the harness starts driving autonomous goal rounds for the
|
|
104
|
+
* session, so creating one is starting the run. The owner's rule is that phase 0 requires explicit
|
|
105
|
+
* approval, which means the projection must be unable to arm anything on its own — hence a default of
|
|
106
|
+
* `false` and an explicit refusal in EVERY branch that would call `create`, including the two
|
|
107
|
+
* replace-a-completed-goal branches (an unapproved run cannot have reached `complete`, but "cannot
|
|
108
|
+
* happen" is what the single unguarded branch relied on too).
|
|
109
|
+
*
|
|
110
|
+
* ⚠ AND IT IS REACHED ON ORDINARY WORK, so the unapproved path is QUIET AND IDEMPOTENT: no goal is
|
|
111
|
+
* created, nothing is written, no error is thrown, and the run's artifacts are untouched. The caller
|
|
112
|
+
* reads {@link RUN_START_NOT_APPROVED} to tell "this run has not been started yet" apart from a real
|
|
113
|
+
* failure, so a normal phase step never surfaces a warning.
|
|
114
|
+
*
|
|
115
|
+
* `approved` is passed IN rather than read here because this module is pure: it takes the goal service
|
|
116
|
+
* seam and nothing else, and the plugin's own filesystem reads live in the runtime (see
|
|
117
|
+
* `RecursiveRuntime.readRunStartApproval`).
|
|
100
118
|
*/
|
|
101
|
-
export function syncRunGoal(
|
|
119
|
+
export function syncRunGoal(
|
|
120
|
+
service: GoalServiceLike | undefined | null,
|
|
121
|
+
agent: AgentHandle,
|
|
122
|
+
runId: string,
|
|
123
|
+
runState: RunState,
|
|
124
|
+
approved = false,
|
|
125
|
+
): SyncResult {
|
|
102
126
|
if (!service) return { ok: false, reason: 'no goals service' }
|
|
103
127
|
const target = RUN_TO_GOAL_PHASE[runState]
|
|
104
128
|
const current = service.get(agent)
|
|
@@ -110,6 +134,7 @@ export function syncRunGoal(service: GoalServiceLike | undefined | null, agent:
|
|
|
110
134
|
if (phase === target) return { ok: true, phase: target, ref }
|
|
111
135
|
// A completed goal is final: the contract allows it to be REPLACED, not resumed.
|
|
112
136
|
if (phase === 'complete') {
|
|
137
|
+
if (!approved) return { ok: false, reason: RUN_START_NOT_APPROVED }
|
|
113
138
|
const created = service.create(agent, { objective: goalObjective(runId, runState) })
|
|
114
139
|
return { ok: true, phase: target, ref: refOf(created), created: true }
|
|
115
140
|
}
|
|
@@ -121,29 +146,45 @@ export function syncRunGoal(service: GoalServiceLike | undefined | null, agent:
|
|
|
121
146
|
// cleared or resumed instead. Never clobber a foreign goal.
|
|
122
147
|
if (current) {
|
|
123
148
|
if (current.phase === 'complete') {
|
|
149
|
+
if (!approved) return { ok: false, reason: RUN_START_NOT_APPROVED }
|
|
124
150
|
const created = service.create(agent, { objective: goalObjective(runId, runState) })
|
|
125
151
|
return { ok: true, phase: target, ref: refOf(created), created: true }
|
|
126
152
|
}
|
|
127
153
|
return { ok: false, reason: 'a non-matching active goal exists (foreign goal not touched)' }
|
|
128
154
|
}
|
|
129
155
|
|
|
130
|
-
// 3. No current goal
|
|
156
|
+
// 3. No current goal. This is where the defect lived: scaffolding a run armed it. A run with no
|
|
157
|
+
// phase-0 approval stays goal-less — the spec exists, the run does not.
|
|
158
|
+
if (!approved) return { ok: false, reason: RUN_START_NOT_APPROVED }
|
|
131
159
|
const created = service.create(agent, { objective: goalObjective(runId, runState) })
|
|
132
160
|
return { ok: true, phase: target, ref: refOf(created), created: true }
|
|
133
161
|
}
|
|
134
162
|
|
|
135
|
-
/**
|
|
163
|
+
/**
|
|
164
|
+
* Block the current run goal (used on a gate-block). Never touches a foreign goal.
|
|
165
|
+
*
|
|
166
|
+
* ⚠ A RUN THAT WAS NEVER STARTED HAS NO GOAL TO BLOCK, so this reports the unapproved state in the
|
|
167
|
+
* same words as {@link syncRunGoal} rather than "no current goal to block": the caller's question is
|
|
168
|
+
* "why is there no goal", and the answer must not depend on which entry point happened to ask.
|
|
169
|
+
*/
|
|
136
170
|
export function blockRunGoal(service: GoalServiceLike | undefined | null, agent: AgentHandle, runId: string, reason: { code: string; message: string }): SyncResult {
|
|
137
171
|
if (!service) return { ok: false, reason: 'no goals service' }
|
|
138
172
|
const current = service.get(agent)
|
|
139
|
-
if (!current) return { ok: false, reason:
|
|
173
|
+
if (!current) return { ok: false, reason: RUN_START_NOT_APPROVED }
|
|
140
174
|
if (!isRunGoal(current, runId)) return { ok: false, reason: 'current goal is not for this run (foreign goal not touched)' }
|
|
141
175
|
const ref = refOf(current)
|
|
142
176
|
const ok = !!service.block(agent, ref, reason)
|
|
143
177
|
return ok ? { ok: true, phase: 'blocked', ref } : { ok: false, reason: 'goal block failed' }
|
|
144
178
|
}
|
|
145
179
|
|
|
146
|
-
/**
|
|
147
|
-
|
|
148
|
-
|
|
180
|
+
/**
|
|
181
|
+
* Bridge a run's blocked goal back to active (used on a reopen).
|
|
182
|
+
*
|
|
183
|
+
* ⚠ REOPEN IS NOT A BACK DOOR TO STARTING A RUN. It routes through {@link syncRunGoal}, so a reopen of
|
|
184
|
+
* an unapproved run cannot create the goal that init deliberately withheld. An APPROVED run is
|
|
185
|
+
* unaffected: its approval outlives the reopen, because the approval is a durable line in the run's
|
|
186
|
+
* own Phase 0 artifact rather than a value held in memory (verified in `tests/run-start-approval.spec.ts`).
|
|
187
|
+
*/
|
|
188
|
+
export function resumeRunGoal(service: GoalServiceLike | undefined | null, agent: AgentHandle, runId: string, approved = false): SyncResult {
|
|
189
|
+
return syncRunGoal(service, agent, runId, 'active', approved)
|
|
149
190
|
}
|
package/src/index.ts
CHANGED
|
@@ -4,6 +4,7 @@ import type { ContextFormed } from '@deepseek-ai/dsh-llm'
|
|
|
4
4
|
import { existsSync } from 'node:fs'
|
|
5
5
|
import { join } from 'node:path'
|
|
6
6
|
import { RecursiveRuntime } from './runtime.ts'
|
|
7
|
+
import type { UserQuestionsLike } from './runtime.ts'
|
|
7
8
|
import type { JobsRegistryLike } from './jobs-runner.ts'
|
|
8
9
|
import { planGateForExit } from './plan-gate.ts'
|
|
9
10
|
import { registerPhaseSkills, type SkillRegistryLike } from './skills-phase.ts'
|
|
@@ -237,6 +238,14 @@ export function apply(ctx: Context, config?: RecursiveModeConfig) {
|
|
|
237
238
|
ctx.inject(['llm'], (llmCtx: Context) => {
|
|
238
239
|
recursive.attachLlmInventory((llmCtx.get('llm') as LlmInventoryLike | undefined) ?? null)
|
|
239
240
|
})
|
|
241
|
+
// ⚠ PHASE 0 — THE HUMAN-QUESTION CHANNEL, resolved the same late-attaching way for the same reason:
|
|
242
|
+
// a composition may mount `userQuestions` after this plugin applies, and the run-start gate asks the
|
|
243
|
+
// person DIRECTLY through it before it arms a goal — which is what keeps a relayed `Start run` from
|
|
244
|
+
// being an approval while a person can actually be asked (see run-start.ts).
|
|
245
|
+
recursive.attachUserQuestions((ctx.get('userQuestions') as UserQuestionsLike | undefined) ?? null)
|
|
246
|
+
ctx.inject(['userQuestions'], (questionsCtx: Context) => {
|
|
247
|
+
recursive.attachUserQuestions((questionsCtx.get('userQuestions') as UserQuestionsLike | undefined) ?? null)
|
|
248
|
+
})
|
|
240
249
|
|
|
241
250
|
// T7 — THE SETTINGS NAMESPACE, APPLIED ON EVERY APPLY. The settings service edits the
|
|
242
251
|
// Loader entry's config and the Loader RE-APPLIES this plugin, so a toggle in the UI
|
|
@@ -22,12 +22,44 @@ import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
|
22
22
|
import type { JsonValue } from '@deepseek-ai/dsh-util-values'
|
|
23
23
|
import type { RecursiveRuntime } from './runtime.ts'
|
|
24
24
|
import { toolError } from './errors.ts'
|
|
25
|
+
import {
|
|
26
|
+
RUN_START_APPROVE,
|
|
27
|
+
RUN_START_ARTIFACT,
|
|
28
|
+
RUN_START_GATE,
|
|
29
|
+
RUN_START_GATE_ID,
|
|
30
|
+
} from './run-start.ts'
|
|
25
31
|
|
|
26
32
|
|
|
27
33
|
/** The identifiers the workflow uses for its three human gates. */
|
|
28
34
|
export const ASK_GATE_IDS = ['tdd-mode', 'qa-signoff', 'gate-block'] as const
|
|
29
35
|
export type AskGateId = (typeof ASK_GATE_IDS)[number]
|
|
30
36
|
|
|
37
|
+
/**
|
|
38
|
+
* PHASE 0 — THE FOURTH GATE, AND WHY IT IS NOT IN `ASK_GATE_IDS`.
|
|
39
|
+
*
|
|
40
|
+
* The three above are the WORKFLOW's gates, and their membership is asserted as exactly those three.
|
|
41
|
+
* Starting a run is a different kind of decision — it decides whether there is a run at all, and
|
|
42
|
+
* approving it ARMS A GOAL the harness will keep driving — so its data lives in `run-start.ts` with its
|
|
43
|
+
* own contract and its own options. Widening the workflow's gate list must not silently widen what may
|
|
44
|
+
* start a run.
|
|
45
|
+
*/
|
|
46
|
+
export type AskAnyGateId = AskGateId | typeof RUN_START_GATE_ID
|
|
47
|
+
|
|
48
|
+
/** Is this gate id the run-start gate? */
|
|
49
|
+
export function isRunStartGate(gateId: string): boolean {
|
|
50
|
+
return gateId === RUN_START_GATE_ID
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Every gate id `recursive_ask` accepts, workflow gates first. */
|
|
54
|
+
export function askGateIds(): string[] {
|
|
55
|
+
return [...ASK_GATE_IDS, RUN_START_GATE_ID]
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** The artifact a gate's answer belongs in — the run-start gate's is fixed to the Phase 0 requirements. */
|
|
59
|
+
export function askGateArtifact(gateId: AskAnyGateId): string {
|
|
60
|
+
return isRunStartGate(gateId) ? RUN_START_ARTIFACT : GATE_DEFAULT_ARTIFACT[gateId as AskGateId]
|
|
61
|
+
}
|
|
62
|
+
|
|
31
63
|
/** The plugin's own documented bounds — see the module comment on why these are not a claimed mirror. */
|
|
32
64
|
export const MAX_HEADER_CHARS = 12
|
|
33
65
|
export const MAX_LABEL_CHARS = 30
|
|
@@ -137,6 +169,42 @@ export function buildAskQuestion(gateId: AskGateId): AskQuestion {
|
|
|
137
169
|
})
|
|
138
170
|
}
|
|
139
171
|
|
|
172
|
+
/**
|
|
173
|
+
* PHASE 0 — build the question for ANY accepted gate, including the run-start gate.
|
|
174
|
+
*
|
|
175
|
+
* A separate entry point rather than a widened `buildAskQuestion` so the three workflow gates keep the
|
|
176
|
+
* exact signature and behaviour their callers (and `runtime.phaseRules`) already rely on.
|
|
177
|
+
*/
|
|
178
|
+
export function buildAskQuestionFor(gateId: AskAnyGateId): AskQuestion {
|
|
179
|
+
if (isRunStartGate(gateId)) {
|
|
180
|
+
return validateAskQuestion({
|
|
181
|
+
id: RUN_START_GATE.id,
|
|
182
|
+
header: RUN_START_GATE.header,
|
|
183
|
+
question: RUN_START_GATE.question,
|
|
184
|
+
options: RUN_START_GATE.options.map((option) => ({ ...option })),
|
|
185
|
+
})
|
|
186
|
+
}
|
|
187
|
+
return buildAskQuestion(gateId as AskGateId)
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* PHASE 0 — validate an answer to ANY accepted gate.
|
|
192
|
+
*
|
|
193
|
+
* The run-start gate accepts only the labels IT offered, exactly like the other three, and the check is
|
|
194
|
+
* the same `ASK_GATES`-shaped test against its own options. An answer of `maybe` is refused rather than
|
|
195
|
+
* recorded, because a recorded non-answer is the failure mode this whole change exists to prevent.
|
|
196
|
+
*/
|
|
197
|
+
export function validateAskAnswerFor(gateId: AskAnyGateId, answer: string): string {
|
|
198
|
+
if (isRunStartGate(gateId)) {
|
|
199
|
+
const offered = RUN_START_GATE.options.map((option) => option.label) as readonly string[]
|
|
200
|
+
if (!offered.includes(answer)) {
|
|
201
|
+
throw new AskValidationError('answer', 'must be one of ' + offered.join(' | ') + ' (got ' + JSON.stringify(answer) + ')')
|
|
202
|
+
}
|
|
203
|
+
return answer
|
|
204
|
+
}
|
|
205
|
+
return validateAskAnswer(gateId as AskGateId, answer)
|
|
206
|
+
}
|
|
207
|
+
|
|
140
208
|
/**
|
|
141
209
|
* Validate an answer against its gate.
|
|
142
210
|
*
|
|
@@ -218,35 +286,49 @@ export function pendingGateFor(artifactFile: string, artifactText: string | null
|
|
|
218
286
|
* ⚠ THE WRITE-BACK IS A MARKER LINE, REPLACED IN PLACE when the artifact already carries one. A
|
|
219
287
|
* second `TDD Mode:` line would leave two answers to one question and make "what was decided?"
|
|
220
288
|
* depend on which a reader found first.
|
|
289
|
+
*
|
|
290
|
+
* ⚠ PHASE 0 — `run-start` IS RECORDED BY THE PLUGIN, NEVER BY A BARE MARKER WRITE. See
|
|
291
|
+
* `recordRunStartAnswer`: it is the only path that can arm a run goal, it prefers the blocking human
|
|
292
|
+
* channel, and it fails closed when no person can be reached.
|
|
221
293
|
*/
|
|
222
294
|
export function createRecursiveAskTool(recursive: RecursiveRuntime) {
|
|
223
295
|
return defineTool({
|
|
224
296
|
name: 'recursive_ask',
|
|
225
|
-
description: 'Ask
|
|
297
|
+
description: 'Ask a human gate as a structured decision (tdd-mode, qa-signoff, gate-block), or ASK TO START A RUN (run-start: nothing runs, and no goal exists, until this gate is approved). Call without `answer` to ask; call with it to write the answer into the artifact as a durable marker. For run-start the mounted human channel is asked first and its own selection wins; when that channel cannot deliver the question, the refusal names the cause and `relay=true` with an explicit `answer` records the person\'s relayed approval. One ask per step.',
|
|
226
298
|
parameters: {
|
|
227
|
-
gate: { type: 'string', description: 'tdd-mode | qa-signoff | gate-block. Required.' },
|
|
299
|
+
gate: { type: 'string', description: 'tdd-mode | qa-signoff | gate-block | run-start. Required. `run-start` is phase 0: approving it records the approval and arms the run goal, which is what makes the harness drive rounds.' },
|
|
228
300
|
runId: { type: 'string', description: 'Run id. Required; must resolve inside the current workspace.' },
|
|
229
|
-
artifact: { type: 'string', description: 'Artifact file the answer belongs in. Optional; defaults per gate (gate-block has none, so it is required for that gate).' },
|
|
230
|
-
answer: { type: 'string', description: 'One of the gate\'s option labels. Omit to ASK.' },
|
|
301
|
+
artifact: { type: 'string', description: 'Artifact file the answer belongs in. Optional; defaults per gate (gate-block has none, so it is required for that gate; run-start is always recorded in 00-requirements.md).' },
|
|
302
|
+
answer: { type: 'string', description: 'One of the gate\'s option labels. Omit to ASK. For run-start, the labels are: ' + RUN_START_GATE.options.map((option) => option.label).join(' | ') + '.' },
|
|
303
|
+
relay: { type: 'boolean', description: 'run-start only, and only after the person has approved in this conversation. Set relay=true when the mounted user-questions channel cannot deliver the run-start question: `answer` then stands in for the channel\'s selection and the result reports source: "relayed" instead of a direct selection. It is refused when the channel reports the question was cancelled, aborted, or timed out, and it is not needed when the person answers the card.' },
|
|
231
304
|
},
|
|
232
305
|
output: {
|
|
233
306
|
schema: { type: 'json' },
|
|
234
307
|
render: (_args, value) => [{ type: 'text', text: JSON.stringify(value, null, 2) }],
|
|
235
308
|
},
|
|
236
|
-
async execute(args: { gate?: string; runId?: string; artifact?: string; answer?: string }, exec) {
|
|
237
|
-
const gateId = (args.gate ?? '').trim() as
|
|
309
|
+
async execute(args: { gate?: string; runId?: string; artifact?: string; answer?: string; relay?: boolean }, exec) {
|
|
310
|
+
const gateId = (args.gate ?? '').trim() as AskAnyGateId
|
|
238
311
|
const runId = args.runId?.trim() ?? ''
|
|
239
312
|
if (runId === '') return { error: toolError('MISSING_RUN_ID') } as const
|
|
240
|
-
if (!
|
|
241
|
-
return { error: toolError('BAD_ASK_GATE', 'gate must be one of ' +
|
|
313
|
+
if (!askGateIds().includes(gateId)) {
|
|
314
|
+
return { error: toolError('BAD_ASK_GATE', 'gate must be one of ' + askGateIds().join(' | ')) } as const
|
|
315
|
+
}
|
|
316
|
+
// ⚠ `relay` IS RUN-START ONLY, AND SAYING SO IS THE POINT. The other three gates never consult the
|
|
317
|
+
// channel, so accepting the flag there would report a fallback that did not happen — the same class
|
|
318
|
+
// of false claim this tool was fixed for.
|
|
319
|
+
if (args.relay === true && !isRunStartGate(gateId)) {
|
|
320
|
+
return { error: toolError('RELAY_ONLY_FOR_RUN_START', 'gate is ' + gateId) } as const
|
|
242
321
|
}
|
|
243
322
|
const root = await recursive.resolveWorkspaceRoot(exec.agent)
|
|
244
323
|
if (!root) return { error: toolError('NO_WORKSPACE') } as const
|
|
245
324
|
|
|
246
|
-
|
|
325
|
+
// The run-start gate's artifact is FIXED: the Phase 0 requirements document is the run's own
|
|
326
|
+
// phase-0 record, and letting a caller aim the approval somewhere else is how an approval ends up
|
|
327
|
+
// in a file no reader looks at. Every other gate keeps its per-gate default and its override.
|
|
328
|
+
const artifact = (isRunStartGate(gateId) ? RUN_START_ARTIFACT : args.artifact ?? GATE_DEFAULT_ARTIFACT[gateId as AskGateId]).trim()
|
|
247
329
|
let question: AskQuestion
|
|
248
330
|
try {
|
|
249
|
-
question =
|
|
331
|
+
question = buildAskQuestionFor(gateId)
|
|
250
332
|
} catch (err) {
|
|
251
333
|
// The plugin's own gate data failing validation is a defect, so it is reported as one
|
|
252
334
|
// rather than asked: a malformed card would be answered by a person who cannot fix it.
|
|
@@ -254,23 +336,291 @@ export function createRecursiveAskTool(recursive: RecursiveRuntime) {
|
|
|
254
336
|
}
|
|
255
337
|
|
|
256
338
|
// ASK.
|
|
257
|
-
|
|
258
|
-
|
|
339
|
+
//
|
|
340
|
+
// ⚠ PHASE 0 EXCEPTION, AND IT IS THE WHOLE POINT OF THE CHANNEL. For the three workflow gates a
|
|
341
|
+
// question is answered by relaying a label, so "no answer means ask". Starting a run is the decision
|
|
342
|
+
// that creates the armed goal, so when this composition mounts the blocking human channel the
|
|
343
|
+
// question is PUT TO THE PERSON whether or not an answer argument arrived — a caller cannot skip the
|
|
344
|
+
// person by supplying one. Only a composition with no channel falls back to the relayed answer, and
|
|
345
|
+
// a channel that FAILED is a third case: it is reported with its cause, and the relayed answer is
|
|
346
|
+
// taken only when the caller asks for the relay in so many words (see `recordRunStartAnswer`).
|
|
347
|
+
const channelMounted = recursive.userQuestionsChannel !== null
|
|
348
|
+
if (args.answer === undefined && !(isRunStartGate(gateId) && channelMounted)) {
|
|
349
|
+
return { gate: gateId, marker: isRunStartGate(gateId) ? RUN_START_GATE.marker : ASK_GATES[gateId as AskGateId].marker, artifact, question } as unknown as JsonValue
|
|
259
350
|
}
|
|
260
351
|
|
|
261
352
|
// RECORD.
|
|
262
|
-
let answer: string
|
|
263
|
-
|
|
264
|
-
answer =
|
|
265
|
-
}
|
|
266
|
-
|
|
353
|
+
let answer: string | undefined
|
|
354
|
+
if (args.answer === undefined) {
|
|
355
|
+
answer = undefined
|
|
356
|
+
} else {
|
|
357
|
+
try {
|
|
358
|
+
answer = validateAskAnswerFor(gateId, args.answer)
|
|
359
|
+
} catch (err) {
|
|
360
|
+
return { error: toolError('BAD_ASK_ANSWER', err instanceof Error ? err.message : String(err)) } as const
|
|
361
|
+
}
|
|
267
362
|
}
|
|
268
363
|
if (artifact === '') {
|
|
269
364
|
return { error: toolError('MISSING_ASK_ARTIFACT', 'this gate needs an explicit artifact to record into') } as const
|
|
270
365
|
}
|
|
271
|
-
|
|
366
|
+
if (isRunStartGate(gateId)) {
|
|
367
|
+
return recordRunStartAnswer(recursive, root, runId, answer, exec, args.relay === true) as unknown as JsonValue
|
|
368
|
+
}
|
|
369
|
+
const marker = answerMarker(gateId as AskGateId, answer as string)
|
|
272
370
|
const written = recursive.recordAskAnswer(root, runId, artifact, marker)
|
|
273
371
|
return { gate: gateId, answer, marker, artifact, path: written.path, replaced: written.replaced } as unknown as JsonValue
|
|
274
372
|
},
|
|
275
373
|
})
|
|
276
374
|
}
|
|
375
|
+
|
|
376
|
+
/**
|
|
377
|
+
* PHASE 0 — record the answer to the run-start gate, and start the run only if it says so.
|
|
378
|
+
*
|
|
379
|
+
* ⚠ THREE OUTCOMES, AND TELLING THEM APART IS THE FIX. The first version of this function collapsed all of
|
|
380
|
+
* them into one `null`: "the channel threw", "the channel resolved with something unrecognisable", and
|
|
381
|
+
* "nobody answered" produced the same refusal, whose text asserted a cause ("so no person was asked") the
|
|
382
|
+
* plugin had already thrown away. A live session paid for that: the call failed after 22.9 s, the operator
|
|
383
|
+
* could not be told why, and the refusal's own advice prescribed the call that had just failed. So:
|
|
384
|
+
*
|
|
385
|
+
* 1. A PERSON WAS REACHED (`unusable`): the channel resolved, so somebody answered, and their answer is
|
|
386
|
+
* not a label this gate offered — a skip, a custom value, several labels at once. That is a DECISION
|
|
387
|
+
* the gate cannot record, and it is final: neither the caller's `answer` nor `relay=true` may replace
|
|
388
|
+
* it. (RM5504)
|
|
389
|
+
* 2. THE CHANNEL FAILED (`unavailable`): no decision came back at all, and the refusal NAMES THE CAUSE
|
|
390
|
+
* from the error the channel threw. Here the run can still be started, because a composition whose
|
|
391
|
+
* channel cannot deliver the question would otherwise be unable to start any run — but only by the
|
|
392
|
+
* caller asking for the relay in so many words (`relay=true`), which the result reports as
|
|
393
|
+
* `source: "relayed"` rather than as a person's own selection. (RM5503)
|
|
394
|
+
* 3. NO CHANNEL IS MOUNTED: the relayed answer is the only possible source, exactly as before. (RM5502
|
|
395
|
+
* when there is no answer either)
|
|
396
|
+
*
|
|
397
|
+
* ⚠ AND A CANCELLED OR CLOSED QUESTION IS NEVER RELAYABLE. `ASK_CANCELLED`, `ASK_ABORTED` and
|
|
398
|
+
* `ASK_TIMED_OUT` are the codes that mean the question was settled from outside this gate — the card was
|
|
399
|
+
* dismissed, the turn was cancelled, or a foreground window ended. The relay is refused for those, so a
|
|
400
|
+
* question the operator stopped cannot be turned into an approval by asking again in the same breath.
|
|
401
|
+
* Every other failure is a composition or capability failure — the question reached nobody — which is the
|
|
402
|
+
* class the relay exists for.
|
|
403
|
+
*
|
|
404
|
+
* ⚠ WHAT THE GATE STILL DOES *NOT* CLAIM: a relayed approval is a relayed approval. The plugin cannot
|
|
405
|
+
* verify that a person gave the label, and it does not pretend otherwise — the result's `source` and
|
|
406
|
+
* `channel` fields say where the decision came from, and a direct selection is preferred whenever the
|
|
407
|
+
* channel can produce one.
|
|
408
|
+
*/
|
|
409
|
+
export async function recordRunStartAnswer(
|
|
410
|
+
recursive: RecursiveRuntime,
|
|
411
|
+
root: string,
|
|
412
|
+
runId: string,
|
|
413
|
+
answer: string | undefined,
|
|
414
|
+
exec: { agent?: unknown; signal?: unknown; callId?: unknown },
|
|
415
|
+
relay = false,
|
|
416
|
+
): Promise<Record<string, unknown>> {
|
|
417
|
+
// The question travels in every refusal: a composition whose channel cannot render a card can still put
|
|
418
|
+
// the exact decision to the person in the transcript, which is what makes the failure recoverable.
|
|
419
|
+
const question = buildAskQuestionFor(RUN_START_GATE_ID)
|
|
420
|
+
const channel = recursive.userQuestionsChannel
|
|
421
|
+
|
|
422
|
+
// 1. ASK THE PERSON DIRECTLY when this composition mounts the channel.
|
|
423
|
+
const channelOutcome: RunStartChannelOutcome | null = channel ? await askRunStartDirectly(channel, exec) : null
|
|
424
|
+
|
|
425
|
+
if (channelOutcome !== null && channelOutcome.kind === 'unusable') {
|
|
426
|
+
// A person WAS asked. Their answer is not an approval this gate can record, and nothing the caller
|
|
427
|
+
// supplies can stand in for it.
|
|
428
|
+
return {
|
|
429
|
+
error: toolError('RUN_START_ANSWER_UNUSABLE', channelOutcome.detail),
|
|
430
|
+
gate: RUN_START_GATE_ID,
|
|
431
|
+
runId,
|
|
432
|
+
artifact: RUN_START_ARTIFACT,
|
|
433
|
+
question,
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
if (channelOutcome !== null && channelOutcome.kind === 'unavailable') {
|
|
438
|
+
const blocked = !relay
|
|
439
|
+
? 'the caller did not ask for the relay'
|
|
440
|
+
: 'the channel reports the question was cancelled, aborted, or timed out, so it is not relayable'
|
|
441
|
+
if (!relay || !channelOutcome.relayable) {
|
|
442
|
+
return {
|
|
443
|
+
error: toolError('RUN_START_UNANSWERED', channelOutcome.detail + ' (' + blocked + ')'),
|
|
444
|
+
gate: RUN_START_GATE_ID,
|
|
445
|
+
runId,
|
|
446
|
+
artifact: RUN_START_ARTIFACT,
|
|
447
|
+
question,
|
|
448
|
+
// The diagnosis, as data: a model can quote the cause, and a test can assert on it rather than on
|
|
449
|
+
// the prose of the sentence above.
|
|
450
|
+
channel: { outcome: 'unavailable', cause: channelOutcome.cause, relayable: channelOutcome.relayable },
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
if (answer === undefined) {
|
|
454
|
+
// ⚠ THE RELAY NEEDS SOMETHING TO RELAY, AND THIS IS NOT RM5502. A channel IS mounted here, so the
|
|
455
|
+
// "this composition mounts no user-questions channel" sentence would be false — reachable by asking
|
|
456
|
+
// for the relay without supplying the answer it relays.
|
|
457
|
+
return {
|
|
458
|
+
error: toolError('RUN_START_UNANSWERED', channelOutcome.detail + ' (the relay was authorised but no answer was supplied, so there is no decision to record)'),
|
|
459
|
+
gate: RUN_START_GATE_ID,
|
|
460
|
+
runId,
|
|
461
|
+
artifact: RUN_START_ARTIFACT,
|
|
462
|
+
question,
|
|
463
|
+
channel: { outcome: 'unavailable', cause: channelOutcome.cause, relayable: channelOutcome.relayable },
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
const fromChannel = channelOutcome !== null && channelOutcome.kind === 'answered' ? channelOutcome.answer : null
|
|
469
|
+
const final = fromChannel ?? answer
|
|
470
|
+
if (final === undefined) {
|
|
471
|
+
// No channel is mounted and no answer was supplied — the only state left here, because an `answered`
|
|
472
|
+
// outcome sets `final`, an `unusable` one returned above, and an `unavailable` one either returned
|
|
473
|
+
// above or carried an answer through the relay. RM5502 says exactly this, and nothing is recorded.
|
|
474
|
+
return {
|
|
475
|
+
error: toolError('RUN_START_NO_CHANNEL'),
|
|
476
|
+
gate: RUN_START_GATE_ID,
|
|
477
|
+
runId,
|
|
478
|
+
artifact: RUN_START_ARTIFACT,
|
|
479
|
+
question,
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
// 2. Validate the decision that is about to become durable. A channel selection has already been
|
|
484
|
+
// filtered to the gate's own labels; a relayed answer has not, and a marker recording an unoffered
|
|
485
|
+
// label would read as a decision while being a transcription error.
|
|
486
|
+
let decided: string
|
|
487
|
+
try {
|
|
488
|
+
decided = validateAskAnswerFor(RUN_START_GATE_ID, final)
|
|
489
|
+
} catch (err) {
|
|
490
|
+
return { error: toolError('BAD_ASK_ANSWER', err instanceof Error ? err.message : String(err)) }
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
// 3. Record it, and start the run only for the approving label. A `Hold` is recorded as the decision it
|
|
494
|
+
// is — declaring the run not started belongs in the run's own record — and starts nothing.
|
|
495
|
+
const outcome = recursive.approveRunStart(root, runId, exec.agent as never, decided)
|
|
496
|
+
return {
|
|
497
|
+
gate: RUN_START_GATE_ID,
|
|
498
|
+
answer: decided,
|
|
499
|
+
artifact: RUN_START_ARTIFACT,
|
|
500
|
+
// Where the decision came from matters to a reader of the transcript: a direct answer is the person's
|
|
501
|
+
// own selection; a relayed one came back through the model. When the relay answered a FAILED channel,
|
|
502
|
+
// the failure travels with the result, so a relayed approval never reads as a direct selection.
|
|
503
|
+
source: fromChannel === null ? 'relayed' : 'user-questions',
|
|
504
|
+
...channelOutcome !== null && channelOutcome.kind === 'unavailable'
|
|
505
|
+
? { channel: { outcome: 'unavailable', cause: channelOutcome.cause, relayable: channelOutcome.relayable, relayed: true } }
|
|
506
|
+
: {},
|
|
507
|
+
path: outcome.path,
|
|
508
|
+
replaced: outcome.replaced,
|
|
509
|
+
// `armed` is the answer to "did the harness get a goal to drive?": the run is started only when the
|
|
510
|
+
// marker took AND the projection armed the goal, so both are reported rather than one implying the
|
|
511
|
+
// other.
|
|
512
|
+
armed: outcome.ok && outcome.goal.ok,
|
|
513
|
+
goal: outcome.goal,
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
/**
|
|
518
|
+
* PHASE 0 — WHAT THE BLOCKING CHANNEL ACTUALLY DID.
|
|
519
|
+
*
|
|
520
|
+
* ⚠ THIS TYPE EXISTS BECAUSE ITS ABSENCE WAS THE DEFECT. The first version returned a bare `null` from a
|
|
521
|
+
* `catch {}` for every failure and for every unrecognisable selection, so "the channel threw NO_PROVIDER",
|
|
522
|
+
* "the caller is not the live root agent", "the person skipped the question" and "the person typed a
|
|
523
|
+
* custom value" were ONE value. The refusal built from it then asserted the one cause it could not know.
|
|
524
|
+
* An outcome carries the cause, the raw message, and whether the failure is the class a relay may answer.
|
|
525
|
+
*/
|
|
526
|
+
export type RunStartChannelOutcome =
|
|
527
|
+
/** The person answered, and their selection is exactly one of the gate's own labels. */
|
|
528
|
+
| { kind: 'answered'; answer: string }
|
|
529
|
+
/** The channel RESOLVED, so a person was reached — but the answer is not a label this gate offered. */
|
|
530
|
+
| { kind: 'unusable'; detail: string }
|
|
531
|
+
/** The channel THREW: no decision came back, and the cause is named rather than discarded. */
|
|
532
|
+
| { kind: 'unavailable'; cause: string; detail: string; relayable: boolean }
|
|
533
|
+
|
|
534
|
+
/**
|
|
535
|
+
* ⚠ THE CODES THAT MEAN THE QUESTION WAS CANCELLED OR CLOSED rather than never delivered: the person
|
|
536
|
+
* dismissed the card, their turn was cancelled, or a foreground window ended. A caller may not convert any
|
|
537
|
+
* of those into an approval by asking for the relay in the same breath. Every other failure means the
|
|
538
|
+
* question reached nobody — a composition or capability failure, which is the class the relay exists for.
|
|
539
|
+
*/
|
|
540
|
+
export const NON_RELAYABLE_CHANNEL_CODES = ['ASK_CANCELLED', 'ASK_ABORTED', 'ASK_TIMED_OUT'] as const
|
|
541
|
+
|
|
542
|
+
/**
|
|
543
|
+
* Name the failure of one `ask()` call, without inventing anything about it.
|
|
544
|
+
*
|
|
545
|
+
* The cause is the error's own `code` when it has one (the harness's `UserQuestionError` carries
|
|
546
|
+
* `NO_PROVIDER`, `CALLER_NOT_LIVE`, `DELEGATED_CALLER`, `ASK_ABORTED`, …), else its `name`, else its
|
|
547
|
+
* JavaScript type. `detail` keeps the message verbatim so a reader sees the channel's own words rather
|
|
548
|
+
* than this plugin's paraphrase — the paraphrase is exactly how the previous version came to assert a
|
|
549
|
+
* cause nobody had.
|
|
550
|
+
*/
|
|
551
|
+
export function classifyChannelFailure(err: unknown): { cause: string; detail: string; relayable: boolean } {
|
|
552
|
+
const code = (err as { code?: unknown } | null | undefined)?.code
|
|
553
|
+
const name = err instanceof Error ? err.name : typeof err
|
|
554
|
+
const message = err instanceof Error ? err.message : String(err)
|
|
555
|
+
const hasCode = typeof code === 'string' && code.trim() !== ''
|
|
556
|
+
const cause = hasCode ? (code as string) : name
|
|
557
|
+
const relayable = !(hasCode && (NON_RELAYABLE_CHANNEL_CODES as readonly string[]).includes(code as string))
|
|
558
|
+
return { cause, detail: 'channel threw ' + name + '[' + cause + ']: ' + message, relayable }
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
/** Describe an answer that arrived but is not a decision this gate can record. */
|
|
562
|
+
function describeUnusableAnswer(item: { selected: string[]; custom?: string } | undefined): string {
|
|
563
|
+
const offered: readonly string[] = RUN_START_GATE.options.map((option) => option.label)
|
|
564
|
+
const list = (values: readonly string[]): string => JSON.stringify(values.join(' | '))
|
|
565
|
+
if (item === undefined) {
|
|
566
|
+
return 'the channel resolved with no answer for question ' + JSON.stringify(RUN_START_GATE.id) + ' at all'
|
|
567
|
+
}
|
|
568
|
+
const raw = item.selected ?? []
|
|
569
|
+
const custom = item.custom?.trim() ?? ''
|
|
570
|
+
if (raw.length === 0 && custom === '') {
|
|
571
|
+
return 'the person skipped the question, and a skip is not an approval'
|
|
572
|
+
}
|
|
573
|
+
if (raw.length === 0) {
|
|
574
|
+
return 'the person answered ' + JSON.stringify(custom) + ' as free text rather than one of ' + list(offered)
|
|
575
|
+
}
|
|
576
|
+
// ⚠ THE SUBSET MATTERS. A UI can return a label the gate never offered, so "not exactly one of mine" is
|
|
577
|
+
// not the same statement as "the person chose something I do not know" — and the refusal says which.
|
|
578
|
+
const recognised = raw.filter((label) => offered.includes(label))
|
|
579
|
+
if (recognised.length === 0) {
|
|
580
|
+
return 'the person selected ' + list(raw) + ', and none of those name a label this gate offered (' + offered.join(' | ') + ')'
|
|
581
|
+
}
|
|
582
|
+
if (recognised.length === raw.length) {
|
|
583
|
+
return 'the person selected ' + list(raw) + ', and an approval is exactly one of ' + list(offered)
|
|
584
|
+
}
|
|
585
|
+
return 'the person selected ' + list(raw) + ', of which only ' + list(recognised) + ' name this gate\'s labels ' + list(offered)
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
/**
|
|
589
|
+
* Ask the run-start question through the blocking channel and report WHAT HAPPENED.
|
|
590
|
+
*
|
|
591
|
+
* ⚠ THE CATCH IS THE POINT. It used to be `catch { return null }` — a blocking human question whose
|
|
592
|
+
* failure cause was erased at the exact moment the cause was the only thing worth knowing. Every path out
|
|
593
|
+
* of this function now says which path it was.
|
|
594
|
+
*
|
|
595
|
+
* The selection is filtered to the gate's OWN labels: a question a UI answered with a free-text custom
|
|
596
|
+
* value must not become an approval just because it arrived on the right channel.
|
|
597
|
+
*/
|
|
598
|
+
export async function askRunStartDirectly(
|
|
599
|
+
channel: NonNullable<RecursiveRuntime['userQuestionsChannel']>,
|
|
600
|
+
exec: { agent?: unknown; signal?: unknown; callId?: unknown },
|
|
601
|
+
): Promise<RunStartChannelOutcome> {
|
|
602
|
+
const known = RUN_START_GATE.options.map((option) => option.label) as readonly string[]
|
|
603
|
+
try {
|
|
604
|
+
// The agent is passed as the LIVE handle the host gave this tool call. The real service validates it
|
|
605
|
+
// against its own registry and rejects when it is not the live root, so a fabricated handle can never
|
|
606
|
+
// produce an answer here.
|
|
607
|
+
const settled = await channel.ask({
|
|
608
|
+
questions: [{
|
|
609
|
+
id: RUN_START_GATE.id,
|
|
610
|
+
header: RUN_START_GATE.header,
|
|
611
|
+
question: RUN_START_GATE.question,
|
|
612
|
+
options: RUN_START_GATE.options.map((option) => ({ ...option })),
|
|
613
|
+
}],
|
|
614
|
+
agent: exec.agent,
|
|
615
|
+
signal: exec.signal,
|
|
616
|
+
// Links the card to this tool call, the way plan-mode's exit does.
|
|
617
|
+
wait: { callId: exec.callId },
|
|
618
|
+
} as never)
|
|
619
|
+
const item = settled.answers.find((entry) => entry.id === RUN_START_GATE.id)
|
|
620
|
+
const selected = item?.selected?.filter((label) => known.includes(label)) ?? []
|
|
621
|
+
if (selected.length !== 1) return { kind: 'unusable', detail: describeUnusableAnswer(item) }
|
|
622
|
+
return { kind: 'answered', answer: selected[0] as string }
|
|
623
|
+
} catch (err) {
|
|
624
|
+
return { kind: 'unavailable', ...classifyChannelFailure(err) }
|
|
625
|
+
}
|
|
626
|
+
}
|