iterate-plugin 3.5.5 → 3.5.7
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/dist/config-loader.js +22 -2
- package/dist/config-write.js +28 -0
- package/dist/evidence.js +5 -1
- package/dist/meta-review.js +5 -1
- package/dist/review.js +27 -9
- package/dist/skill-prompt.js +86 -27
- package/dist/tools/checkpoint.js +11 -4
- package/dist/tools/decision-log.js +11 -0
- package/dist/tools/history.js +1 -1
- package/dist/tools/prune.js +29 -6
- package/dist/tools/transcript.js +17 -6
- package/dist/transcript.js +78 -2
- package/lib/client.js +63 -31
- package/lib/parse.js +28 -0
- package/package.json +12 -8
- package/src/client/index.ts +45 -24
- package/src/config-loader.ts +20 -2
- package/src/config-write.ts +32 -0
- package/src/evidence.ts +5 -1
- package/src/meta-review.ts +5 -1
- package/src/review.ts +40 -8
- package/src/skill-prompt.ts +86 -27
- package/src/tools/checkpoint.ts +12 -4
- package/src/tools/decision-log.ts +12 -0
- package/src/tools/history.ts +1 -1
- package/src/tools/prune.ts +28 -6
- package/src/tools/transcript.ts +16 -5
- package/src/transcript.ts +85 -2
- package/src/types.ts +9 -4
package/src/review.ts
CHANGED
|
@@ -162,13 +162,17 @@ export function aggregateRounds(
|
|
|
162
162
|
// Clamp the allocation bound so a hostile round number cannot OOM the tool.
|
|
163
163
|
const effectiveMax = Math.min(maxRound, Math.max(1, roundCap * 2))
|
|
164
164
|
|
|
165
|
-
const findingsByRound: number[] =
|
|
166
|
-
for (
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
165
|
+
const findingsByRound: number[] = new Array(effectiveMax).fill(0)
|
|
166
|
+
for (const key of firstRoundByKey.keys()) {
|
|
167
|
+
const firstRound = firstRoundByKey.get(key) ?? 1
|
|
168
|
+
// Fold rounds BEYOND the allocation bound into the highest slot instead of
|
|
169
|
+
// dropping them: a finding first seen in an over-cap round was previously
|
|
170
|
+
// invisible to `findingsByRound`, so a run whose final rounds exceeded the
|
|
171
|
+
// cap looked "converged" even though a real round found new issues. Folding
|
|
172
|
+
// keeps the counts truthful (the sum still equals totalFindings) while the
|
|
173
|
+
// memory bound stays fixed.
|
|
174
|
+
const idx = Math.min(firstRound, effectiveMax) - 1
|
|
175
|
+
findingsByRound[idx] = (findingsByRound[idx] || 0) + 1
|
|
172
176
|
}
|
|
173
177
|
|
|
174
178
|
return { findings: dedupeFindings(merged), findingsByRound, firstRoundByKey }
|
|
@@ -282,8 +286,13 @@ export function buildReviewReport(input: {
|
|
|
282
286
|
for (const r of filteredRounds) {
|
|
283
287
|
if (typeof r.round === 'number' && r.round > lastRound) lastRound = r.round
|
|
284
288
|
}
|
|
289
|
+
// `lastRound` can exceed the array length when over-cap rounds were FOLDED
|
|
290
|
+
// into the last slot (see aggregateRounds). Read the same clamped index
|
|
291
|
+
// computeConvergence uses, so a real over-cap round is never mis-read as 0
|
|
292
|
+
// new findings (which would falsely report "converged").
|
|
293
|
+
const idx = Math.min(lastRound, findingsByRound.length) - 1
|
|
285
294
|
const lastRoundCount =
|
|
286
|
-
lastRound > 0 ? (findingsByRound[
|
|
295
|
+
lastRound > 0 && idx >= 0 ? (findingsByRound[idx] ?? 0) : 0
|
|
287
296
|
const converged = filteredRounds.length > 0 && lastRoundCount === 0
|
|
288
297
|
|
|
289
298
|
// Attach the normal-mode fix count to the summary (dry-run leaves it absent).
|
|
@@ -630,6 +639,12 @@ export function reviewerTaskPrompt(input: {
|
|
|
630
639
|
* every dimension reviewer inspects/considers them before judging.
|
|
631
640
|
*/
|
|
632
641
|
attachments?: ReviewAttachment[]
|
|
642
|
+
/**
|
|
643
|
+
* Configured reasoning effort ('low' | 'medium' | 'high') to apply to this
|
|
644
|
+
* review pass. Injected as a directive so the harness can honor it when
|
|
645
|
+
* running each reviewer subagent. Omitted → follow the provider default.
|
|
646
|
+
*/
|
|
647
|
+
effort?: 'low' | 'medium' | 'high'
|
|
633
648
|
}): string {
|
|
634
649
|
const parts: string[] = []
|
|
635
650
|
parts.push(
|
|
@@ -637,6 +652,9 @@ export function reviewerTaskPrompt(input: {
|
|
|
637
652
|
`Goal: ${input.goal}`,
|
|
638
653
|
`Scope: ${input.scope === 'full' ? 'entire codebase' : 'changed files only'}.`,
|
|
639
654
|
)
|
|
655
|
+
if (input.effort) {
|
|
656
|
+
parts.push(`Reasoning effort for this review pass: ${input.effort}.`)
|
|
657
|
+
}
|
|
640
658
|
if (input.focus) {
|
|
641
659
|
parts.push(`FOCUS: ${input.focus}`)
|
|
642
660
|
}
|
|
@@ -744,6 +762,12 @@ export function buildReviewPlan(input: {
|
|
|
744
762
|
fallbackToFull: boolean
|
|
745
763
|
/** The attachments threaded into every reviewer prompt (empty when none). */
|
|
746
764
|
attachments: ReviewAttachment[]
|
|
765
|
+
/**
|
|
766
|
+
* Configured reasoning effort carried on the plan for the orchestrator to
|
|
767
|
+
* honor (null when unset → provider default). Also injected as a directive
|
|
768
|
+
* into every dimension's reviewer prompt.
|
|
769
|
+
*/
|
|
770
|
+
reasoningEffort: 'low' | 'medium' | 'high' | null
|
|
747
771
|
} {
|
|
748
772
|
// Defensive reads: a malformed config (e.g. `dimensions` as a non-array, or
|
|
749
773
|
// `review`/`atomic` missing) must degrade to sane defaults instead of
|
|
@@ -751,6 +775,12 @@ export function buildReviewPlan(input: {
|
|
|
751
775
|
const language = input.config.language === 'zh' ? 'Chinese (中文)' : 'English'
|
|
752
776
|
const goal = input.config.goal ?? ''
|
|
753
777
|
const configuredScope = input.config.review?.scope ?? 'full'
|
|
778
|
+
const reasoningEffort =
|
|
779
|
+
input.config.reasoning_effort === 'low' ||
|
|
780
|
+
input.config.reasoning_effort === 'medium' ||
|
|
781
|
+
input.config.reasoning_effort === 'high'
|
|
782
|
+
? input.config.reasoning_effort
|
|
783
|
+
: null
|
|
754
784
|
const dimensions = Array.isArray(input.config.dimensions) ? input.config.dimensions : []
|
|
755
785
|
const maxLines = input.config.atomic?.max_lines ?? 20
|
|
756
786
|
const changedFiles = Array.isArray(input.changedFiles) ? input.changedFiles : []
|
|
@@ -816,6 +846,7 @@ export function buildReviewPlan(input: {
|
|
|
816
846
|
scopeFiles: batch,
|
|
817
847
|
focus: focusMap.get(d),
|
|
818
848
|
attachments,
|
|
849
|
+
effort: reasoningEffort ?? undefined,
|
|
819
850
|
}),
|
|
820
851
|
findingsSchema: findingsSchema(),
|
|
821
852
|
})
|
|
@@ -832,5 +863,6 @@ export function buildReviewPlan(input: {
|
|
|
832
863
|
changedFiles: effectiveChangedOnly ? changedFiles : [],
|
|
833
864
|
fallbackToFull,
|
|
834
865
|
attachments,
|
|
866
|
+
reasoningEffort,
|
|
835
867
|
}
|
|
836
868
|
}
|
package/src/skill-prompt.ts
CHANGED
|
@@ -20,7 +20,7 @@ You have the iterate plugin installed, which registers these tools:
|
|
|
20
20
|
- \`iterate_diff\` — show the accumulated diff for a fixed file (vs its original backup) or a per-file summary of all fixes
|
|
21
21
|
- \`iterate_rollback\` — revert a fix by id: restore the file from its backup, remove the fix from the registry, log a \`revert\` entry. Use when a round's validation fails
|
|
22
22
|
- \`iterate_checkpoint\` — save / load / clear an iteration checkpoint (\`.iterate/checkpoint.json\`) so a long run can resume where it left off
|
|
23
|
-
- \`iterate_status\` — summarize the current run: mode, round, fixes applied, architectural remaining, decision-log size, checkpoint presence, and whether the run was interrupted (a checkpoint
|
|
23
|
+
- \`iterate_status\` — summarize the current run: mode, round, fixes applied, architectural remaining, decision-log size, checkpoint presence, and whether the run was interrupted (a checkpoint on disk with no newer decision-log activity means the previous run was interrupted and can be resumed)
|
|
24
24
|
- \`iterate_history\` — inspect the runtime state in detail: decision-log entries and applied fixes (optionally scoped to a round or a fixed file)
|
|
25
25
|
- \`iterate_prune\` — remove stale runtime artifacts (\`.iterate/\` entries). Defaults to a read-only dry-run that reports what WOULD be removed; pass \`dryRun:false\` to actually prune.
|
|
26
26
|
- \`iterate_transcript\` — runtime observatory file (\`.iterate/transcript.json\`). \`read\` fetches the persisted manifest including any steering \`nudge\` for this run's reviewers; \`capture\` (call once after the final report) persists the per-reviewer threads, convergence trend, findings, fixes, checkpoint, and timeline so the client observatory panel reflects the run; \`nudge\` sets/clears steering text the next round's reviewers read. Purely local, never touches source files.
|
|
@@ -81,6 +81,13 @@ const maxRounds = plan.maxReviewRounds
|
|
|
81
81
|
const knownIntentional = (plan.knownIntentional || []) // config personalization filter, applied in aggregate
|
|
82
82
|
let known = [] // cumulative DEDUPED findings fed back to reviewers
|
|
83
83
|
const rounds = [] // raw per-round findings
|
|
84
|
+
// Tracks whether the LAST executed round produced usable reviewer output. A
|
|
85
|
+
// round where every reviewer subagent failed (or output stayed schema-invalid
|
|
86
|
+
// after retries) is INCONCLUSIVE — it must never make the run look converged.
|
|
87
|
+
let lastRoundOk = true
|
|
88
|
+
// Slot index of the current round within this run's rounds array. A resumed
|
|
89
|
+
// run starts at startRound > 1, so the round NUMBER is not a valid array index.
|
|
90
|
+
let roundSlot = -1
|
|
84
91
|
|
|
85
92
|
phase('transcript')
|
|
86
93
|
// Read any steering nudge written (via iterate_transcript nudge) for this run's reviewers.
|
|
@@ -93,9 +100,14 @@ const steering = transRead && typeof transRead.nudge === 'string' && transRead.n
|
|
|
93
100
|
phase('review')
|
|
94
101
|
for (let r = 1; r <= maxRounds; r++) {
|
|
95
102
|
log('round ' + r + ' of ' + maxRounds + ' — finding NEW issues only')
|
|
103
|
+
roundSlot = rounds.length
|
|
96
104
|
let agg = null
|
|
97
105
|
let schemaInvalid = false
|
|
98
106
|
let attempts = 0
|
|
107
|
+
// Declared OUTSIDE the do-block: it is read after the retry loop exits, and a
|
|
108
|
+
// block-scoped const here raised ReferenceError at runtime (crashing both
|
|
109
|
+
// canonical workflows after the first round).
|
|
110
|
+
let reviewersOk = true
|
|
99
111
|
do {
|
|
100
112
|
attempts += 1
|
|
101
113
|
// Schema validation retry: on the 2nd+ pass, nudge reviewers toward strict JSON.
|
|
@@ -118,6 +130,12 @@ for (let r = 1; r <= maxRounds; r++) {
|
|
|
118
130
|
JSON.stringify(known) + nudge + '\\nReturn the findings JSON object.'
|
|
119
131
|
return agent(base + extra, Object.assign({ label: 'review:' + dim + ':r' + r, schema: meta.findingsSchema }, backend))
|
|
120
132
|
}))
|
|
133
|
+
// A per-dimension reviewer subagent resolves \`null\` on child failure; the
|
|
134
|
+
// aggregate below would report an all-failed round as an empty round, and
|
|
135
|
+
// the convergence math would then read "0 new findings". An all-failed
|
|
136
|
+
// round is NOT a clean pass — it is inconclusive, so the convergence gate
|
|
137
|
+
// below refuses it before any "converged" signal fires.
|
|
138
|
+
reviewersOk = raw.length > 0 ? raw.some(x => x !== null && typeof x === 'object') : true
|
|
121
139
|
const thisRound = {
|
|
122
140
|
round: r,
|
|
123
141
|
findings: [].concat(...raw.map(x => x && x.findings ? x.findings : [])),
|
|
@@ -125,7 +143,7 @@ for (let r = 1; r <= maxRounds; r++) {
|
|
|
125
143
|
// gate can compare self-reported reads against the assigned inventory.
|
|
126
144
|
readFiles: [].concat(...raw.map(x => x && Array.isArray(x.readFiles) ? x.readFiles : [])),
|
|
127
145
|
}
|
|
128
|
-
if (rounds.length
|
|
146
|
+
if (roundSlot < rounds.length) rounds[roundSlot] = thisRound; else rounds.push(thisRound)
|
|
129
147
|
// Deterministic aggregate: cross-round dedupe + known_intentional filter + severity sort.
|
|
130
148
|
agg = await agent(
|
|
131
149
|
'Call iterate_review({operation:"aggregate", mode:"dry-run", rounds:' + JSON.stringify(rounds) + ', maxReviewRounds:' + maxRounds + ', knownIntentional:' + JSON.stringify(knownIntentional) + '}) and return the report JSON.',
|
|
@@ -145,23 +163,30 @@ for (let r = 1; r <= maxRounds; r++) {
|
|
|
145
163
|
// final summary can explain WHY the round was inconclusive.
|
|
146
164
|
log('round ' + r + ' output STILL schema-invalid after 3 attempts — marking round inconclusive (NOT converged)')
|
|
147
165
|
}
|
|
166
|
+
const roundUnusable = schemaInvalid || !reviewersOk
|
|
167
|
+
if (roundUnusable) lastRoundOk = false
|
|
148
168
|
// Feed the DEDUPED + already-filtered set back (not raw findings) so the known
|
|
149
|
-
// list stays bounded and reviewers never see the same issue twice.
|
|
150
|
-
|
|
169
|
+
// list stays bounded and reviewers never see the same issue twice. Never feed
|
|
170
|
+
// an unusable round's empty result — it would erase the still-valid known list
|
|
171
|
+
// and let reviewers re-report everything next round.
|
|
172
|
+
if (!roundUnusable && agg && agg.report && Array.isArray(agg.report.findings)) known = agg.report.findings
|
|
151
173
|
// A round counts as "converged" ONLY when the aggregate accepted it
|
|
152
|
-
// (schema-valid) AND
|
|
153
|
-
//
|
|
154
|
-
//
|
|
155
|
-
//
|
|
156
|
-
|
|
174
|
+
// (schema-valid) AND its reviewers actually returned output AND it genuinely
|
|
175
|
+
// found zero NEW findings. If reviewers all failed or their output stayed
|
|
176
|
+
// schema-invalid after retries, the round is INCONCLUSIVE — never report an
|
|
177
|
+
// empty-but-broken round as converged (an invalid/failed round would
|
|
178
|
+
// otherwise masquerade as a clean pass and stop the run early).
|
|
179
|
+
if (!roundUnusable && !schemaInvalid) {
|
|
157
180
|
const newCount =
|
|
158
181
|
agg && agg.report && agg.report.convergence
|
|
159
182
|
? agg.report.convergence.findingsByRound[r - 1]
|
|
160
183
|
: thisRound.findings.length
|
|
161
184
|
if (newCount === 0) {
|
|
162
185
|
log('round ' + r + ' found 0 new findings — converged')
|
|
186
|
+
lastRoundOk = true
|
|
163
187
|
break
|
|
164
188
|
}
|
|
189
|
+
lastRoundOk = true
|
|
165
190
|
}
|
|
166
191
|
}
|
|
167
192
|
|
|
@@ -197,8 +222,13 @@ return {
|
|
|
197
222
|
mode: 'dry-run',
|
|
198
223
|
goal: report.goal,
|
|
199
224
|
rounds: rounds.length,
|
|
200
|
-
|
|
201
|
-
|
|
225
|
+
// The aggregate's convergence flag is only trustworthy when the last round
|
|
226
|
+
// was usable — an all-failed/schema-invalid final round looks like "0 new"
|
|
227
|
+
// to the convergence math but is an inconclusive run, never a clean pass.
|
|
228
|
+
converged: report.convergence.converged && lastRoundOk,
|
|
229
|
+
stoppedReason: report.convergence.converged && !lastRoundOk
|
|
230
|
+
? 'inconclusive'
|
|
231
|
+
: report.convergence.stoppedReason,
|
|
202
232
|
findingsByRound: report.convergence.findingsByRound,
|
|
203
233
|
totalFindings: report.summary.totalFindings,
|
|
204
234
|
bySeverity: { critical: report.summary.critical, high: report.summary.high, medium: report.summary.medium, low: report.summary.low },
|
|
@@ -260,7 +290,7 @@ const configRes = await agent(
|
|
|
260
290
|
const cfg = (configRes && configRes.config) ? configRes.config : null
|
|
261
291
|
const atomicMaxLines = (cfg && cfg.atomic && cfg.atomic.max_lines) ? cfg.atomic.max_lines : 20
|
|
262
292
|
const planRes = await agent(
|
|
263
|
-
'Call iterate_review({operation:"plan", mode:"normal", maxReviewRounds:' +
|
|
293
|
+
'Call iterate_review({operation:"plan", mode:"normal"' + (args.maxRounds ? ', maxReviewRounds:' + args.maxRounds : '') + '}) and return the plan JSON.',
|
|
264
294
|
Object.assign({ label: 'review:plan' }, backend)
|
|
265
295
|
)
|
|
266
296
|
const plan = (planRes && planRes.plan) ? planRes.plan : null
|
|
@@ -274,9 +304,16 @@ const architectural = (checkpoint && Array.isArray(checkpoint.findings)) ? check
|
|
|
274
304
|
let fixedCount = (checkpoint && typeof checkpoint.fixedCount === 'number') ? checkpoint.fixedCount : 0
|
|
275
305
|
let converged = false
|
|
276
306
|
let abortedByValidation = false
|
|
307
|
+
let schemaFailed = false // last round produced no usable reviewer output (schema-invalid after retries / all reviewers failed)
|
|
277
308
|
let failedCommands = []
|
|
278
309
|
let configErrors = [] // validation commands NOT in validation.commands (config gap, no rollback)
|
|
279
310
|
const fixRecords = [] // observatory fix records collected round by round
|
|
311
|
+
// Slot index of the current round within this run's rounds array. A resumed
|
|
312
|
+
// run starts at startRound > 1, so the round NUMBER is not a valid array index.
|
|
313
|
+
let roundSlot = -1
|
|
314
|
+
// Highest round number actually executed by THIS run (carried so a resumed run
|
|
315
|
+
// reports its true round, not the count of rounds it happened to run).
|
|
316
|
+
let lastRound = startRound - 1
|
|
280
317
|
|
|
281
318
|
// Read any steering nudge intended for this run's reviewers.
|
|
282
319
|
const transRead = await agent(
|
|
@@ -288,6 +325,8 @@ const steering = transRead && typeof transRead.nudge === 'string' && transRead.n
|
|
|
288
325
|
phase('loop')
|
|
289
326
|
for (let r = startRound; r <= maxRounds; r++) {
|
|
290
327
|
log('round ' + r + ' of ' + maxRounds + ' — review current state, fix atomics via iterate_fix, validate')
|
|
328
|
+
roundSlot = rounds.length
|
|
329
|
+
lastRound = r
|
|
291
330
|
// Audit-trail: record the round start (SKILL.md Phase 4 requires per-round records).
|
|
292
331
|
await agent(
|
|
293
332
|
'Call iterate_decision_log({operation:"append", type:"round_start", round:' + r + ', data:{maxRounds:' + maxRounds + ', fixedSoFar:' + fixedCount + '}})',
|
|
@@ -296,6 +335,10 @@ for (let r = startRound; r <= maxRounds; r++) {
|
|
|
296
335
|
let agg = null
|
|
297
336
|
let schemaInvalid = false
|
|
298
337
|
let attempts = 0
|
|
338
|
+
// Declared OUTSIDE the do-block: it is read after the retry loop exits, and a
|
|
339
|
+
// block-scoped const here raised ReferenceError at runtime (crashing both
|
|
340
|
+
// canonical workflows after the first round).
|
|
341
|
+
let reviewersOk = true
|
|
299
342
|
do {
|
|
300
343
|
attempts += 1
|
|
301
344
|
// Schema validation retry: on the 2nd+ pass, nudge reviewers toward strict JSON.
|
|
@@ -316,12 +359,16 @@ for (let r = startRound; r <= maxRounds; r++) {
|
|
|
316
359
|
'\\n Do NOT re-report already-known architectural findings: ' + JSON.stringify(architectural) + nudge + '\\nReturn the findings JSON object.'
|
|
317
360
|
return agent(base + extra, Object.assign({ label: 'review:' + dim + ':r' + r, schema: meta.findingsSchema }, backend))
|
|
318
361
|
}))
|
|
362
|
+
// ALL reviewers returning null (child failures) means this round produced
|
|
363
|
+
// no usable output — an empty aggregate must not read like a clean
|
|
364
|
+
// "nothing to fix" convergence. Gate the loop exit + convergence on it.
|
|
365
|
+
reviewersOk = raw.length > 0 ? raw.some(x => x !== null && typeof x === 'object') : true
|
|
319
366
|
const thisRound = {
|
|
320
367
|
round: r,
|
|
321
368
|
findings: [].concat(...raw.map(x => x && x.findings ? x.findings : [])),
|
|
322
369
|
readFiles: [].concat(...raw.map(x => x && Array.isArray(x.readFiles) ? x.readFiles : [])),
|
|
323
370
|
}
|
|
324
|
-
if (rounds.length
|
|
371
|
+
if (roundSlot < rounds.length) rounds[roundSlot] = thisRound; else rounds.push(thisRound)
|
|
325
372
|
|
|
326
373
|
// Deterministic dedupe / known_intentional filter / severity sort for this round.
|
|
327
374
|
// \`fixedCount\` is threaded into the report summary so the client dashboard can
|
|
@@ -339,10 +386,19 @@ for (let r = startRound; r <= maxRounds; r++) {
|
|
|
339
386
|
log('retry ' + attempts + ': round ' + r + ' output failed schema validation — re-running reviewers with strict-JSON emphasis')
|
|
340
387
|
}
|
|
341
388
|
} while (schemaInvalid && attempts < 3)
|
|
342
|
-
if (schemaInvalid) {
|
|
343
|
-
// Bounded exit: never loop forever
|
|
344
|
-
//
|
|
345
|
-
|
|
389
|
+
if (schemaInvalid || !reviewersOk) {
|
|
390
|
+
// Bounded exit: never loop forever, and never let an all-failed/empty round
|
|
391
|
+
// read like a clean convergence. Stop the run WITHOUT applying fixes for
|
|
392
|
+
// this round, record the failure in the audit trail, and leave the
|
|
393
|
+
// checkpoint in place so a user/DM can resume or re-roll after fixing the
|
|
394
|
+
// reviewer configuration.
|
|
395
|
+
schemaFailed = true
|
|
396
|
+
log('round ' + r + ' produced NO usable reviewer output (schema-invalid after retries or all reviewers failed) — stopping run (NOT converged)')
|
|
397
|
+
await agent(
|
|
398
|
+
'Call iterate_decision_log({operation:"append", type:"round_failed", round:' + r + ', data:{reason:"' + (schemaInvalid ? 'schema_invalid' : 'no_usable_reviewer_output') + '", rolledBack:0}})',
|
|
399
|
+
Object.assign({ label: 'log:schemaFailed:r' + r }, backend)
|
|
400
|
+
)
|
|
401
|
+
break
|
|
346
402
|
}
|
|
347
403
|
const findings = (agg && agg.report && agg.report.findings) ? agg.report.findings : thisRound.findings
|
|
348
404
|
const atomic = findings.filter(f => f.is_atomic === true)
|
|
@@ -467,7 +523,7 @@ for (let r = startRound; r <= maxRounds; r++) {
|
|
|
467
523
|
|
|
468
524
|
phase('report')
|
|
469
525
|
await agent(
|
|
470
|
-
'Call iterate_decision_log({operation:"append", type:"report", round:' +
|
|
526
|
+
'Call iterate_decision_log({operation:"append", type:"report", round:' + lastRound +
|
|
471
527
|
', data:{mode:"normal", fixed:' + fixedCount + ', architectural:' + architectural.length + '}})',
|
|
472
528
|
{ label: 'report:log' }
|
|
473
529
|
)
|
|
@@ -476,7 +532,7 @@ const statusRes = await agent(
|
|
|
476
532
|
{ label: 'status:final' }
|
|
477
533
|
)
|
|
478
534
|
const status = (statusRes && statusRes.ok) ? statusRes : null
|
|
479
|
-
if (!abortedByValidation) {
|
|
535
|
+
if (!abortedByValidation && !schemaFailed) {
|
|
480
536
|
// Iteration finished cleanly → clear the checkpoint so the next run starts fresh.
|
|
481
537
|
await agent(
|
|
482
538
|
'Call iterate_checkpoint({ operation: "clear" }) and return {ok, existed}.',
|
|
@@ -490,25 +546,28 @@ if (!abortedByValidation) {
|
|
|
490
546
|
// would hide the very thing F5 is meant to resume. Reflect what is on disk.
|
|
491
547
|
const obsCheckpoint = {
|
|
492
548
|
mode: 'normal',
|
|
493
|
-
round:
|
|
549
|
+
round: lastRound,
|
|
494
550
|
maxRounds: maxRounds,
|
|
495
551
|
fixedCount: fixedCount,
|
|
496
552
|
resumeCount: effectiveResumeCount,
|
|
497
553
|
}
|
|
498
|
-
const stopReason =
|
|
499
|
-
?
|
|
500
|
-
:
|
|
554
|
+
const stopReason = schemaFailed
|
|
555
|
+
? 'schema_invalid'
|
|
556
|
+
: abortedByValidation
|
|
557
|
+
? (configErrors.length > 0 ? 'aborted_by_config' : 'aborted_by_validation')
|
|
558
|
+
: (converged ? 'converged' : 'max_rounds_reached')
|
|
501
559
|
await agent(
|
|
502
|
-
'Call iterate_transcript({operation:"capture", mode:"normal", goal:' + JSON.stringify(plan.goal) + ', maxRounds:' + maxRounds + ', roundsExecuted:' +
|
|
560
|
+
'Call iterate_transcript({operation:"capture", mode:"normal", goal:' + JSON.stringify(plan.goal) + ', maxRounds:' + maxRounds + ', roundsExecuted:' + lastRound + ', findingsByRound:' + JSON.stringify(rounds.map(rr => (rr.findings && rr.findings.length) ? rr.findings.length : 0)) + ', stoppedReason:"' + stopReason + '", fixes:' + JSON.stringify(fixRecords) + ', checkpoint:' + JSON.stringify(obsCheckpoint) + ', rounds:' + JSON.stringify(rounds.map(rr => ({ round: rr.round, findings: rr.findings, readFiles: rr.readFiles }))) + '}). Return {operation:"ok"}.',
|
|
503
561
|
{ label: 'transcript:capture' }
|
|
504
562
|
)
|
|
505
563
|
return {
|
|
506
564
|
mode: 'normal',
|
|
507
565
|
goal: plan.goal,
|
|
508
|
-
roundsExecuted:
|
|
566
|
+
roundsExecuted: lastRound,
|
|
509
567
|
maxRounds: maxRounds,
|
|
510
568
|
converged: converged,
|
|
511
569
|
abortedByValidation: abortedByValidation,
|
|
570
|
+
schemaFailed: schemaFailed,
|
|
512
571
|
failedCommands: failedCommands,
|
|
513
572
|
configErrors: configErrors,
|
|
514
573
|
stoppedReason: stopReason,
|
|
@@ -529,12 +588,12 @@ return {
|
|
|
529
588
|
Key rules for normal mode:
|
|
530
589
|
- Fixers are the ONLY agents allowed to write files, and they must go through \`iterate_fix\` — never edit files directly. That is what gives every change a backup, a diff, and a rollback path. Reviewers read only. Architectural findings are reported, never auto-fixed.
|
|
531
590
|
- Aggregate the current round deterministically (\`report.findings\`) before fixing, so fixes act on deduped/filtered/sorted findings.
|
|
532
|
-
- **Schema validation & retry**: when \`reviewer.output_schema_validation\` is on (default), retry the round's reviewers up to 2 times (3 attempts total) when \`aggregate\` reports \`schemaValidation\` valid=false for it, then re-aggregate. Never forward schema-invalid findings into \`iterate_fix\`. If the output is still schema-invalid after the 3rd attempt
|
|
591
|
+
- **Schema validation & retry**: when \`reviewer.output_schema_validation\` is on (default), retry the round's reviewers up to 2 times (3 attempts total) when \`aggregate\` reports \`schemaValidation\` valid=false for it, then re-aggregate. Never forward schema-invalid findings into \`iterate_fix\`. If the output is still schema-invalid after the 3rd attempt — or every reviewer subagent failed outright (no usable output at all) — the round is INCONCLUSIVE: \`break\` the loop, log a \`round_failed\` entry, set \`schemaFailed = true\`, and report \`stoppedReason:"schema_invalid"\`/\`"no_usable_reviewer_output"\`. Never report an inconclusive round as a clean convergence, and keep the checkpoint so the run can be resumed after the reviewer configuration is fixed.
|
|
533
592
|
- Apply atomic fixes **per file**: one fixer agent handles all findings for a given file serially (so the same file is never edited concurrently); different files are fixed in parallel.
|
|
534
593
|
- **Resume**: load the checkpoint first; if a previous run left one, continue from \`checkpoint.round + 1\` (its \`fixedCount\` and deduped \`findings\` are carried forward).
|
|
535
594
|
- **Validate after every round** of fixes; on ANY validation failure, roll back the round's fixes via \`iterate_rollback\` and stop (the checkpoint is left in place so the run can be resumed). EXCEPTION: \`iterate_validate\` returning \`allowed:false\` means the command is simply not in \`validation.commands\` — a config gap, not broken code. In that case abort WITHOUT rolling back the round's fixes and surface the missing command + file so the user can fix the trust list.
|
|
536
595
|
- **Checkpoint after every round**; clear it only when the iteration completes cleanly.
|
|
537
|
-
- Stop when a round produces nothing to fix (converged)
|
|
596
|
+
- Stop when a round produces nothing to fix (converged), maxReviewRounds is reached, validation aborts the run, or a round yields no usable reviewer output (\`stoppedReason:"schema_invalid"\`).
|
|
538
597
|
- Every round, every rollback, and the final report go to the append-only decision log.
|
|
539
598
|
- Close with \`iterate_status\` metrics and surface the convergence indicators (fixed count, remaining architectural count, abort reason) in the final summary.
|
|
540
599
|
|
package/src/tools/checkpoint.ts
CHANGED
|
@@ -177,9 +177,17 @@ export function computeStatus(input: {
|
|
|
177
177
|
findingsCount: Array.isArray(checkpoint?.findings) ? checkpoint.findings.length : 0,
|
|
178
178
|
totalDecisionLogEntries: entries.length,
|
|
179
179
|
hasCheckpoint: checkpoint !== null,
|
|
180
|
-
// A checkpoint left on disk
|
|
181
|
-
//
|
|
182
|
-
|
|
180
|
+
// A checkpoint left on disk is the durable "the run did not finish" signal
|
|
181
|
+
// — EXCEPT when a decision-log entry is NEWER than the checkpoint: the
|
|
182
|
+
// workflow saves a checkpoint at the end of each round (right after that
|
|
183
|
+
// round's review_result log), so a live run proceeding past its last
|
|
184
|
+
// checkpoint would otherwise be flagged as "interrupted" for its whole
|
|
185
|
+
// duration. A fresh checkpoint (or no log at all) is genuinely leftover.
|
|
186
|
+
interrupted:
|
|
187
|
+
checkpoint !== null &&
|
|
188
|
+
!(lastEntry != null &&
|
|
189
|
+
checkpoint.updatedAt &&
|
|
190
|
+
(Date.parse(checkpoint.updatedAt) || 0) >= (Date.parse(lastEntry.timestamp) || 0)),
|
|
183
191
|
resumeCount: checkpoint?.resumeCount ?? 0,
|
|
184
192
|
checkpoint,
|
|
185
193
|
lastUpdated,
|
|
@@ -354,7 +362,7 @@ export function registerStatusTool(ctx: { tools: { register: (def: ReturnType<ty
|
|
|
354
362
|
findingsCount: { type: 'integer' },
|
|
355
363
|
totalDecisionLogEntries: { type: 'integer' },
|
|
356
364
|
hasCheckpoint: { type: 'boolean' },
|
|
357
|
-
interrupted: { type: 'boolean', description: 'True when a checkpoint exists
|
|
365
|
+
interrupted: { type: 'boolean', description: 'True when a checkpoint exists and no decision-log entry is newer than it — the previous run left a checkpoint that nothing has resumed or progressed past.' },
|
|
358
366
|
resumeCount: { type: 'integer', description: 'How many times the current checkpoint has already been resumed.' },
|
|
359
367
|
lastUpdated: { oneOf: [{ type: 'string' }, { type: 'null' }] },
|
|
360
368
|
qualityGate: { type: 'json', description: 'v3.0: persisted quality-gate snapshot (.iterate/quality-gate.json), when present.' },
|
|
@@ -121,6 +121,18 @@ function recountEntries(filePath: string): number {
|
|
|
121
121
|
}
|
|
122
122
|
}
|
|
123
123
|
|
|
124
|
+
/**
|
|
125
|
+
* Drop the cached entry count for a log file. The cache is keyed by the log
|
|
126
|
+
* path and assumes "same byte size ⇒ same entry count"; a prune rewrite
|
|
127
|
+
* (temp + atomic rename) replaces the file with a DIFFERENT number of entries,
|
|
128
|
+
* so only a stale-cache invalidation keeps the fast path honest. Without it, a
|
|
129
|
+
* rewrite that lands on the exact same byte size as the previous append would
|
|
130
|
+
* make the next append report a wrong `count`.
|
|
131
|
+
*/
|
|
132
|
+
export function invalidateLogCountCache(filePath: string): void {
|
|
133
|
+
entryCountCache.delete(filePath)
|
|
134
|
+
}
|
|
135
|
+
|
|
124
136
|
/** All valid DecisionLogEntry `type` values (must stay in sync with Types). */
|
|
125
137
|
const VALID_ENTRY_TYPES = new Set<DecisionLogEntry['type']>([
|
|
126
138
|
'round_start',
|
package/src/tools/history.ts
CHANGED
|
@@ -102,7 +102,7 @@ export function registerHistoryTool(ctx: { tools: { register: (def: ReturnType<t
|
|
|
102
102
|
},
|
|
103
103
|
limit: {
|
|
104
104
|
type: 'integer',
|
|
105
|
-
description: `Max entries to return (default: ${DEFAULT_LIMIT}, cap: ${MAX_LIMIT}).
|
|
105
|
+
description: `Max entries to return (default: ${DEFAULT_LIMIT}, cap: ${MAX_LIMIT}). Returns the newest window in chronological order.`,
|
|
106
106
|
},
|
|
107
107
|
path: {
|
|
108
108
|
type: 'string',
|
package/src/tools/prune.ts
CHANGED
|
@@ -24,13 +24,13 @@
|
|
|
24
24
|
* - Each deletion is logged to the decision log (when not dry-run).
|
|
25
25
|
*/
|
|
26
26
|
|
|
27
|
-
import { existsSync, readdirSync, rmSync, unlinkSync } from 'node:fs'
|
|
27
|
+
import { existsSync, readdirSync, rmSync, unlinkSync, statSync } from 'node:fs'
|
|
28
28
|
import { join } from 'node:path'
|
|
29
29
|
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
30
30
|
import type { JsonValue } from '@deepseek-ai/dsh-util-values'
|
|
31
31
|
import { resolveProjectRootForExec } from '../config-loader.ts'
|
|
32
32
|
import { writeTextAtomic, writeJsonAtomic } from '../atomic-fs.ts'
|
|
33
|
-
import { readDecisionEntries, appendDecisionEntry, acquireLogLock } from './decision-log.ts'
|
|
33
|
+
import { readDecisionEntries, appendDecisionEntry, acquireLogLock, invalidateLogCountCache } from './decision-log.ts'
|
|
34
34
|
import { readRegistry, removeRecord, recomputeRoundCounts } from './fix.ts'
|
|
35
35
|
import { readExperienceBank, writeExperienceBank } from './experience-store.ts'
|
|
36
36
|
import { readDefenseEvents, writeDefenseEvents } from './defense-store.ts'
|
|
@@ -154,6 +154,7 @@ export function inspectPrune(
|
|
|
154
154
|
): {
|
|
155
155
|
oldLogEntries: number
|
|
156
156
|
hasCheckpoint: boolean
|
|
157
|
+
checkpointStale: boolean
|
|
157
158
|
staleBackups: string[]
|
|
158
159
|
staleTemps: string[]
|
|
159
160
|
emptyRounds: number[]
|
|
@@ -170,8 +171,20 @@ export function inspectPrune(
|
|
|
170
171
|
const entries = readDecisionEntries(projectRoot)
|
|
171
172
|
const oldLogEntries = entries.filter((e) => e.timestamp < cutoff).length
|
|
172
173
|
|
|
173
|
-
// 2. Checkpoint presence.
|
|
174
|
+
// 2. Checkpoint presence. A checkpoint is only STALE once it is older than
|
|
175
|
+
// retainDays — a fresh checkpoint means a run was interrupted recently and
|
|
176
|
+
// may still be resumed, so `hasCheckpoint` is kept as pure existence while
|
|
177
|
+
// `checkpointStale` decides whether a prune actually deletes it.
|
|
174
178
|
const hasCheckpoint = existsSync(checkpointPath(projectRoot))
|
|
179
|
+
let checkpointStale = false
|
|
180
|
+
if (hasCheckpoint) {
|
|
181
|
+
try {
|
|
182
|
+
const st = statSync(checkpointPath(projectRoot))
|
|
183
|
+
if (st && st.mtimeMs < Date.parse(cutoff)) checkpointStale = true
|
|
184
|
+
} catch {
|
|
185
|
+
checkpointStale = true // unreadable mtime — treat as garbage / deletable
|
|
186
|
+
}
|
|
187
|
+
}
|
|
175
188
|
|
|
176
189
|
// 3. Stale fix backups: .bak files whose fix-id prefix is not in the registry.
|
|
177
190
|
const registry = readRegistry(projectRoot)
|
|
@@ -233,6 +246,7 @@ export function inspectPrune(
|
|
|
233
246
|
return {
|
|
234
247
|
oldLogEntries,
|
|
235
248
|
hasCheckpoint,
|
|
249
|
+
checkpointStale,
|
|
236
250
|
staleBackups,
|
|
237
251
|
staleTemps,
|
|
238
252
|
emptyRounds,
|
|
@@ -277,6 +291,12 @@ function rewriteDecisionLogKeepingRecent(projectRoot: string, cutoff: string): {
|
|
|
277
291
|
const deleted = entries.length - kept.length
|
|
278
292
|
if (deleted === 0) return { deleted: 0 }
|
|
279
293
|
writeTextAtomic(logPath, kept.map((e) => JSON.stringify(e)).join('\n') + '\n')
|
|
294
|
+
// The append-side entry-count cache is keyed by path and assumes "same
|
|
295
|
+
// byte size ⇒ same count"; the rename above replaced the file with a
|
|
296
|
+
// different number of entries, so drop the stale cache or the next
|
|
297
|
+
// append can report a wrong entryCount when the sizes coincidentally
|
|
298
|
+
// match.
|
|
299
|
+
invalidateLogCountCache(logPath)
|
|
280
300
|
// A concurrent appender (cross-process, lock-unavailable path) may have
|
|
281
301
|
// landed new lines after our read. If the log grew during the write
|
|
282
302
|
// window, re-read and prune again instead of accepting a lost audit trail.
|
|
@@ -333,8 +353,10 @@ export function executePrune(
|
|
|
333
353
|
if (error) result.errors.push(error)
|
|
334
354
|
}
|
|
335
355
|
|
|
336
|
-
// 2. Remove checkpoint
|
|
337
|
-
|
|
356
|
+
// 2. Remove checkpoint — ONLY when it is actually stale (older than
|
|
357
|
+
// retainDays). A fresh checkpoint is a live resume point for a recently
|
|
358
|
+
// interrupted run; pruning it would silently destroy the ability to resume.
|
|
359
|
+
if (report.checkpointStale) {
|
|
338
360
|
try {
|
|
339
361
|
rmSync(checkpointPath(projectRoot), { force: true })
|
|
340
362
|
result.deletedCheckpoint = true
|
|
@@ -466,7 +488,7 @@ export function registerPruneTool(ctx: { tools: { register: (def: ReturnType<typ
|
|
|
466
488
|
const lines = [
|
|
467
489
|
`[dry-run] prune report (retainDays=${value.retainDays}):`,
|
|
468
490
|
` Decision-log entries to remove: ${report?.oldLogEntries ?? '?'} (of ${report?.totalLogEntries ?? '?'})`,
|
|
469
|
-
` Checkpoint to delete: ${report?.
|
|
491
|
+
` Checkpoint to delete: ${report?.checkpointStale ? 'yes (stale)' : report?.hasCheckpoint ? 'no (fresh — resume point, kept)' : 'none'}`,
|
|
470
492
|
` Stale backups to delete: ${(report?.staleBackups as string[] | undefined)?.length ?? 0}`,
|
|
471
493
|
` Stray temp files to delete: ${(report?.staleTemps as string[] | undefined)?.length ?? 0}`,
|
|
472
494
|
` Empty rounds to trim: ${(report?.emptyRounds as number[] | undefined)?.length ?? 0}`,
|
package/src/tools/transcript.ts
CHANGED
|
@@ -160,7 +160,7 @@ export function registerTranscriptTool(ctx: {
|
|
|
160
160
|
description: 'For `capture`: array of applied fixes [{id, file, round, summary, linesAdded, linesRemoved, success}].',
|
|
161
161
|
},
|
|
162
162
|
refReadFiles: { type: 'json', description: 'For `capture`: flat array of all read files across rounds (optional).' },
|
|
163
|
-
stoppedReason: { type: 'string', description: 'For `capture`: why the run ended — "converged" | "max_rounds_reached" | "aborted_by_validation". When omitted, derived from the convergence trend (trailing 0 = converged, otherwise = max_rounds_reached once a round ran).' },
|
|
163
|
+
stoppedReason: { type: 'string', description: 'For `capture`: why the run ended — "converged" | "max_rounds_reached" | "aborted_by_validation" | "aborted_by_config" | "schema_invalid" | "no_usable_reviewer_output" | "inconclusive". When omitted, derived from the convergence trend (trailing 0 = converged, otherwise = max_rounds_reached once a round ran).' },
|
|
164
164
|
text: { type: 'string', description: 'For `nudge`: steering text to set (or null to clear).' },
|
|
165
165
|
path: { type: 'string', description: 'Project root directory (default: current working directory).' },
|
|
166
166
|
},
|
|
@@ -358,12 +358,23 @@ function rehydrateBuilder(manifest: TranscriptManifest, approval: 'ask' | 'deny'
|
|
|
358
358
|
maxRounds: manifest.maxRounds,
|
|
359
359
|
})
|
|
360
360
|
for (const r of Array.isArray(manifest.rounds) ? manifest.rounds : []) {
|
|
361
|
+
// A malformed/null round row must be skipped, never a throw (the caller
|
|
362
|
+
// wraps rehydrate in a fallback, but preserving sibling rows is better).
|
|
363
|
+
if (!r || typeof r !== 'object') continue
|
|
361
364
|
builder.roundStart(r.round, manifest.maxRounds)
|
|
362
365
|
for (const t of Array.isArray(r.threads) ? r.threads : []) {
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
366
|
+
// restoreThread (not reviewerStart + reviewerMessage(join)) preserves the
|
|
367
|
+
// message ARRAY boundaries and the thread's own dimension: rehydration
|
|
368
|
+
// previously collapsed each thread's messages into one string and, once a
|
|
369
|
+
// round hit the per-round thread cap, silently DROPPED the extra threads
|
|
370
|
+
// / mis-merged their findings into the previous dimension's thread.
|
|
371
|
+
builder.restoreThread(r.round, {
|
|
372
|
+
dimension: t.dimension,
|
|
373
|
+
attempt: t.attempt,
|
|
374
|
+
messages: t.messages,
|
|
375
|
+
readFiles: t.readFiles,
|
|
376
|
+
findings: t.findings,
|
|
377
|
+
})
|
|
367
378
|
}
|
|
368
379
|
}
|
|
369
380
|
for (let idx = 0; idx < (manifest.convergence ?? []).length; idx += 1) {
|