@try-works/dsh-recursive-mode 0.3.1 → 0.4.1

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.
Files changed (118) hide show
  1. package/README.md +959 -0
  2. package/lib/client.js +9 -2
  3. package/lib/closeout-report.d.ts +113 -0
  4. package/lib/closeout-standards.d.ts +35 -0
  5. package/lib/closeout.d.ts +12 -0
  6. package/lib/commands.d.ts +1 -1
  7. package/lib/config.d.ts +202 -0
  8. package/lib/delegation.d.ts +123 -3
  9. package/lib/enforcement.d.ts +90 -1
  10. package/lib/errors.d.ts +168 -0
  11. package/lib/git-context.d.ts +17 -0
  12. package/lib/guard-log.d.ts +39 -0
  13. package/lib/handoff.d.ts +29 -0
  14. package/lib/hooks.d.ts +103 -0
  15. package/lib/identity.d.ts +61 -0
  16. package/lib/index.d.ts +32 -12
  17. package/lib/index.js +9819 -3858
  18. package/lib/job-log.d.ts +34 -0
  19. package/lib/jobs-runner.d.ts +105 -0
  20. package/lib/json-safe.d.ts +33 -0
  21. package/lib/lock.d.ts +42 -0
  22. package/lib/memory-feedback.d.ts +52 -0
  23. package/lib/memory-select.d.ts +78 -0
  24. package/lib/memory.d.ts +137 -0
  25. package/lib/model-inventory.d.ts +106 -0
  26. package/lib/phase-graph.d.ts +111 -0
  27. package/lib/phase-rules.d.ts +67 -8
  28. package/lib/plan-gate.d.ts +68 -0
  29. package/lib/policy-globs.d.ts +222 -0
  30. package/lib/policy-write.d.ts +42 -0
  31. package/lib/policy.d.ts +39 -0
  32. package/lib/recursive_ask.tool.d.ts +88 -0
  33. package/lib/recursive_closeout.tool.d.ts +1 -1
  34. package/lib/recursive_delegate.tool.d.ts +22 -0
  35. package/lib/recursive_preview.tool.d.ts +48 -0
  36. package/lib/recursive_review.tool.d.ts +28 -0
  37. package/lib/result-cap.d.ts +70 -0
  38. package/lib/review-round.d.ts +82 -0
  39. package/lib/review.d.ts +9 -0
  40. package/lib/role-route.d.ts +122 -0
  41. package/lib/router.d.ts +90 -5
  42. package/lib/runtime.d.ts +252 -12
  43. package/lib/settlement.d.ts +132 -0
  44. package/lib/skills-phase.d.ts +71 -0
  45. package/lib/status.d.ts +53 -1
  46. package/lib/teams-loop.d.ts +91 -2
  47. package/lib/training.d.ts +211 -0
  48. package/lib/ts-lint.d.ts +15 -0
  49. package/lib/types.d.ts +48 -0
  50. package/lib/workflow-audit.d.ts +207 -0
  51. package/package.json +29 -30
  52. package/preset/recursive/agent.cordis.yml +2 -2
  53. package/preset/recursive.patch.yml +312 -0
  54. package/scripts/e2e-run.mjs +51 -0
  55. package/scripts/link-dsh.mjs +233 -0
  56. package/scripts/live/fake-llm.mjs +150 -0
  57. package/scripts/live-session-plugin.mjs +179 -0
  58. package/scripts/live-session-stock.mjs +106 -0
  59. package/scripts/live-session.mjs +139 -0
  60. package/src/client/derive.ts +18 -2
  61. package/src/closeout-report.ts +274 -0
  62. package/src/closeout-standards.ts +102 -0
  63. package/src/closeout.ts +39 -2
  64. package/src/commands.ts +116 -4
  65. package/src/config.ts +113 -0
  66. package/src/delegation.ts +336 -18
  67. package/src/enforcement.ts +262 -72
  68. package/src/errors.ts +197 -0
  69. package/src/git-context.ts +33 -2
  70. package/src/guard-log.ts +134 -0
  71. package/src/handoff.ts +62 -0
  72. package/src/hooks.ts +316 -0
  73. package/src/identity.ts +230 -0
  74. package/src/index.ts +385 -20
  75. package/src/job-log.ts +112 -0
  76. package/src/jobs-runner.ts +222 -0
  77. package/src/json-safe.ts +75 -0
  78. package/src/lock.ts +153 -16
  79. package/src/memory-feedback.ts +185 -0
  80. package/src/memory-select.ts +187 -0
  81. package/src/memory.ts +309 -0
  82. package/src/model-inventory.ts +196 -0
  83. package/src/phase-graph.ts +191 -0
  84. package/src/phase-rules.ts +236 -0
  85. package/src/plan-gate.ts +111 -0
  86. package/src/policy-globs.ts +636 -0
  87. package/src/policy-write.ts +210 -0
  88. package/src/policy.ts +70 -5
  89. package/src/recursive_ask.tool.ts +276 -0
  90. package/src/recursive_audit_team.tool.ts +7 -3
  91. package/src/recursive_closeout.tool.ts +36 -35
  92. package/src/recursive_delegate.tool.ts +194 -0
  93. package/src/recursive_init.tool.ts +4 -3
  94. package/src/recursive_lint.tool.ts +81 -6
  95. package/src/recursive_lock.tool.ts +21 -4
  96. package/src/recursive_phase.tool.ts +3 -2
  97. package/src/recursive_preview.tool.ts +142 -0
  98. package/src/recursive_review.tool.ts +190 -0
  99. package/src/recursive_scratch.tool.ts +5 -4
  100. package/src/recursive_status.tool.ts +3 -2
  101. package/src/recursive_worktree.tool.ts +6 -5
  102. package/src/result-cap.ts +130 -0
  103. package/src/review-round.ts +335 -0
  104. package/src/review.ts +17 -3
  105. package/src/role-route.ts +230 -0
  106. package/src/router.ts +128 -2
  107. package/src/runtime.ts +968 -39
  108. package/src/settlement.ts +355 -0
  109. package/src/skills-phase.ts +143 -0
  110. package/src/snapshot.ts +39 -8
  111. package/src/status.ts +209 -4
  112. package/src/teams-loop.ts +223 -9
  113. package/src/training.ts +565 -0
  114. package/src/ts-lint.ts +38 -4
  115. package/src/types.ts +51 -0
  116. package/src/workflow-audit.ts +288 -0
  117. package/scripts/install-preset.cmd +0 -7
  118. package/scripts/install-preset.js +0 -101
package/src/runtime.ts CHANGED
@@ -1,37 +1,55 @@
1
1
  import { Service, type Context } from '@deepseek-ai/cordis'
2
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
3
- import { join } from 'node:path'
2
+ import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs'
3
+ import { dirname, join } from 'node:path'
4
4
  import { lintRun } from './ts-lint.ts'
5
5
  import { requirementsContent, worktreeContent, laterPhaseContent, detectGitContext, RUN_SCAFFOLD_DIRS, type GitContext } from './init-templates.ts'
6
- import { foldRun, resolveRunDir } from './status.ts'
6
+ import { foldRun, getMdFieldValue, pendingWork, resolveRunDir } from './status.ts'
7
7
  import {
8
8
  getLockStatus,
9
+ PHASE_SEQUENCE,
9
10
  getNextLegalPhase,
10
11
  getPrerequisiteBlockers,
11
12
  getStaleDownstreamPhases,
12
13
  invalidateReceipt,
13
14
  lockHashFromContent,
15
+ validateReceiptChain,
14
16
  writeReceipt,
17
+ type ReceiptChainResult,
15
18
  } from './lock.ts'
16
- import type { RecursiveStatusResult } from './types.ts'
19
+ import type { PendingWorkItem, RecursiveStatusResult } from './types.ts'
20
+ import { findOperation, countOperations, operationId, recordOperation } from './identity.ts'
21
+ import { createHookRegistry, type HookRegistry } from './hooks.ts'
22
+ import { runTracked, abortReason, type JobsRegistryLike } from './jobs-runner.ts'
23
+ import { resolveSubagentTarget } from './role-route.ts'
24
+ import { checkModelChoice, describeInventory, type LlmInventoryLike } from './model-inventory.ts'
25
+ import { recordJobRun } from './job-log.ts'
26
+ import { toolError } from './errors.ts'
27
+ import { readGuardDecisions, type GuardDecisionRecord } from './guard-log.ts'
17
28
  import { resolveControlPlaneRoot, type WorkspaceRegistryLike } from './workspace.ts'
18
29
  import { phaseRulesFor, type PhaseRules } from './phase-rules.ts'
19
- import { closeoutPhase } from './closeout.ts'
30
+ import { closeoutReport, writeCloseoutReceipt } from './closeout-report.ts'
20
31
  import { readScratch, writeScratch, appendScratch, type ScratchTarget } from './scratch.ts'
21
32
  import { buildReviewBundle, type ReviewBundleInput } from './review.ts'
33
+ import { readMemoryEntries, retrieveMemory, renderMemorySection, selectMemory } from './memory.ts'
34
+ import { readFeedback, recordInjection, settleInjections } from './memory-feedback.ts'
35
+ import { runPhase8Trigger, resolveExtractor, spawnExtractorRunner } from './training.ts'
36
+ import { buildAskQuestion, GATE_DEFAULT_ARTIFACT, pendingGateFor } from './recursive_ask.tool.ts'
37
+ import { contractDigest } from './policy.ts'
38
+ import type { WorkflowEngineLike } from './workflow-audit.ts'
22
39
  import { createHandoff, createChildBrief, replyPath, childScratchPath, buildDelegationPrompt, type HandoffInput, type ChildBriefInput } from './handoff.ts'
23
- import { loadRouterPolicy, routerPolicyPath, resolveRole, capabilityProbe, delegationDecisionBasis, type RouterPolicy, type SubagentProviderLike, type RouteDecision, type CapabilityProbe } from './router.ts'
24
- import { delegate, delegateContinuable, validateReferences, writeActionRecord, evaluateDelegationResult, reviewOutputSchema, defaultReviewToolFilter, type SubagentsRuntimeLike, type SubagentStartRequestLike, type SubagentResultLike, type Reference, type ActionRecordInput, type ContinuableDelegationLike, type SubagentParentHandle } from './delegation.ts'
40
+ import { loadRouterPolicy, routerPolicyPath, resolveRole, capabilityProbe, delegationDecisionBasis, type RouterPolicy, type RouterPolicyOverrides, type SubagentProviderLike, type RouteDecision, type CapabilityProbe } from './router.ts'
41
+ import { delegate, delegateContinuable, drainContinuableChildren, remainingDepthFor, validateReferences, referencesFromResult, writeActionRecord, evaluateDelegationResult, reviewOutputSchema, defaultReviewToolFilter, type SubagentsRuntimeLike, type SubagentStartRequestLike, type SubagentResultLike, type Reference, type ActionRecordInput, type ContinuableDelegationLike, type SubagentParentHandle } from './delegation.ts'
25
42
  import { validateTransition, coupleGateBlockToGoal, type PhaseTransitionIntent, type RecursivePhaseState, type GateCheckResult } from './lifecycle.ts'
