@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/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',
@@ -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(service: GoalServiceLike | undefined | null, agent: AgentHandle, runId: string, runState: RunState): SyncResult {
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 -> create and arm.
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
- /** Block the current run goal (used on a gate-block). Never touches a foreign goal. */
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: 'no current goal to block' }
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
- /** Bridge a run's blocked goal back to active (used on a reopen). */
147
- export function resumeRunGoal(service: GoalServiceLike | undefined | null, agent: AgentHandle, runId: string): SyncResult {
148
- return syncRunGoal(service, agent, runId, 'active')
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 one of the three human gates as a structured decision (tdd-mode, qa-signoff, gate-block), or record the answer. Call without `answer` to ask; call with it to write the answer into the artifact as a durable marker. One ask per step.',
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 AskGateId
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 (!ASK_GATE_IDS.includes(gateId)) {
241
- return { error: toolError('BAD_ASK_GATE', 'gate must be one of ' + ASK_GATE_IDS.join(' | ')) } as const
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
- const artifact = (args.artifact ?? GATE_DEFAULT_ARTIFACT[gateId]).trim()
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 = buildAskQuestion(gateId)
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
- if (args.answer === undefined) {
258
- return { gate: gateId, marker: ASK_GATES[gateId].marker, artifact, question } as unknown as JsonValue
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
- try {
264
- answer = validateAskAnswer(gateId, args.answer)
265
- } catch (err) {
266
- return { error: toolError('BAD_ASK_ANSWER', err instanceof Error ? err.message : String(err)) } as const
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
- const marker = answerMarker(gateId, answer)
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
+ }