iterate-plugin 3.5.5 → 3.5.6
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 +60 -21
- package/dist/tools/checkpoint.js +11 -4
- package/dist/tools/history.js +1 -1
- package/dist/tools/prune.js +22 -5
- 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 +11 -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 +60 -21
- package/src/tools/checkpoint.ts +12 -4
- package/src/tools/history.ts +1 -1
- package/src/tools/prune.ts +21 -5
- package/src/tools/transcript.ts +16 -5
- package/src/transcript.ts +85 -2
- package/src/types.ts +9 -4
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,10 @@ 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
|
|
84
88
|
|
|
85
89
|
phase('transcript')
|
|
86
90
|
// Read any steering nudge written (via iterate_transcript nudge) for this run's reviewers.
|
|
@@ -118,6 +122,12 @@ for (let r = 1; r <= maxRounds; r++) {
|
|
|
118
122
|
JSON.stringify(known) + nudge + '\\nReturn the findings JSON object.'
|
|
119
123
|
return agent(base + extra, Object.assign({ label: 'review:' + dim + ':r' + r, schema: meta.findingsSchema }, backend))
|
|
120
124
|
}))
|
|
125
|
+
// A per-dimension reviewer subagent resolves \`null\` on child failure; the
|
|
126
|
+
// aggregate below would report an all-failed round as an empty round, and
|
|
127
|
+
// the convergence math would then read "0 new findings". An all-failed
|
|
128
|
+
// round is NOT a clean pass — it is inconclusive, so the convergence gate
|
|
129
|
+
// below refuses it before any "converged" signal fires.
|
|
130
|
+
const reviewersOk = raw.length > 0 ? raw.some(x => x !== null && typeof x === 'object') : true
|
|
121
131
|
const thisRound = {
|
|
122
132
|
round: r,
|
|
123
133
|
findings: [].concat(...raw.map(x => x && x.findings ? x.findings : [])),
|
|
@@ -145,23 +155,30 @@ for (let r = 1; r <= maxRounds; r++) {
|
|
|
145
155
|
// final summary can explain WHY the round was inconclusive.
|
|
146
156
|
log('round ' + r + ' output STILL schema-invalid after 3 attempts — marking round inconclusive (NOT converged)')
|
|
147
157
|
}
|
|
158
|
+
const roundUnusable = schemaInvalid || !reviewersOk
|
|
159
|
+
if (roundUnusable) lastRoundOk = false
|
|
148
160
|
// 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
|
-
|
|
161
|
+
// list stays bounded and reviewers never see the same issue twice. Never feed
|
|
162
|
+
// an unusable round's empty result — it would erase the still-valid known list
|
|
163
|
+
// and let reviewers re-report everything next round.
|
|
164
|
+
if (!roundUnusable && agg && agg.report && Array.isArray(agg.report.findings)) known = agg.report.findings
|
|
151
165
|
// A round counts as "converged" ONLY when the aggregate accepted it
|
|
152
|
-
// (schema-valid) AND
|
|
153
|
-
//
|
|
154
|
-
//
|
|
155
|
-
//
|
|
156
|
-
|
|
166
|
+
// (schema-valid) AND its reviewers actually returned output AND it genuinely
|
|
167
|
+
// found zero NEW findings. If reviewers all failed or their output stayed
|
|
168
|
+
// schema-invalid after retries, the round is INCONCLUSIVE — never report an
|
|
169
|
+
// empty-but-broken round as converged (an invalid/failed round would
|
|
170
|
+
// otherwise masquerade as a clean pass and stop the run early).
|
|
171
|
+
if (!roundUnusable && !schemaInvalid) {
|
|
157
172
|
const newCount =
|
|
158
173
|
agg && agg.report && agg.report.convergence
|
|
159
174
|
? agg.report.convergence.findingsByRound[r - 1]
|
|
160
175
|
: thisRound.findings.length
|
|
161
176
|
if (newCount === 0) {
|
|
162
177
|
log('round ' + r + ' found 0 new findings — converged')
|
|
178
|
+
lastRoundOk = true
|
|
163
179
|
break
|
|
164
180
|
}
|
|
181
|
+
lastRoundOk = true
|
|
165
182
|
}
|
|
166
183
|
}
|
|
167
184
|
|
|
@@ -197,8 +214,13 @@ return {
|
|
|
197
214
|
mode: 'dry-run',
|
|
198
215
|
goal: report.goal,
|
|
199
216
|
rounds: rounds.length,
|
|
200
|
-
|
|
201
|
-
|
|
217
|
+
// The aggregate's convergence flag is only trustworthy when the last round
|
|
218
|
+
// was usable — an all-failed/schema-invalid final round looks like "0 new"
|
|
219
|
+
// to the convergence math but is an inconclusive run, never a clean pass.
|
|
220
|
+
converged: report.convergence.converged && lastRoundOk,
|
|
221
|
+
stoppedReason: report.convergence.converged && !lastRoundOk
|
|
222
|
+
? 'inconclusive'
|
|
223
|
+
: report.convergence.stoppedReason,
|
|
202
224
|
findingsByRound: report.convergence.findingsByRound,
|
|
203
225
|
totalFindings: report.summary.totalFindings,
|
|
204
226
|
bySeverity: { critical: report.summary.critical, high: report.summary.high, medium: report.summary.medium, low: report.summary.low },
|
|
@@ -260,7 +282,7 @@ const configRes = await agent(
|
|
|
260
282
|
const cfg = (configRes && configRes.config) ? configRes.config : null
|
|
261
283
|
const atomicMaxLines = (cfg && cfg.atomic && cfg.atomic.max_lines) ? cfg.atomic.max_lines : 20
|
|
262
284
|
const planRes = await agent(
|
|
263
|
-
'Call iterate_review({operation:"plan", mode:"normal", maxReviewRounds:' +
|
|
285
|
+
'Call iterate_review({operation:"plan", mode:"normal"' + (args.maxRounds ? ', maxReviewRounds:' + args.maxRounds : '') + '}) and return the plan JSON.',
|
|
264
286
|
Object.assign({ label: 'review:plan' }, backend)
|
|
265
287
|
)
|
|
266
288
|
const plan = (planRes && planRes.plan) ? planRes.plan : null
|
|
@@ -274,6 +296,7 @@ const architectural = (checkpoint && Array.isArray(checkpoint.findings)) ? check
|
|
|
274
296
|
let fixedCount = (checkpoint && typeof checkpoint.fixedCount === 'number') ? checkpoint.fixedCount : 0
|
|
275
297
|
let converged = false
|
|
276
298
|
let abortedByValidation = false
|
|
299
|
+
let schemaFailed = false // last round produced no usable reviewer output (schema-invalid after retries / all reviewers failed)
|
|
277
300
|
let failedCommands = []
|
|
278
301
|
let configErrors = [] // validation commands NOT in validation.commands (config gap, no rollback)
|
|
279
302
|
const fixRecords = [] // observatory fix records collected round by round
|
|
@@ -316,6 +339,10 @@ for (let r = startRound; r <= maxRounds; r++) {
|
|
|
316
339
|
'\\n Do NOT re-report already-known architectural findings: ' + JSON.stringify(architectural) + nudge + '\\nReturn the findings JSON object.'
|
|
317
340
|
return agent(base + extra, Object.assign({ label: 'review:' + dim + ':r' + r, schema: meta.findingsSchema }, backend))
|
|
318
341
|
}))
|
|
342
|
+
// ALL reviewers returning null (child failures) means this round produced
|
|
343
|
+
// no usable output — an empty aggregate must not read like a clean
|
|
344
|
+
// "nothing to fix" convergence. Gate the loop exit + convergence on it.
|
|
345
|
+
const reviewersOk = raw.length > 0 ? raw.some(x => x !== null && typeof x === 'object') : true
|
|
319
346
|
const thisRound = {
|
|
320
347
|
round: r,
|
|
321
348
|
findings: [].concat(...raw.map(x => x && x.findings ? x.findings : [])),
|
|
@@ -339,10 +366,19 @@ for (let r = startRound; r <= maxRounds; r++) {
|
|
|
339
366
|
log('retry ' + attempts + ': round ' + r + ' output failed schema validation — re-running reviewers with strict-JSON emphasis')
|
|
340
367
|
}
|
|
341
368
|
} while (schemaInvalid && attempts < 3)
|
|
342
|
-
if (schemaInvalid) {
|
|
343
|
-
// Bounded exit: never loop forever
|
|
344
|
-
//
|
|
345
|
-
|
|
369
|
+
if (schemaInvalid || !reviewersOk) {
|
|
370
|
+
// Bounded exit: never loop forever, and never let an all-failed/empty round
|
|
371
|
+
// read like a clean convergence. Stop the run WITHOUT applying fixes for
|
|
372
|
+
// this round, record the failure in the audit trail, and leave the
|
|
373
|
+
// checkpoint in place so a user/DM can resume or re-roll after fixing the
|
|
374
|
+
// reviewer configuration.
|
|
375
|
+
schemaFailed = true
|
|
376
|
+
log('round ' + r + ' produced NO usable reviewer output (schema-invalid after retries or all reviewers failed) — stopping run (NOT converged)')
|
|
377
|
+
await agent(
|
|
378
|
+
'Call iterate_decision_log({operation:"append", type:"round_failed", round:' + r + ', data:{reason:"' + (schemaInvalid ? 'schema_invalid' : 'no_usable_reviewer_output') + '", rolledBack:0}})',
|
|
379
|
+
Object.assign({ label: 'log:schemaFailed:r' + r }, backend)
|
|
380
|
+
)
|
|
381
|
+
break
|
|
346
382
|
}
|
|
347
383
|
const findings = (agg && agg.report && agg.report.findings) ? agg.report.findings : thisRound.findings
|
|
348
384
|
const atomic = findings.filter(f => f.is_atomic === true)
|
|
@@ -476,7 +512,7 @@ const statusRes = await agent(
|
|
|
476
512
|
{ label: 'status:final' }
|
|
477
513
|
)
|
|
478
514
|
const status = (statusRes && statusRes.ok) ? statusRes : null
|
|
479
|
-
if (!abortedByValidation) {
|
|
515
|
+
if (!abortedByValidation && !schemaFailed) {
|
|
480
516
|
// Iteration finished cleanly → clear the checkpoint so the next run starts fresh.
|
|
481
517
|
await agent(
|
|
482
518
|
'Call iterate_checkpoint({ operation: "clear" }) and return {ok, existed}.',
|
|
@@ -495,9 +531,11 @@ if (!abortedByValidation) {
|
|
|
495
531
|
fixedCount: fixedCount,
|
|
496
532
|
resumeCount: effectiveResumeCount,
|
|
497
533
|
}
|
|
498
|
-
const stopReason =
|
|
499
|
-
?
|
|
500
|
-
:
|
|
534
|
+
const stopReason = schemaFailed
|
|
535
|
+
? 'schema_invalid'
|
|
536
|
+
: abortedByValidation
|
|
537
|
+
? (configErrors.length > 0 ? 'aborted_by_config' : 'aborted_by_validation')
|
|
538
|
+
: (converged ? 'converged' : 'max_rounds_reached')
|
|
501
539
|
await agent(
|
|
502
540
|
'Call iterate_transcript({operation:"capture", mode:"normal", goal:' + JSON.stringify(plan.goal) + ', maxRounds:' + maxRounds + ', roundsExecuted:' + rounds.length + ', 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
541
|
{ label: 'transcript:capture' }
|
|
@@ -509,6 +547,7 @@ return {
|
|
|
509
547
|
maxRounds: maxRounds,
|
|
510
548
|
converged: converged,
|
|
511
549
|
abortedByValidation: abortedByValidation,
|
|
550
|
+
schemaFailed: schemaFailed,
|
|
512
551
|
failedCommands: failedCommands,
|
|
513
552
|
configErrors: configErrors,
|
|
514
553
|
stoppedReason: stopReason,
|
|
@@ -529,12 +568,12 @@ return {
|
|
|
529
568
|
Key rules for normal mode:
|
|
530
569
|
- 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
570
|
- 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
|
|
571
|
+
- **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
572
|
- 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
573
|
- **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
574
|
- **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
575
|
- **Checkpoint after every round**; clear it only when the iteration completes cleanly.
|
|
537
|
-
- Stop when a round produces nothing to fix (converged)
|
|
576
|
+
- 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
577
|
- Every round, every rollback, and the final report go to the append-only decision log.
|
|
539
578
|
- Close with \`iterate_status\` metrics and surface the convergence indicators (fixed count, remaining architectural count, abort reason) in the final summary.
|
|
540
579
|
|
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.' },
|
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,7 +24,7 @@
|
|
|
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'
|
|
@@ -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,
|
|
@@ -333,8 +347,10 @@ export function executePrune(
|
|
|
333
347
|
if (error) result.errors.push(error)
|
|
334
348
|
}
|
|
335
349
|
|
|
336
|
-
// 2. Remove checkpoint
|
|
337
|
-
|
|
350
|
+
// 2. Remove checkpoint — ONLY when it is actually stale (older than
|
|
351
|
+
// retainDays). A fresh checkpoint is a live resume point for a recently
|
|
352
|
+
// interrupted run; pruning it would silently destroy the ability to resume.
|
|
353
|
+
if (report.checkpointStale) {
|
|
338
354
|
try {
|
|
339
355
|
rmSync(checkpointPath(projectRoot), { force: true })
|
|
340
356
|
result.deletedCheckpoint = true
|
|
@@ -466,7 +482,7 @@ export function registerPruneTool(ctx: { tools: { register: (def: ReturnType<typ
|
|
|
466
482
|
const lines = [
|
|
467
483
|
`[dry-run] prune report (retainDays=${value.retainDays}):`,
|
|
468
484
|
` Decision-log entries to remove: ${report?.oldLogEntries ?? '?'} (of ${report?.totalLogEntries ?? '?'})`,
|
|
469
|
-
` Checkpoint to delete: ${report?.
|
|
485
|
+
` Checkpoint to delete: ${report?.checkpointStale ? 'yes (stale)' : report?.hasCheckpoint ? 'no (fresh — resume point, kept)' : 'none'}`,
|
|
470
486
|
` Stale backups to delete: ${(report?.staleBackups as string[] | undefined)?.length ?? 0}`,
|
|
471
487
|
` Stray temp files to delete: ${(report?.staleTemps as string[] | undefined)?.length ?? 0}`,
|
|
472
488
|
` 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) {
|
package/src/transcript.ts
CHANGED
|
@@ -98,7 +98,10 @@ function normalizeFinding(input: unknown): TranscriptFinding | null {
|
|
|
98
98
|
sev === 'critical' || sev === 'high' || sev === 'medium' || sev === 'low'
|
|
99
99
|
? sev
|
|
100
100
|
: 'low'
|
|
101
|
-
|
|
101
|
+
// Only a non-negative integer is a real line anchor; a fractional/malformed
|
|
102
|
+
// value (e.g. 3.7 from a bypassed schema) must not leak into the manifest.
|
|
103
|
+
const line =
|
|
104
|
+
typeof f.line === 'number' && Number.isInteger(f.line) && f.line >= 0 ? f.line : 0
|
|
102
105
|
return {
|
|
103
106
|
dimension,
|
|
104
107
|
file,
|
|
@@ -267,7 +270,8 @@ export class ReviewTranscriptBuilder {
|
|
|
267
270
|
const att = typeof attempt === 'number' && Number.isFinite(attempt) ? Math.floor(attempt) : 1
|
|
268
271
|
this.roundStart(this.round)
|
|
269
272
|
const live = this.rounds[this.round - 1]! as LiveRound | undefined
|
|
270
|
-
if (live
|
|
273
|
+
if (!live) return
|
|
274
|
+
if (this.threadCount(live) < MAX_THREADS_PER_ROUND) {
|
|
271
275
|
live.threads.push({
|
|
272
276
|
dimension: dim || 'review',
|
|
273
277
|
attempt: att > 0 ? att : 1,
|
|
@@ -275,7 +279,86 @@ export class ReviewTranscriptBuilder {
|
|
|
275
279
|
readFiles: [],
|
|
276
280
|
findings: [],
|
|
277
281
|
})
|
|
282
|
+
} else {
|
|
283
|
+
// Thread cap reached: route the extra dimension/retry into ONE shared
|
|
284
|
+
// "other" overflow thread instead of silently dropping it. A dropped
|
|
285
|
+
// reviewerStart used to leave `currentThread()` pointing at the PREVIOUS
|
|
286
|
+
// dimension's thread, so its findings were mis-attributed to that
|
|
287
|
+
// dimension. The overflow thread stays bounded (at most ONE extra thread
|
|
288
|
+
// per round) while keeping over-cap findings attributable to "other".
|
|
289
|
+
let overflow = live.threads.find((t) => t.dimension === 'other')
|
|
290
|
+
if (!overflow) {
|
|
291
|
+
overflow = {
|
|
292
|
+
dimension: 'other',
|
|
293
|
+
attempt: att > 0 ? att : 1,
|
|
294
|
+
messages: [],
|
|
295
|
+
readFiles: [],
|
|
296
|
+
findings: [],
|
|
297
|
+
}
|
|
298
|
+
live.threads.push(overflow)
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
this.touch()
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* Restore ONE persisted thread during rehydration, preserving the message
|
|
306
|
+
* ARRAY boundaries (a rehydrated manifest is re-serialized on the next nudge
|
|
307
|
+
* edit, so collapsed messages would silently rewrite the evidence) and the
|
|
308
|
+
* thread's own dimension (never merging it into a previous dimension's
|
|
309
|
+
* thread). Bypasses the live per-round cap — the manifest already enforced
|
|
310
|
+
* it at capture time — but every restored field is hard-bounded so a hostile
|
|
311
|
+
* hand-edited manifest can never blow up memory.
|
|
312
|
+
*/
|
|
313
|
+
restoreThread(
|
|
314
|
+
round: number,
|
|
315
|
+
thread: {
|
|
316
|
+
dimension?: unknown
|
|
317
|
+
attempt?: unknown
|
|
318
|
+
messages?: unknown
|
|
319
|
+
readFiles?: unknown
|
|
320
|
+
findings?: unknown
|
|
321
|
+
},
|
|
322
|
+
): void {
|
|
323
|
+
const r =
|
|
324
|
+
typeof round === 'number' && Number.isFinite(round)
|
|
325
|
+
? Math.min(Math.floor(round), MAX_ROUNDS)
|
|
326
|
+
: 1
|
|
327
|
+
this.roundStart(r)
|
|
328
|
+
const live = this.rounds[this.round - 1]
|
|
329
|
+
if (!live) return
|
|
330
|
+
if (!thread || typeof thread !== 'object') return
|
|
331
|
+
// Hostile-manifest guard: keep the live hard bound (with headroom for the
|
|
332
|
+
// single overflow thread) and newest-first truncate beyond it.
|
|
333
|
+
if (live.threads.length >= MAX_THREADS_PER_ROUND * 2 + 1) return
|
|
334
|
+
const dim =
|
|
335
|
+
typeof thread.dimension === 'string' && thread.dimension.trim()
|
|
336
|
+
? thread.dimension.trim()
|
|
337
|
+
: 'review'
|
|
338
|
+
const att =
|
|
339
|
+
typeof thread.attempt === 'number' && Number.isFinite(thread.attempt)
|
|
340
|
+
? Math.floor(thread.attempt)
|
|
341
|
+
: 1
|
|
342
|
+
const t: LiveThread = {
|
|
343
|
+
dimension: dim,
|
|
344
|
+
attempt: att > 0 ? att : 1,
|
|
345
|
+
messages: [],
|
|
346
|
+
readFiles: [],
|
|
347
|
+
findings: [],
|
|
348
|
+
}
|
|
349
|
+
const messages = Array.isArray(thread.messages) ? thread.messages : []
|
|
350
|
+
for (const m of messages.slice(0, MAX_MESSAGES_PER_THREAD)) {
|
|
351
|
+
if (typeof m === 'string' && m.trim()) t.messages.push(m)
|
|
352
|
+
}
|
|
353
|
+
if (Array.isArray(thread.readFiles)) {
|
|
354
|
+
t.readFiles.push(...dedupePaths(thread.readFiles as string[]))
|
|
355
|
+
}
|
|
356
|
+
if (Array.isArray(thread.findings)) {
|
|
357
|
+
for (const f of thread.findings) {
|
|
358
|
+
if (normalizeFinding(f)) pushThreadFinding(t, f)
|
|
359
|
+
}
|
|
278
360
|
}
|
|
361
|
+
live.threads.push(t)
|
|
279
362
|
this.touch()
|
|
280
363
|
}
|
|
281
364
|
|
package/src/types.ts
CHANGED
|
@@ -18,9 +18,12 @@ export interface IterateConfig {
|
|
|
18
18
|
}
|
|
19
19
|
/**
|
|
20
20
|
* LLM reasoning effort for review passes ('low' | 'medium' | 'high').
|
|
21
|
-
* Absent → follow the provider default.
|
|
22
|
-
*
|
|
23
|
-
*
|
|
21
|
+
* Absent → follow the provider default. Accepted by `iterate_config` write
|
|
22
|
+
* validation (config-write), exposed in the settings guide, and carried on
|
|
23
|
+
* the review plan (`buildReviewPlan.reasoningEffort`, plus an effort
|
|
24
|
+
* directive injected into every reviewer prompt) so the orchestrating
|
|
25
|
+
* harness can honor it per reviewer subagent. The plugin itself never reads
|
|
26
|
+
* or modifies the provider request body.
|
|
24
27
|
*/
|
|
25
28
|
reasoning_effort?: 'low' | 'medium' | 'high'
|
|
26
29
|
reviewer: {
|
|
@@ -226,7 +229,9 @@ export interface IterationStatus {
|
|
|
226
229
|
findingsCount: number
|
|
227
230
|
totalDecisionLogEntries: number
|
|
228
231
|
hasCheckpoint: boolean
|
|
229
|
-
/** True when a checkpoint is present
|
|
232
|
+
/** True when a checkpoint is present AND no decision-log entry is newer than
|
|
233
|
+
* it — i.e. the previous run left a checkpoint and nothing has logged since
|
|
234
|
+
* (a live run that saved progress keeps `interrupted=false`). */
|
|
230
235
|
interrupted: boolean
|
|
231
236
|
/** How many times the current checkpoint has already been resumed. */
|
|
232
237
|
resumeCount: number
|