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/dist/config-loader.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { existsSync, readFileSync } from 'node:fs';
|
|
1
|
+
import { existsSync, readFileSync, realpathSync } from 'node:fs';
|
|
2
2
|
import { homedir } from 'node:os';
|
|
3
3
|
import { join, resolve, sep } from 'node:path';
|
|
4
4
|
import yaml from 'js-yaml';
|
|
@@ -201,7 +201,27 @@ export function resolveProjectRoot(input, sessionCwd) {
|
|
|
201
201
|
if (raw.includes('\0')) {
|
|
202
202
|
return { ok: false, reason: 'Refusing project root containing NUL bytes.' };
|
|
203
203
|
}
|
|
204
|
-
const
|
|
204
|
+
const rootLexical = raw ? resolve(raw) : resolve(effectiveCwd(sessionCwd));
|
|
205
|
+
// Collapse symlinks (documented contract: "collapsing `..` and symlinks") —
|
|
206
|
+
// a path through a symlinked directory must resolve to its REAL location so
|
|
207
|
+
// two aliases of the same project share one `.iterate/` state, and tools can
|
|
208
|
+
// never read outside the resolved root via a link. Only when the path
|
|
209
|
+
// exists: a not-yet-created target (dirs created through the tools) keeps
|
|
210
|
+
// the lexical path so the caller can still write into it. The filesystem
|
|
211
|
+
// root check runs against the NORMALIZED path, so a symlink to `/` is
|
|
212
|
+
// refused too.
|
|
213
|
+
let root = rootLexical;
|
|
214
|
+
try {
|
|
215
|
+
if (existsSync(rootLexical)) {
|
|
216
|
+
const real = realpathSync(rootLexical);
|
|
217
|
+
if (real)
|
|
218
|
+
root = real;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
catch {
|
|
222
|
+
// realpath can throw on a broken link or a permission fence — keep the
|
|
223
|
+
// lexical path (existing behavior) rather than failing the resolution.
|
|
224
|
+
}
|
|
205
225
|
if (!root || root === sep) {
|
|
206
226
|
return { ok: false, reason: 'Refusing filesystem root as project root.' };
|
|
207
227
|
}
|
package/dist/config-write.js
CHANGED
|
@@ -25,6 +25,8 @@ export const MAX_MAX_ROUNDS = 100;
|
|
|
25
25
|
export const MAX_ATOMIC_MAX_LINES = 10_000;
|
|
26
26
|
/** Upper bound on `atomic.max_adjacent_methods`. */
|
|
27
27
|
export const MAX_MAX_ADJACENT_METHODS = 200;
|
|
28
|
+
/** Upper bound on `reviewer.scope_chunk_size` (files per reviewer task batch). */
|
|
29
|
+
export const MAX_SCOPE_CHUNK_SIZE = 1000;
|
|
28
30
|
/** Keep at most this many timestamped config backups (older ones are removed). */
|
|
29
31
|
export const MAX_CONFIG_BACKUPS = 5;
|
|
30
32
|
/**
|
|
@@ -78,6 +80,32 @@ export function validateConfigUpdates(updates) {
|
|
|
78
80
|
errors.push(`updates.max_rounds must be an integer between 1 and ${MAX_MAX_ROUNDS}`);
|
|
79
81
|
}
|
|
80
82
|
}
|
|
83
|
+
if ('reasoning_effort' in updates) {
|
|
84
|
+
if (updates.reasoning_effort !== undefined &&
|
|
85
|
+
updates.reasoning_effort !== 'low' &&
|
|
86
|
+
updates.reasoning_effort !== 'medium' &&
|
|
87
|
+
updates.reasoning_effort !== 'high') {
|
|
88
|
+
errors.push('updates.reasoning_effort must be "low", "medium", or "high"');
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
if ('reviewer' in updates) {
|
|
92
|
+
const rv = updates.reviewer;
|
|
93
|
+
if (!rv || typeof rv !== 'object') {
|
|
94
|
+
errors.push('updates.reviewer must be an object');
|
|
95
|
+
}
|
|
96
|
+
else {
|
|
97
|
+
for (const boolKey of ['output_schema_validation', 'evidence_validation', 'coverage_validation']) {
|
|
98
|
+
if (rv[boolKey] !== undefined && typeof rv[boolKey] !== 'boolean') {
|
|
99
|
+
errors.push(`updates.reviewer.${boolKey} must be a boolean`);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
if (rv.scope_chunk_size !== undefined &&
|
|
103
|
+
(typeof rv.scope_chunk_size !== 'number' || !Number.isInteger(rv.scope_chunk_size) ||
|
|
104
|
+
rv.scope_chunk_size < 1 || rv.scope_chunk_size > MAX_SCOPE_CHUNK_SIZE)) {
|
|
105
|
+
errors.push(`updates.reviewer.scope_chunk_size must be an integer between 1 and ${MAX_SCOPE_CHUNK_SIZE}`);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
81
109
|
if ('review' in updates) {
|
|
82
110
|
const r = updates.review;
|
|
83
111
|
if (!r || typeof r !== 'object') {
|
package/dist/evidence.js
CHANGED
|
@@ -83,7 +83,11 @@ export function verifyLineBounds(line, text) {
|
|
|
83
83
|
if (line === undefined || line === null || line === WHOLE_FILE_LINE) {
|
|
84
84
|
return { inBounds: true, lineTotal };
|
|
85
85
|
}
|
|
86
|
-
|
|
86
|
+
// Only a positive INTEGER is a valid line anchor. A fractional line (e.g.
|
|
87
|
+
// 3.7 from a bypassed schema) or a negative value is out of bounds — a
|
|
88
|
+
// fractional line "within" the file would otherwise be accepted and poison
|
|
89
|
+
// the evidence trail with a location nothing can jump to.
|
|
90
|
+
if (line < 1 || !Number.isInteger(line))
|
|
87
91
|
return { inBounds: false, lineTotal };
|
|
88
92
|
return { inBounds: line <= lineTotal, lineTotal };
|
|
89
93
|
}
|
package/dist/meta-review.js
CHANGED
|
@@ -129,7 +129,11 @@ export function metaReviewReport(report) {
|
|
|
129
129
|
? reportRounds[reportRounds.length - 1].round
|
|
130
130
|
: null;
|
|
131
131
|
const lastRoundNew = lastRecordedRound !== null && lastRecordedRound > 0
|
|
132
|
-
|
|
132
|
+
// Over-cap rounds are FOLDED by aggregateRounds into the final slot, so a
|
|
133
|
+
// reported round number may exceed the array length — read the SAME
|
|
134
|
+
// clamped index buildReviewReport/computeConvergence use, or a real
|
|
135
|
+
// over-cap round would be mis-read as 0 new (flag mismatch).
|
|
136
|
+
? Number(findingsByRound[Math.min(lastRecordedRound, findingsByRound.length) - 1] ?? 0)
|
|
133
137
|
: null;
|
|
134
138
|
const expectedConverged = lastRoundNew === 0;
|
|
135
139
|
if (report.convergence?.converged !== expectedConverged) {
|
package/dist/review.js
CHANGED
|
@@ -144,14 +144,17 @@ export function aggregateRounds(rounds, maxReviewRounds) {
|
|
|
144
144
|
}
|
|
145
145
|
// Clamp the allocation bound so a hostile round number cannot OOM the tool.
|
|
146
146
|
const effectiveMax = Math.min(maxRound, Math.max(1, roundCap * 2));
|
|
147
|
-
const findingsByRound =
|
|
148
|
-
for (
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
147
|
+
const findingsByRound = new Array(effectiveMax).fill(0);
|
|
148
|
+
for (const key of firstRoundByKey.keys()) {
|
|
149
|
+
const firstRound = firstRoundByKey.get(key) ?? 1;
|
|
150
|
+
// Fold rounds BEYOND the allocation bound into the highest slot instead of
|
|
151
|
+
// dropping them: a finding first seen in an over-cap round was previously
|
|
152
|
+
// invisible to `findingsByRound`, so a run whose final rounds exceeded the
|
|
153
|
+
// cap looked "converged" even though a real round found new issues. Folding
|
|
154
|
+
// keeps the counts truthful (the sum still equals totalFindings) while the
|
|
155
|
+
// memory bound stays fixed.
|
|
156
|
+
const idx = Math.min(firstRound, effectiveMax) - 1;
|
|
157
|
+
findingsByRound[idx] = (findingsByRound[idx] || 0) + 1;
|
|
155
158
|
}
|
|
156
159
|
return { findings: dedupeFindings(merged), findingsByRound, firstRoundByKey };
|
|
157
160
|
}
|
|
@@ -246,7 +249,12 @@ export function buildReviewReport(input) {
|
|
|
246
249
|
if (typeof r.round === 'number' && r.round > lastRound)
|
|
247
250
|
lastRound = r.round;
|
|
248
251
|
}
|
|
249
|
-
|
|
252
|
+
// `lastRound` can exceed the array length when over-cap rounds were FOLDED
|
|
253
|
+
// into the last slot (see aggregateRounds). Read the same clamped index
|
|
254
|
+
// computeConvergence uses, so a real over-cap round is never mis-read as 0
|
|
255
|
+
// new findings (which would falsely report "converged").
|
|
256
|
+
const idx = Math.min(lastRound, findingsByRound.length) - 1;
|
|
257
|
+
const lastRoundCount = lastRound > 0 && idx >= 0 ? (findingsByRound[idx] ?? 0) : 0;
|
|
250
258
|
const converged = filteredRounds.length > 0 && lastRoundCount === 0;
|
|
251
259
|
// Attach the normal-mode fix count to the summary (dry-run leaves it absent).
|
|
252
260
|
const computed = summarize(sorted);
|
|
@@ -517,6 +525,9 @@ export function attachmentClause(attachments) {
|
|
|
517
525
|
export function reviewerTaskPrompt(input) {
|
|
518
526
|
const parts = [];
|
|
519
527
|
parts.push(`You are the "${input.dimension}" reviewer for the iterate review.`, `Goal: ${input.goal}`, `Scope: ${input.scope === 'full' ? 'entire codebase' : 'changed files only'}.`);
|
|
528
|
+
if (input.effort) {
|
|
529
|
+
parts.push(`Reasoning effort for this review pass: ${input.effort}.`);
|
|
530
|
+
}
|
|
520
531
|
if (input.focus) {
|
|
521
532
|
parts.push(`FOCUS: ${input.focus}`);
|
|
522
533
|
}
|
|
@@ -574,6 +585,11 @@ export function buildReviewPlan(input) {
|
|
|
574
585
|
const language = input.config.language === 'zh' ? 'Chinese (中文)' : 'English';
|
|
575
586
|
const goal = input.config.goal ?? '';
|
|
576
587
|
const configuredScope = input.config.review?.scope ?? 'full';
|
|
588
|
+
const reasoningEffort = input.config.reasoning_effort === 'low' ||
|
|
589
|
+
input.config.reasoning_effort === 'medium' ||
|
|
590
|
+
input.config.reasoning_effort === 'high'
|
|
591
|
+
? input.config.reasoning_effort
|
|
592
|
+
: null;
|
|
577
593
|
const dimensions = Array.isArray(input.config.dimensions) ? input.config.dimensions : [];
|
|
578
594
|
const maxLines = input.config.atomic?.max_lines ?? 20;
|
|
579
595
|
const changedFiles = Array.isArray(input.changedFiles) ? input.changedFiles : [];
|
|
@@ -632,6 +648,7 @@ export function buildReviewPlan(input) {
|
|
|
632
648
|
scopeFiles: batch,
|
|
633
649
|
focus: focusMap.get(d),
|
|
634
650
|
attachments,
|
|
651
|
+
effort: reasoningEffort ?? undefined,
|
|
635
652
|
}),
|
|
636
653
|
findingsSchema: findingsSchema(),
|
|
637
654
|
});
|
|
@@ -647,5 +664,6 @@ export function buildReviewPlan(input) {
|
|
|
647
664
|
changedFiles: effectiveChangedOnly ? changedFiles : [],
|
|
648
665
|
fallbackToFull,
|
|
649
666
|
attachments,
|
|
667
|
+
reasoningEffort,
|
|
650
668
|
};
|
|
651
669
|
}
|
package/dist/skill-prompt.js
CHANGED
|
@@ -19,7 +19,7 @@ You have the iterate plugin installed, which registers these tools:
|
|
|
19
19
|
- \`iterate_diff\` — show the accumulated diff for a fixed file (vs its original backup) or a per-file summary of all fixes
|
|
20
20
|
- \`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
|
|
21
21
|
- \`iterate_checkpoint\` — save / load / clear an iteration checkpoint (\`.iterate/checkpoint.json\`) so a long run can resume where it left off
|
|
22
|
-
- \`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
|
|
22
|
+
- \`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)
|
|
23
23
|
- \`iterate_history\` — inspect the runtime state in detail: decision-log entries and applied fixes (optionally scoped to a round or a fixed file)
|
|
24
24
|
- \`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.
|
|
25
25
|
- \`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.
|
|
@@ -80,6 +80,13 @@ const maxRounds = plan.maxReviewRounds
|
|
|
80
80
|
const knownIntentional = (plan.knownIntentional || []) // config personalization filter, applied in aggregate
|
|
81
81
|
let known = [] // cumulative DEDUPED findings fed back to reviewers
|
|
82
82
|
const rounds = [] // raw per-round findings
|
|
83
|
+
// Tracks whether the LAST executed round produced usable reviewer output. A
|
|
84
|
+
// round where every reviewer subagent failed (or output stayed schema-invalid
|
|
85
|
+
// after retries) is INCONCLUSIVE — it must never make the run look converged.
|
|
86
|
+
let lastRoundOk = true
|
|
87
|
+
// Slot index of the current round within this run's rounds array. A resumed
|
|
88
|
+
// run starts at startRound > 1, so the round NUMBER is not a valid array index.
|
|
89
|
+
let roundSlot = -1
|
|
83
90
|
|
|
84
91
|
phase('transcript')
|
|
85
92
|
// Read any steering nudge written (via iterate_transcript nudge) for this run's reviewers.
|
|
@@ -92,9 +99,14 @@ const steering = transRead && typeof transRead.nudge === 'string' && transRead.n
|
|
|
92
99
|
phase('review')
|
|
93
100
|
for (let r = 1; r <= maxRounds; r++) {
|
|
94
101
|
log('round ' + r + ' of ' + maxRounds + ' — finding NEW issues only')
|
|
102
|
+
roundSlot = rounds.length
|
|
95
103
|
let agg = null
|
|
96
104
|
let schemaInvalid = false
|
|
97
105
|
let attempts = 0
|
|
106
|
+
// Declared OUTSIDE the do-block: it is read after the retry loop exits, and a
|
|
107
|
+
// block-scoped const here raised ReferenceError at runtime (crashing both
|
|
108
|
+
// canonical workflows after the first round).
|
|
109
|
+
let reviewersOk = true
|
|
98
110
|
do {
|
|
99
111
|
attempts += 1
|
|
100
112
|
// Schema validation retry: on the 2nd+ pass, nudge reviewers toward strict JSON.
|
|
@@ -117,6 +129,12 @@ for (let r = 1; r <= maxRounds; r++) {
|
|
|
117
129
|
JSON.stringify(known) + nudge + '\\nReturn the findings JSON object.'
|
|
118
130
|
return agent(base + extra, Object.assign({ label: 'review:' + dim + ':r' + r, schema: meta.findingsSchema }, backend))
|
|
119
131
|
}))
|
|
132
|
+
// A per-dimension reviewer subagent resolves \`null\` on child failure; the
|
|
133
|
+
// aggregate below would report an all-failed round as an empty round, and
|
|
134
|
+
// the convergence math would then read "0 new findings". An all-failed
|
|
135
|
+
// round is NOT a clean pass — it is inconclusive, so the convergence gate
|
|
136
|
+
// below refuses it before any "converged" signal fires.
|
|
137
|
+
reviewersOk = raw.length > 0 ? raw.some(x => x !== null && typeof x === 'object') : true
|
|
120
138
|
const thisRound = {
|
|
121
139
|
round: r,
|
|
122
140
|
findings: [].concat(...raw.map(x => x && x.findings ? x.findings : [])),
|
|
@@ -124,7 +142,7 @@ for (let r = 1; r <= maxRounds; r++) {
|
|
|
124
142
|
// gate can compare self-reported reads against the assigned inventory.
|
|
125
143
|
readFiles: [].concat(...raw.map(x => x && Array.isArray(x.readFiles) ? x.readFiles : [])),
|
|
126
144
|
}
|
|
127
|
-
if (rounds.length
|
|
145
|
+
if (roundSlot < rounds.length) rounds[roundSlot] = thisRound; else rounds.push(thisRound)
|
|
128
146
|
// Deterministic aggregate: cross-round dedupe + known_intentional filter + severity sort.
|
|
129
147
|
agg = await agent(
|
|
130
148
|
'Call iterate_review({operation:"aggregate", mode:"dry-run", rounds:' + JSON.stringify(rounds) + ', maxReviewRounds:' + maxRounds + ', knownIntentional:' + JSON.stringify(knownIntentional) + '}) and return the report JSON.',
|
|
@@ -144,23 +162,30 @@ for (let r = 1; r <= maxRounds; r++) {
|
|
|
144
162
|
// final summary can explain WHY the round was inconclusive.
|
|
145
163
|
log('round ' + r + ' output STILL schema-invalid after 3 attempts — marking round inconclusive (NOT converged)')
|
|
146
164
|
}
|
|
165
|
+
const roundUnusable = schemaInvalid || !reviewersOk
|
|
166
|
+
if (roundUnusable) lastRoundOk = false
|
|
147
167
|
// Feed the DEDUPED + already-filtered set back (not raw findings) so the known
|
|
148
|
-
// list stays bounded and reviewers never see the same issue twice.
|
|
149
|
-
|
|
168
|
+
// list stays bounded and reviewers never see the same issue twice. Never feed
|
|
169
|
+
// an unusable round's empty result — it would erase the still-valid known list
|
|
170
|
+
// and let reviewers re-report everything next round.
|
|
171
|
+
if (!roundUnusable && agg && agg.report && Array.isArray(agg.report.findings)) known = agg.report.findings
|
|
150
172
|
// A round counts as "converged" ONLY when the aggregate accepted it
|
|
151
|
-
// (schema-valid) AND
|
|
152
|
-
//
|
|
153
|
-
//
|
|
154
|
-
//
|
|
155
|
-
|
|
173
|
+
// (schema-valid) AND its reviewers actually returned output AND it genuinely
|
|
174
|
+
// found zero NEW findings. If reviewers all failed or their output stayed
|
|
175
|
+
// schema-invalid after retries, the round is INCONCLUSIVE — never report an
|
|
176
|
+
// empty-but-broken round as converged (an invalid/failed round would
|
|
177
|
+
// otherwise masquerade as a clean pass and stop the run early).
|
|
178
|
+
if (!roundUnusable && !schemaInvalid) {
|
|
156
179
|
const newCount =
|
|
157
180
|
agg && agg.report && agg.report.convergence
|
|
158
181
|
? agg.report.convergence.findingsByRound[r - 1]
|
|
159
182
|
: thisRound.findings.length
|
|
160
183
|
if (newCount === 0) {
|
|
161
184
|
log('round ' + r + ' found 0 new findings — converged')
|
|
185
|
+
lastRoundOk = true
|
|
162
186
|
break
|
|
163
187
|
}
|
|
188
|
+
lastRoundOk = true
|
|
164
189
|
}
|
|
165
190
|
}
|
|
166
191
|
|
|
@@ -196,8 +221,13 @@ return {
|
|
|
196
221
|
mode: 'dry-run',
|
|
197
222
|
goal: report.goal,
|
|
198
223
|
rounds: rounds.length,
|
|
199
|
-
|
|
200
|
-
|
|
224
|
+
// The aggregate's convergence flag is only trustworthy when the last round
|
|
225
|
+
// was usable — an all-failed/schema-invalid final round looks like "0 new"
|
|
226
|
+
// to the convergence math but is an inconclusive run, never a clean pass.
|
|
227
|
+
converged: report.convergence.converged && lastRoundOk,
|
|
228
|
+
stoppedReason: report.convergence.converged && !lastRoundOk
|
|
229
|
+
? 'inconclusive'
|
|
230
|
+
: report.convergence.stoppedReason,
|
|
201
231
|
findingsByRound: report.convergence.findingsByRound,
|
|
202
232
|
totalFindings: report.summary.totalFindings,
|
|
203
233
|
bySeverity: { critical: report.summary.critical, high: report.summary.high, medium: report.summary.medium, low: report.summary.low },
|
|
@@ -259,7 +289,7 @@ const configRes = await agent(
|
|
|
259
289
|
const cfg = (configRes && configRes.config) ? configRes.config : null
|
|
260
290
|
const atomicMaxLines = (cfg && cfg.atomic && cfg.atomic.max_lines) ? cfg.atomic.max_lines : 20
|
|
261
291
|
const planRes = await agent(
|
|
262
|
-
'Call iterate_review({operation:"plan", mode:"normal", maxReviewRounds:' +
|
|
292
|
+
'Call iterate_review({operation:"plan", mode:"normal"' + (args.maxRounds ? ', maxReviewRounds:' + args.maxRounds : '') + '}) and return the plan JSON.',
|
|
263
293
|
Object.assign({ label: 'review:plan' }, backend)
|
|
264
294
|
)
|
|
265
295
|
const plan = (planRes && planRes.plan) ? planRes.plan : null
|
|
@@ -273,9 +303,16 @@ const architectural = (checkpoint && Array.isArray(checkpoint.findings)) ? check
|
|
|
273
303
|
let fixedCount = (checkpoint && typeof checkpoint.fixedCount === 'number') ? checkpoint.fixedCount : 0
|
|
274
304
|
let converged = false
|
|
275
305
|
let abortedByValidation = false
|
|
306
|
+
let schemaFailed = false // last round produced no usable reviewer output (schema-invalid after retries / all reviewers failed)
|
|
276
307
|
let failedCommands = []
|
|
277
308
|
let configErrors = [] // validation commands NOT in validation.commands (config gap, no rollback)
|
|
278
309
|
const fixRecords = [] // observatory fix records collected round by round
|
|
310
|
+
// Slot index of the current round within this run's rounds array. A resumed
|
|
311
|
+
// run starts at startRound > 1, so the round NUMBER is not a valid array index.
|
|
312
|
+
let roundSlot = -1
|
|
313
|
+
// Highest round number actually executed by THIS run (carried so a resumed run
|
|
314
|
+
// reports its true round, not the count of rounds it happened to run).
|
|
315
|
+
let lastRound = startRound - 1
|
|
279
316
|
|
|
280
317
|
// Read any steering nudge intended for this run's reviewers.
|
|
281
318
|
const transRead = await agent(
|
|
@@ -287,6 +324,8 @@ const steering = transRead && typeof transRead.nudge === 'string' && transRead.n
|
|
|
287
324
|
phase('loop')
|
|
288
325
|
for (let r = startRound; r <= maxRounds; r++) {
|
|
289
326
|
log('round ' + r + ' of ' + maxRounds + ' — review current state, fix atomics via iterate_fix, validate')
|
|
327
|
+
roundSlot = rounds.length
|
|
328
|
+
lastRound = r
|
|
290
329
|
// Audit-trail: record the round start (SKILL.md Phase 4 requires per-round records).
|
|
291
330
|
await agent(
|
|
292
331
|
'Call iterate_decision_log({operation:"append", type:"round_start", round:' + r + ', data:{maxRounds:' + maxRounds + ', fixedSoFar:' + fixedCount + '}})',
|
|
@@ -295,6 +334,10 @@ for (let r = startRound; r <= maxRounds; r++) {
|
|
|
295
334
|
let agg = null
|
|
296
335
|
let schemaInvalid = false
|
|
297
336
|
let attempts = 0
|
|
337
|
+
// Declared OUTSIDE the do-block: it is read after the retry loop exits, and a
|
|
338
|
+
// block-scoped const here raised ReferenceError at runtime (crashing both
|
|
339
|
+
// canonical workflows after the first round).
|
|
340
|
+
let reviewersOk = true
|
|
298
341
|
do {
|
|
299
342
|
attempts += 1
|
|
300
343
|
// Schema validation retry: on the 2nd+ pass, nudge reviewers toward strict JSON.
|
|
@@ -315,12 +358,16 @@ for (let r = startRound; r <= maxRounds; r++) {
|
|
|
315
358
|
'\\n Do NOT re-report already-known architectural findings: ' + JSON.stringify(architectural) + nudge + '\\nReturn the findings JSON object.'
|
|
316
359
|
return agent(base + extra, Object.assign({ label: 'review:' + dim + ':r' + r, schema: meta.findingsSchema }, backend))
|
|
317
360
|
}))
|
|
361
|
+
// ALL reviewers returning null (child failures) means this round produced
|
|
362
|
+
// no usable output — an empty aggregate must not read like a clean
|
|
363
|
+
// "nothing to fix" convergence. Gate the loop exit + convergence on it.
|
|
364
|
+
reviewersOk = raw.length > 0 ? raw.some(x => x !== null && typeof x === 'object') : true
|
|
318
365
|
const thisRound = {
|
|
319
366
|
round: r,
|
|
320
367
|
findings: [].concat(...raw.map(x => x && x.findings ? x.findings : [])),
|
|
321
368
|
readFiles: [].concat(...raw.map(x => x && Array.isArray(x.readFiles) ? x.readFiles : [])),
|
|
322
369
|
}
|
|
323
|
-
if (rounds.length
|
|
370
|
+
if (roundSlot < rounds.length) rounds[roundSlot] = thisRound; else rounds.push(thisRound)
|
|
324
371
|
|
|
325
372
|
// Deterministic dedupe / known_intentional filter / severity sort for this round.
|
|
326
373
|
// \`fixedCount\` is threaded into the report summary so the client dashboard can
|
|
@@ -338,10 +385,19 @@ for (let r = startRound; r <= maxRounds; r++) {
|
|
|
338
385
|
log('retry ' + attempts + ': round ' + r + ' output failed schema validation — re-running reviewers with strict-JSON emphasis')
|
|
339
386
|
}
|
|
340
387
|
} while (schemaInvalid && attempts < 3)
|
|
341
|
-
if (schemaInvalid) {
|
|
342
|
-
// Bounded exit: never loop forever
|
|
343
|
-
//
|
|
344
|
-
|
|
388
|
+
if (schemaInvalid || !reviewersOk) {
|
|
389
|
+
// Bounded exit: never loop forever, and never let an all-failed/empty round
|
|
390
|
+
// read like a clean convergence. Stop the run WITHOUT applying fixes for
|
|
391
|
+
// this round, record the failure in the audit trail, and leave the
|
|
392
|
+
// checkpoint in place so a user/DM can resume or re-roll after fixing the
|
|
393
|
+
// reviewer configuration.
|
|
394
|
+
schemaFailed = true
|
|
395
|
+
log('round ' + r + ' produced NO usable reviewer output (schema-invalid after retries or all reviewers failed) — stopping run (NOT converged)')
|
|
396
|
+
await agent(
|
|
397
|
+
'Call iterate_decision_log({operation:"append", type:"round_failed", round:' + r + ', data:{reason:"' + (schemaInvalid ? 'schema_invalid' : 'no_usable_reviewer_output') + '", rolledBack:0}})',
|
|
398
|
+
Object.assign({ label: 'log:schemaFailed:r' + r }, backend)
|
|
399
|
+
)
|
|
400
|
+
break
|
|
345
401
|
}
|
|
346
402
|
const findings = (agg && agg.report && agg.report.findings) ? agg.report.findings : thisRound.findings
|
|
347
403
|
const atomic = findings.filter(f => f.is_atomic === true)
|
|
@@ -466,7 +522,7 @@ for (let r = startRound; r <= maxRounds; r++) {
|
|
|
466
522
|
|
|
467
523
|
phase('report')
|
|
468
524
|
await agent(
|
|
469
|
-
'Call iterate_decision_log({operation:"append", type:"report", round:' +
|
|
525
|
+
'Call iterate_decision_log({operation:"append", type:"report", round:' + lastRound +
|
|
470
526
|
', data:{mode:"normal", fixed:' + fixedCount + ', architectural:' + architectural.length + '}})',
|
|
471
527
|
{ label: 'report:log' }
|
|
472
528
|
)
|
|
@@ -475,7 +531,7 @@ const statusRes = await agent(
|
|
|
475
531
|
{ label: 'status:final' }
|
|
476
532
|
)
|
|
477
533
|
const status = (statusRes && statusRes.ok) ? statusRes : null
|
|
478
|
-
if (!abortedByValidation) {
|
|
534
|
+
if (!abortedByValidation && !schemaFailed) {
|
|
479
535
|
// Iteration finished cleanly → clear the checkpoint so the next run starts fresh.
|
|
480
536
|
await agent(
|
|
481
537
|
'Call iterate_checkpoint({ operation: "clear" }) and return {ok, existed}.',
|
|
@@ -489,25 +545,28 @@ if (!abortedByValidation) {
|
|
|
489
545
|
// would hide the very thing F5 is meant to resume. Reflect what is on disk.
|
|
490
546
|
const obsCheckpoint = {
|
|
491
547
|
mode: 'normal',
|
|
492
|
-
round:
|
|
548
|
+
round: lastRound,
|
|
493
549
|
maxRounds: maxRounds,
|
|
494
550
|
fixedCount: fixedCount,
|
|
495
551
|
resumeCount: effectiveResumeCount,
|
|
496
552
|
}
|
|
497
|
-
const stopReason =
|
|
498
|
-
?
|
|
499
|
-
:
|
|
553
|
+
const stopReason = schemaFailed
|
|
554
|
+
? 'schema_invalid'
|
|
555
|
+
: abortedByValidation
|
|
556
|
+
? (configErrors.length > 0 ? 'aborted_by_config' : 'aborted_by_validation')
|
|
557
|
+
: (converged ? 'converged' : 'max_rounds_reached')
|
|
500
558
|
await agent(
|
|
501
|
-
'Call iterate_transcript({operation:"capture", mode:"normal", goal:' + JSON.stringify(plan.goal) + ', maxRounds:' + maxRounds + ', roundsExecuted:' +
|
|
559
|
+
'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"}.',
|
|
502
560
|
{ label: 'transcript:capture' }
|
|
503
561
|
)
|
|
504
562
|
return {
|
|
505
563
|
mode: 'normal',
|
|
506
564
|
goal: plan.goal,
|
|
507
|
-
roundsExecuted:
|
|
565
|
+
roundsExecuted: lastRound,
|
|
508
566
|
maxRounds: maxRounds,
|
|
509
567
|
converged: converged,
|
|
510
568
|
abortedByValidation: abortedByValidation,
|
|
569
|
+
schemaFailed: schemaFailed,
|
|
511
570
|
failedCommands: failedCommands,
|
|
512
571
|
configErrors: configErrors,
|
|
513
572
|
stoppedReason: stopReason,
|
|
@@ -528,12 +587,12 @@ return {
|
|
|
528
587
|
Key rules for normal mode:
|
|
529
588
|
- 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.
|
|
530
589
|
- Aggregate the current round deterministically (\`report.findings\`) before fixing, so fixes act on deduped/filtered/sorted findings.
|
|
531
|
-
- **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
|
|
590
|
+
- **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.
|
|
532
591
|
- 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.
|
|
533
592
|
- **Resume**: load the checkpoint first; if a previous run left one, continue from \`checkpoint.round + 1\` (its \`fixedCount\` and deduped \`findings\` are carried forward).
|
|
534
593
|
- **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.
|
|
535
594
|
- **Checkpoint after every round**; clear it only when the iteration completes cleanly.
|
|
536
|
-
- Stop when a round produces nothing to fix (converged)
|
|
595
|
+
- 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"\`).
|
|
537
596
|
- Every round, every rollback, and the final report go to the append-only decision log.
|
|
538
597
|
- Close with \`iterate_status\` metrics and surface the convergence indicators (fixed count, remaining architectural count, abort reason) in the final summary.
|
|
539
598
|
|
package/dist/tools/checkpoint.js
CHANGED
|
@@ -148,9 +148,16 @@ export function computeStatus(input) {
|
|
|
148
148
|
findingsCount: Array.isArray(checkpoint?.findings) ? checkpoint.findings.length : 0,
|
|
149
149
|
totalDecisionLogEntries: entries.length,
|
|
150
150
|
hasCheckpoint: checkpoint !== null,
|
|
151
|
-
// A checkpoint left on disk
|
|
152
|
-
//
|
|
153
|
-
|
|
151
|
+
// A checkpoint left on disk is the durable "the run did not finish" signal
|
|
152
|
+
// — EXCEPT when a decision-log entry is NEWER than the checkpoint: the
|
|
153
|
+
// workflow saves a checkpoint at the end of each round (right after that
|
|
154
|
+
// round's review_result log), so a live run proceeding past its last
|
|
155
|
+
// checkpoint would otherwise be flagged as "interrupted" for its whole
|
|
156
|
+
// duration. A fresh checkpoint (or no log at all) is genuinely leftover.
|
|
157
|
+
interrupted: checkpoint !== null &&
|
|
158
|
+
!(lastEntry != null &&
|
|
159
|
+
checkpoint.updatedAt &&
|
|
160
|
+
(Date.parse(checkpoint.updatedAt) || 0) >= (Date.parse(lastEntry.timestamp) || 0)),
|
|
154
161
|
resumeCount: checkpoint?.resumeCount ?? 0,
|
|
155
162
|
checkpoint,
|
|
156
163
|
lastUpdated,
|
|
@@ -315,7 +322,7 @@ export function registerStatusTool(ctx) {
|
|
|
315
322
|
findingsCount: { type: 'integer' },
|
|
316
323
|
totalDecisionLogEntries: { type: 'integer' },
|
|
317
324
|
hasCheckpoint: { type: 'boolean' },
|
|
318
|
-
interrupted: { type: 'boolean', description: 'True when a checkpoint exists
|
|
325
|
+
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.' },
|
|
319
326
|
resumeCount: { type: 'integer', description: 'How many times the current checkpoint has already been resumed.' },
|
|
320
327
|
lastUpdated: { oneOf: [{ type: 'string' }, { type: 'null' }] },
|
|
321
328
|
qualityGate: { type: 'json', description: 'v3.0: persisted quality-gate snapshot (.iterate/quality-gate.json), when present.' },
|
|
@@ -122,6 +122,17 @@ function recountEntries(filePath) {
|
|
|
122
122
|
return 1;
|
|
123
123
|
}
|
|
124
124
|
}
|
|
125
|
+
/**
|
|
126
|
+
* Drop the cached entry count for a log file. The cache is keyed by the log
|
|
127
|
+
* path and assumes "same byte size ⇒ same entry count"; a prune rewrite
|
|
128
|
+
* (temp + atomic rename) replaces the file with a DIFFERENT number of entries,
|
|
129
|
+
* so only a stale-cache invalidation keeps the fast path honest. Without it, a
|
|
130
|
+
* rewrite that lands on the exact same byte size as the previous append would
|
|
131
|
+
* make the next append report a wrong `count`.
|
|
132
|
+
*/
|
|
133
|
+
export function invalidateLogCountCache(filePath) {
|
|
134
|
+
entryCountCache.delete(filePath);
|
|
135
|
+
}
|
|
125
136
|
/** All valid DecisionLogEntry `type` values (must stay in sync with Types). */
|
|
126
137
|
const VALID_ENTRY_TYPES = new Set([
|
|
127
138
|
'round_start',
|
package/dist/tools/history.js
CHANGED
|
@@ -84,7 +84,7 @@ export function registerHistoryTool(ctx) {
|
|
|
84
84
|
},
|
|
85
85
|
limit: {
|
|
86
86
|
type: 'integer',
|
|
87
|
-
description: `Max entries to return (default: ${DEFAULT_LIMIT}, cap: ${MAX_LIMIT}).
|
|
87
|
+
description: `Max entries to return (default: ${DEFAULT_LIMIT}, cap: ${MAX_LIMIT}). Returns the newest window in chronological order.`,
|
|
88
88
|
},
|
|
89
89
|
path: {
|
|
90
90
|
type: 'string',
|
package/dist/tools/prune.js
CHANGED
|
@@ -23,12 +23,12 @@
|
|
|
23
23
|
* - dryRun=true by default — the caller must explicitly opt into deletion.
|
|
24
24
|
* - Each deletion is logged to the decision log (when not dry-run).
|
|
25
25
|
*/
|
|
26
|
-
import { existsSync, readdirSync, rmSync, unlinkSync } from 'node:fs';
|
|
26
|
+
import { existsSync, readdirSync, rmSync, unlinkSync, statSync } from 'node:fs';
|
|
27
27
|
import { join } from 'node:path';
|
|
28
28
|
import { defineTool } from '@deepseek-ai/dsh-tools';
|
|
29
29
|
import { resolveProjectRootForExec } from "../config-loader.js";
|
|
30
30
|
import { writeTextAtomic, writeJsonAtomic } from "../atomic-fs.js";
|
|
31
|
-
import { readDecisionEntries, appendDecisionEntry, acquireLogLock } from "./decision-log.js";
|
|
31
|
+
import { readDecisionEntries, appendDecisionEntry, acquireLogLock, invalidateLogCountCache } from "./decision-log.js";
|
|
32
32
|
import { readRegistry, recomputeRoundCounts } from "./fix.js";
|
|
33
33
|
import { readExperienceBank, writeExperienceBank } from "./experience-store.js";
|
|
34
34
|
import { readDefenseEvents, writeDefenseEvents } from "./defense-store.js";
|
|
@@ -143,8 +143,22 @@ export function inspectPrune(projectRoot, retainDays) {
|
|
|
143
143
|
// 1. Decision-log entries older than retainDays.
|
|
144
144
|
const entries = readDecisionEntries(projectRoot);
|
|
145
145
|
const oldLogEntries = entries.filter((e) => e.timestamp < cutoff).length;
|
|
146
|
-
// 2. Checkpoint presence.
|
|
146
|
+
// 2. Checkpoint presence. A checkpoint is only STALE once it is older than
|
|
147
|
+
// retainDays — a fresh checkpoint means a run was interrupted recently and
|
|
148
|
+
// may still be resumed, so `hasCheckpoint` is kept as pure existence while
|
|
149
|
+
// `checkpointStale` decides whether a prune actually deletes it.
|
|
147
150
|
const hasCheckpoint = existsSync(checkpointPath(projectRoot));
|
|
151
|
+
let checkpointStale = false;
|
|
152
|
+
if (hasCheckpoint) {
|
|
153
|
+
try {
|
|
154
|
+
const st = statSync(checkpointPath(projectRoot));
|
|
155
|
+
if (st && st.mtimeMs < Date.parse(cutoff))
|
|
156
|
+
checkpointStale = true;
|
|
157
|
+
}
|
|
158
|
+
catch {
|
|
159
|
+
checkpointStale = true; // unreadable mtime — treat as garbage / deletable
|
|
160
|
+
}
|
|
161
|
+
}
|
|
148
162
|
// 3. Stale fix backups: .bak files whose fix-id prefix is not in the registry.
|
|
149
163
|
const registry = readRegistry(projectRoot);
|
|
150
164
|
const activeIds = new Set();
|
|
@@ -204,6 +218,7 @@ export function inspectPrune(projectRoot, retainDays) {
|
|
|
204
218
|
return {
|
|
205
219
|
oldLogEntries,
|
|
206
220
|
hasCheckpoint,
|
|
221
|
+
checkpointStale,
|
|
207
222
|
staleBackups,
|
|
208
223
|
staleTemps,
|
|
209
224
|
emptyRounds,
|
|
@@ -248,6 +263,12 @@ function rewriteDecisionLogKeepingRecent(projectRoot, cutoff) {
|
|
|
248
263
|
if (deleted === 0)
|
|
249
264
|
return { deleted: 0 };
|
|
250
265
|
writeTextAtomic(logPath, kept.map((e) => JSON.stringify(e)).join('\n') + '\n');
|
|
266
|
+
// The append-side entry-count cache is keyed by path and assumes "same
|
|
267
|
+
// byte size ⇒ same count"; the rename above replaced the file with a
|
|
268
|
+
// different number of entries, so drop the stale cache or the next
|
|
269
|
+
// append can report a wrong entryCount when the sizes coincidentally
|
|
270
|
+
// match.
|
|
271
|
+
invalidateLogCountCache(logPath);
|
|
251
272
|
// A concurrent appender (cross-process, lock-unavailable path) may have
|
|
252
273
|
// landed new lines after our read. If the log grew during the write
|
|
253
274
|
// window, re-read and prune again instead of accepting a lost audit trail.
|
|
@@ -292,8 +313,10 @@ export function executePrune(projectRoot, retainDays, report) {
|
|
|
292
313
|
if (error)
|
|
293
314
|
result.errors.push(error);
|
|
294
315
|
}
|
|
295
|
-
// 2. Remove checkpoint
|
|
296
|
-
|
|
316
|
+
// 2. Remove checkpoint — ONLY when it is actually stale (older than
|
|
317
|
+
// retainDays). A fresh checkpoint is a live resume point for a recently
|
|
318
|
+
// interrupted run; pruning it would silently destroy the ability to resume.
|
|
319
|
+
if (report.checkpointStale) {
|
|
297
320
|
try {
|
|
298
321
|
rmSync(checkpointPath(projectRoot), { force: true });
|
|
299
322
|
result.deletedCheckpoint = true;
|
|
@@ -422,7 +445,7 @@ export function registerPruneTool(ctx) {
|
|
|
422
445
|
const lines = [
|
|
423
446
|
`[dry-run] prune report (retainDays=${value.retainDays}):`,
|
|
424
447
|
` Decision-log entries to remove: ${report?.oldLogEntries ?? '?'} (of ${report?.totalLogEntries ?? '?'})`,
|
|
425
|
-
` Checkpoint to delete: ${report?.
|
|
448
|
+
` Checkpoint to delete: ${report?.checkpointStale ? 'yes (stale)' : report?.hasCheckpoint ? 'no (fresh — resume point, kept)' : 'none'}`,
|
|
426
449
|
` Stale backups to delete: ${report?.staleBackups?.length ?? 0}`,
|
|
427
450
|
` Stray temp files to delete: ${report?.staleTemps?.length ?? 0}`,
|
|
428
451
|
` Empty rounds to trim: ${report?.emptyRounds?.length ?? 0}`,
|