iterate-plugin 3.5.7 → 3.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. package/README.md +64 -20
  2. package/README.zh-CN.md +53 -17
  3. package/dist/approval-gate.js +143 -23
  4. package/dist/atomic-fs.js +148 -17
  5. package/dist/config-loader.js +509 -34
  6. package/dist/config-write.js +83 -22
  7. package/dist/evidence.js +141 -87
  8. package/dist/file-lock.js +165 -0
  9. package/dist/index.js +54 -28
  10. package/dist/jobs.js +56 -11
  11. package/dist/live.js +52 -13
  12. package/dist/meta-review.js +132 -28
  13. package/dist/method-scope.js +31 -8
  14. package/dist/review-scope.js +32 -9
  15. package/dist/review.js +96 -17
  16. package/dist/session-hooks.js +55 -13
  17. package/dist/skill-prompt.js +396 -66
  18. package/dist/tools/checkpoint.js +85 -19
  19. package/dist/tools/config.js +44 -2
  20. package/dist/tools/context.js +22 -5
  21. package/dist/tools/decision-log.js +111 -27
  22. package/dist/tools/defense-events.js +90 -31
  23. package/dist/tools/defense-store.js +28 -3
  24. package/dist/tools/experience-bank.js +108 -43
  25. package/dist/tools/experience-store.js +54 -4
  26. package/dist/tools/fix.js +347 -119
  27. package/dist/tools/history.js +68 -14
  28. package/dist/tools/present.js +80 -0
  29. package/dist/tools/prune.js +150 -53
  30. package/dist/tools/quality-gate.js +52 -2
  31. package/dist/tools/quality-store.js +17 -1
  32. package/dist/tools/review.js +158 -7
  33. package/dist/tools/transcript.js +173 -36
  34. package/dist/tools/triage.js +67 -12
  35. package/dist/tools/validate.js +115 -24
  36. package/dist/transcript.js +267 -4
  37. package/lib/client.js +945 -181
  38. package/lib/parse.js +760 -57
  39. package/package.json +14 -12
  40. package/src/approval-gate.ts +160 -23
  41. package/src/atomic-fs.ts +152 -17
  42. package/src/client/index.ts +600 -129
  43. package/src/config-loader.ts +542 -35
  44. package/src/config-write.ts +108 -26
  45. package/src/evidence.ts +194 -92
  46. package/src/file-lock.ts +184 -0
  47. package/src/index.ts +53 -29
  48. package/src/jobs.ts +63 -12
  49. package/src/live.ts +48 -11
  50. package/src/meta-review.ts +151 -34
  51. package/src/method-scope.ts +31 -8
  52. package/src/review-scope.ts +33 -10
  53. package/src/review.ts +96 -17
  54. package/src/session-hooks.ts +54 -12
  55. package/src/skill-prompt.ts +396 -66
  56. package/src/tools/checkpoint.ts +81 -19
  57. package/src/tools/config.ts +44 -2
  58. package/src/tools/context.ts +25 -5
  59. package/src/tools/decision-log.ts +115 -22
  60. package/src/tools/defense-events.ts +87 -31
  61. package/src/tools/defense-store.ts +30 -3
  62. package/src/tools/experience-bank.ts +112 -44
  63. package/src/tools/experience-store.ts +54 -5
  64. package/src/tools/fix.ts +258 -20
  65. package/src/tools/history.ts +74 -15
  66. package/src/tools/present.ts +89 -0
  67. package/src/tools/prune.ts +154 -51
  68. package/src/tools/quality-gate.ts +57 -2
  69. package/src/tools/quality-store.ts +18 -1
  70. package/src/tools/review.ts +155 -6
  71. package/src/tools/transcript.ts +178 -36
  72. package/src/tools/triage.ts +70 -13
  73. package/src/tools/validate.ts +138 -32
  74. package/src/transcript.ts +279 -4
  75. package/src/types.ts +29 -1
package/dist/index.js CHANGED
@@ -47,34 +47,60 @@ import { ITERATE_SKILL_PROMPT } from "./skill-prompt.js";
47
47
  export const name = 'iterate-plugin';
48
48
  export const inject = ['tools', 'systemPrompt'];
