@try-works/dsh-recursive-mode 0.4.3 → 0.4.5

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.
@@ -419,14 +419,36 @@ function lockOrderRule(artifact: unknown, runDir: string | undefined): ToolPolic
419
419
  /**
420
420
  * Locked-artifact write rule: a denial when the target carries
421
421
  * `Status: LOCKED`, `null` otherwise. Only a run-tree `*.md` is a candidate —
422
- * the same admission test the pre-T16 branch used.
422
+ * the same admission test the pre-T16 branch used, now asked of the RESOLVED
423
+ * path as well (see the ADMISSION note below).
424
+ *
425
+ * A caller with no `worktreeRoot` still gets `null` for every target, absolute
426
+ * ones included: that is the pre-existing behaviour and it is left alone here —
427
+ * the guard always carries a root, so nothing that reaches it changes.
423
428
  */
424
429
  function lockedWriteRule(target: string | null, worktreeRoot: string | undefined): ToolPolicyPredicateMatch | null {
425
430
  if (!target || !worktreeRoot) return null
426
431
  const normalized = target.replace(/\\/g, '/')
427
- if (!normalized.endsWith('.md') || !normalized.includes('/.recursive/run/')) return null
432
+ if (!normalized.endsWith('.md')) return null
428
433
  const abs = resolveFrom(worktreeRoot, normalized)
429
- if (!abs || getLockStatus(abs) !== 'LOCKED') return null
434
+ if (!abs) return null
435
+ // ADMISSION — a target is a candidate when it NAMES the run tree, and the marker is
436
+ // looked for on the path the target RESOLVES to as well as on the string as written.
437
+ //
438
+ // The string test alone requires a separator BEFORE `.recursive`, so it admitted an
439
+ // ABSOLUTE target and missed a REPO-RELATIVE one (`.recursive/run/<id>/00-requirements.md`,
440
+ // the form a model actually types, and its backslash spelling too). The rule then
441
+ // ABSTAINED, the phase baseline saw a write INSIDE the run tree — allowed by design — and
442
+ // the catch-all allowed a write to an artifact whose `Status:` is LOCKED. The resolved test
443
+ // is the fix, and it is the same question asked of the path the string names; `getLockStatus`
444
+ // below already used `abs`, so the two halves of this rule now agree on one path.
445
+ //
446
+ // The string test is KEPT rather than replaced, so that no absolute spelling denied today
447
+ // becomes allowed: a literal target that carries the marker but resolves away from it
448
+ // (`…/.recursive/run/../…`) is still admitted, exactly as before.
449
+ const resolved = abs.replace(/\\/g, '/')
450
+ if (!normalized.includes('/.recursive/run/') && !resolved.includes('/.recursive/run/')) return null
451
+ if (getLockStatus(abs) !== 'LOCKED') return null
430
452
  return { verdict: 'deny', detail: normalized + ' carries Status: LOCKED (reopen explicitly to edit)' }
431
453
  }
432
454
 
@@ -18,7 +18,7 @@
18
18
  * present-but-empty value would stop the deferral and pin the choice to nothing, which is a different state
19
19
  * from "unconfigured" and not one a user can see. Absence is the honest representation of "I have no opinion".
20
20
  *
21
- * 3. **WRITE ATOMICALLY.** A temp file and a rename, the same pattern the preset installer uses. A policy file
21
+ * 3. **WRITE ATOMICALLY.** A temp file and a rename, the same pattern the preset installer used, before it was retired. A policy file
22
22
  * half-written because a process died mid-write would make `loadRouterPolicy` fall back to the built-in
23
23
  * self-audit policy — and it does that SILENTLY, on purpose (it never throws). So a torn write here would look
24
24
  * exactly like "the user configured nothing", for every role, until someone read the file.
@@ -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,42 @@ 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. 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(' | ') + '.' },
231
303
  },
232
304
  output: {
233
305
  schema: { type: 'json' },
234
306
  render: (_args, value) => [{ type: 'text', text: JSON.stringify(value, null, 2) }],
235
307
  },