26
43
  import { resolveEnforcementConfig, DEFAULT_ENFORCEMENT, evaluateToolGuard, detectTamper, type EnforcementConfig, type ToolGuardDecision, type ToolExecLike } from './enforcement.ts'
27
44
  import type { Session } from '@deepseek-ai/dsh-session'
28
45
  import { renderRecursivePolicy, type PolicyContext } from './policy.ts'
29
46
  import { snapshotWorkspace } from './snapshot.ts'
30
47
  import { createLinkedWorktree, promoteBranch, listWorktrees, defaultWorktreeBranch, type CreateWorktreeResult, type PromoteBranchResult } from './worktree.ts'
31
- import { gitFacts } from './git-context.ts'
48
+ import { changedPaths, gitFacts } from './git-context.ts'
32
49
  import { syncRunGoal, blockRunGoal, resumeRunGoal, type GoalServiceLike } from './goals-projection.ts'
33
50
  import { auditToPass, renderTaskHistory, type TeamRuntimeLike, type AuditToPassResult, type TeamCallerHandle, type TeamTaskViewLike, type AuditRoundOutcome } from './teams-loop.ts'
34
51
  import type { ContinuableChildId, ContinuableMessageId } from './delegation.ts'
52
+ import { runChildIds } from './settlement.ts'
35
53
 