49
49
  export function apply(ctx) {
50
- // 1. Register the 17 tools (14 original + 3 v3.0)
51
- registerConfigTool(ctx);
52
- registerValidateTool(ctx);
53
- registerDecisionLogTool(ctx);
54
- registerContextTool(ctx);
55
- registerReviewTool(ctx);
56
- registerTriageTool(ctx);
57
- registerFixTool(ctx);
58
- registerDiffTool(ctx);
59
- registerRollbackTool(ctx);
60
- registerCheckpointTool(ctx);
61
- registerStatusTool(ctx);
62
- registerHistoryTool(ctx);
63
- registerPruneTool(ctx);
64
- registerTranscriptTool(ctx);
65
- // v3.0: Quality Command Center tools
66
- registerExperienceBankTool(ctx);
67
- registerQualityGateTool(ctx);
68
- registerDefenseEventsTool(ctx);
69
- // 2. Wire the observatory approval gate onto dsh's tools/pre-execute waterfall,
70
- // and the live reviewer-activity feed onto tools/result.
50
+ // 0. Wire the SAFETY hooks BEFORE any tool is registered.
51
+ // Registration is a list of 17 independent calls; if one of them threw
52
+ // (duplicate name, malformed definition, hostile ctx) while the gate was
53
+ // registered last, the tools registered up to that point would keep
54
+ // running with NO `tools/pre-execute` approval gate — a fail-open hole
55
+ // created by load order. Registering the gate first means a partial load
56
+ // can only ever err on the side of MORE gating, never less.
71
57
  registerSessionHooks(ctx);
72
58
  registerLiveCapture(ctx);
73
- // 2. Inject the iterate skill prompt as a system prompt section
74
- // This teaches the model how to write iterate workflow scripts using the tools.
75
- ctx.systemPrompt.section({
76
- name: 'iterate-skill',
77
- order: 100,
78
- text: ITERATE_SKILL_PROMPT,
79
- });
59
+ // 1. Register the 17 tools (14 original + 3 v3.0). Each registration is
60
+ // isolated: one failing tool must not take down the other 16, the gate
61
+ // above, or the prompt below (a partial plugin that still gates and
62
+ // teaches the workflow beats an aborted `apply`). Failures are logged,
63
+ // never swallowed silently.
64
+ const toolRegistrations = [
65
+ ['iterate_config', () => registerConfigTool(ctx)],
66
+ ['iterate_validate', () => registerValidateTool(ctx)],
67
+ ['iterate_decision_log', () => registerDecisionLogTool(ctx)],
68
+ ['iterate_context', () => registerContextTool(ctx)],
69
+ ['iterate_review', () => registerReviewTool(ctx)],
70
+ ['iterate_triage', () => registerTriageTool(ctx)],
71
+ ['iterate_fix', () => registerFixTool(ctx)],
72
+ ['iterate_diff', () => registerDiffTool(ctx)],
73
+ ['iterate_rollback', () => registerRollbackTool(ctx)],
74
+ ['iterate_checkpoint', () => registerCheckpointTool(ctx)],
75
+ ['iterate_status', () => registerStatusTool(ctx)],
76
+ ['iterate_history', () => registerHistoryTool(ctx)],
77
+ ['iterate_prune', () => registerPruneTool(ctx)],
78
+ ['iterate_transcript', () => registerTranscriptTool(ctx)],
79
+ // v3.0: Quality Command Center tools
80
+ ['iterate_experience', () => registerExperienceBankTool(ctx)],
81
+ ['iterate_quality_gate', () => registerQualityGateTool(ctx)],
82
+ ['iterate_defense_events', () => registerDefenseEventsTool(ctx)],
83
+ ];
84
+ for (const [toolName, register] of toolRegistrations) {
85
+ try {
86
+ register();
87
+ }
88
+ catch (err) {
89
+ console.warn(`[iterate] failed to register ${toolName}; continuing.`, err);
90
+ }
91
+ }
92
+ // 2. Inject the iterate skill prompt as a system prompt section.
93
+ // This teaches the model how to write iterate workflow scripts using the
94
+ // tools. Guarded for the same reason as above: a prompt-injection failure
95
+ // must not undo a load that already wired the gate and the tools.
96
+ try {
97
+ ctx.systemPrompt.section({
98
+ name: 'iterate-skill',
99
+ order: 100,
100
+ text: ITERATE_SKILL_PROMPT,
101
+ });
102
+ }
103
+ catch (err) {
104
+ console.warn('[iterate] failed to inject the skill prompt section; continuing.', err);
105
+ }
80
106
  }
package/dist/jobs.js CHANGED
@@ -11,10 +11,19 @@
11
11
  * - `ctx.jobs` only exists when the dsh host loaded a job registry + a
12
12
  * controller serves the calling owner (`@deepseek-ai/dsh-tool-jobs` or an
13
13
  * equivalent). When it is missing, `start()` throws or is absent — we
14
- * detect both and fall through to plain execution, so the Job Panel is a
15
- * pure enhancement and never breaks a tool call.
14
+ * detect both (including a throwing `start` GETTER) and fall through to
15
+ * plain execution, so the Job Panel is a pure enhancement and never breaks
16
+ * a tool call.
16
17
  * - The registry is memory-only and panel rows are read-only (no progress
17
18
  * updates), so these jobs are completion records, not control channels.