236
308
  async execute(args: { gate?: string; runId?: string; artifact?: string; answer?: string }, exec) {
237
- const gateId = (args.gate ?? '').trim() as AskGateId
309
+ const gateId = (args.gate ?? '').trim() as AskAnyGateId
238
310
  const runId = args.runId?.trim() ?? ''
239
311
  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
312
+ if (!askGateIds().includes(gateId)) {
313
+ return { error: toolError('BAD_ASK_GATE', 'gate must be one of ' + askGateIds().join(' | ')) } as const
242
314
  }
243
315
  const root = await recursive.resolveWorkspaceRoot(exec.agent)
244
316
  if (!root) return { error: toolError('NO_WORKSPACE') } as const
245
317
 
246
- const artifact = (args.artifact ?? GATE_DEFAULT_ARTIFACT[gateId]).trim()
318
+ // The run-start gate's artifact is FIXED: the Phase 0 requirements document is the run's own
319
+ // phase-0 record, and letting a caller aim the approval somewhere else is how an approval ends up
320
+ // in a file no reader looks at. Every other gate keeps its per-gate default and its override.
321
+ const artifact = (isRunStartGate(gateId) ? RUN_START_ARTIFACT : args.artifact ?? GATE_DEFAULT_ARTIFACT[gateId as AskGateId]).trim()
247
322
  let question: AskQuestion
248
323
  try {
249
- question = buildAskQuestion(gateId)
324
+ question = buildAskQuestionFor(gateId)
250
325
  } catch (err) {
251
326
  // The plugin's own gate data failing validation is a defect, so it is reported as one
252
327
  // rather than asked: a malformed card would be answered by a person who cannot fix it.
@@ -254,23 +329,149 @@ export function createRecursiveAskTool(recursive: RecursiveRuntime) {
254
329
  }
255
330
 
256
331
  // ASK.
257
- if (args.answer === undefined) {
258
- return { gate: gateId, marker: ASK_GATES[gateId].marker, artifact, question } as unknown as JsonValue
332
+ //
333
+ // ⚠ PHASE 0 EXCEPTION, AND IT IS THE WHOLE POINT OF THE CHANNEL. For the three workflow gates a
334
+ // question is answered by relaying a label, so "no answer means ask". Starting a run is the decision
335
+ // that creates the armed goal, so when this composition mounts the blocking human channel the
336
+ // question is PUT TO THE PERSON whether or not an answer argument arrived — a caller cannot skip the
337
+ // person by supplying one. Only a composition with no channel falls back to the relayed answer.
338
+ const channelMounted = recursive.userQuestionsChannel !== null
339
+ if (args.answer === undefined && !(isRunStartGate(gateId) && channelMounted)) {
340
+ return { gate: gateId, marker: isRunStartGate(gateId) ? RUN_START_GATE.marker : ASK_GATES[gateId as AskGateId].marker, artifact, question } as unknown as JsonValue
259
341
  }
260
342
 
261
343
  // 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
344
+ let answer: string | undefined
345
+ if (args.answer === undefined) {
346
+ answer = undefined
347
+ } else {
348
+ try {
349
+ answer = validateAskAnswerFor(gateId, args.answer)
350
+ } catch (err) {
351
+ return { error: toolError('BAD_ASK_ANSWER', err instanceof Error ? err.message : String(err)) } as const
352
+ }
267
353
  }
268
354
  if (artifact === '') {
269
355
  return { error: toolError('MISSING_ASK_ARTIFACT', 'this gate needs an explicit artifact to record into') } as const
270
356
  }
271
- const marker = answerMarker(gateId, answer)
357
+ if (isRunStartGate(gateId)) {
358
+ return recordRunStartAnswer(recursive, root, runId, answer, exec) as unknown as JsonValue
359
+ }
360
+ const marker = answerMarker(gateId as AskGateId, answer as string)
272
361
  const written = recursive.recordAskAnswer(root, runId, artifact, marker)
273
362
  return { gate: gateId, answer, marker, artifact, path: written.path, replaced: written.replaced } as unknown as JsonValue
274
363
  },
275
364
  })
276
365
  }