36
54
  declare module '@deepseek-ai/cordis' {
37
55
  interface Context {
@@ -57,6 +75,54 @@ export interface LintArtifactResult {
57
75
  passed: boolean
58
76
  }
59
77
 
78
+ /**
79
+ * T15 (G): the folded status PLUS the rolling guard-decision evidence. Declared
80
+ * as an intersection rather than by editing RecursiveStatusResult/foldRun — the
81
+ * fold's own shape is parity-asserted and stays exactly as it was.
82
+ */
83
+ export type RecursiveStatusWithGuardDecisions = RecursiveStatusResult & {
84
+ guardDecisions?: GuardDecisionRecord[]
85
+ /**
86
+ * T18: unresolved in-flight work, derived from the run directory on every call.
87
+ * Always present (empty when nothing is in flight) so consumers need no null
88
+ * check, and non-empty explains a `RM4403` lock refusal.
89
+ */
90
+ pendingWork?: PendingWorkItem[]
91
+ /**
92
+ * T32: the receipt-chain verdict, derived read-only on every call. Always present
93
+ * so a caller can read `ok` without a null check; non-empty `breaks` names the
94
+ * first broken link. This is what makes a spliced or edited chain VISIBLE rather
95
+ * than merely detectable in a test.
96
+ */
97
+ receiptChain?: ReceiptChainResult
98
+ /**
99
+ * T22: the local identifier of the policy section's STABLE prefix.
100
+ *
101
+ * Surfaced so "did the contract change under me?" is answerable from the status alone — the same
102
+ * digest the prompt carries, so a reader can compare them without re-rendering anything. It is an
103
+ * IDENTIFIER, not a cache directive: whether any provider caches the prefix is provider-side and
104
+ * unverified, which is why the item's "largest cost lever" label was withdrawn.
105
+ */
106
+ contractDigest?: string
107
+ }
108
+
109
+ /** How many recent decisions to read from the log before scoping to one run. */
110
+ const GUARD_DECISION_READ_LIMIT = 200
111
+
112
+ /**
113
+ * T28: the delegating agent's own delegation depth, read structurally rather than by
114
+ * importing the harness's `delegationDepthOf`. The plugin already models every
115
+ * harness touchpoint as a minimal structural seam, and this keeps that convention —
116
+ * an absent depth means top level, which is the safe reading for a budget.
117
+ */
118
+ function parentDepthOf(parent: unknown): number {
119
+ const depth = (parent as { options?: { subagentDepth?: unknown } } | null | undefined)?.options?.subagentDepth
120
+ return typeof depth === 'number' && Number.isSafeInteger(depth) && depth > 0 ? depth : 0
121
+ }
122
+
123
+ /** How many of that run's decisions the status surface carries. */
124
+ const GUARD_DECISION_SURFACE_LIMIT = 20
125
+
60
126
  const ARTIFACT_STUB = {
61
127
  '00-requirements.md': ['Run: ', 'Phase: 0', 'Status: DRAFT', 'Workflow version: recursive-mode-audit-v2', 'Inputs: none', 'Outputs: none', 'Scope note: '],
62
128
  '00-worktree.md': ['Run: ', 'Phase: 0 (Worktree)', 'Status: DRAFT', 'Workflow version: recursive-mode-audit-v2', 'Inputs: none', 'Outputs: none', 'Scope note: '],
@@ -73,16 +139,199 @@ const ARTIFACT_STUB = {
73
139
  export class RecursiveRuntime extends Service {
74
140
  /** Recursive-mode runtime service. Owns run-state reads + lock/init/lint operations. */
75
141
 
76
- constructor(ctx: Context, config: { repoRoot?: string; workspaceRegistry?: WorkspaceRegistryLike; goals?: GoalServiceLike | null } = {}) {
142
+ constructor(ctx: Context, config: { repoRoot?: string; workspaceRegistry?: WorkspaceRegistryLike; goals?: GoalServiceLike | null; jobs?: JobsRegistryLike | null; subagents?: SubagentsRuntimeLike | null; workflow?: WorkflowEngineLike | null } = {}) {
77
143
  super(ctx, 'recursive')
78
144
  this.repoRoot = config.repoRoot ?? process.cwd()
79
145
  this.workspaceRegistry = config.workspaceRegistry ?? null
80
146
  this.goalsService = config.goals ?? null
147
+ // T10: the native jobs registry is OPTIONAL. Absent, long operations run inline and say
148
+ // so; see `runTracked` for why that is better than refusing to work without a board.
149
+ this.jobs = config.jobs ?? null
150
+ // T39: the subagents seam, resolved at the COMPOSITION like the other optional services.
151
+ this.subagentsSeam = config.subagents ?? null
152
+ // T2: the workflow engine, likewise.
153
+ this.workflow = config.workflow ?? null
154
+ }
155
+
156
+ /** T10: the native jobs registry, when the composition mounts one. */
157
+ private readonly jobs: JobsRegistryLike | null
158
+
159
+ /**
160
+ * T23 — write a gate's answer into an artifact as a marker line.
161
+ *
162
+ * ⚠ REPLACED IN PLACE when the artifact already carries that gate's marker: two `TDD Mode:` lines
163
+ * would leave two answers to one question and make "what was decided?" depend on which a reader
164
+ * found first. The write is confined to the run directory, and an artifact that does not exist is
165
+ * CREATED — a decision recorded nowhere is not recorded.
166
+ */
167
+ recordAskAnswer(root: string, runId: string, artifact: string, marker: string): { path: string; replaced: boolean } {
168
+ const dir = join(root, '.recursive', 'run', runId)
169
+ const path = join(dir, artifact)
170
+ const label = marker.slice(2).split(':')[0].trim()
171
+ let content = ''
172
+ try {
173
+ content = readFileSync(path, 'utf8')
174
+ } catch {
175
+ // A missing artifact is created below, so the decision still has somewhere to live.
176
+ }
177
+ const lines = content === '' ? [] : content.replace(/\n$/, '').split('\n')
178
+ const at = lines.findIndex((line) => line.startsWith('- ' + label + ':'))
179
+ const replaced = at >= 0
180
+ if (replaced) lines[at] = marker
181
+ else lines.push(marker)
182
+ mkdirSync(dir, { recursive: true })
183
+ writeFileSync(path, lines.join('\n') + '\n', 'utf8')
184
+ return { path, replaced }
185
+ }
186
+
187
+ /**
188
+ * T2: the workflow engine, when the composition mounts one.
189
+ *
190
+ * OPTIONAL like every other seam here — without it an audit fan-out reports that it could not be
191
+ * orchestrated rather than pretending a fan-out happened. The engine's `workflow/*` events are
192
+ * observe-only, so this is used to START a run and await its result, never to drive one.
193
+ */
194
+ private readonly workflow: WorkflowEngineLike | null
195
+
196
+ /**
197
+ * T39: the subagents seam the composition mounted, used when a caller does not pass one.
198
+ *
199
+ * ⚠ WHY THIS EXISTS, measured rather than assumed: `recursive_review.tool.ts` — the ONLY
200
+ * production caller of `delegateReview` — passes **no `subagents`** at its call site, and
201
+ * `delegateReview` reports *"no ctx.subagents runtime available (self-audit fallback)"* when
202
+ * the input lacks one. So on a composition that HAS the service, the review tool's rounds
203
+ * never reached a child at all, and the fallback message blamed a missing runtime that was
204
+ * in fact mounted. Resolving the seam here fixes the wiring without asking every call site to
205
+ * remember, while an explicit `input.subagents` still wins for a test or a narrower caller.
206
+ */
207
+ private subagentsSeam: SubagentsRuntimeLike | null
208
+ /** FU-19: the host's provider/model inventory, or null when no llm service is mounted. */
209
+ private llmInventory: LlmInventoryLike | null = null
210
+
211
+ /**
212
+ * ⚠ FU-9 — ATTACH THE SEAM WHEN THE SERVICE APPEARS, not only when this plugin happens to apply.
213
+ *
214
+ * The composition resolved the seam with a ONE-SHOT `ctx.get('subagents')` at apply time, and a live run
215
+ * showed what that costs: the review fell back to self-audit, the action record said
216
+ * `Execution Mode: self-audit (continuable)` and `Status: failed`, and **no child was ever started** — while
217
+ * the child DIRECTORY existed all along, because the plugin writes its own brief before calling any service.
218
+ * I read the directory and built a host-limitation story on top of it; the record said otherwise.
219
+ *
220
+ * If the subagents service is mounted by a later loader layer, a one-shot get returns undefined and nothing
221
+ * re-resolves it. `ctx.inject(['subagents'], …)` is the harness's own pattern for exactly this, and calling
222
+ * this method from there makes the seam arrive whenever it arrives. Idempotent: the last attach wins, which
223
+ * is what a re-apply after a reload wants.
224
+ */
225
+ attachSubagents(seam: SubagentsRuntimeLike | null): void {
226
+ this.subagentsSeam = seam
227
+ }
228
+
229
+ /**
230
+ * ⚠ FU-19 — THE LLM INVENTORY SEAM, resolved the same late-attaching way the subagents seam is and for the same
231
+ * measured reason: a one-shot `ctx.get` at apply time misses a service mounted by a later layer.
232
+ *
233
+ * Null is a legitimate value and it is NOT treated as "no models exist" — `describeInventory(null)` reports a
234
+ * named unavailability, and `checkModelChoice` turns that into the `unverified` verdict. A missing inventory
235
+ * therefore never silently approves a model and never silently replaces one.
236
+ */
237
+ attachLlmInventory(seam: LlmInventoryLike | null): void {
238
+ this.llmInventory = seam
239
+ }
240
+
241
+ /** What the composition attached, for a caller that needs to report or assert it. */
242
+ attachedSubagents(): SubagentsRuntimeLike | null {
243
+ return this.subagentsSeam
244
+ }
245
+
246
+ /**
247
+ * ⚠ FU-9 — THE ROUTER'S PROVIDER MAP, BUILT FROM THE SEAM THAT IS ALREADY ATTACHED.
248
+ *
249
+ * `router.ts` returns the NATIVE tier for the first of `[role, 'spawn', 'fork', 'dsh-sdk']` present in this
250
+ * map. Handed `{}` it tried the external CLI route (null in the default policy) and fell to the policy
251
+ * fallback — self-audit — with a message naming neither. The router already preferred native; nobody ever
252
+ * gave it a name. A live review self-audited for five rounds because of it.
253
+ *
254
+ * `SubagentProviderLike` is only a DESCRIPTOR (`{ name, capabilities? }`), so a provider is registered by
255
+ * ASKING the service for it rather than by wrapping it. A service that cannot enumerate yields an empty map
256
+ * and the previous behaviour, which is the correct degradation rather than a guess about the shape.
257
+ */
258
+ private providerMapFromSeam(): Record<string, SubagentProviderLike> {
259
+ const seam = this.subagentsSeam
260
+ this.lastProviderNames = []
261
+ if (seam === null) return {}
262
+ const map: Record<string, SubagentProviderLike> = {}
263
+ // ⚠ ASK THE SERVICE WHAT IT HAS, FIRST. The previous version only probed three invented names and
264
+ // registered whatever came back — and the live record then said `provider spawn`, so the router faithfully
265
+ // returned a name the host does not serve and `start('spawn', …)` produced NOTHING. A name this plugin made
266
+ // up is not a provider the host knows, and the difference is a child that runs versus a silent no-op.
267
+ try {
268
+ const listed = seam.list?.()
269
+ if (Array.isArray(listed)) {
270
+ for (const entry of listed) {
271
+ if (typeof entry === 'string' && entry !== '') map[entry] = { name: entry }
272
+ else if (entry !== null && typeof entry === 'object' && typeof (entry as { name?: unknown }).name === 'string') {
273
+ const named = entry as { name: string } & SubagentProviderLike
274
+ map[named.name] = named
275
+ }
276
+ }
277
+ }
278
+ } catch {
279
+ // A service that cannot enumerate is not an error: the probes below are the fallback, not the plan.
280
+ }
281
+ // The probes stay as a FALLBACK for a service that exposes getProvider but no list. Whatever they return is
282
+ // registered under the name ASKED FOR, which is only sound because the name came from a real lookup.
283
+ for (const name of ['spawn', 'fork', 'dsh-sdk']) {
284
+ if (map[name] !== undefined) continue
285
+ try {
286
+ const found = seam.getProvider?.(name)
287
+ if (found !== undefined && found !== null) map[name] = found as SubagentProviderLike
288
+ } catch {
289
+ // An unavailable name is not an error: the next candidate still gets its turn.
290
+ }
291
+ }
292
+ this.lastProviderNames = Object.keys(map)
293
+ return map
294
+ }
295
+
296
+ /** The provider names the last `providerMapFromSeam` call registered, for the record and for assertions. */
297
+ private lastProviderNames: string[] = []
298
+
299
+ /** What the router could choose from, so a failure can say whether the name it used was ever on offer. */
300
+ knownProviderNames(): string[] {
301
+ return this.lastProviderNames
81
302
  }
82
303
 
83
304
  private readonly repoRoot: string
84
305
  private readonly workspaceRegistry: WorkspaceRegistryLike | null
85
306
  private readonly goalsService: GoalServiceLike | null
307
+ /**
308
+ * T27 — the hook registry, EXPOSED so a sibling plugin can participate in a run
309
+ * without patching this one:
310
+ *
311
+ * ctx.recursive.hooks.register('pre_trigger', { name: 'my-check', priority: 10, run })
312
+ *
313
+ * That is the whole point of the item: the plugin's own enforcement will be
314
+ * re-expressed as built-in hooks on this same registry, so a sibling and a built-in
315
+ * are peers — same ordering rules, same failure policy, same audit trail — rather
316
+ * than one being privileged code and the other a guest.
317
+ *
318
+ * Public and created eagerly: a registry that has to be "got" before it can be used
319
+ * is a registry whose ordering depends on when someone remembered to fetch it.
320
+ */
321
+ readonly hooks: HookRegistry = createHookRegistry()
322
+
323
+ /**
324
+ * T7 — router overrides from the settings namespace. Kept beside the config rather than
325
+ * merged into the file so the workspace's `recursive-router.json` stays the declarative
326
+ * source: `loadRouterPolicy` reads the file and lays these on top.
327
+ */
328
+ private _routerOverrides: RouterPolicyOverrides | undefined = undefined
329
+
330
+ /** T7: set (or clear) the router overrides. Called from `apply` on every plugin load. */
331
+ setRouterOverrides(overrides: RouterPolicyOverrides | undefined): void {
332
+ this._routerOverrides = overrides
333
+ }
334
+
86
335
  private _enforcementConfig: EnforcementConfig | null = null
87
336
 
88
337
  /**
@@ -166,18 +415,85 @@ export class RecursiveRuntime extends Service {
166
415
  * Run-scoped closeout receipt scaffold (R2), rooted under the given
167
416
  * workspace root. Refuses runIds outside the root (never crosses workspaces).
168
417
  */
169
- closeoutRun(root: string, runId: string, phase: string) {
418
+ async closeoutRun(root: string, runId: string, phase: string, agent?: { session?: { header?: { cwd?: string } } } | null) {
170
419
  const runDir = join(root, '.recursive', 'run', runId)
171
420
  const runRoot = join(root, '.recursive', 'run')
172
421
  // workspace-scoping guard: the run must be under this root
173
422
  if (!runDir.startsWith(runRoot) || !existsSync(runDir)) {
174
423
  return { error: 'Run not found in current workspace: ' + runId }
175
424
  }
425
+ // Declared OUTSIDE the try so the failure path can report it too: a refused closeout still tells the
426
+ // caller whether its children were released.
427
+ let drain: { children: number; drained: boolean; reason?: string } | null = null
176
428
  try {
177
- const result = closeoutPhase(runDir, phase)
178
- return { closeoutPhase: phase, runId, ...result }
429
+ // T30 — THE RUN-CLOSE TRIGGER, at the RE-RUN of closeout phase 08 and nowhere else.
430
+ //
431
+ // ⚠ DETECTED FROM THE RECEIPT THAT ALREADY EXISTS, not from a counter kept beside it: if phase 08
432
+ // has a receipt BEFORE this closeout runs, then it has been closed out before and this is the
433
+ // re-run the parent asks for. A first lock would otherwise train the run on itself.
434
+ //
435
+ // ⚠ THE SEAMS ARE THE REAL FILESYSTEM HERE — this IS the production call site, so the write and
436
+ // read seams that the tests inject are bound to the actual run root, and the registry read goes
437
+ // to `memory/MEMORY.md` under the same root.
438
+ const isPhase8 = phase.startsWith('08') || phase === '08-memory-impact.md'
439
+ const rerun = isPhase8 && existsSync(join(runDir, 'locks', '08-memory-impact.receipt.json'))
440
+ // ⚠ FU-3 — THE DRAIN IS COMPUTED **BEFORE** THE STUB WRITE, and the first version had it after:
441
+ // `closeoutPhase` now THROWS when the artifact is already LOCKED (the FU-8 guard), so a drain placed
442
+ // after that call never ran on exactly the runs that reach closeout twice — the ones that HAVE
443
+ // children to release. Draining is a run-close action and does not depend on scaffolding a stub.
444
+ const children = isPhase8 ? runChildIds(runDir) : []
445
+ drain = isPhase8
446
+ ? this.subagentsSeam === null
447
+ ? { children: children.length, drained: false, reason: 'no subagents runtime is mounted, so the children could not be drained' }
448
+ : await drainContinuableChildren(this.subagentsSeam, agent as never, children)
449
+ .then(() => ({ children: children.length, drained: true }))
450
+ .catch((err: unknown) => ({ children: children.length, drained: false, reason: err instanceof Error ? err.message : String(err) }))
451
+ : null
452
+ // ⚠ FU-12 — THE CLOSEOUT NO LONGER WRITES TO THE PHASE DOCUMENT.
453
+ // It READS the artifact, reports what the standard requires and what is missing, and records that
454
+ // examination as a receipt of its own under locks/<stem>.closeout.receipt.json — never over the doc.
455
+ const result = closeoutReport(runDir, phase)
456
+ // P3b: settle this run's injections against its own outcome. Only phases that LOCKED are evidence, and
457
+ // a run settles once, at closeout.
458
+ settleInjections(root, runDir, PHASE_SEQUENCE.filter((file) => getLockStatus(join(runDir, file)) === 'LOCKED'))
459
+ writeCloseoutReceipt(runDir, phase)
460
+ const training = isPhase8
461
+ ? runPhase8Trigger(root, runId, {
462
+ rerun,
463
+ // The extractor is resolved but NOT spawned here: this plugin never embeds one, and an
464
+ // unset command must surface as exit 2 rather than as a silent success.
465
+ extractorAvailable: resolveExtractor(process.env) !== null,
466
+ // FU-5: the PRODUCTION runner — spawn the command with `stdio: 'ignore'` and read the file it
467
+ // was asked to write. `stdio: 'ignore'` is deliberate: this sandbox denies a child the piped
468
+ // stdio a capture needs, and the response file is the parent's own interface anyway.
469
+ runner: spawnExtractorRunner({ cwd: root, responseFile: join(runDir, 'training-response.json') }),
470
+ write: (relativePath, content) => {
471
+ const target = join(root, relativePath)
472
+ mkdirSync(dirname(target), { recursive: true })
473
+ writeFileSync(target, content, 'utf8')
474
+ return relativePath
475
+ },
476
+ readText: (relativePath) => {
477
+ try {
478
+ return readFileSync(join(root, relativePath), 'utf8')
479
+ } catch {
480
+ return null
481
+ }
482
+ },
483
+ })
484
+ : null
485
+ // ⚠ THE SECOND drain ASSIGNMENT WAS REMOVED (FU-12): it re-drained on the success path and
486
+ // overwrote the value the throw path depends on. The assignment above is the one that matters.
487
+
488
+ // ⚠ THE DRAIN HAPPENS BEFORE THE STUB WRITE, and the first version got this wrong: the FU-8 guard
489
+ // THROWS when 08 is already LOCKED, so a drain placed after it never ran on exactly the runs that
490
+ // reach closeout twice — leaking every child of a completed run. Draining is a RUN-CLOSE action and
491
+ // does not depend on scaffolding a stub, so it happens first and is reported on BOTH paths.
492
+ return { closeoutPhase: phase, runId, ...result, ...(training === null ? {} : { training }), ...(drain === null ? {} : { drain }) }
179
493
  } catch (err) {
180
- return { error: (err as Error).message }
494
+ // The drain result is carried into the failure path too: a refused closeout still tells the caller
495
+ // whether its children were released.
496
+ return { error: (err as Error).message, ...(typeof drain !== 'undefined' && drain !== null ? { drain } : {}) }
181
497
  }
182
498
  }
183
499
 
@@ -214,6 +530,19 @@ export class RecursiveRuntime extends Service {
214
530
  * ctx.subagents with the full request (R4). Workspace-scoped: every path
215
531
  * resolves under the session's control-plane root.
216
532
  *
533
+ * DELEGATION IS ALWAYS CONTINUABLE (T35). The default mode is `continuable`:
534
+ * an explicit `mode: 'one-shot'` is the ONLY way to give up the repair path,
535
+ * and that path only exists for a caller that genuinely discards the result.
536
+ *
537
+ * WHY THIS IS THE DEFAULT. A one-shot child is NOT resumable — the harness
538
+ * rejects a resume with "subagent cannot be resumed" — so choosing one-shot
539
+ * forfeits the ability to send a failed review back to the agent that did the
540
+ * work. A continuable child has ONE durable Session across activations, so a
541
+ * REVISE reaches the SAME child with its working context intact instead of
542
+ * spawning a fresh one that must re-read the whole handoff to rediscover what
543
+ * it already knew. Since verification that cannot be followed by repair is
544
+ * just a complaint, the repair path is the default rather than an option.
545
+ *
217
546
  * `mode: 'continuable'` (T4) runs the audit→repair→re-audit loop on ONE
218
547
  * durable continuable child (startContinuable → followup with the repair
219
548
  * instruction → settle) and drains the child on closeout. It requires an
@@ -241,18 +570,98 @@ export class RecursiveRuntime extends Service {
241
570
  subagents?: SubagentsRuntimeLike
242
571
  maxDepth?: number
243
572
  toolFilter?: unknown
573
+ /**
574
+ * ⚠ FU-17 — THE BRIEF SLICE, WHEN THE CALLER OWNS IT.
575
+ *
576
+ * A review's slice is written here because a reviewer's briefing is review-shaped by definition. A WORK
577
+ * delegation needs the opposite kind of briefing — what to produce and the standard it will be linted
578
+ * against — and that is computed by `buildWorkSlice` from the phase rules. This seam lets a work caller pass
579
+ * it in without this method growing a second, drifting copy of the phase standard.
580
+ *
581
+ * ADDITIVE BY CONSTRUCTION: absent, the review slice below is built exactly as it always was, which is what
582
+ * keeps the review path's behaviour provable rather than merely claimed.
583
+ */
584
+ slice?: string
585
+ /**
586
+ * ⚠ FU-17 — whether this delegation is WORK or a REVIEW. It changes two things and nothing else: the slice
587
+ * (when `slice` is passed) and the `Purpose` line of the action record, so a reader of the run can tell a
588
+ * child that produced something from a child that judged something.
589
+ */
590
+ kind?: 'review' | 'work'
591
+ /**
592
+ * T35: which child lifecycle to use. DEFAULT `continuable` — a one-shot
593
+ * child cannot be resumed, so one-shot forfeits the repair path and must be
594
+ * requested explicitly by a caller that will discard the result.
595
+ */
596
+ /**
597
+ * ⚠ FU-18 — IS THIS ROUND A CONTINUATION OF AN OPEN DELEGATION RATHER THAN A FRESH ONE?
598
+ *
599
+ * Set by a caller that is resuming the same child to deliver FEEDBACK. It is the difference between "run this
600
+ * operation again", which the T19 guard exists to refuse, and "carry on with the operation that is already
601
+ * open", which the guard must not refuse — see the guard below for why the obvious alternative is worse.
602
+ */
603
+ continuing?: boolean
604
+ /**
605
+ * ⚠ FU-19 — A PER-CALL CHOICE, which is what "change them on demand" means. Both win over every configured
606
+ * level, and both are optional: absent means "resolve the ladder", NOT "clear".
607
+ */
608
+ providerOverride?: string | null
609
+ modelOverride?: string | null
244
610
  mode?: 'one-shot' | 'continuable'
245
611
  awaitRoundResult?: (childId: ContinuableChildId, messageId: ContinuableMessageId) => Promise<SubagentResultLike | null>
612
+ /**
613
+ * T39: interrupt the LIVE child when this delegation's job is killed — the one thing T10's
614
+ * synchronous call sites cannot do, and the reason a delegation's kill can be genuinely
615
+ * pre-emptive. Optional: without it a kill still parks the round and cannot reach the
616
+ * child, which is stated rather than implied.
617
+ */
618
+ interrupt?: (childId: string, reason: string) => void
246
619
  maxRounds?: number
247
620
  /** T4: the exact live direct-parent Agent (object-identity authority). */
248
621
  parent?: SubagentParentHandle
249
622
  }) {
250
- const policy = loadRouterPolicy(input.policyPath ?? routerPolicyPath(input.root))
251
- const providers = input.providers ?? {}
623
+ const policy = loadRouterPolicy(input.policyPath ?? routerPolicyPath(input.root), this._routerOverrides)
624
+ // ⚠ FU-9 — THE ROUTER GETS THE SAME SEAM FALLBACK THE DELEGATION ONE LINE BELOW ALREADY HAD, and not
625
+ // getting it is why a live review self-audited for five rounds of investigation. `resolveRole` tries
626
+ // `[role, 'spawn', 'fork', 'dsh-sdk']` against this map and returns the NATIVE tier for the first name it
627
+ // finds; handed `{}` it fell through to an external CLI the policy leaves null and then to the policy
628
+ // fallback, and its message named none of that. The router already preferred native — it was never given a
629
+ // name to prefer. `SubagentProviderLike` is only a descriptor (`{ name, capabilities? }`), so a provider is
630
+ // registered by ASKING the service for it; a service that cannot enumerate yields an empty map and the old
631
+ // behaviour, which is the correct degradation rather than a guess.
632
+ const providers = input.providers ?? this.providerMapFromSeam()
633
+ // T39: the seam the caller passed, else the one the COMPOSITION mounted. See the field's
634
+ // comment: without this the review tool's rounds silently self-audited on a host that had
635
+ // the service all along.
636
+ const subagents: SubagentsRuntimeLike | undefined = input.subagents ?? this.subagentsSeam ?? undefined
252
637
  const decision = resolveRole(input.role, policy, providers)
253
638
  const probe = capabilityProbe({ providers, role: input.role, policy })
254
639
 
255
640
  // R1 bundle + R2 handoff/brief/prompt (file-backed context-in contract).
641
+ // T14 — PRIOR-RUN MEMORY, retrieved by relevance to THIS phase/artifact/role and written into
642
+ // the bundle. Two facts decided the shape: there is NO native memory service (measured), so the
643
+ // store is the plugin's own `.recursive/memory/` layer; and the prompt references the bundle by
644
+ // PATH, so the content must go INTO the bundle rather than beside the prompt.
645
+ //
646
+ // `memoryRefs` is filled as well as the content, and it is NOT a duplicate: it was a DEAD SLOT —
647
+ // rendered by the bundle and set by nobody — so the memory section a reviewer was meant to see
648
+ // never existed. Refs give traceability; the content is what a reviewer can actually cite.
649
+ const memoryEntries = retrieveMemory(
650
+ readMemoryEntries(
651
+ (path) => { try { return readFileSync(path, 'utf8') } catch { return null } },
652
+ (kind) => {
653
+ try {
654
+ return readdirSync(join(input.root, '.recursive', 'memory', kind))
655
+ .filter((name) => name.endsWith('.md'))
656
+ .map((name) => join(input.root, '.recursive', 'memory', kind, name))
657
+ } catch {
658
+ // An absent or unreadable memory directory is a missing advantage, not a failed review.
659
+ return []
660
+ }
661
+ },
662
+ ),
663
+ [input.phase, input.role, ...input.auditQuestions].join(' '),
664
+ )
256
665
  const bundle = buildReviewBundle({
257
666
  root: input.root,
258
667
  runId: input.runId,
@@ -265,6 +674,10 @@ export class RecursiveRuntime extends Service {
265
674
  codeRefs: input.codeRefs,
266
675
  changedFiles: input.changedFiles,
267
676
  diffBasis: input.diffBasis,
677
+ ...(memoryEntries.length === 0 ? {} : {
678
+ memory: renderMemorySection(memoryEntries),
679
+ memoryRefs: [...new Set(memoryEntries.map((entry) => entry.source))],
680
+ }),
268
681
  })
269
682
  const handoffPath = createHandoff({
270
683
  root: input.root,
@@ -288,7 +701,9 @@ export class RecursiveRuntime extends Service {
288
701
  runId: input.runId,
289
702
  delegationId: input.delegationId,
290
703
  childId: input.childId,
291
- slice: 'Perform the delegated ' + input.role + ' for run ' + input.runId + ' (' + input.phase + ') and write your submission to reply.md.',
704
+ // ⚠ FU-17 — `input.slice` wins when a work caller supplies one; otherwise this is byte-for-byte the review
705
+ // slice it has always been. A work brief cannot be built here without a second copy of the phase standard.
706
+ slice: input.slice ?? 'Perform the delegated ' + input.role + ' for run ' + input.runId + ' (' + input.phase + ') and write your submission to reply.md.',
292
707
  })
293
708
  const prompt = buildDelegationPrompt({
294
709
  root: input.root,
@@ -302,36 +717,274 @@ export class RecursiveRuntime extends Service {
302
717
  // R4: plugin-driven delegation with the full request shape. `parent` is the
303
718
  // exact live direct-parent Agent (object-identity authority in the live
304
719
  // subagent service); absent it, the live start() rejects the request.
720
+ // T28: the depth budget replaces the hardcoded `?? 2`. The parent's own depth is
721
+ // read structurally (the plugin's seam style) and SUBTRACTED, so the configured
722
+ // ceiling bounds the whole recursion rather than being re-granted at every level
723
+ // — a "depth 2" budget that resets per level bounds nothing.
724
+ // T9: notes about routing decisions that could NOT be applied, so a caller is told rather
725
+ // than left to infer it from a model that silently did not take effect.
726
+ const routingNotes: string[] = []
305
727
  const request: SubagentStartRequestLike = {
306
728
  prompt: [{ type: 'text', text: prompt }],
307
729
  label: input.delegationId + '/' + input.childId,
308
730
  outputSchema: reviewOutputSchema(),
309
731
  toolFilter: input.toolFilter ?? defaultReviewToolFilter(),
310
- maxDepth: input.maxDepth ?? 2,
732
+ maxDepth: remainingDepthFor(this.enforcementConfig.budgets, parentDepthOf(input.parent), input.maxDepth),
311
733
  }
312
734
  if (input.parent !== undefined) request.parent = input.parent
313
735
 
736
+ // ⚠ FU-19 — THE PROVIDER/MODEL LADDER, APPLIED WHERE THE PROVIDER SAYS IT CAN BE.
737
+ //
738
+ // The ladder (per-call → phase → role → general default) is resolved by `resolveSubagentTarget`, which also
739
+ // reports WHICH LEVEL chose each value — so "why did this child run on that model" is answerable from the
740
+ // decision rather than by reading this code. `modelForRole` was the single-level version of this and is gone.
741
+ //
742
+ // ⚠ THE CAPABILITY GATE STAYS AUTHORITATIVE, unchanged: the harness REJECTS a start that sends `agentOptions`
743
+ // to a provider without the `agentOptions` capability, so gating on the model alone would BREAK delegations on
744
+ // providers that do not accept overrides — a routing feature that takes down the delegation it was meant to
745
+ // improve. A choice that cannot be honoured is REPORTED, never silently dropped.
746
+ const target = resolveSubagentTarget({
747
+ role: input.role,
748
+ phase: input.phase,
749
+ policy,
750
+ ...(input.providerOverride === undefined && input.modelOverride === undefined
751
+ ? {}
752
+ : { override: { provider: input.providerOverride ?? null, model: input.modelOverride ?? null } }),
753
+ ladderProvider: decision.provider ?? null,
754
+ })
755
+ if (target.provider !== null && target.provider !== decision.provider) {
756
+ // The user named a provider. The router still says WHICH TIER it resolved, but the name used to create the
757
+ // child is the user's — and the note states both rather than leaving two answers in the record.
758
+ routingNotes.push(
759
+ 'provider ' + target.provider + ' chosen from ' + target.chosen.provider
760
+ + ' (the router tier resolved as ' + decision.tier + ')',
761
+ )
762
+ }
763
+ if (target.model !== null) {
764
+ const chosenProvider = target.provider ?? decision.provider ?? ''
765
+ const capable = providers[chosenProvider]?.capabilities?.agentOptions === true
766
+ // ⚠ FU-19 — THE CHOICE IS CHECKED AGAINST WHAT DSH ACTUALLY HAS, and the three-state verdict decides what
767
+ // happens next. `missing` means the inventory ANSWERED and does not have this model: it is NOT applied, and
768
+ // crucially NO OTHER MODEL IS SUBSTITUTED — the child inherits the session default and the note says so.
769
+ // `unverified` (no inventory to ask) still applies the model, because refusing on the strength of a service
770
+ // that merely was not mounted would refuse something the user asked for on no evidence.
771
+ const check = checkModelChoice(await describeInventory(this.llmInventory), target.model)
772
+ if (check.verdict === 'missing') {
773
+ routingNotes.push(
774
+ 'model ' + target.model + ' was chosen (from ' + target.chosen.model + '), but ' + check.reason
775
+ + ' — it was NOT applied and no other model was substituted, so the child inherits the session default',
776
+ )
777
+ } else if (!capable) {
778
+ routingNotes.push(
779
+ 'model ' + target.model + ' was chosen (from ' + target.chosen.model + '), but provider '
780
+ + (chosenProvider || '(none)') + ' does not declare the agentOptions capability, so the model was NOT applied',
781
+ )
782
+ } else {
783
+ request.agentOptions = { model: target.model }
784
+ routingNotes.push('model ' + target.model + ' applied (from ' + target.chosen.model + '); inventory says: ' + check.reason)
785
+ }
786
+ }
787
+
788
+ // T19 — DETERMINISTIC OPERATION IDENTITY for the delegation itself. The id
789
+ // covers the inputs PLUS the artifact's BODY, and the body is what keeps a
790
+ // re-review of a REPAIRED artifact a genuinely different operation rather than a
791
+ // recognised repeat. `childId` is deliberately NOT part of it: a fresh round
792
+ // allocates one at random, so including it would make the id meaningless.
793
+ //
794
+ // The body — not the `LockHash` — for the reason T37 found the hard way: a lock
795
+ // hash covers the wall-clock `LockedAt`, so it changes between two locks of
796
+ // byte-identical content and would make this identity fire only half the time.
797
+ const artifactBody = (() => {
798
+ try {
799
+ return lockHashFromContent(
800
+ readFileSync(input.artifactPath, 'utf8').replace(/^[ \t]*Status:.*$/m, '').replace(/^[ \t]*LockedAt:.*\n?/m, ''),
801
+ )
802
+ } catch {
803
+ return 'absent'
804
+ }
805
+ })()
806
+ const operation = operationId({
807
+ act: 'delegate-review',
808
+ input: {
809
+ runId: input.runId,
810
+ phase: input.phase,
811
+ role: input.role,
812
+ delegationId: input.delegationId,
813
+ body: artifactBody,
814
+ },
815
+ })
816
+
817
+ /**
818
+ * T39 — the PRODUCTION provider of the interrupt seam.
819
+ *
820
+ * The delegation's job kill must reach the LIVE child, and the child is interrupted
821
+ * through the SAME `subagents.interrupt` seam the continuable lifecycle already uses —
822
+ * never a second stop path, so a board kill and a lifecycle stop cannot drift apart. The
823
+ * authority is `{ kind: 'ancestor', agent: parent }`: the parent Agent is the object-
824
+ * identity authority the seam expects, exactly as `followup` uses it.
825
+ *
826
+ * An explicit `input.interrupt` still WINS, so a caller with a better authority (a user-
827
+ * initiated stop, say) can supply one. With no seam and no parent there is nothing to
828
+ * interrupt, and this quietly does nothing rather than throwing: the kill already parked
829
+ * the round, and a kill that cannot reach a child must not become an error in its place.
830
+ */
831
+ const interruptChild = input.interrupt ?? ((childId: string, reason: string) => {
832
+ const seam = subagents
833
+ if (seam?.interrupt === undefined || input.parent === undefined) return
834
+ try {
835
+ seam.interrupt(childId as ContinuableChildId, { kind: 'ancestor', agent: input.parent })
836
+ } catch {
837
+ // Best-effort, like the call site that uses it.
838
+ }
839
+ })
840
+
314
841
  let result: SubagentResultLike | null = null
315
842
  let error: string | null = null
316
843
  let continuable: ContinuableDelegationLike | null = null
844
+ // T36: a parked round is NOT an error — it means the child has not settled yet.
845
+ // Kept apart from `error` so a turn-shaped caller can resume instead of treating
846
+ // an ordinary wait as a failure of the delegation.
847
+ let parked = false
848
+ let parkedReason: string | null = null
849
+ // The run directory the operation index lives under. Computed once, and the
850
+ // index is skipped entirely when there is no root: `join('', …)` would otherwise
851
+ // resolve a RELATIVE path and could read or write an unrelated directory.
852
+ const operationsDir = input.root ? join(input.root, '.recursive', 'run', input.runId) : ''
853
+
854
+ // T19 — a RECOGNISED repeat is not re-executed. Only an operation that already
855
+ // COMPLETED and was ACCEPTED blocks a retry: a failed or refused attempt must
856
+ // stay retryable, and a repaired artifact is a different operation entirely
857
+ // because its body is part of the id. Without this the same review could spawn a
858
+ // second reviewer for an artifact that has not changed.
859
+ const prior = operationsDir === '' ? null : findOperation(operationsDir, operation)
860
+ // ⚠ FU-18 — A CONTINUATION IS NOT A REPEAT, and the distinction has to live HERE rather than in the operation
861
+ // id. The tempting fix is to fold the round into the id so a feedback round becomes a different operation,
862
+ // and it is the wrong one: T28's children budget below counts DISTINCT OPERATIONS and its own comment says a
863
+ // resumed turn "re-enters here with the same id and must not be counted as another child". Making a
864
+ // continuation a new identity would fix this refusal and silently miscount the budget instead.
865
+ //
866
+ // The collision it resolves: the T19 guard refuses an operation already accepted against an UNCHANGED
867
+ // artifact, which is right in general — re-reviewing an artifact nothing has touched since it passed wastes a
868
+ // child. But when the main agent sends FEEDBACK, the artifact has not changed YET: the child has not repaired
869
+ // it. So the guard refused exactly the call that starts the repair. A continuation is the operation still
870
+ // running, so it is exempt; a genuinely fresh delegation against an unchanged artifact is still refused.
871
+ if (prior?.outcome === 'accepted' && input.continuing !== true) {
872
+ throw new Error(
873
+ 'review of ' + input.phase + ' is a recognised repeat of an operation already accepted (operation ' +
874
+ operation + '); the artifact has not changed since it passed',
875
+ )
876
+ }
877
+
878
+ // T28 — THE CHILDREN BUDGET, counted FROM the index rather than tracked beside it,
879
+ // so the bound cannot drift from the operations it bounds. Distinct operations,
880
+ // not records: a resumed turn re-enters here with the same id and must not be
881
+ // counted as another child.
882
+ if (operationsDir !== '') {
883
+ const started = countOperations(operationsDir, 'delegate-review', input.phase)
884
+ if (started >= this.enforcementConfig.budgets.maxChildrenPerPhase) {
885
+ throw new Error(
886
+ 'children budget reached: phase ' + input.phase + ' has already started ' + started +
887
+ ' delegation(s), and the configured cap is ' + this.enforcementConfig.budgets.maxChildrenPerPhase,
888
+ )
889
+ }
890
+ }
891
+
317
892
  if (decision.tier === 'native' || decision.tier === 'external-cli') {
318
- if (!input.subagents) {
893
+ if (!subagents) {
319
894
  error = 'no ctx.subagents runtime available (self-audit fallback)'
320
- } else if (input.mode === 'continuable') {
321
- // T4: one durable child carries every round; start() is never called.
895
+ } else if (input.mode !== 'one-shot') {
896
+ // T35: continuable BY DEFAULT — only an explicit 'one-shot' opts out of
897
+ // the repair path, because a one-shot child cannot be resumed.
322
898
  continuable = await delegateContinuable({
323
- subagents: input.subagents,
324
- provider: decision.provider as string,
899
+ subagents,
900
+ provider: (target.provider ?? decision.provider ?? '') as string,
325
901
  label: input.delegationId + '/' + input.childId,
326
902
  prompt,
327
903
  childId: input.childId,
328
- maxDepth: input.maxDepth ?? 2,
904
+ maxDepth: remainingDepthFor(this.enforcementConfig.budgets, parentDepthOf(input.parent), input.maxDepth),
329
905
  toolFilter: input.toolFilter ?? defaultReviewToolFilter(),
330
906
  maxRounds: input.maxRounds ?? 3,
331
- awaitRoundResult: input.awaitRoundResult,
907
+ // T9: the model the role resolved to, when the provider accepts overrides. It must be
908
+ // forwarded THROUGH the continuable delegation — that seam builds the start request
909
+ // itself, so setting it on `request` alone would never reach the provider.
910
+ ...(request.agentOptions === undefined ? {} : { agentOptions: request.agentOptions }),
911
+ // T39: the ROUND AWAIT is the hangable part of a delegation — the child may work for
912
+ // minutes — so it is what the board should be able to see as a running job.
913
+ //
914
+ // ⚠ SAFETY BY CONSTRUCTION, which is why this wrapper is safe to add at all: the
915
+ // seam's contract is `SubagentResultLike | null`, and `delegateContinuable` reads a
916
+ // null as PARKED. So every non-completed job outcome returns `null` — never a throw
917
+ // and never an invented result — which means a killed or failed round parks exactly
918
+ // as an unobserved round does. This wrapper therefore CANNOT convert a parked round
919
+ // into a settlement, and the property holds without a special case.
920
+ //
921
+ // ⚠ MEASURED, and it corrects T39's own plan: `delegateReview` has NO interrupt
922
+ // seam — its input carries none, and this module has no interrupt path (the audit
923
+ // loop has one only because IT receives the teams runtime). So the job's cancel
924
+ // cannot yet pre-empt the child; wiring that needs the seam added here first. The
925
+ // job still gives the board visibility, and the signal is honoured at the boundary.
926
+ awaitRoundResult: input.awaitRoundResult === undefined ? undefined : async (childId, messageId) => {
927
+ const awaited = await runTracked(this.jobs, {
928
+ kind: 'delegation',
929
+ label: 'delegation round ' + input.runId + ' ' + input.phase,
930
+ run: async ({ report, signal }) => {
931
+ report('awaiting the review round')
932
+
933
+ // T39 (part 2) — THE PRE-EMPTIVE KILL. A job kill aborts the signal, and this
934
+ // listener turns that abort into an INTERRUPT of the LIVE CHILD, so the kill
935
+ // stops the WORK rather than merely stopping our wait for it. That is the
936
+ // difference between this call site and T10's synchronous ones, where no such
937
+ // thing is possible.
938
+ //
939
+ // The seam is OPTIONAL and the no-seam case stays honest: without a provider
940
+ // the kill still parks the round and never throws — it simply cannot reach the
941
+ // child, and nothing here pretends otherwise.
942
+ const onAbort = () => {
943
+ try {
944
+ interruptChild(input.childId, abortReason(signal))
945
+ } catch {
946
+ // Best-effort by design: an interrupt that throws must not replace the
947
+ // kill's own outcome with an unrelated error.
948
+ }
949
+ }
950
+ if (signal?.aborted === true) onAbort()
951
+ else signal?.addEventListener('abort', onAbort, { once: true })
952
+
953
+ try {
954
+ // ⚠ FU-9 — THE `?.` IS THE FIX FOR THE WHOLE INVESTIGATION. `signal` is typed as an
955
+ // `AbortSignal` but a caller can reach here with nothing, and `signal.aborted` on undefined
956
+ // throws `Cannot read properties of undefined (reading 'aborted')` — which the delegation's
957
+ // catch recorded as its reason, the driver misread as "no continuable repair path", and which
958
+ // therefore meant THE CHILD NEVER STARTED. Every artifact this investigation chased — no child
959
+ // session, no reply, no settlement, four brief-only child directories — was downstream of this
960
+ // one unguarded property read. An absent signal means "nobody can cancel this", not "crash".
961
+ if (signal?.aborted === true) throw new Error('the round was cancelled before it was awaited')
962
+ return await input.awaitRoundResult!(childId, messageId)
963
+ } finally {
964
+ signal?.removeEventListener('abort', onAbort)
965
+ }
966
+ },
967
+ })
968
+ if (awaited.status !== 'completed') return null
969
+ // T39: record the round where the run id is known — same pattern as the lint, and
970
+ // for the same reason (an event would carry the job, not the run).
971
+ recordJobRun(input.root, input.runId, {
972
+ kind: 'delegation',
973
+ label: 'delegation round ' + input.runId + ' ' + input.phase,
974
+ result: awaited,
975
+ })
976
+ return awaited.value ?? null
977
+ },
332
978
  parent: input.parent,
333
979
  })
334
- if (continuable.fellBackToOneShot) {
980
+ if (continuable.parked === true) {
981
+ // T36: no settlement has landed for this round yet. The child is still
982
+ // working (or a repair was just sent), so the caller resumes on a later
983
+ // turn with the SAME child id. `accepted` stays false, and no `result` is
984
+ // fabricated — an unobserved round is never an approval.
985
+ parked = true
986
+ parkedReason = continuable.reason ?? null
987
+ } else if (continuable.fellBackToOneShot) {
335
988
  // The seam has no continuable capability — keep the one-shot result.
336
989
  result = continuable.rounds[0]?.result ?? null
337
990
  if (!result) error = 'continuable fallback produced no result'
@@ -344,8 +997,8 @@ export class RecursiveRuntime extends Service {
344
997
  } else {
345
998
  try {
346
999
  result = await delegate({
347
- subagents: input.subagents,
348
- provider: decision.provider as string,
1000
+ subagents,
1001
+ provider: (target.provider ?? decision.provider ?? '') as string,
349
1002
  request,
350
1003
  })
351
1004
  } catch (err) {
@@ -356,7 +1009,41 @@ export class RecursiveRuntime extends Service {
356
1009
  error = 'delegation resolved to ' + decision.tier + ' (' + decision.reason + ')'
357
1010
  }
358
1011
 
359
- const evaluation = result ? evaluateDelegationResult(result) : { accepted: false, reason: error ?? 'no result' }
1012
+ const rawEvaluation = result ? evaluateDelegationResult(result) : { accepted: false, reason: error ?? 'no result' }
1013
+
1014
+ // T8 — THE WIRING BUG THE ITEM'S RESCOPE NAMED. `validateReferences` existed, was
1015
+ // exported, was even reachable through this runtime — and was called by NOTHING on the
1016
+ // delegate path. The module header had always claimed the opposite ("validates the
1017
+ // child's references before writing an action record"), so a reviewer could cite files
1018
+ // that do not exist, or paths that escape the root, and the review was recorded as a
1019
+ // PASS. A claim nobody checks is worse than no claim, because it reads as evidence.
1020
+ //
1021
+ // Placed BEFORE the operation record and the action record, which is what makes it
1022
+ // matter: a review with unverifiable references is never indexed as `accepted`, so the
1023
+ // T19 repeat guard treats it as retryable instead of freezing a bad review in place.
1024
+ //
1025
+ // A delegation that claims NOTHING is not checked — refusing an empty list would be a
1026
+ // policy change (is a reference-free review invalid?) rather than a wiring fix, and it
1027
+ // is recorded here as a deliberate boundary rather than an oversight.
1028
+ const claims = result ? referencesFromResult(result) : []
1029
+ const referenceCheck = claims.length === 0 ? null : validateReferences(input.root, claims)
1030
+ const evaluation = referenceCheck === null || referenceCheck.ok
1031
+ ? rawEvaluation
1032
+ : { accepted: false, reason: 'review references failed: ' + referenceCheck.failures.join('; ') }
1033
+
1034
+ // T19 — persist the attempt so a RESTART can match it. Only an accepted review is
1035
+ // recorded as `accepted`, which is what makes the recognition above block a
1036
+ // genuine repeat while leaving every failure path retryable. Best-effort: a
1037
+ // failed index write must never change the outcome of a review that already ran.
1038
+ if (operationsDir !== '') {
1039
+ recordOperation(operationsDir, {
1040
+ id: operation,
1041
+ act: 'delegate-review',
1042
+ at: new Date().toISOString().replace(/\.\d{3}Z$/, 'Z'),
1043
+ outcome: evaluation.accepted ? 'accepted' : 'unaccepted',
1044
+ phase: input.phase,
1045
+ })
1046
+ }
360
1047
 
361
1048
  // R6: record the attempt as an action record (accepted only if evaluation passes).
362
1049
  const actionRecordPath = writeActionRecord({
@@ -364,8 +1051,10 @@ export class RecursiveRuntime extends Service {
364
1051
  runId: input.runId,
365
1052
  subagentId: input.childId,
366
1053
  phase: input.phase,
367
- purpose: input.role + ' for run ' + input.runId,
368
- executionMode: decision.tier + (input.mode === 'continuable' ? ' (continuable)' : ''),
1054
+ // ⚠ FU-17 — the kind is stated in the record. `Status` says accepted or failed; nothing said whether the
1055
+ // child PRODUCED the phase's work or JUDGED it, and a reader of a run could not tell the two apart.
1056
+ purpose: input.role + (input.kind === 'work' ? ' (work)' : '') + ' for run ' + input.runId,
1057
+ executionMode: decision.tier + (input.mode !== 'one-shot' ? ' (continuable)' : ''),
369
1058
  artifactPath: input.artifactPath,
370
1059
  upstreamArtifacts: input.upstreamArtifacts,
371
1060
  reviewBundle: bundle.repoRelativePath,
@@ -375,8 +1064,44 @@ export class RecursiveRuntime extends Service {
375
1064
  findings: evaluation.accepted && result?.structured ? [(result.structured as { verdict?: string })?.verdict ?? 'accepted'] : undefined,
376
1065
  success: evaluation.accepted,
377
1066
  stopReason: result?.stopReason,
1067
+ // ⚠ FU-9 — AND SAY WHICH KIND OF FAILURE, because the record previously could not. `result == null` means
1068
+ // the provider never produced anything at all (never started, or returned nothing) — which is what the
1069
+ // live record's `Stop Reason: n/a` was quietly telling me — while a present result that failed to be
1070
+ // accepted means a child DID run and its work was refused. Different problems, identical artifacts.
1071
+ failure: evaluation.accepted
1072
+ ? undefined
1073
+ : result == null
1074
+ // ⚠ THE TWO STATES ARE NOT THE SAME AND THE MESSAGE USED TO CONFLATE THEM. The one-shot path cannot
1075
+ // resolve to nothing — the host's `start` returns a run or throws (assertCapabilities, expectProvider)
1076
+ // — so a null result on the CONTINUABLE path means the opposite of what I first wrote: the start WAS
1077
+ // made and NO SETTLEMENT ARRIVED within the wait. That distinction cost me two rounds of looking at
1078
+ // provider names, so the record now states which path a run took and what it was waiting for.
1079
+ ? (input.mode !== 'one-shot'
1080
+ ? 'the continuable start was made and NO SETTLEMENT arrived within the wait (the child never reported,'
1081
+ + ' or never ran); tier ' + decision.tier + ', provider ' + (decision.provider ?? 'none chosen')
1082
+ + ', names on offer [' + (this.lastProviderNames.join(', ') || 'none') + ']'
1083
+ // ⚠ FU-9 — THE PARENT IDENTITY, because the host refuses a prompt when it cannot resolve the
1084
+ // parent session as a live Agent (`subagent/parent-unavailable`, index.ts L429-436), and the tool
1085
+ // builds this handle with a CAST (`exec.agent as unknown as SubagentParentHandle`). A cast is not
1086
+ // a contract: if the id here is not the one the host looks up, the refusal is real and the
1087
+ // classifier's crash has been hiding it. Printing it here costs nothing and settles the question.
1088
+ + '; parent id ' + ((input.parent as { id?: string } | undefined)?.id ?? 'none')
1089
+ + ', parent session keys [' + (input.parent === undefined ? 'no parent' : Object.keys(input.parent as object).join(', ')) + ']'
1090
+ : 'no delegate result was produced by the one-shot path; tier ' + decision.tier
1091
+ + ', provider ' + (decision.provider ?? 'none chosen'))
1092
+ : 'the delegation returned without acceptance; stop reason ' + (result.stopReason ?? 'none reported'),
378
1093
  })
379
1094
 
1095
+ // T35: report the mode that ACTUALLY ran, not the one that was asked for. A
1096
+ // missing continuable capability is a real loss — the review can no longer be
1097
+ // sent back to the child that did the work — so it is NAMED rather than
1098
+ // hidden behind a generic success. `continuable-unavailable` is the honest
1099
+ // label for that case: the delegation still happened, the repair path did not.
1100
+ const delegationMode: 'continuable' | 'one-shot' | 'continuable-unavailable' | 'none'
1101
+ = continuable !== null
1102
+ ? (continuable.fellBackToOneShot ? 'continuable-unavailable' : 'continuable')
1103
+ : result !== null ? 'one-shot' : 'none'
1104
+
380
1105
  // T4: the durable child id is reported for the caller (a tool/closeout that
381
1106
  // holds the live parent Agent may drain it explicitly); the HOST owns the
382
1107
  // teardown drain (drainContinuableDescendants) at session close — this loop
@@ -393,9 +1118,28 @@ export class RecursiveRuntime extends Service {
393
1118
  request,
394
1119
  result,
395
1120
  evaluation,
1121
+ /** T9: routing decisions that could NOT be applied, so a caller is told rather than left to infer. */
1122
+ routingNotes,
396
1123
  actionRecordPath,
397
1124
  error,
398
- continuable: continuable ? { rounds: continuable.rounds, childId: continuable.childId, fellBackToOneShot: continuable.fellBackToOneShot } : null,
1125
+ /** T35: which child lifecycle actually carried this delegation. */
1126
+ delegationMode,
1127
+ /** T19: the deterministic id of this review, persisted so a restart can match it. */
1128
+ operationId: operation,
1129
+ /**
1130
+ * T36: true when the round has NOT settled yet, so the caller resumes with
1131
+ * `continuable.childId` on a later turn. `parkedReason` carries the loop's own
1132
+ * sentence ("the child is still working") without it being an `error`.
1133
+ */
1134
+ parked,
1135
+ parkedReason,
1136
+ continuable: continuable ? { rounds: continuable.rounds, childId: continuable.childId, fellBackToOneShot: continuable.fellBackToOneShot, parked: continuable.parked === true,
1137
+ // ⚠ FU-9 — `ok` AND `reason` TRAVEL WITH IT. The adapter that builds the review driver's view read only
1138
+ // `rounds`, `childId`, `fellBackToOneShot` and `parked`, so every failure branch's REASON — the whole
1139
+ // point of the field — was discarded one layer above the message that needed it, and `ok: false` was
1140
+ // replaced by a hardcoded `ok: true`. A driver cannot report which branch fired if the branch's own
1141
+ // name never reaches it.
1142
+ ok: continuable.ok, reason: continuable.reason } : null,
399
1143
  }
400
1144
  }
401
1145
 
@@ -406,7 +1150,7 @@ export class RecursiveRuntime extends Service {
406
1150
 
407
1151
  /** R7: probe availability for a role and render the decision basis prose. */
408
1152
  probeDelegation(root: string, role: string, providers: Record<string, SubagentProviderLike> = {}) {
409
- const policy = loadRouterPolicy(routerPolicyPath(root))
1153
+ const policy = loadRouterPolicy(routerPolicyPath(root), this._routerOverrides)
410
1154
  const decision = resolveRole(role, policy, providers)
411
1155
  const probe = capabilityProbe({ providers, role, policy })
412
1156
  return {
@@ -450,12 +1194,36 @@ export class RecursiveRuntime extends Service {
450
1194
  return null
451
1195
  }
452
1196
 
453
- async status(runId?: string, agent?: { session?: { header?: { cwd?: string } } } | null): Promise<RecursiveStatusResult | null> {
1197
+ async status(runId?: string, agent?: { session?: { header?: { cwd?: string } } } | null): Promise<RecursiveStatusWithGuardDecisions | null> {
454
1198
  const root = await this.resolveRootFor(agent)
455
1199
  if (!root) return null
456
1200
  const resolved = resolveRunDir(root, runId)
457
1201
  if (!resolved) return null
458
- return foldRun(resolved.runDir, resolved.runId)
1202
+ // T15 (G): surface the rolling guard-decision evidence BESIDE the fold, spread
1203
+ // over it — foldRun's own output shape is parity-asserted (status.parity) and
1204
+ // must not change. Scoped to the resolved run so the field answers "what did
1205
+ // the guard decide about THIS run", newest first.
1206
+ const guardDecisions = readGuardDecisions(root, GUARD_DECISION_READ_LIMIT)
1207
+ .filter((d) => d.runId === resolved.runId)
1208
+ .slice(0, GUARD_DECISION_SURFACE_LIMIT)
1209
+ // T18: the pending set rides beside the fold for the same reason — foldRun's
1210
+ // shape is parity-asserted. Always present (empty when nothing is in flight)
1211
+ // so a consumer needs no null dance, and DERIVED on every call rather than
1212
+ // stored, so it cannot go stale.
1213
+ //
1214
+ // T32: the receipt-chain verdict rides here too. A chain that has been edited or
1215
+ // spliced must be VISIBLE on the status a caller actually reads, not only inside
1216
+ // a test — that was the whole finding: the mechanism was written and read by
1217
+ // nothing. Read-only, so asking cannot change the answer.
1218
+ return {
1219
+ ...foldRun(resolved.runDir, resolved.runId),
1220
+ guardDecisions,
1221
+ pendingWork: pendingWork(resolved.runDir),
1222
+ receiptChain: validateReceiptChain(resolved.runDir, resolved.runId),
1223
+ // T22: the same identifier the prompt's stable prefix carries, so a reader can compare status
1224
+ // against prompt without re-rendering the section.
1225
+ contractDigest: contractDigest(this.enforcementConfig),
1226
+ }
459
1227
  }
460
1228
 
461
1229
  /**
@@ -465,14 +1233,68 @@ export class RecursiveRuntime extends Service {
465
1233
  * rules + instructions. Returns null when no active phase exists. This is the
466
1234
  * canonical data source for the recursive_phase tool.
467
1235
  */
468
- async phaseRules(runId?: string, agent?: { session?: { header?: { cwd?: string } } } | null): Promise<(PhaseRules & { runId: string; phase: string }) | null> {
1236
+ async phaseRules(runId?: string, agent?: { session?: { header?: { cwd?: string } } } | null, files?: readonly string[]): Promise<(PhaseRules & { runId: string; phase: string; memory: string; memoryReason: string }) | null> {
469
1237
  const root = await this.resolveRootFor(agent)
470
1238
  if (!root) return null
471
1239
  const resolved = resolveRunDir(root, runId)
472
1240
  if (!resolved) return null
473
1241
  const phase = getNextLegalPhase(resolved.runDir)
474
1242
  if (!phase) return null
475
- return { runId: resolved.runId, phase, ...phaseRulesFor(phase) }
1243
+ // T29 — MEMORY AT RUN ENTRY, ON THIS CALL AND NOWHERE ELSE.
1244
+ //
1245
+ // ⚠ THE ONCE-GATE IS THIS FUNCTION, not a mechanism added beside it: `recursive_phase` calls it
1246
+ // once per phase entry, so riding the injection on its EXISTING return is what keeps it
1247
+ // once-per-run instead of once-per-turn. A second dedupe would be a second thing to get wrong.
1248
+ //
1249
+ // ⚠ AND NOTHING RELEVANT INJECTS NOTHING — `memory` is the empty string, with the reason saying
1250
+ // why, rather than a section that fabricates relevance the plane does not have.
1251
+ const requirements = (() => {
1252
+ try {
1253
+ return readFileSync(join(resolved.runDir, '00-requirements.md'), 'utf8')
1254
+ } catch {
1255
+ // A run with no requirements yet has a weaker query, not an error: the paths still count.
1256
+ return ''
1257
+ }
1258
+ })()
1259
+ // `phase` IS the artifact file name (`getNextLegalPhase` returns one of PHASE_SEQUENCE), so the gate
1260
+ // lookup needs no mapping — and the artifact's own text is what says whether it is still owed.
1261
+ const pending = pendingGateFor(phase, (() => {
1262
+ try {
1263
+ return readFileSync(join(resolved.runDir, phase), 'utf8')
1264
+ } catch {
1265
+ return null
1266
+ }
1267
+ })())
1268
+ const selection = selectMemory(root, {
1269
+ query: requirements.slice(0, 4000),
1270
+ // FU-4: the run's OWN changed paths, computed when the caller supplies none — so T29's path
1271
+ // weighting is fed by a real run rather than only by tests. `[]` from a non-git root is fine: the
1272
+ // query still ranks, and a memory hint must never be why a phase call fails.
1273
+ files: files ?? changedPaths(root),
1274
+ // P2: the phase in play, so an entry declaring it applies here outranks general guidance.
1275
+ // P3b: the counters, read ONCE here and handed to the ranking — the book is evidence about retrieval,
1276
+ // and where it lives is the caller's business, not the ranker's.
1277
+ feedback: readFeedback(root),
1278
+ phase,
1279
+ })
1280
+ // P3b: record what this phase was shown, so the loop has evidence to settle at closeout.
1281
+ recordInjection(resolved.runDir, selection.shards.map((shard) => ({
1282
+ source: shard.entry.source,
1283
+ title: shard.entry.title,
1284
+ score: shard.score,
1285
+ })), phase)
1286
+ return {
1287
+ runId: resolved.runId,
1288
+ phase,
1289
+ ...phaseRulesFor(phase),
1290
+ memory: selection.injected ? renderMemorySection(selection.shards.map((shard) => shard.entry)) : '',
1291
+ memoryReason: selection.reason,
1292
+ // FU-7 — THE PHASE-ENTRY CALL POINTS. Phase 03 owes a `TDD Mode` decision and phase 05 a
1293
+ // `QA Execution Mode` one; both are surfaced HERE, at the entry the tool already makes, so a
1294
+ // caller does not have to know the workflow's gate vocabulary to be asked the right question.
1295
+ // Omitted entirely once the artifact carries the marker (that IS the once-gate).
1296
+ ...(pending === null ? {} : { ask: { gate: pending, ...buildAskQuestion(pending), artifact: GATE_DEFAULT_ARTIFACT[pending] } }),
1297
+ }
476
1298
  }
477
1299
 
478
1300
  /**
@@ -495,9 +1317,33 @@ export class RecursiveRuntime extends Service {
495
1317
  let scaffoldRoot = root
496
1318
  let worktree: CreateWorktreeResult | undefined
497
1319
  if (opts?.createWorktree) {
498
- worktree = createLinkedWorktree({ repoRoot: root, runId, baseBranch: opts.baseBranch })
1320
+ // T10: a linked-worktree create is the operation the item names as the "hung" one — git
1321
+ // can take a long time on a large repo — and like the linter it CANNOT stop mid-flight,
1322
+ // because `createLinkedWorktree` is synchronous. What the job buys is therefore the same
1323
+ // and is stated plainly: the board SEES it running and the caller can stop WAITING.
1324
+ //
1325
+ // The failure path is unchanged: `createLinkedWorktree` reports `{ ok: false, error }`
1326
+ // rather than throwing, so a refused create still throws exactly what it threw before.
1327
+ const created = await runTracked(this.jobs, {
1328
+ kind: 'worktree',
1329
+ label: 'worktree ' + runId,
1330
+ run: async ({ report, signal }) => {
1331
+ report('creating ' + runId)
1332
+ if (signal.aborted) throw new Error('worktree create cancelled before it started')
1333
+ return createLinkedWorktree({ repoRoot: root, runId, baseBranch: opts.baseBranch })
1334
+ },
1335
+ })
1336
+ if (created.status !== 'completed' || created.value === undefined) {
1337
+ throw new Error(created.detail ?? created.error ?? 'worktree create did not complete')
1338
+ }
1339
+ worktree = created.value
499
1340
  if (!worktree.ok) throw new Error(worktree.error ?? 'worktree create failed')
500
1341
  scaffoldRoot = worktree.worktreeDir
1342
+ // T39: recorded into the SCAFFOLD root, because a worktree run's layer lives INSIDE the
1343
+ // worktree — recording it against the main checkout would file it where no run exists.
1344
+ // A create that FAILED records nothing here: there is no run layer to record into, and
1345
+ // the failure already reaches the caller as the thrown error above.
1346
+ recordJobRun(scaffoldRoot, runId, { kind: 'worktree', label: 'worktree ' + runId, result: created })
501
1347
  }
502
1348
  const runDir = join(scaffoldRoot, '.recursive', 'run', runId)
503
1349
  mkdirSync(runDir, { recursive: true })
@@ -614,6 +1460,16 @@ export class RecursiveRuntime extends Service {
614
1460
  try { this.blockRunToGoal(agent, runId, { code: 'prerequisite-blockers', message }) } catch { /* best-effort */ }
615
1461
  throw new Error('Prerequisite blockers: ' + blockers.map(b => b.artifact + ' (' + b.status + ')').join(', '))
616
1462
  }
1463
+ // T18 — QUIESCENCE. A lock is only sound at a point where nothing is in
1464
+ // flight, so a run with an unresolved delegation refuses to lock and names
1465
+ // what is blocking. Checked AFTER the prerequisite gate deliberately: the
1466
+ // monotonic lock-order rule is the canonical one that the parity goldens and
1467
+ // every existing test know, and a run that is both out of order AND has a
1468
+ // delegation open still reports ordering first, exactly as before this item.
1469
+ const inFlight = pendingWork(runDir)
1470
+ if (inFlight.length > 0) {
1471
+ throw new Error(toolError('PENDING_WORK', inFlight.map((p) => p.detail).join('; ')))
1472
+ }
617
1473
  let content = readFileSync(artifactPath, 'utf8')
618
1474
  const lockedAt = new Date().toISOString().replace(/\.\d{3}Z$/, 'Z')
619
1475
  content = setOrInsertField(content, 'Status', 'LOCKED', ['Phase'])
@@ -639,10 +1495,51 @@ export class RecursiveRuntime extends Service {
639
1495
  private reopenArtifact(root: string, runDir: string, runId: string, artifact: string, artifactPath: string, agent?: { session?: { header?: { cwd?: string } } } | null): LockArtifactResult {
640
1496
  if (!existsSync(artifactPath)) throw new Error('Artifact not found: ' + artifact)
641
1497
  let content = readFileSync(artifactPath, 'utf8')
1498
+
1499
+ // T19 — DETERMINISTIC OPERATION IDENTITY. Reopen is the one genuinely DESTRUCTIVE
1500
+ // operation here: it strips the lock fields and invalidates every downstream
1501
+ // receipt, so running it twice by accident destroys evidence. A retry after a
1502
+ // partial failure was previously indistinguishable from a fresh request.
1503
+ //
1504
+ // The id covers the INPUTS plus the STATE BEING REOPENED, and that third
1505
+ // component is what makes the rule correct rather than merely present:
1506
+ // - a retry after a FAILED reopen still sees the same state, because a failed
1507
+ // reopen leaves the artifact locked — so the retry IS recognised, which is
1508
+ // the case the item exists for;
1509
+ // - a DELIBERATE second reopen after a REPAIR sees a different state, so it is
1510
+ // a different operation and runs — the legitimate workflow stays intact.
1511
+ // Keying on `{runId, artifact}` alone would have made the second reopen a no-op
1512
+ // and quietly broken reopen→fix→lock→reopen.
1513
+ //
1514
+ // The state is the artifact's BODY — its content with the lock fields normalised
1515
+ // out — and NOT its `LockHash`. That distinction is a bug fix, found by T37's
1516
+ // flake hunt: `LockHash` covers `LockedAt`, which is wall-clock truncated to the
1517
+ // SECOND, so two locks of byte-identical content hash differently whenever the
1518
+ // clock crosses a second boundary. Keying on it made this guard fire or not
1519
+ // depending on timing — it failed 6 of 12 measured runs. The body is stable
1520
+ // across a re-lock of identical content and differs as soon as the author
1521
+ // changes anything that matters.
1522
+ const bodyAtEntry = lockHashFromContent(
1523
+ content.replace(/^[ \t]*Status:.*$/m, '').replace(/^[ \t]*LockedAt:.*\n?/m, ''),
1524
+ )
1525
+ const operation = operationId({ act: 'reopen', input: { runId, artifact, body: bodyAtEntry } })
1526
+ if (findOperation(runDir, operation)?.outcome === 'applied') {
1527
+ // This exact operation already completed on this exact state. Reported as a
1528
+ // recognised repeat rather than executed again — a second strip-and-invalidate
1529
+ // would destroy whatever the first one left behind.
1530
+ throw new Error(
1531
+ 'reopen ' + artifact + ' is a recognised repeat of an operation already applied (operation ' +
1532
+ operation + '); refusing to reopen it a second time',
1533
+ )
1534
+ }
1535
+
642
1536
  content = content.replace(/^[ \t]*Status:.*$/m, 'Status: `DRAFT`')
643
1537
  content = content.replace(/^[ \t]*LockedAt:.*\n?/m, '')
644
1538
  content = content.replace(/^[ \t]*LockHash:.*\n?/m, '')
645
1539
  writeFileSync(artifactPath, content, 'utf8')
1540
+ // Record the attempt AFTER it succeeded, best-effort: a failed index write must
1541
+ // never change the outcome of an operation the caller already performed.
1542
+ recordOperation(runDir, { id: operation, act: 'reopen', at: new Date().toISOString().replace(/\.\d{3}Z$/, 'Z'), outcome: 'applied' })
646
1543
  const hadReceipt = invalidateReceipt(runDir, artifact)
647
1544
  const stale = getStaleDownstreamPhases(runDir, artifact)
648
1545
  for (const entry of stale) invalidateReceipt(runDir, entry.artifact)
@@ -675,8 +1572,40 @@ export class RecursiveRuntime extends Service {
675
1572
  if (!existsSync(artifactPath)) {
676
1573
  return { artifact: target, runId, errors: ['Artifact not found'], warnings: [], passed: false }
677
1574
  }
678
- const result = lintRun(root, runId)
679
- const stdout = [...result.errors, ...result.warnings].join('\n')
1575
+ const linted = await runTracked(this.jobs, {
1576
+ kind: 'lint',
1577
+ // The label is what the board shows, so it names the run AND the artifact.
1578
+ label: 'lint ' + runId + ' ' + target,
1579
+ // NO TIMEOUT HERE, deliberately: a deadline would silently convert a slow-but-working
1580
+ // lint into a failure, which is a policy change rather than visibility. The job gives
1581
+ // the board a running entry and a kill switch, which is what the item asks for;
1582
+ // `runTracked`'s `timeoutMs` stays available to a caller who wants a deadline.
1583
+ run: async ({ report, signal }) => {
1584
+ report('linting ' + target)
1585
+ // A synchronous in-process linter cannot be interrupted mid-call, so the signal is
1586
+ // honoured at the BOUNDARY. Stated plainly rather than implied: a kill settles the
1587
+ // JOB — the board and the caller stop waiting — but a hung `lintRun` call itself
1588
+ // runs to completion in this process, because JavaScript cannot pre-empt it.
1589
+ if (signal.aborted) throw new Error('lint cancelled before it started')
1590
+ return lintRun(root, runId)
1591
+ },
1592
+ })
1593
+ // T39: record the run in the RUN LAYER, where the run id is known — see job-log.ts for why
1594
+ // this is a file rather than an event subscription (an event carries a job id, not the run
1595
+ // it belongs to, and parsing run ids back out of labels is a mapping that breaks silently).
1596
+ recordJobRun(root, runId, { kind: 'lint', label: 'lint ' + runId + ' ' + target, result: linted })
1597
+ if (linted.status !== 'completed' || linted.value === undefined) {
1598
+ // A killed or failed job is reported through the EXISTING error path, so the tool's
1599
+ // payload shape does not change and every existing assertion still holds.
1600
+ return {
1601
+ artifact: target,
1602
+ runId,
1603
+ errors: [linted.detail ?? linted.error ?? 'lint did not complete'],
1604
+ warnings: [],
1605
+ passed: false,
1606
+ }
1607
+ }
1608
+ const stdout = [...linted.value.errors, ...linted.value.warnings].join('\n')
680
1609
  return parseLintOutput(stdout, target, runId)
681
1610
  }
682
1611