19
+ * - Cancellation is honest, not theatrical. `JobHooks.cancel` is REQUIRED by
20
+ * `@deepseek-ai/dsh-jobs` (the registry calls it on kill/teardown), but the
21
+ * wrapped tool `fn` exposes no abort channel — a panel kill cannot stop it.
22
+ * So `cancel` records the REQUEST (synchronous + idempotent, first reason
23
+ * wins), best-effort surfaces it on the record's progress line, and lets the
24
+ * job settle with `fn`'s TRUE outcome, with the request annotated in
25
+ * `detail`. Settling the record `killed` while `fn()` keeps running would
26
+ * show a stopped job that is still running — a lie in the Job Panel.
18
27
  */
19
28
  /**
20
29
  * Read `ctx.jobs` defensively: the dsh plugin context can be a Proxy whose
@@ -30,11 +39,25 @@ function safeJobs(ctx) {
30
39
  return undefined;
31
40
  }
32
41
  }
42
+ /**
43
+ * Read `jobs.start` defensively: the `start` GETTER itself can throw on a
44
+ * hostile/proxied registry, and that read must degrade the same way as a
45
+ * missing registry — never escape `runWithJob`.
46
+ */
47
+ function safeStart(jobs) {
48
+ try {
49
+ return jobs?.start;
50
+ }
51
+ catch {
52
+ return undefined;
53
+ }
54
+ }
33
55
  /**
34
56
  * Run `fn` wrapped in a dsh background job, settling it completed/failed
35
- * with the execution's outcome. When the host exposes no job registry (or
36
- * refuses the start), `fn` runs untouched and `null` is returned — the Job
37
- * Panel is an enhancement, never a dependency.
57
+ * with the execution's ACTUAL outcome (a requested cancel never falsifies it
58
+ * — see the header). When the host exposes no job registry (or refuses the
59
+ * start), `fn` runs untouched and `null` is returned — the Job Panel is an
60
+ * enhancement, never a dependency.
38
61
  *
39
62
  * @param ctx the dsh plugin context (may or may not expose `jobs`).
40
63
  * @param kind iterate job kind registered via {@link IterateJobKind}.
@@ -44,21 +67,43 @@ function safeJobs(ctx) {
44
67
  */