366
+
367
+ /**
368
+ * PHASE 0 — record the answer to the run-start gate, and start the run only if it says so.
369
+ *
370
+ * ⚠ THIS GATE NEVER ACCEPTS A RELAYED ANSWER WHILE A HUMAN CHANNEL IS MOUNTED. That is the rule that makes
371
+ * an approval a human act rather than an inference: when `ctx.userQuestions` is present, the question is
372
+ * PUT TO THE PERSON and nothing else can settle it — not the caller's own `answer` argument, and not a
373
+ * fabrication, because `ask()` resolves only with a real selection. A person's decline is likewise final
374
+ * for that call and cannot be overridden by a model that asked for `Start run` in the same breath.
375
+ *
376
+ * ⚠ AND WHEN NO CHANNEL IS MOUNTED, THE RELAYED ANSWER IS THE ONLY POSSIBLE SOURCE, so it is used — that
377
+ * is the same contract the other three gates have always had, and refusing it would leave a composition
378
+ * without the channel unable to start any run at all. The question is surfaced first by the ASK branch
379
+ * (the card data the host renders), and the model's `answer` is that person's selection coming back.
380
+ *
381
+ * ⚠ WHAT THE GATE THEREFORE DOES *NOT* CLAIM, stated rather than implied: in a composition with no
382
+ * `userQuestions` channel, a plugin cannot verify that a person was really asked, so a model could in
383
+ * principle relay a label nobody gave. That is a property of the relay, not of this gate — and it is the
384
+ * reason the channel is consulted in preference whenever it exists. See the header of `run-start.ts`.
385
+ */
386
+ export async function recordRunStartAnswer(
387
+ recursive: RecursiveRuntime,
388
+ root: string,
389
+ runId: string,
390
+ answer: string | undefined,
391
+ exec: { agent?: unknown; signal?: unknown; callId?: unknown },
392
+ ): Promise<Record<string, unknown>> {
393
+ // 1. ASK THE PERSON DIRECTLY when this composition mounts the channel. An abort or a dismissal is not
394
+ // consent, so it settles nothing — and, because the channel was available, it does not hand the
395
+ // decision back to the caller either (that is the refusal below).
396
+ const channel = recursive.userQuestionsChannel
397
+ const fromChannel = channel ? await askRunStartDirectly(channel, exec) : null
398
+ if (channel !== null && fromChannel === null) {
399
+ // The channel exists, so a person COULD have been asked and was not: no answerer, no live root agent,
400
+ // a dismissal, an abort, or a selection that is not one of this gate's labels. An approval nobody
401
+ // gave is not recorded, and neither is the caller's argument.
402
+ return { error: toolError('RUN_START_UNANSWERED') }
403
+ }
404
+ const final = fromChannel ?? answer
405
+ if (final === undefined) {
406
+ // No channel and no answer: there is nothing a person said, so nothing is recorded.
407
+ return { error: toolError('RUN_START_NO_CHANNEL') }
408
+ }
409
+
410
+ // 2. Validate the decision that is about to become durable. A channel selection has already been
411
+ // filtered to the gate's own labels; a relayed answer has not, and a marker recording an unoffered
412
+ // label would read as a decision while being a transcription error.
413
+ let decided: string
414
+ try {
415
+ decided = validateAskAnswerFor(RUN_START_GATE_ID, final)
416
+ } catch (err) {
417
+ return { error: toolError('BAD_ASK_ANSWER', err instanceof Error ? err.message : String(err)) }
418
+ }
419
+
420
+ // 3. Record it, and start the run only for the approving label. A `Hold` is recorded as the decision it
421
+ // is — declaring the run not started belongs in the run's own record — and starts nothing.
422
+ const outcome = recursive.approveRunStart(root, runId, exec.agent as never, decided)
423
+ return {
424
+ gate: RUN_START_GATE_ID,
425
+ answer: decided,
426
+ artifact: RUN_START_ARTIFACT,
427
+ // Where the decision came from matters to a reader of the transcript: a direct answer is the person's
428
+ // own selection; a relayed one came back through the model.
429
+ source: fromChannel === null ? 'relayed' : 'user-questions',
430
+ path: outcome.path,
431
+ replaced: outcome.replaced,
432
+ // `armed` is the answer to "did the harness get a goal to drive?": the run is started only when the
433
+ // marker took AND the projection armed the goal, so both are reported rather than one implying the
434
+ // other.
435
+ armed: outcome.ok && outcome.goal.ok,
436
+ goal: outcome.goal,
437
+ }
438
+ }
439
+
440
+ /**
441
+ * Ask the run-start question through the blocking channel and return the selection, or null when the
442
+ * person was not reachable (no answerer, no live root agent, a dismissal, an abort).
443
+ *
444
+ * The selection is filtered to the gate's OWN labels before it is returned: a question a UI answered with
445
+ * a free-text custom value must not become an approval just because it arrived on the right channel.
446
+ */
447
+ async function askRunStartDirectly(
448
+ channel: NonNullable<RecursiveRuntime['userQuestionsChannel']>,
449
+ exec: { agent?: unknown; signal?: unknown; callId?: unknown },
450
+ ): Promise<string | null> {
451
+ const known = RUN_START_GATE.options.map((option) => option.label) as readonly string[]
452
+ try {
453
+ // The agent is passed as the LIVE handle the host gave this tool call. The real service validates it
454
+ // against its own registry and rejects when it is not the live root, so a fabricated handle can never
455
+ // produce an answer here.
456
+ const settled = await channel.ask({
457
+ questions: [{
458
+ id: RUN_START_GATE.id,
459
+ header: RUN_START_GATE.header,
460
+ question: RUN_START_GATE.question,
461
+ options: RUN_START_GATE.options.map((option) => ({ ...option })),
462
+ }],
463
+ agent: exec.agent,
464
+ signal: exec.signal,
465
+ // Links the card to this tool call, the way plan-mode's exit does.
466
+ wait: { callId: exec.callId },
467
+ } as never)
468
+ const item = settled.answers.find((entry) => entry.id === RUN_START_GATE.id)
469
+ const selected = item?.selected?.filter((label) => known.includes(label)) ?? []
470
+ if (selected.length !== 1) return null
471
+ return selected[0]
472
+ } catch {
473
+ // Quiet by design: the caller reports the situation, and this function's job is only to say whether a
474
+ // person answered.
475
+ return null
476
+ }
477
+ }
@@ -3,10 +3,25 @@ import { codeRuntimeRefusal, toolError } from './errors.ts'
3
3
  import type { JsonValue } from '@deepseek-ai/dsh-util-values'
