@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
@@ -0,0 +1,335 @@
1
+ /**
2
+ * T36 — the turn-shaped review driver: start → park → resume → repair.
3
+ *
4
+ * WHY A STATE MACHINE AND NOT A LOOP. The harness offers no parent-side
5
+ * await-settlement promise: a continuable child's verdict arrives as a durable user
6
+ * message on a LATER turn. So a review cannot be one function call that waits. It is
7
+ * a sequence of turns, each of which either advances the round or honestly reports
8
+ * that it is waiting — and the child id has to survive between those turns, which is
9
+ * what the state file here is for. A tool call is the agent's only interface, so the
10
+ * driver is designed to be driven once per turn, exactly like `recursive_audit_team`
11
+ * advances the teams board one transition per call.
12
+ *
13
+ * THE FOUR OUTCOMES THAT MATTER:
14
+ * `reviewing` the child has not settled yet — call again later
15
+ * `revised` the round came back REVISE, so a repair instruction went to the
16
+ * SAME child and the driver is waiting for the re-submission
17
+ * `approved` a settled round read APPROVE and the work is accepted
18
+ * `rejected` a settled round read REJECT — the reviewer says do not proceed
19
+ * `unavailable` is the fifth, and it is not a failure of the work: it means the
20
+ * delegation could not run continuably at all, so the repair path does not exist.
21
+ * Naming that is the whole point of T35's `delegationMode`.
22
+ *
23
+ * THE VERDICT IS READ FROM `reply.md`, FAIL-CLOSED. A settlement carries free-form
24
+ * text, and the loop's structured reader treats "no verdict" as APPROVE; a review
25
+ * round must not. `readVerdictFromReply` is used instead, so prose, an empty reply
26
+ * or an off-vocabulary answer becomes REVISE with a repair instruction rather than a
27
+ * false approval.
28
+ */
29
+ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
30
+ import { dirname, join } from 'node:path'
31
+ import { readRepairFromReply, readVerdictFromReply, type ContinuableDelegationLike, type DelegationVerdict } from './delegation.ts'
32
+
33
+ /** The durable record of an in-flight review, so the next turn can resume it. */
34
+ export interface ReviewState {
35
+ delegationId: string
36
+ /** The durable child carrying this review, stable across turns. */
37
+ childId: string
38
+ phase: string
39
+ role: string
40
+ /** How many rounds the loop has observed so far. */
41
+ rounds: number
42
+ startedAt: string
43
+ lastVerdict?: DelegationVerdict
44
+ /**
45
+ * ⚠ FU-17 — whether this round carries a VERDICT (`review`) or a DELIVERABLE (`work`). Optional and defaulted
46
+ * on read, so a state file written before this field existed still means what it always meant.
47
+ */
48
+ kind?: 'review' | 'work'
49
+ }
50
+
51
+ export interface AdvanceReviewOutcome {
52
+ status: 'reviewing' | 'revised' | 'approved' | 'rejected' | 'unavailable' | 'submitted'
53
+ childId?: string
54
+ verdict?: DelegationVerdict
55
+ rounds: number
56
+ /** One sentence for the agent, saying what to do next. */
57
+ message: string
58
+ }
59
+
60
+ /** Where one delegation's review state lives (beside its handoff and replies). */
61
+ export function reviewStatePath(runDir: string, delegationId: string): string {
62
+ return join(runDir, 'subagents', delegationId, 'review-state.json')
63
+ }
64
+
65
+ /** Read an in-flight review, or null when there is none (or it is unreadable). */
66
+ export function readReviewState(runDir: string, delegationId: string): ReviewState | null {
67
+ let raw: string
68
+ try {
69
+ raw = readFileSync(reviewStatePath(runDir, delegationId), 'utf8')
70
+ } catch {
71
+ return null
72
+ }
73
+ try {
74
+ const parsed = JSON.parse(raw) as Partial<ReviewState> | null
75
+ if (parsed === null || typeof parsed !== 'object') return null
76
+ if (typeof parsed.childId !== 'string' || parsed.childId === '') return null
77
+ if (typeof parsed.delegationId !== 'string') return null
78
+ const state: ReviewState = {
79
+ delegationId: parsed.delegationId,
80
+ childId: parsed.childId,
81
+ phase: typeof parsed.phase === 'string' ? parsed.phase : '',
82
+ role: typeof parsed.role === 'string' ? parsed.role : '',
83
+ rounds: typeof parsed.rounds === 'number' && Number.isFinite(parsed.rounds) ? parsed.rounds : 0,
84
+ startedAt: typeof parsed.startedAt === 'string' ? parsed.startedAt : '',
85
+ }
86
+ if (parsed.lastVerdict === 'APPROVE' || parsed.lastVerdict === 'REVISE' || parsed.lastVerdict === 'REJECT') {
87
+ state.lastVerdict = parsed.lastVerdict
88
+ }
89
+ return state
90
+ } catch {
91
+ // A corrupt state file means "no known review", never a crash: the worst case is
92
+ // that a fresh review starts, which the caller can see from the returned status.
93
+ return null
94
+ }
95
+ }
96
+
97
+ export function writeReviewState(runDir: string, state: ReviewState): string {
98
+ const path = reviewStatePath(runDir, state.delegationId)
99
+ mkdirSync(dirname(path), { recursive: true })
100
+ writeFileSync(path, JSON.stringify(state, null, 2) + '\n', 'utf8')
101
+ return path
102
+ }
103
+
104
+ /** Forget a finished review. A finished review must not be resumable by accident. */
105
+ export function clearReviewState(runDir: string, delegationId: string): void {
106
+ const path = reviewStatePath(runDir, delegationId)
107
+ if (existsSync(path)) rmSync(path, { force: true })
108
+ }
109
+
110
+ /**
111
+ * Advance one review by exactly one turn.
112
+ *
113
+ * `delegate` is injected rather than called directly so this driver can be tested
114
+ * against a scripted loop, and so the caller owns how the review is dispatched
115
+ * (which provider, which bundle, which parent). It receives `resumeChild` when a
116
+ * child is already carrying the review — a resumed round must NEVER re-establish
117
+ * the child, which would orphan the one already doing the work.
118
+ */
119
+ export async function advanceReview(input: {
120
+ runDir: string
121
+ delegationId: string
122
+ phase: string
123
+ role: string
124
+ /** Dispatch one turn of the continuable loop, resuming `resumeChild` when given. */
125
+ delegate: (args: { resumeChild?: string }) => Promise<ContinuableDelegationLike>
126
+ /** The child's reply text for the settled round, or '' when it has not written one. */
127
+ readReply: (childId: string) => string
128
+ /**
129
+ * Deliver a repair instruction to the child.
130
+ *
131
+ * NEEDED BECAUSE THE LOOP CAN STOP EARLY ON A MISREAD. The loop's own verdict
132
+ * reader falls back to APPROVE when a result carries no structured verdict, so a
133
+ * child that answered in prose can make the loop believe it was approved and
134
+ * END — leaving no repair sent and the child idle. This driver re-reads the reply
135
+ * fail-closed and disagrees, so it must be able to send the repair itself;
136
+ * otherwise the round would sit in `revised` forever with nothing ever asking the
137
+ * child to fix anything.
138
+ */
139
+ sendRepair?: (childId: string, instruction: string) => Promise<void>
140
+ now?: () => string
141
+ /**
142
+ * ⚠ FU-17 — WHICH KIND OF ROUND THIS IS, and it defaults to `'review'`.
143
+ *
144
+ * The kind is a PARAMETER rather than a forked code path, so every existing caller and spec keeps the exact
145
+ * behaviour it had — that is what makes "review is unchanged" a proof instead of a hope. A `'work'` round is
146
+ * the same round: same child, same state file, same park-and-resume mechanics, same repair delivery. The one
147
+ * difference is what a settlement MEANS: a work round's settlement is a DELIVERABLE, so the driver must not
148
+ * read an APPROVE out of prose that was never a verdict.
149
+ */
150
+ kind?: 'review' | 'work'
151
+ /**
152
+ * The main agent's feedback for a `'work'` round — delivered to the SAME child, which then repairs with its
153
+ * working set intact. Ignored for reviews, whose repair text is read from the reviewer's own reply.
154
+ */
155
+ instruction?: string
156
+ }): Promise<AdvanceReviewOutcome> {
157
+ const { runDir, delegationId } = input
158
+ const kind = input.kind ?? 'review'
159
+ const state = readReviewState(runDir, delegationId)
160
+ const resumeChild = state?.childId
161
+
162
+ let result: ContinuableDelegationLike
163
+ try {
164
+ result = await input.delegate(resumeChild === undefined ? {} : { resumeChild })
165
+ } catch (err) {
166
+ return {
167
+ status: 'unavailable',
168
+ childId: resumeChild,
169
+ rounds: state?.rounds ?? 0,
170
+ message: 'the review could not be dispatched: ' + (err instanceof Error ? err.message : String(err)),
171
+ }
172
+ }
173
+
174
+ // The delegation ran, but not continuably: the review happened without the repair
175
+ // path. Named, never hidden behind a success — this is T35's whole reason.
176
+ if (result.fellBackToOneShot === true) {
177
+ // ⚠ FU-9 — AND SAY WHICH CONDITION, when the delegation kept it. The sentence below used to name two
178
+ // possibilities for a branch that covers three, so a live run's cause was undecidable from its own report.
179
+ return {
180
+ status: 'unavailable',
181
+ rounds: result.rounds.length,
182
+ message: 'the review ran WITHOUT the continuable repair path'
183
+ + (result.reason !== undefined
184
+ ? ' — ' + result.reason + ' — so a failed review cannot be sent back to the child that did the work'
185
+ : ' (no continuable seam or no live parent), so a failed review cannot be sent back to the child that did the work'),
186
+ }
187
+ }
188
+
189
+ const childId = result.childId ?? resumeChild
190
+ const rounds = result.rounds.length
191
+ const repaired = result.rounds.some((round) => (round as { revise?: boolean }).revise === true)
192
+
193
+ if (result.parked === true) {
194
+ // Still working, or a repair was just sent and we are waiting for the re-submission.
195
+ if (childId !== undefined) {
196
+ writeReviewState(runDir, {
197
+ delegationId,
198
+ childId,
199
+ phase: input.phase,
200
+ role: input.role,
201
+ rounds,
202
+ startedAt: state?.startedAt ?? (input.now ?? defaultNow)(),
203
+ ...(repaired ? { lastVerdict: 'REVISE' as const } : {}),
204
+ })
205
+ }
206
+ return repaired
207
+ ? {
208
+ status: 'revised',
209
+ childId,
210
+ verdict: 'REVISE',
211
+ rounds,
212
+ message: 'the review came back REVISE; a repair instruction was delivered to the SAME child '
213
+ + '(childId ' + String(childId) + '). Call recursive_review again after it settles.',
214
+ }
215
+ : {
216
+ status: 'reviewing',
217
+ childId,
218
+ rounds,
219
+ message: 'the review is still running (childId ' + String(childId) + '). No settlement has landed yet; '
220
+ + 'call recursive_review again after it settles.',
221
+ }
222
+ }
223
+
224
+ // ⚠ FU-17 — A WORK ROUND ENDS AT ITS DELIVERABLE, and nothing is read out of it. A review must fail closed
225
+ // (prose is not an approval); a WORK round has no verdict to fail closed ON, so parsing one would manufacture
226
+ // a judgement the child never made. The main agent judges — that is the whole point of delegating work to a
227
+ // child and keeping the decision in the parent.
228
+ if (kind === 'work') {
229
+ const childId = result.childId ?? resumeChild
230
+ const rounds = result.rounds.length
231
+ // The main agent's feedback, when it has any: delivered to the SAME child so the repair keeps its context.
232
+ const feedback = input.instruction?.trim() ?? ''
233
+ if (feedback !== '' && childId !== undefined && input.sendRepair !== undefined) {
234
+ try {
235
+ await input.sendRepair(childId, feedback)
236
+ } catch {
237
+ // The instruction could not be delivered; the child is still kept, so a later call can retry.
238
+ return {
239
+ status: 'unavailable',
240
+ childId,
241
+ rounds,
242
+ message: 'the work round settled, but the feedback could not be delivered to the child ('
243
+ + String(childId) + '); it is kept, so call again to retry',
244
+ }
245
+ }
246
+ return {
247
+ status: 'revised',
248
+ childId,
249
+ rounds,
250
+ message: 'feedback was delivered to the SAME child (' + String(childId)
251
+ + '); call again after its re-submission settles',
252
+ }
253
+ }
254
+ return {
255
+ status: 'submitted',
256
+ childId,
257
+ rounds,
258
+ message: 'the delegated work is in: read the child\'s reply and judge it. Send feedback with the delegate '
259
+ + 'tool if it is not good enough — the same child will repair it.',
260
+ }
261
+ }
262
+
263
+ // A settled round: read the verdict from the child's own reply, FAIL-CLOSED.
264
+ const replyText = childId === undefined ? '' : input.readReply(childId)
265
+ const verdict = readVerdictFromReply(replyText)
266
+
267
+ if (result.accepted && verdict === 'APPROVE') {
268
+ clearReviewState(runDir, delegationId)
269
+ return { status: 'approved', childId, verdict, rounds, message: 'the review passed: verdict APPROVE.' }
270
+ }
271
+ if (verdict === 'REJECT') {
272
+ clearReviewState(runDir, delegationId)
273
+ return {
274
+ status: 'rejected',
275
+ childId,
276
+ verdict,
277
+ rounds,
278
+ message: 'the reviewer rejected the work: verdict REJECT. Do not proceed; resolve it before locking.',
279
+ }
280
+ }
281
+
282
+ // Anything else — an unreadable reply, a stopped child, a REVISE the loop ended on —
283
+ // is a REPAIR, not a pass: keep the state so the child can be told to fix it, and
284
+ // TELL IT, since the loop may have stopped believing it was approved.
285
+ if (childId !== undefined) {
286
+ writeReviewState(runDir, {
287
+ delegationId,
288
+ childId,
289
+ phase: input.phase,
290
+ role: input.role,
291
+ rounds,
292
+ startedAt: state?.startedAt ?? (input.now ?? defaultNow)(),
293
+ lastVerdict: 'REVISE',
294
+ })
295
+ }
296
+ let repairSent = false
297
+ if (childId !== undefined && input.sendRepair !== undefined) {
298
+ try {
299
+ await input.sendRepair(childId, readRepairFromReply(replyText))
300
+ repairSent = true
301
+ } catch {
302
+ // The repair could not be delivered; the child is still kept, so a later turn
303
+ // can retry. Reported below rather than swallowed.
304
+ }
305
+ }
306
+ // BOTH facts are always stated: WHY the round is a repair, and what happened to
307
+ // the child — because a caller that does not know the child was kept cannot know
308
+ // whether a later turn can still repair it.
309
+ const why = result.accepted
310
+ ? 'the reviewer approved, but the reply could not be read as an APPROVE; treated as REVISE so it is not '
311
+ + 'accepted on a technicality.'
312
+ : 'the round ended without an approval.'
313
+ const what = repairSent
314
+ ? ' A repair instruction was sent to the SAME child.'
315
+ : ' The child is kept so the repair can be sent to it.'
316
+ // ⚠ FU-9 — AND SAY WHY THE DELEGATION SAYS SO, when it says anything. `rounds: 0` plus a canned sentence hid
317
+ // one of THREE named failure branches (a caught error, "no continuable interrupt seam", "REVISE verdict
318
+ // without a repair instruction") for two rounds of live runs. The reason was already in the result and this
319
+ // message simply did not print it — the ninth surface in this sequence that could not say what the code knew.
320
+ // ADDITIVE: a result carrying no reason produces exactly the message it always did, so no caller changes.
321
+ const detail = result.reason !== undefined && result.reason !== ''
322
+ ? ' The delegation reported: ' + result.reason + '.'
323
+ : ''
324
+ return {
325
+ status: 'revised',
326
+ childId,
327
+ verdict: 'REVISE',
328
+ rounds,
329
+ message: why + what + detail,
330
+ }
331
+ }
332
+
333
+ function defaultNow(): string {
334
+ return new Date().toISOString().replace(/\.\d{3}Z$/, 'Z')
335
+ }
package/src/review.ts CHANGED
@@ -33,6 +33,15 @@ export interface ReviewBundleInput {
33
33
  addenda?: string[]
34
34
  priorEvidence?: string[]
35
35
  memoryRefs?: string[]
36
+ /**
37
+ * T14 — the RETRIEVED prior-run memory, rendered, written into the bundle body.
38
+ *
39
+ * ⚠ `memoryRefs` above is the slot the bundle already had, and nothing ever filled it — so the
40
+ * memory section a reviewer was supposed to see did not exist. The two are complementary, not
41
+ * duplicates: `memoryRefs` is WHERE the memory lives (traceability), and this is the CONTENT,
42
+ * because a reviewer cannot cite a path whose contents it was never given.
43
+ */
44
+ memory?: string
36
45
  changedFiles?: string[]
37
46
  }