45
68
  export async function runWithJob(ctx, kind, label, fn) {
46
69
  const jobs = safeJobs(ctx);
47
- if (!jobs || typeof jobs.start !== 'function') {
70
+ const start = safeStart(jobs);
71
+ if (!jobs || !start || typeof start !== 'function') {
48
72
  return { result: await fn(), jobId: null };
49
73
  }
50
74
  let settle;
51
75
  const done = new Promise((resolve) => {
52
76
  settle = resolve;
53
77
  });
78
+ // Honest cancel bookkeeping: the request is recorded (first reason wins),
79
+ // but `done` is NOT settled here — `fn()` is still running and the panel
80
+ // must not claim otherwise. The settlement below carries `fn`'s real
81
+ // outcome, annotated with the request.
82
+ let cancelRequested = null;
83
+ const annotate = (detail) => cancelRequested === null
84
+ ? detail
85
+ : `${detail} (cancel requested: ${cancelRequested} — the wrapped tool call has no abort channel and ran to completion)`;
54
86
  let jobId = null;
55
87
  try {
56
- jobId = jobs.start({
88
+ jobId = start.call(jobs, {
57
89
  kind,
58
90
  label,
59
- run: () => ({
91
+ run: (job) => ({
60
92
  done,
61
- cancel: () => settle({ status: 'killed', detail: 'cancelled' }),
93
+ cancel: (reason) => {
94
+ if (cancelRequested !== null)
95
+ return; // idempotent — first reason wins
96
+ cancelRequested = reason ?? 'cancel requested';
97
+ // Best-effort panel hint while the record is stopping; the registry
98
+ // drops writes it cannot apply, and a throwing update must not
99
+ // propagate out of the registry's kill path.
100
+ try {
101
+ job?.updateProgress?.('cancel requested — waiting for the tool call to finish (no abort channel)');
102
+ }
103
+ catch {
104
+ /* progress hint is cosmetic */
105
+ }
106
+ },
62
107
  }),
63
108
  });
64
109
  }
@@ -69,13 +114,13 @@ export async function runWithJob(ctx, kind, label, fn) {
69
114
  }
70
115
  try {
71
116
  const result = await fn();
72
- settle({ status: 'completed', detail: 'done' });
117
+ settle({ status: 'completed', detail: annotate('done') });
73
118
  return { result, jobId };
74
119
  }
75
120
  catch (error) {
76
121
  settle({
77
122
  status: 'failed',
78
- detail: error instanceof Error ? error.message : 'execution failed',
123
+ detail: annotate(error instanceof Error ? error.message : 'execution failed'),
79
124
  });
80
125
  throw error;
81
126
  }
package/dist/live.js CHANGED
@@ -18,6 +18,9 @@
18
18
  * Safety:
19
19
  * - Read-only observer: never mutates source files; writes only the NDJSON
20
20
  * live file under `.iterate/`.
21
+ * - Privacy switch: when the project's effective config sets
22
+ * `observatory.capture: false`, nothing is persisted (the file stays
23
+ * absent/unchanged). An unreadable config keeps capture ON (defaults).
21
24
  * - The live file is byte-capped (rewrite to last N lines when it grows too
22
25
  * large) so it can never grow unbounded.
23
26
  * - Any capture failure is swallowed (fire-and-forget) so it can never block
@@ -27,7 +30,7 @@ import { mkdir, readFile, stat, appendFile } from 'node:fs/promises';
27
30
  import { existsSync } from 'node:fs';
28
31
  import { writeTextAtomicAsync } from "./atomic-fs.js";
29
32
  import { join } from 'node:path';
30
- import { resolveProjectRoot } from "./config-loader.js";
33
+ import { loadEffectiveConfig, resolveProjectRoot } from "./config-loader.js";
31
34
  /** Keep at most this many live activity entries. */
32
35
  export const LIVE_MAX_ENTRIES = 300;
33
36
  /** Rewrite the live file when its byte size exceeds this threshold. */
@@ -51,14 +54,41 @@ function enqueueLiveWrite(task) {
51
54
  liveWriteQueue = next.catch(() => { });
52
55
  return next;
53
56
  }
54
- /** Append one activity record to the project's live feed (byte-capped). */
57
+ /**
58
+ * True when the project's effective config still allows live capture.
59
+ * `observatory.capture: false` opts the project out — nothing is persisted.
60
+ * Failure modes keep TODAY'S behavior: `loadEffectiveConfig` never throws on
61
+ * an unreadable config (it falls back to defaults, capture on), and any
62
+ * unexpected loader error here also degrades to capture ON rather than
63
+ * silently eating activity the operator expected to see.
64
+ */
65
+ function captureEnabled(projectRoot) {
66
+ try {
67
+ return loadEffectiveConfig(projectRoot).config.observatory?.capture !== false;
68
+ }
69
+ catch {
70
+ return true;
71
+ }
72
+ }
73
+ /**
74
+ * Append one activity record to the project's live feed (byte-capped).
75
+ * Never rejects: every failure (capture disabled, `.iterate` existing as a
76
+ * regular file, locked/unwritable feed) is swallowed — the header promises
77
+ * this path can never crash or block a tool call (`void appendLive(...)`
78
+ * would otherwise turn a rejection into an unhandled rejection).
79
+ */
55
80
  export function appendLive(projectRoot, entry) {
56
81
  return enqueueLiveWrite(async () => {
57
- const file = liveFilePath(projectRoot);
58
- const line = JSON.stringify(entry) + '\n';
59
- await mkdir(join(projectRoot, '.iterate'), { recursive: true });
60
- // Amortized O(1): only read+rewrite when the file has grown past the cap.
61
82
  try {
83
+ // Privacy: capture off → do not touch the feed at all.
84
+ if (!captureEnabled(projectRoot))
85
+ return;
86
+ const file = liveFilePath(projectRoot);
87
+ const line = JSON.stringify(entry) + '\n';
88
+ // mkdir is INSIDE the guard: `.iterate` may be a regular file (or the
89
+ // dir unwritable) — it must reject neither the caller nor the queue.
90
+ await mkdir(join(projectRoot, '.iterate'), { recursive: true });
91
+ // Amortized O(1): only read+rewrite when the file has grown past the cap.
62
92
  const st = await stat(file).catch(() => null);
63
93
  if (st && st.size > LIVE_MAX_BYTES) {
64
94
  const raw = await readFile(file, 'utf-8');
@@ -167,12 +197,21 @@ export async function readLive(projectRoot) {
167
197
  */
168
198
  export function registerLiveCapture(ctx) {
169
199
  ctx.on('tools/result', (exec) => {
170
- const root = projectRootOf(exec);
171
- if (!root)
172
- return;
173
- const entry = classifyTool(exec.name, exec.arguments, root);
174
- if (!entry)
175
- return;
176
- void appendLive(root, entry);
200
+ // Defensive: a hostile/proxied exec whose getters throw (`agent`,
201
+ // `name`, `arguments`) must not abort the observer — unlike the guarded
202
+ // siblings below, an unguarded throw here would lose the record and
203
+ // surface an error from a read-only hook. Degrade to a no-op.
204
+ try {
205
+ const root = projectRootOf(exec);
206
+ if (!root)
207
+ return;
208
+ const entry = classifyTool(exec.name, exec.arguments, root);
209
+ if (!entry)
210
+ return;
211
+ void appendLive(root, entry);
212
+ }
213
+ catch {
214
+ // An observer must never throw.
215
+ }
177
216
  });
178
217
  }
@@ -13,7 +13,7 @@
13
13
  * the actual subagent-driven meta-review critique; all deterministic math
14
14
  * lives here.
15
15
  */
16
- import { sortFindings } from "./review.js";
16
+ import { isValidRoundNumber, sortFindings } from "./review.js";
17
17
  /**
18
18
  * Number of distinct consistency checks performed by `metaReviewReport`.
19
19
  * The check set is: COUNT_MATCH, SEVERITY_SUM, DIMENSION_SUM, DIMENSION_UNKNOWN,
@@ -26,6 +26,50 @@ export const META_REVIEW_CHECKS = 10;
26
26
  * remainder is folded into a "+N more" suffix.
27
27
  */
28
28
  export const COVERAGE_LIST_TRUNCATE = 10;
29
+ /**
30
+ * Hard sanity ceiling on a reported round number.
31
+ *
32
+ * `rounds[].round` is model/JSON-authored and only needs to be a number to
33
+ * reach this audit. Round-gap detection enumerates `min..max` of the PRESENT
34
+ * round numbers, so an absurd value (rounds `{1, 1e15}` or a round of
35
+ * `Infinity`) used to turn a linear check into an effectively-infinite loop —
36
+ * a trivial CPU/memory denial of service (and one ROUND_GAP issue per missing
37
+ * round). Anything above this ceiling is rejected as an invalid round
38
+ * (ROUND_NUMBER) and excluded from gap enumeration instead of being walked.
39
+ * Generously above any real run: `config.max_rounds` is capped at 100.
40
+ */
41
+ export const MAX_REVIEW_ROUND_NUMBER = 1000;
42
+ /**
43
+ * Max individual ROUND_GAP issues emitted per audit. The remainder is folded
44
+ * into a single "+N more" issue so a wide-but-legal gap can never flood the
45
+ * issue list (each issue carries a detail string).
46
+ */
47
+ export const MAX_ROUND_GAP_REPORTS = 50;
48
+ /**
49
+ * Hard bound on the round-gap enumeration span. Below
50
+ * MAX_REVIEW_ROUND_NUMBER on purpose: even a legal range (e.g. round 1 plus
51
+ * round 1000) is too wide for per-number gap reporting to mean anything, so
52
+ * it collapses to ONE "range too wide" issue instead of ~1000 iterations and
53
+ * ~1000 issues. Together with the round-number ceiling this makes the
54
+ * enumeration O(MAX_ROUND_GAP_SPAN) no matter what reaches it.
55
+ */
56
+ export const MAX_ROUND_GAP_SPAN = 500;
57
+ /** Bounded, non-throwing list preview for issue detail strings. */
58
+ function previewList(values, max = 20) {
59
+ const shown = values
60
+ .slice(0, max)
61
+ .map((v) => (typeof v === 'number' ? String(v) : typeof v === 'string' ? v : '[object]'))
62
+ .join(', ');
63
+ return values.length > max ? `${shown}, … (+${values.length - max} more)` : shown;
64
+ }
65
+ /** Bounded, non-throwing object preview (a hostile round may be circular). */
66
+ function previewRound(r) {
67
+ if (r === null || typeof r !== 'object')
68
+ return String(r);
69
+ const roundNo = r.round;
70
+ const findings = r.findings;
71
+ return `{round: ${String(roundNo)}, findings: ${Array.isArray(findings) ? `array(${findings.length})` : typeof findings}}`;
72
+ }
29
73
  /**
30
74
  * Audit a ReviewReport for internal consistency.
31
75
  *
@@ -79,7 +123,9 @@ export function metaReviewReport(report) {
79
123
  const sevCounts = { critical: 0, high: 0, medium: 0, low: 0 };
80
124
  for (const f of findings) {
81
125
  const s = f?.severity;
82
- if (s && s in sevCounts)
126
+ // hasOwn, not `in`: `'__proto__' in sevCounts` is true via inheritance,
127
+ // and an out-of-spec severity must simply not be bucketed.
128
+ if (s && Object.hasOwn(sevCounts, s))
83
129
  sevCounts[s]++;
84
130
  }
85
131
  const bucketSum = sevCounts.critical + sevCounts.high + sevCounts.medium + sevCounts.low;
@@ -116,18 +162,23 @@ export function metaReviewReport(report) {
116
162
  : [];
117
163
  const convSum = findingsByRound.reduce((a, b) => a + Number(b) || 0, 0);
118
164
  if (convSum !== total) {
119
- add('CONVERGENCE_SUM', 'high', 'convergence.findingsByRound does not sum to totalFindings', `findingsByRound ${JSON.stringify(findingsByRound)} sums to ${convSum}, ` +
165
+ add('CONVERGENCE_SUM', 'high', 'convergence.findingsByRound does not sum to totalFindings', `findingsByRound [${previewList(findingsByRound)}] sums to ${convSum}, ` +
120
166
  `but totalFindings is ${total}.`);
121
167
  }
122
168
  // `findingsByRound` is indexed by the actual round number (round r → index
123
- // r-1), so the "last round" is the LAST RECORDED round's reported number, not
124
- // the array's last index (the array is sized to the highest round, which only
125
- // equals the record count for contiguous 1..N round numbers). Read the flag
126
- // consistency the same way buildReviewReport/computeConvergence set it.
169
+ // r-1), so the "last round" is the HIGHEST VALID round number, not the
170
+ // array's last element (buildReviewReport sorts its round list, but this
171
+ // audit runs on arbitrary JSON, and a malformed trailing round — `1.5`,
172
+ // `Infinity` — must not be used as an index: Math.min(1.5, len) - 1 is
173
+ // fractional and reads `undefined` → 0 new → a false CONVERGENCE_FLAG).
127
174
  const reportRounds = Array.isArray(report.rounds) ? report.rounds : [];
128
- const lastRecordedRound = reportRounds.length > 0 && typeof reportRounds[reportRounds.length - 1]?.round === 'number'
129
- ? reportRounds[reportRounds.length - 1].round
130
- : null;
175
+ let lastRecordedRound = null;
176
+ for (const r of reportRounds) {
177
+ if (!r || typeof r !== 'object' || !isValidRoundNumber(r.round))
178
+ continue;
179
+ if (lastRecordedRound === null || r.round > lastRecordedRound)
180
+ lastRecordedRound = r.round;
181
+ }
131
182
  const lastRoundNew = lastRecordedRound !== null && lastRecordedRound > 0
132
183
  // Over-cap rounds are FOLDED by aggregateRounds into the final slot, so a
133
184
  // reported round number may exceed the array length — read the SAME
@@ -144,8 +195,15 @@ export function metaReviewReport(report) {
144
195
  const rounds = Array.isArray(report.rounds) ? report.rounds : [];
145
196
  const seenRounds = new Set();
146
197
  for (const [index, r] of rounds.entries()) {
147
- if (!r || typeof r.round !== 'number' || r.round < 1) {
148
- add('ROUND_NUMBER', 'medium', 'A round has a missing or non-positive round number', `round: ${JSON.stringify(r)}`);
198
+ // Reject everything the deterministic core would refuse: a missing,
199
+ // non-integer, non-finite or absurdly large round number. This is also
200
+ // the DoS guard for ROUND_GAP below — a round of `Infinity`/1e15 would
201
+ // otherwise be treated as a legitimate endpoint of the enumeration range.
202
+ if (!r ||
203
+ typeof r !== 'object' ||
204
+ !isValidRoundNumber(r.round) ||
205
+ r.round > MAX_REVIEW_ROUND_NUMBER) {
206
+ add('ROUND_NUMBER', 'medium', 'A round has a missing, non-integer or out-of-range round number', `round: ${previewRound(r)} (round numbers must be integers in 1..${MAX_REVIEW_ROUND_NUMBER})`);
149
207
  continue;
150
208
  }
151
209
  seenRounds.add(r.round);
@@ -163,9 +221,30 @@ export function metaReviewReport(report) {
163
221
  if (present.length > 0) {
164
222
  const min = present[0];
165
223
  const max = present[present.length - 1];
166
- for (let i = min; i <= max; i++) {
167
- if (!seenRounds.has(i)) {
168
- add('ROUND_GAP', 'medium', `Round ${i} is missing from the round sequence`, `rounds present: ${present.join(', ')}.`);
224
+ const span = max - min + 1;
225
+ const presentPreview = previewList(present, 20);
226
+ // Hard bound on the enumeration: `min`/`max` come from untrusted round
227
+ // numbers, so an unguarded `for (i = min; i <= max; i++)` over
228
+ // {1, 1e15} is an effectively-infinite loop (CPU DoS) that would also
229
+ // push ~1e15 issues. Rounds above MAX_REVIEW_ROUND_NUMBER are rejected
230
+ // above, which keeps `span` bounded on its own; this guard stays as the
231
+ // belt-and-braces backstop so the walk is O(MAX_ROUND_GAP_SPAN) no
232
+ // matter what reaches it.
233
+ if (span > MAX_ROUND_GAP_SPAN) {
234
+ add('ROUND_GAP', 'medium', `Round sequence ${min}..${max} is too wide to audit for gaps`, `${span} round numbers spanned; only rounds present were accepted: ${presentPreview}.`);
235
+ }
236
+ else {
237
+ const missing = [];
238
+ for (let i = min; i <= max; i++) {
239
+ if (!seenRounds.has(i))
240
+ missing.push(i);
241
+ }
242
+ for (const m of missing.slice(0, MAX_ROUND_GAP_REPORTS)) {
243
+ add('ROUND_GAP', 'medium', `Round ${m} is missing from the round sequence`, `rounds present: ${presentPreview}.`);
244
+ }
245
+ if (missing.length > MAX_ROUND_GAP_REPORTS) {
246
+ add('ROUND_GAP', 'medium', `${missing.length - MAX_ROUND_GAP_REPORTS} more rounds are missing (list capped)`, `${missing.length} of the ${span} numbers in ${min}..${max} are absent; only the first ` +
247
+ `${MAX_ROUND_GAP_REPORTS} are listed individually. rounds present: ${presentPreview}.`);
169
248
  }
170
249
  }
171
250
  }
@@ -177,6 +256,35 @@ export function metaReviewReport(report) {
177
256
  issues,
178
257
  };
179
258
  }
259
+ /**
260
+ * Human-readable reason for a failed evidence attestation.
261
+ *
262
+ * `file_too_large` / `binary_file` are DISTINCT from `line_out_of_range` (see
263
+ * evidence.ts): the file EXISTS but cannot be line-addressed, so saying
264
+ * "does not exist" would send the reader looking for a missing file.
265
+ */
266
+ function describeEvidenceViolation(v) {
267
+ switch (v.error) {
268
+ case 'file_too_large':
269
+ return `${v.file} is too large to line-address (over the evidence size cap)`;
270
+ case 'binary_file':
271
+ return `${v.file} is a binary (NUL-containing) file that cannot be line-addressed`;
272
+ case 'line_out_of_range':
273
+ return v.lineTotal !== undefined && v.lineTotal !== null
274
+ ? `${v.line} is beyond this file's ${v.lineTotal} lines`
275
+ : `${v.file} is not a regular line-addressable file (directory/device/FIFO)`;
276
+ default:
277
+ return `${v.file} does not exist at all (verifiable read required)`;
278
+ }
279
+ }
280
+ /** Headline for a failed evidence attestation (matches the detail above). */
281
+ function evidenceViolationSummary(v) {
282
+ const unaddressable = v.error === 'file_too_large' || v.error === 'binary_file';
283
+ const head = unaddressable
284
+ ? 'Finding cannot be anchored to line-addressable code'
285
+ : 'Finding references non-existent code';
286
+ return `${head}: ${v.file}${v.line ? `:${v.line}` : ''}`;
287
+ }
180
288
  /**
181
289
  * Build the final review report: pair the source report with its meta-review
182
290
  * verdict and a rolled-up summary. Pure and deterministic.
@@ -221,30 +329,26 @@ export function buildFinalReviewReport(report, opts = {}) {
221
329
  for (const violation of evidence.results) {
222
330
  if (violation.error === undefined)
223
331
  continue;
224
- const detail = violation.error === 'line_out_of_range'
225
- ? violation.lineTotal !== undefined && violation.lineTotal !== null
226
- ? `${violation.line} is beyond this file's ${violation.lineTotal} lines`
227
- : `${violation.file} is a binary/unreadable file not line-addressable`
228
- : `${violation.file} does not exist at all (verifiable read required)`;
332
+ const detail = describeEvidenceViolation(violation);
333
+ const summary = evidenceViolationSummary(violation);
229
334
  let roundHint = '';
230
335
  if (report && violation.file) {
231
336
  // Try to attribute the poisoned finding to the round that first
232
- // surfaced it (best-effort; report rounds carry it).
233
- for (const r of report.rounds ?? []) {
234
- const matched = (r.findings ?? []).some((fnd) => !!fnd && fnd?.file === violation.file && fnd?.line === violation.line);
337
+ // surfaced it (best-effort; report rounds carry it). `rounds` is
338
+ // untrusted JSON, so a null entry must not throw here.
339
+ for (const r of (Array.isArray(report.rounds) ? report.rounds : []) ?? []) {
340
+ const findings = Array.isArray(r?.findings) ? r.findings : [];
341
+ const matched = findings.some((fnd) => !!fnd && fnd?.file === violation.file && fnd?.line === violation.line);
235
342
  if (matched) {
236
- roundHint = ` (round ${r.round})`;
343
+ roundHint = ` (round ${String(r.round)})`;
237
344
  break;
238
345
  }
239
346
  }
240
347
  }
241
- const summary = `Finding references non-existent code: ${violation.file}` +
242
- (violation.line ? `:${violation.line}` : '') +
243
- roundHint;
244
348
  meta.issues.push({
245
349
  code: 'EVIDENCE_VIOLATION',
246
350
  severity: 'critical',
247
- summary,
351
+ summary: summary + roundHint,
248
352
  detail: detail + '. Review results must anchor to real, read code.',
249
353
  });
250
354
  }
@@ -68,7 +68,11 @@ const SIGNATURE_PATTERNS = [
68
68
  * Returns a sorted array of `{ name, line }` (1-based line numbers).
69
69
  */
70
70
  export function collectMethodSignatures(text) {
71
- const lines = text.split('\n');
71
+ // Defensive: callers hand in file content read from disk / tool args, which
72
+ // may be missing or wrong-typed when a fix record is malformed. A non-string
73
+ // would throw on `.split` inside the atomic-fix gate.
74
+ const source = typeof text === 'string' ? text : '';
75
+ const lines = source.split('\n');
72
76
  const out = [];
73
77
  // Set lookup instead of scanning the growing array (O(S²) → O(S)).
74
78
  const seen = new Set();
@@ -95,7 +99,7 @@ export function collectMethodSignatures(text) {
95
99
  }
96
100
  /** Number of physical lines in `text` (a trailing newline does not add a line). */
97
101
  export function countTextLines(text) {
98
- if (text === '')
102
+ if (typeof text !== 'string' || text === '')
99
103
  return 0;
100
104
  const parts = text.split('\n');
101
105
  return parts[parts.length - 1] === '' ? parts.length - 1 : parts.length;
@@ -107,11 +111,14 @@ export function countTextLines(text) {
107
111
  * span runs to the final non-blank line of the file.
108
112
  */
109
113
  export function collectMethodSpans(text) {
110
- const signatures = collectMethodSignatures(text);
114
+ // Same non-string guard as collectMethodSignatures: this runs inside the
115
+ // atomic-fix gate on content that may be missing.
116
+ const source = typeof text === 'string' ? text : '';
117
+ const signatures = collectMethodSignatures(source);
111
118
  if (signatures.length === 0)
112
119
  return [];
113
- const lines = text.split('\n');
114
- const lineCount = countTextLines(text);
120
+ const lines = source.split('\n');
121
+ const lineCount = countTextLines(source);
115
122
  /** Last non-blank line at or before `candidate`. */
116
123
  function trimBlank(endCandidate, floor) {
117
124
  let end = endCandidate;
@@ -137,6 +144,18 @@ export function collectMethodSpans(text) {
137
144
  function intersects(spanStart, spanEnd, regionStart, regionEnd) {
138
145
  return spanStart <= regionEnd && spanEnd >= regionStart;
139
146
  }
147
+ /**
148
+ * True when a hunk's coordinates are usable for span intersection.
149
+ *
150
+ * `hunks` is a `ChangedRegion[]` that reaches this gate from a diff computed
151
+ * on tool-arg content; a malformed entry (`NaN`, `Infinity`, a negative or
152
+ * fractional line number) would build a `NaN`/negative region that either
153
+ * matches every method or none, silently wrong-sizing the adjacent-method
154
+ * verdict. Non-finite and negative starts/counts are simply skipped.
155
+ */
156
+ function isValidRegion(start, count) {
157
+ return Number.isInteger(start) && start >= 0 && Number.isInteger(count) && count >= 1;
158
+ }
140
159
  /**
141
160
  * Count the distinct methods a set of diff hunks touches.
142
161
  *
@@ -150,13 +169,17 @@ function intersects(spanStart, spanEnd, regionStart, regionEnd) {
150
169
  * - Methods touched by both sides are counted once (keyed name@startLine).
151
170
  */
152
171
  export function countTouchedMethods(before, after, hunks) {
153
- if (hunks.length === 0)
172
+ // A non-array `hunks` (malformed fix record / tool arg) is "nothing
173
+ // changed", matching the empty-hunk case — never a throw in the fix gate.
174
+ if (!Array.isArray(hunks) || hunks.length === 0)
154
175
  return 0;
155
176
  const beforeSpans = collectMethodSpans(before);
156
177
  const afterSpans = collectMethodSpans(after);
157
178
  const touched = new Set();
158
179
  for (const h of hunks) {
159
- if (h.oldLines > 0) {
180
+ if (!h || typeof h !== 'object')
181
+ continue;
182
+ if (h.oldLines > 0 && isValidRegion(h.oldStart, h.oldLines)) {
160
183
  const oldEnd = h.oldStart + h.oldLines - 1;
161
184
  for (const s of beforeSpans) {
162
185
  if (intersects(s.startLine, s.endLine, h.oldStart, oldEnd)) {
@@ -164,7 +187,7 @@ export function countTouchedMethods(before, after, hunks) {
164
187
  }
165
188
  }
166
189
  }
167
- if (h.newLines > 0) {
190
+ if (h.newLines > 0 && isValidRegion(h.newStart, h.newLines)) {
168
191
  const newEnd = h.newStart + h.newLines - 1;
169
192
  for (const s of afterSpans) {
170
193
  if (intersects(s.startLine, s.endLine, h.newStart, newEnd)) {