4
4
  import type { RecursiveRuntime } from './runtime.ts'
5
5
 
6
+ /**
7
+ * PHASE 0 — SCAFFOLDING IS NOT STARTING, AND THE TOOL SAYS SO AT THE MOMENT IT MATTERS.
8
+ *
9
+ * A spec may legitimately exist before a run does: this tool writes the run directory and every phase
10
+ * document, and it still does. What it must NOT do is start the run, because starting is creating and
11
+ * arming the goal the harness drives autonomous rounds from. That is the owner's rule — *"phase 0
12
+ * requires explicit approval to start a run and goal"* — so the description below names the gate and the
13
+ * result carries `runStartApproval`, which is the pointer a caller needs: the run is inert until
14
+ * `recursive_ask` answers `run-start`.
15
+ *
16
+ * `runStartApproval` is read from the run's own Phase 0 artifact on every call, so it is the TRUE state
17
+ * rather than "this call created something": re-initialising an APPROVED run reports `approved: true`
18
+ * (and the run keeps its goal), which is what a caller re-scaffolding a run it already started needs to
19
+ * see.
20
+ */
6
21
  export function createRecursiveInitTool(recursive: RecursiveRuntime) {
7
22
  return defineTool({
8
23
  name: 'recursive_init',
9
- description: 'Scaffold a new recursive-mode run directory (or ensure an existing one) with stub artifact headers. Delegates to the RecursiveRuntime service (no duplicated scaffolding logic). When createWorktree is true, a linked worktree is created first and the run is scaffolded inside it.',
24
+ description: 'Scaffold a new recursive-mode run directory (or ensure an existing one) with stub artifact headers. Delegates to the RecursiveRuntime service (no duplicated scaffolding logic). When createWorktree is true, a linked worktree is created first and the run is scaffolded inside it. THIS DOES NOT START THE RUN: no goal exists until the user approves phase 0 through recursive_ask gate=run-start, so the result carries runStartApproval — read it and ask.',
10
25
  parameters: {
11
26
  runId: { type: 'string', description: 'Run id (e.g. 03-something). Required.' },
12
27
  createWorktree: { type: 'boolean', description: 'If true, create a linked worktree at .worktrees/<runId>/ and scaffold the run inside it (default: false).' },
@@ -0,0 +1,123 @@
1
+ /**
2
+ * PHASE 0 — STARTING A RUN IS A HUMAN DECISION, NOT A SIDE EFFECT OF SCAFFOLDING.
3
+ *
4
+ * THE DEFECT THIS CLOSES. `recursive_init` scaffolded a run and the plugin then CREATED AND ARMED a
5
+ * goal for it in the same breath (`syncRunGoal`'s "no current goal -> create and arm" branch). A goal
6
+ * is not a label: `goals.create` returns an ARMED view, and the harness immediately begins driving
7
+ * autonomous goal rounds for the session. So asking for a run spec was enough to start an unattended
8
+ * run — the owner's rule is the opposite: *"creating a spec before a run exists should not create a
9
+ * goal. Phase 0 requires explicit approval to start a run and goal."*
10
+ *
11
+ * WHAT "APPROVAL" IS, EXACTLY. The approving label of the `run-start` gate of `recursive_ask`
12
+ * (`Start run`, as opposed to `Hold`), recorded here as a durable `- Run Start: Start run` line in the
13
+ * run's Phase 0 requirements artifact. Three things make that an explicit human act rather than an
14
+ * inference:
15
+ *
16
+ * 1. NO DEFAULT, AND THE VALUE IS THE DECISION. The line is written by the gate itself into the Phase 0
17
+ * requirements document, and the gate REFUSES an answer that is not one of the labels it offered.
18
+ * An unoffered answer is a transcription error wearing the shape of a decision, and a `Hold` is not
19
+ * an approval in any spelling — see {@link readRunStartApproval}, which matches the approving VALUE
20
+ * and nothing else, so the presence of a `Run Start` line is never on its own consent.
21
+ * 2. IT IS ASKED, NOT ASSUMED. When the composition mounts `ctx.userQuestions` — the harness's own
22
+ * blocking human channel, the same one plan-mode's exit uses — the question is PUT TO THE PERSON and
23
+ * only their selection is recorded; a caller-supplied answer cannot stand in for it, and a channel
24
+ * that cannot reach anyone ends the call without a decision (RM5503). Only a composition with no
25
+ * channel at all falls back to the relayed answer, which is the contract the other three gates have.
26
+ * 3. THE GOAL CANNOT BE CREATED WITHOUT IT. `syncRunGoal` refuses to create a goal for a run whose
27
+ * approval record is absent, in EVERY branch that would create one — not only the "no goal yet"
28
+ * branch. That is the property `tests/run-start-approval.spec.ts` asserts, because a single
29
+ * unguarded branch is exactly how this defect existed in the first place.
30
+ *
31
+ * A SPEC MAY EXIST BEFORE A RUN EXISTS, and this module does not forbid that: the scaffold, the Phase
32
+ * 0 artifacts and every later phase document are all created by `recursive_init` as before. What is
33
+ * withheld is the GOAL — the object that makes the harness drive rounds. A run that is scaffolded and
34
+ * never approved is a spec: readable, editable, lockable, and inert.
35
+ */
36
+ import { readFileSync } from 'node:fs'
37
+ import { join } from 'node:path'
38
+ import { getMdFieldValue } from './status.ts'
39
+
40
+ /** The gate id `recursive_ask` answers for a run start. Deliberately NOT in ASK_GATE_IDS. */
41
+ export const RUN_START_GATE_ID = 'run-start'
42
+
43
+ /** The Phase 0 artifact the approval is recorded in. */
44
+ export const RUN_START_ARTIFACT = '00-requirements.md'
45
+
46
+ /** The artifact field the approval reads back from. */
47
+ export const RUN_START_MARKER = 'Run Start'
48
+
49
+ /** The approving label. The ONLY label that starts a run. */
50
+ export const RUN_START_APPROVE = 'Start run'
51
+
52
+ /** The withholding label: the spec stays a spec. */
53
+ export const RUN_START_HOLD = 'Hold'
54
+
55
+ /**
56
+ * WHY THIS GATE IS NOT IN `ASK_GATE_IDS`. Those three are the WORKFLOW's gates — phase-3 test
57
+ * evidence, phase-5 sign-off, resolving a gate block — and their membership is asserted as exactly
58
+ * three. Starting a run is a different kind of decision: it is the one that decides whether there is
59
+ * a run at all. It lives here, with its own contract, so widening the workflow's gate list cannot
60
+ * quietly widen what may start a run.
61
+ */
62
+ export const RUN_START_GATE = {
63
+ id: RUN_START_GATE_ID,
64
+ header: 'Start run',
65
+ question: 'Approve phase 0 and start this run? Approving creates an armed goal the harness will keep driving.',
66
+ options: [
67
+ { label: RUN_START_APPROVE, description: 'Record the approval and arm the run goal.' },
68
+ { label: RUN_START_HOLD, description: 'Leave the spec inert: no run goal, no autonomous rounds.' },
69
+ ],
70
+ marker: RUN_START_MARKER,
71
+ } as const
72
+
73
+ /** The durable line an approval writes. */
74
+ export function runStartApprovalLine(): string {
75
+ return '- ' + RUN_START_MARKER + ': ' + RUN_START_APPROVE
76
+ }
77
+
78
+ /** Is a `run-start` answer the approving one? */
79
+ export function isRunStartApproval(answer: string): boolean {
80
+ return answer.trim() === RUN_START_APPROVE
81
+ }
82
+
83
+ /** Where the Phase 0 requirements artifact lives for a run rooted at `root`. */
84
+ export function runStartArtifactPath(root: string, runId: string): string {
85
+ return join(root, '.recursive', 'run', runId, RUN_START_ARTIFACT)
86
+ }
87
+
88
+ /** The artifact text, or null when the file is absent (a read failure is not an approval). */
89
+ export function readRunStartArtifact(root: string, runId: string): string | null {
90
+ try {
91
+ return readFileSync(runStartArtifactPath(root, runId), 'utf8')
92
+ } catch {
93
+ return null
94
+ }
95
+ }
96
+
97
+ /**
98
+ * The approval state of a run, read from its Phase 0 artifact.
99
+ *
100
+ * ⚠ MATCHED ON THE VALUE, NOT ON THE LINE'S PRESENCE. `getMdFieldValue` returns the field's VALUE, so
101
+ * a recorded `- Run Start: Hold` is refused here — a check for "is there a Run Start line?" would read
102
+ * a refusal as consent, which is the one mistake this whole module exists to prevent.
103
+ */
104
+ export function readRunStartApproval(root: string, runId: string): { approved: boolean; artifact: string; reason: string } {
105
+ const content = readRunStartArtifact(root, runId)
106
+ if (content === null) {
107
+ return { approved: false, artifact: RUN_START_ARTIFACT, reason: 'the Phase 0 requirements artifact does not exist yet' }
108
+ }
109
+ const value = getMdFieldValue(content, RUN_START_MARKER)
110
+ if (value === null) {
111
+ return { approved: false, artifact: RUN_START_ARTIFACT, reason: 'no ' + RUN_START_MARKER + ' decision has been recorded' }
112
+ }
113
+ if (!isRunStartApproval(value)) {
114
+ return { approved: false, artifact: RUN_START_ARTIFACT, reason: RUN_START_MARKER + ' is ' + JSON.stringify(value) + ', which does not start a run' }
115
+ }
116
+ return { approved: true, artifact: RUN_START_ARTIFACT, reason: '' }
117
+ }
118
+
119
+ /**
120
+ * The ONE refusal reason the projection returns before approval, exported so every caller branches on
121
+ * the same string instead of re-typing it (a re-typed reason is a caller that silently stops matching).
122
+ */
123
+ export const RUN_START_NOT_APPROVED = 'run not started: phase 0 approval has not been granted'