38
47
 
@@ -79,8 +88,7 @@ function pathList(title: string, values: string[]): string[] {
79
88
  * when a path would escape the workspace root (fail loud, never a silent
80
89
  * bundle).
81
90
  */
82
- export function buildReviewBundle(input: ReviewBundleInput): ReviewBundleResult {
83
- const { root, runId, phase, role } = input
91
+ export function buildReviewBundle(input: ReviewBundleInput): ReviewBundleResult { const { root, runId, phase, role } = input
84
92
  const runDir = resolveUnderRoot(root, '.recursive/run/' + runId)
85
93
  if (!existsSync(runDir)) {
86
94
  throw new Error('Run directory does not exist: ' + runId)
@@ -162,7 +170,13 @@ export function buildReviewBundle(input: ReviewBundleInput): ReviewBundleResult
162
170
  ]
163
171
 
164
172
  const markdown = lines.join('\n')
165
- writeFileSync(bundlePath, markdown, 'utf8')
173
+ // T14: the retrieved prior-run memory goes INTO the bundle, because the prompt references the
174
+ // bundle by PATH — a section appended to the prompt instead would sit outside the document the
175
+ // reviewer is told to read, and a reviewer that cites memory must be citing what it was given.
176
+ const withMemory = input.memory === undefined || input.memory.trim() === ''
177
+ ? markdown
178
+ : markdown + '\n\n## Prior-run memory\n\n' + input.memory.trim() + '\n'
179
+ writeFileSync(bundlePath, withMemory, 'utf8')
166
180
 
167
181
  return {
168
182
  bundlePath,
@@ -0,0 +1,230 @@
1
+ /**
2
+ * T9 → FU-19 — per-role model routing, and now a full provider/model ladder.
3
+ *
4
+ * WHY. A delegation would otherwise run on whatever model the child inherits: the reviewer that must be rigorous
5
+ * and the repairer that will iterate many times become the same choice by accident. The item asks for them to
6
+ * differ — and FU-19 extends that to a general default, per-phase overrides, and a per-call choice.
7
+ *
8
+ * ⚠ THE PARAGRAPH THAT USED TO BE HERE WAS TRUE WHEN WRITTEN AND IS NOW FALSE, so it is replaced rather than
9
+ * left to mislead. It said the plugin "cannot by itself force a child onto a model", because
10
+ * `SubagentStartRequestLike` had no model field and model selection was the host's concern. Both halves changed:
11
+ * the harness accepts `agentOptions` on a start, and `delegateReview` now sets `request.agentOptions = { model }`
12
+ * — but ONLY for a provider that declares the `agentOptions` capability, because the harness REJECTS such a start
13
+ * otherwise.
14
+ *
15
+ * SO WHAT THIS MODULE PROMISES TODAY, precisely:
16
+ * - it resolves WHICH provider and model a child should get, from the ladder in `resolveSubagentTarget`, and it
17
+ * reports which level chose each value;
18
+ * - the caller (not this module) applies them, because applying is a decision with consequences — a model on a
19
+ * provider that cannot take overrides is NOT applied and is REPORTED rather than silently dropped;
20
+ * - and the model is checked against what DSH actually has (`model-inventory.ts`), with an `unverified` verdict
21
+ * when there is no inventory to ask, so a choice is never silently approved OR silently replaced.
22
+ *
23
+ * A module that claimed more than that would be lying about where the choice is made. The comment above is the
24
+ * second half of that lesson: a stale rationale is how a working feature gets deleted by accident.
25
+ */
26
+ import type { RouterPolicy } from './router.ts'
27
+
28
+ /** What kind of work a role does — the distinction the item is about. */
29
+ export type RoleKind = 'review' | 'repair' | 'unknown'
30
+
31
+ /**
32
+ * The roles this plugin scaffolds, classified by the JOB rather than by seniority.
33
+ *
34
+ * Review work wants rigour and reads carefully once; repair work wants many cheap iterations.
35
+ * An unrecognised role is `unknown` rather than silently assumed to be a reviewer: assuming
36
+ * would put an unreviewed role on the expensive path (or a reviewer on the cheap one) with no
37
+ * signal anywhere, which is the sort of quiet mismatch this item exists to remove.
38
+ */
39
+ const REVIEW_ROLES: readonly string[] = ['code-reviewer', 'memory-auditor', 'auditor', 'reviewer']
40
+ const REPAIR_ROLES: readonly string[] = ['implementer', 'repairer', 'fixer']
41
+
42
+ export function roleKindOf(role: string): RoleKind {
43
+ const normalised = role.trim().toLowerCase()
44
+ if (REVIEW_ROLES.includes(normalised)) return 'review'
45
+ if (REPAIR_ROLES.includes(normalised)) return 'repair'
46
+ return 'unknown'
47
+ }
48
+
49
+ /** One role's resolved route: what kind of work it is, and which model the policy names. */
50
+ export interface RoleRoute {
51
+ role: string
52
+ kind: RoleKind
53
+ /** The policy's model for this role, or null when it names none. */
54
+ model: string | null
55
+ /** Where the model came from — `unset` is a fact worth carrying, not an absence. */
56
+ source: 'policy' | 'unset'
57
+ /** One self-sufficient sentence, including the unknown-role case. */
58
+ reason: string
59
+ }
60
+
61
+ /**
62
+ * Resolve a role's route from the policy.
63
+ *
64
+ * Never throws and never invents a model: an unknown role, an unconfigured role and a role whose
65
+ * policy entry names no model each produce a route with a reason that says which it is. A caller
66
+ * that wants to refuse an unknown role can; a caller that wants to proceed knows exactly what it
67
+ * is proceeding with.
68
+ */
69
+ export function routeForRole(role: string, policy: RouterPolicy): RoleRoute {
70
+ const kind = roleKindOf(role)
71
+ const configured = policy.role_routes?.[role]
72
+ const model = typeof configured?.model === 'string' && configured.model.trim() !== '' ? configured.model : null
73
+ const source: RoleRoute['source'] = model === null ? 'unset' : 'policy'
74
+
75
+ if (kind === 'unknown') {
76
+ return {
77
+ role,
78
+ kind,
79
+ model,
80
+ source,
81
+ reason: 'role ' + role + ' is not a role this plugin classifies, so no review/repair expectation is asserted for it'
82
+ + (model === null ? ' and the policy names no model.' : ' (the policy does name model ' + model + ').'),
83
+ }
84
+ }
85
+ if (model === null) {
86
+ return {
87
+ role,
88
+ kind,
89
+ model,
90
+ source,
91
+ reason: kind + ' role ' + role + ' has no model in the policy, so it inherits the child default'
92
+ + ' — the review/repair split is NOT in effect for it.',
93
+ }
94
+ }
95
+ return {
96
+ role,
97
+ kind,
98
+ model,
99
+ source,
100
+ reason: kind + ' role ' + role + ' resolves to model ' + model + ' from the policy.',
101
+ }
102
+ }
103
+
104
+ /**
105
+ * The model the caller should use for a role, or null to inherit.
106
+ *
107
+ * A one-line convenience for a caller that only needs the value; {@link routeForRole} is what a
108
+ * caller should use when it wants to SAY why.
109
+ */
110
+ export function modelForRole(role: string, policy: RouterPolicy): string | null {
111
+ return routeForRole(role, policy).model
112
+ }
113
+
114
+ /**
115
+ * ⚠ FU-19 — WHICH PROVIDER AND MODEL A DELEGATED CHILD WOULD ACTUALLY GET, AND WHO CHOSE EACH.
116
+ *
117
+ * ## The words, because they were doing too much work
118
+ *
119
+ * "Provider" is the thing that CREATES a child. In this harness it is resolved by a tier ladder, and the tiers
120
+ * have names that are not self-explanatory:
121
+ *
122
+ * - **native** — a provider the harness runs ITSELF, in-process. It is what `ctx.subagents` serves; in the
123
+ * session I measured it advertised exactly two: `spawn` and `fork`. "Native" means "the harness's own",
124
+ * as opposed to something it shells out to.
125
+ * - **external-cli** — a registered provider that drives a SEPARATE installed program (Codex, Claude Code and
126
+ * friends). A child still exists, but the work happens in another process the user installed.
127
+ * - **self-audit** — no child is created at all: the main agent reviews its own work. This is the honest
128
+ * fallback, and it is NAMED rather than hidden.
129
+ * - **local-controller** — the host's own controller. I have not measured this tier's behaviour in a live
130
+ * session, so I will not describe it further here.
131
+ *
132
+ * ## Why "a provider but no model" — the part my earlier wording got wrong
133
+ *
134
+ * A MODEL CAN BE LEFT UNSET ON PURPOSE, AND THAT IS NOT THE SAME AS "NO MODEL". Absent means **inherited**: the
135
+ * plugin sends no `agentOptions.model` at all, and the child runs on whatever the session/provider default is.
136
+ * That is the measured behaviour — `delegation.ts` forwards `agentOptions` only when the caller supplies them,
137
+ * and `modelForRole` returns null precisely to mean "inherit".
138
+ *
139
+ * So the floor of the ladder is not a gap. A child cannot exist without a provider, so a provider is always
140
+ * resolved (by the ladder, or by the user); a model is only sent when someone actually chose one, because
141
+ * inventing one would silently override the session's own setting — and overriding a user's session default
142
+ * without being asked is worse than inheriting it.
143
+ *
144
+ * ## The precedence, and the labels it reports
145
+ *
146
+ * per-call override → phase route → role route → general default → inherit
147
+ *
148
+ * Every value carries the label of the level that produced it, so "why did this child run on that model?" is
149
+ * answerable from the decision alone rather than by reading this function.
150
+ */
151
+ export interface SubagentTarget {
152
+ role: string
153
+ /** The provider that would create the child, or null when nothing resolved one. */
154
+ provider: string | null
155
+ /** The model to ask for, or null meaning INHERIT — do not send `agentOptions.model` at all. */
156
+ model: string | null
157
+ /** Plain-language provenance for each value: which level chose it. */
158
+ chosen: { provider: string; model: string }
159
+ /** One sentence a reader can act on. */
160
+ reason: string
161
+ }
162
+
163
+ /** Which level produced a value. `inherit` is a decision, not an absence. */
164
+ export type ChoiceSource = 'per-call' | 'phase' | 'role' | 'general' | 'ladder' | 'inherit'
165
+
166
+ function nonEmpty(value: string | null | undefined): string | null {
167
+ return typeof value === 'string' && value.trim() !== '' ? value.trim() : null
168
+ }
169
+
170
+ export function resolveSubagentTarget(input: {
171
+ role: string
172
+ /** The phase the delegation is for, when it is known — the narrowest configured level. */
173
+ phase?: string
174
+ policy: RouterPolicy
175
+ /** A per-call override from the tool: wins over every configured level. */
176
+ override?: { provider?: string | null; model?: string | null }
177
+ /** What the tier ladder resolved. Used when no level named a provider, and labelled as the ladder's. */
178
+ ladderProvider?: string | null
179
+ }): SubagentTarget {
180
+ const { role, policy } = input
181
+ const phase = input.phase?.trim() ?? ''
182
+ const phaseRoute = phase === '' ? undefined : policy.phase_routes?.[phase]
183
+ const roleRoute = policy.role_routes?.[role]
184
+ const general = policy.defaults.subagent
185
+
186
+ const pick = (value: string | null | undefined, source: ChoiceSource): { value: string | null; source: ChoiceSource } =>
187
+ ({ value: nonEmpty(value), source })
188
+
189
+ const providerChoice = [
190
+ pick(input.override?.provider, 'per-call'),
191
+ pick(phaseRoute?.provider, 'phase'),
192
+ pick(roleRoute?.provider, 'role'),
193
+ pick(general?.provider, 'general'),
194
+ pick(input.ladderProvider, 'ladder'),
195
+ ].find((candidate) => candidate.value !== null) ?? { value: null, source: 'inherit' as ChoiceSource }
196
+
197
+ const modelChoice = [
198
+ pick(input.override?.model, 'per-call'),
199
+ pick(phaseRoute?.model, 'phase'),
200
+ pick(roleRoute?.model, 'role'),
201
+ pick(general?.model, 'general'),
202
+ ].find((candidate) => candidate.value !== null) ?? { value: null, source: 'inherit' as ChoiceSource }
203
+
204
+ const where = (source: ChoiceSource): string => {
205
+ switch (source) {
206
+ case 'per-call': return 'this call'
207
+ case 'phase': return 'the phase ' + phase + ' override'
208
+ case 'role': return 'the ' + role + ' role route'
209
+ case 'general': return 'the general subagent default'
210
+ case 'ladder': return 'the provider ladder'
211
+ default: return 'nothing — it is inherited'
212
+ }
213
+ }
214
+
215
+ const providerText = providerChoice.value === null
216
+ ? 'no provider was resolved'
217
+ : providerChoice.value + ' (from ' + where(providerChoice.source) + ')'
218
+ const modelText = modelChoice.value === null
219
+ ? 'no model is sent, so the child inherits the session default'
220
+ : modelChoice.value + ' (from ' + where(modelChoice.source) + ')'
221
+
222
+ return {
223
+ role,
224
+ provider: providerChoice.value,
225
+ model: modelChoice.value,
226
+ chosen: { provider: providerChoice.source, model: modelChoice.source },
227
+ reason: 'child for role ' + role + (phase === '' ? '' : ' in phase ' + phase) + ': provider ' + providerText
228
+ + '; model ' + modelText + '.',
229
+ }
230
+ }