@try-works/dsh-recursive-mode 0.3.1 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +959 -0
- package/lib/client.js +9 -2
- package/lib/closeout-report.d.ts +113 -0
- package/lib/closeout-standards.d.ts +35 -0
- package/lib/closeout.d.ts +12 -0
- package/lib/commands.d.ts +1 -1
- package/lib/config.d.ts +202 -0
- package/lib/delegation.d.ts +123 -3
- package/lib/enforcement.d.ts +90 -1
- package/lib/errors.d.ts +168 -0
- package/lib/git-context.d.ts +17 -0
- package/lib/guard-log.d.ts +39 -0
- package/lib/handoff.d.ts +29 -0
- package/lib/hooks.d.ts +103 -0
- package/lib/identity.d.ts +61 -0
- package/lib/index.d.ts +32 -12
- package/lib/index.js +9819 -3858
- package/lib/job-log.d.ts +34 -0
- package/lib/jobs-runner.d.ts +105 -0
- package/lib/json-safe.d.ts +33 -0
- package/lib/lock.d.ts +42 -0
- package/lib/memory-feedback.d.ts +52 -0
- package/lib/memory-select.d.ts +78 -0
- package/lib/memory.d.ts +137 -0
- package/lib/model-inventory.d.ts +106 -0
- package/lib/phase-graph.d.ts +111 -0
- package/lib/phase-rules.d.ts +67 -8
- package/lib/plan-gate.d.ts +68 -0
- package/lib/policy-globs.d.ts +222 -0
- package/lib/policy-write.d.ts +42 -0
- package/lib/policy.d.ts +39 -0
- package/lib/recursive_ask.tool.d.ts +88 -0
- package/lib/recursive_closeout.tool.d.ts +1 -1
- package/lib/recursive_delegate.tool.d.ts +22 -0
- package/lib/recursive_preview.tool.d.ts +48 -0
- package/lib/recursive_review.tool.d.ts +28 -0
- package/lib/result-cap.d.ts +70 -0
- package/lib/review-round.d.ts +82 -0
- package/lib/review.d.ts +9 -0
- package/lib/role-route.d.ts +122 -0
- package/lib/router.d.ts +90 -5
- package/lib/runtime.d.ts +252 -12
- package/lib/settlement.d.ts +132 -0
- package/lib/skills-phase.d.ts +71 -0
- package/lib/status.d.ts +53 -1
- package/lib/teams-loop.d.ts +91 -2
- package/lib/training.d.ts +211 -0
- package/lib/ts-lint.d.ts +15 -0
- package/lib/types.d.ts +48 -0
- package/lib/workflow-audit.d.ts +207 -0
- package/package.json +29 -30
- package/preset/recursive.patch.yml +312 -0
- package/scripts/e2e-run.mjs +51 -0
- package/scripts/link-dsh.mjs +233 -0
- package/scripts/live/fake-llm.mjs +150 -0
- package/scripts/live-session-plugin.mjs +179 -0
- package/scripts/live-session-stock.mjs +106 -0
- package/scripts/live-session.mjs +139 -0
- package/src/client/derive.ts +18 -2
- package/src/closeout-report.ts +274 -0
- package/src/closeout-standards.ts +102 -0
- package/src/closeout.ts +39 -2
- package/src/commands.ts +116 -4
- package/src/config.ts +113 -0
- package/src/delegation.ts +336 -18
- package/src/enforcement.ts +262 -72
- package/src/errors.ts +197 -0
- package/src/git-context.ts +33 -2
- package/src/guard-log.ts +134 -0
- package/src/handoff.ts +62 -0
- package/src/hooks.ts +316 -0
- package/src/identity.ts +230 -0
- package/src/index.ts +385 -20
- package/src/job-log.ts +112 -0
- package/src/jobs-runner.ts +222 -0
- package/src/json-safe.ts +75 -0
- package/src/lock.ts +153 -16
- package/src/memory-feedback.ts +185 -0
- package/src/memory-select.ts +187 -0
- package/src/memory.ts +309 -0
- package/src/model-inventory.ts +196 -0
- package/src/phase-graph.ts +191 -0
- package/src/phase-rules.ts +236 -0
- package/src/plan-gate.ts +111 -0
- package/src/policy-globs.ts +636 -0
- package/src/policy-write.ts +210 -0
- package/src/policy.ts +70 -5
- package/src/recursive_ask.tool.ts +276 -0
- package/src/recursive_audit_team.tool.ts +7 -3
- package/src/recursive_closeout.tool.ts +36 -35
- package/src/recursive_delegate.tool.ts +194 -0
- package/src/recursive_init.tool.ts +4 -3
- package/src/recursive_lint.tool.ts +81 -6
- package/src/recursive_lock.tool.ts +21 -4
- package/src/recursive_phase.tool.ts +3 -2
- package/src/recursive_preview.tool.ts +142 -0
- package/src/recursive_review.tool.ts +190 -0
- package/src/recursive_scratch.tool.ts +5 -4
- package/src/recursive_status.tool.ts +3 -2
- package/src/recursive_worktree.tool.ts +6 -5
- package/src/result-cap.ts +130 -0
- package/src/review-round.ts +335 -0
- package/src/review.ts +17 -3
- package/src/role-route.ts +230 -0
- package/src/router.ts +128 -2
- package/src/runtime.ts +968 -39
- package/src/settlement.ts +355 -0
- package/src/skills-phase.ts +143 -0
- package/src/snapshot.ts +39 -8
- package/src/status.ts +209 -4
- package/src/teams-loop.ts +223 -9
- package/src/training.ts +565 -0
- package/src/ts-lint.ts +38 -4
- package/src/types.ts +51 -0
- package/src/workflow-audit.ts +288 -0
- package/scripts/install-preset.cmd +0 -7
- 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
|
-
|
|
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
|
+
}
|