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
@@ -4,16 +4,29 @@ import { defineTool } from '@deepseek-ai/dsh-tools';
4
4
  import yaml from 'js-yaml';
5
5
  import { resolveProjectRootForExec } from "../config-loader.js";
6
6
  import { writeTextAtomic } from "../atomic-fs.js";
7
+ import { configBackupSuffix } from "../config-write.js";
8
+ import { WHOLE_FILE_LINE } from "../evidence.js";
7
9
  const CONFIG_FILE = 'iterate.config.yaml';
8
10
  /** Personalization key that holds the known-intentional list. */
9
11
  const PERSONALIZATION_KEY = 'personalization';
10
12
  const KNOWN_INTENTIONAL_KEY = 'known_intentional';
11
13
  /** Max entries per single `apply` call. */
12
14
  const MAX_ENTRIES = 500;
15
+ /**
16
+ * TOTAL cap on `personalization.known_intentional`.
17
+ *
18
+ * `MAX_ENTRIES` only bounds one `apply` payload, so the list itself grew
19
+ * without limit across sessions — every review round then ran
20
+ * `filterKnownIntentional` as O(findings × entries) over an ever-longer list,
21
+ * and the config file (backed up on every write) grew with it. Merges now
22
+ * evict the OLDEST entries (list order is write order — incoming entries are
23
+ * appended) beyond this bound and report how many were dropped.
24
+ */
25
+ export const MAX_TOTAL_KNOWN_INTENTIONAL = 1000;
13
26
  /** How many timestamped config backups are retained (older ones are removed). */
14
27
  export const MAX_TRIAGE_BACKUPS = 5;
15
- /** Whole-file marker line (matches review.ts filterKnownIntentional semantics). */
16
- const WHOLE_FILE_LINE = 0;
28
+ /** Whole-file marker line — the shared constant from evidence.ts (also used by
29
+ * review-scope/filterKnownIntentional), so every consumer agrees on `0`. */
17
30
  // ─── Pure helpers (exported for unit tests) ─────────────────────────────────
18
31
  /**
19
32
  * Normalize a caller-supplied `line` value.
@@ -86,11 +99,15 @@ export function entryKey(entry) {
86
99
  /**
87
100
  * Merge incoming entries into the existing known-intentional list.
88
101
  * Existing entries are never mutated; incoming entries whose key already
89
- * exists are skipped. Returns the merged list plus add/skip counts.
102
+ * exists are skipped. The result is then bounded by
103
+ * {@link MAX_TOTAL_KNOWN_INTENTIONAL}: the list is append-ordered, so when a
104
+ * merge overflows, the entries at the FRONT (the oldest ones) are evicted and
105
+ * counted in `dropped` — the caller reports that number instead of silently
106
+ * losing verdicts.
90
107
  *
91
108
  * @param {KnownIntentional[]} existing
92
109
  * @param {KnownIntentional[]} incoming
93
- * @returns {{ merged: KnownIntentional[], added: number, skipped: number }}
110
+ * @returns {{ merged: KnownIntentional[], added: number, skipped: number, dropped: number }}
94
111
  */
95
112
  export function mergeKnownIntentional(existing, incoming) {
96
113
  const seen = new Set();
@@ -114,7 +131,16 @@ export function mergeKnownIntentional(existing, incoming) {
114
131
  merged.push(entry);
115
132
  added++;
116
133
  }
117
- return { merged, added, skipped };
134
+ // Total bound: evict oldest-first. Incoming entries sit at the END of
135
+ // `merged`, so they survive — a session cannot evict its own fresh verdicts
136
+ // by overflowing the list, and one apply adds at most MAX_ENTRIES entries,
137
+ // which bounds how far a single merge can overshoot the cap.
138
+ let dropped = 0;
139
+ if (merged.length > MAX_TOTAL_KNOWN_INTENTIONAL) {
140
+ dropped = merged.length - MAX_TOTAL_KNOWN_INTENTIONAL;
141
+ merged.splice(0, dropped);
142
+ }
143
+ return { merged, added, skipped, dropped };
118
144
  }
119
145
  /**
120
146
  * Build a NEW config object with `personalization.known_intentional` set to
@@ -146,10 +172,15 @@ export function readKnownIntentional(config) {
146
172
  typeof e === 'object' &&
147
173
  typeof e.file === 'string');
148
174
  }
149
- /** Build a filesystem-safe backup suffix from the current time. */
150
- export function backupSuffix(now = new Date()) {
151
- return now.toISOString().replace(/[:.]/g, '-');
152
- }
175
+ /**
176
+ * Build a filesystem-safe backup suffix from the current time.
177
+ *
178
+ * Re-exported from config-write: BOTH writers back up `iterate.config.yaml`,
179
+ * and the shared implementation carries the same-millisecond collision guard
180
+ * (two backups created in the same ms used to produce the identical
181
+ * `config.bak-…` path, so the second silently overwrote the first).
182
+ */
183
+ export const backupSuffix = configBackupSuffix;
153
184
  /**
154
185
  * Bound the timestamped config backups: after a fresh one is written, delete
155
186
  * every older `config.bak-*` file beyond the newest `keep`. Best-effort — a
@@ -205,7 +236,7 @@ function applyEntries(projectRoot, incoming) {
205
236
  return { ok: false, error: `Failed to read config: ${String(err)}` };
206
237
  }
207
238
  const existing = readKnownIntentional(config);
208
- const { merged, added, skipped } = mergeKnownIntentional(existing, incoming);
239
+ const { merged, added, skipped, dropped } = mergeKnownIntentional(existing, incoming);
209
240
  const nextConfig = buildConfigWithKnownIntentional(config, merged);
210
241
  const hadFile = existsSync(configPath);
211
242
  const backupPath = hadFile ? `${configPath}.bak-${backupSuffix()}` : null;
@@ -247,7 +278,7 @@ function applyEntries(projectRoot, incoming) {
247
278
  // project never collects an unbounded pile of config snapshots.
248
279
  if (backupPath)
249
280
  pruneOldConfigBackups(configPath);
250
- return { ok: true, added, skipped, count: merged.length, configPath, backupPath };
281
+ return { ok: true, added, skipped, dropped, count: merged.length, configPath, backupPath };
251
282
  }
252
283
  /**
253
284
  * Register the `iterate_triage` tool.
@@ -266,13 +297,31 @@ function applyEntries(projectRoot, incoming) {
266
297
  export function registerTriageTool(ctx) {
267
298
  ctx.tools.register(defineTool({
268
299
  name: 'iterate_triage',
300
+ // Pending-call card (#12): `apply` rewrites iterate.config.yaml (with
301
+ // backup + rollback) — it must read as a config write, not a read, and
302
+ // name how many entries it will merge. `list` is a plain read and keeps
303
+ // the default presentation. Pure: derived from args only.
304
+ presentCall: (args) => {
305
+ const a = args;
306
+ if (a.operation !== 'apply')
307
+ return undefined;
308
+ const count = Array.isArray(a.entries) ? a.entries.length : 0;
309
+ return {
310
+ card: 'generic',
311
+ title: count > 0
312
+ ? `Apply ${count} known_intentional ${count === 1 ? 'entry' : 'entries'} to iterate.config.yaml`
313
+ : 'Apply known_intentional entries to iterate.config.yaml',
314
+ kind: 'edit',
315
+ };
316
+ },
269
317
  // `list` is a pure config read; `apply` rewrites iterate.config.yaml →
270
318
  // only list joins a parallel dispatch group.
271
319
  isConcurrencySafe: (args) => args.operation === 'list',
272
320
  description: 'Manage `personalization.known_intentional` entries in iterate.config.yaml. ' +
273
321
  'Use `apply` to write back triage verdicts (entries where the reviewer said "known intentional") so ' +
274
322
  'future review rounds filter them out. Entries are deduped by file|dimension|line and the config is ' +
275
- 'backed up before writing. Use `list` to read the current entries. ' +
323
+ 'backed up before writing. The list is capped at 1000 entries — when a merge overflows it, the OLDEST ' +
324
+ 'entries are evicted and the result reports `dropped`. Use `list` to read the current entries. ' +
276
325
  'The client browser cannot write files, so this tool is the write-back channel for the triage panel.',
277
326
  parameters: {
278
327
  operation: {
@@ -301,6 +350,8 @@ export function registerTriageTool(ctx) {
301
350
  operation: { type: 'string', required: true },
302
351
  added: { type: 'integer' },
303
352
  skipped: { type: 'integer' },
353
+ /** Entries evicted from the FRONT of the list (total cap hit). */
354
+ dropped: { type: 'integer' },
304
355
  count: { type: 'integer' },
305
356
  path: { type: 'string' },
306
357
  backupPath: { oneOf: [{ type: 'string' }, { type: 'null' }] },
@@ -360,6 +411,10 @@ export function registerTriageTool(ctx) {
360
411
  operation: 'apply',
361
412
  added: result.added,
362
413
  skipped: result.skipped,
414
+ // Surface the overflow count: a merge that hit the total cap
415
+ // evicted the OLDEST entries, and the caller must be able to see
416
+ // how many verdicts were dropped instead of silently losing them.
417
+ dropped: result.dropped,
363
418
  count: result.count,
364
419
  path: result.configPath,
365
420
  backupPath: result.backupPath ?? null,
@@ -5,6 +5,8 @@ const DEFAULT_TIMEOUT_MS = 120_000;
5
5
  /** Hard ceiling on a single validation command's runtime, so a model cannot
6
6
  * pin the tool open indefinitely via an unbounded `timeout` argument. */
7
7
  const MAX_TIMEOUT_MS = 600_000;
8
+ /** Default cap on a validation command's captured stdout+stderr (10 MB). */
9
+ const DEFAULT_MAX_BUFFER = 10 * 1024 * 1024;
8
10
  /**
9
11
  * Clamp a caller-supplied timeout (ms) to a sane range.
10
12
  * Non-finite / non-positive values fall back to the default; any value above
@@ -25,35 +27,70 @@ export function clampTimeout(ms) {
25
27
  * instead of pinning the dispatch open until the timeout elapses. The result
26
28
  * distinguishes "cancelled by the caller" (`canceled: true`) from "ran past
27
29
  * `timeoutMs`" (`timedOut: true`).
30
+ *
31
+ * `maxBuffer` is injectable for tests (defaults to the production 10 MB cap);
32
+ * hitting it sets `truncated: true` so callers can report the cut output
33
+ * instead of silently returning a partial stream.
34
+ *
35
+ * Never rejects: `exec()` validates its arguments synchronously and can THROW
36
+ * before registering its callback (a NUL byte in the command raises
37
+ * ERR_INVALID_ARG_VALUE). Inside a promise executor that throw would reject
38
+ * the promise and escape `execute` unguarded, so it is caught and resolved as
39
+ * a `startError` result instead.
28
40
  */
29
- export async function runCommand(command, cwd, timeoutMs, signal) {
41
+ export async function runCommand(command, cwd, timeoutMs, signal, maxBuffer = DEFAULT_MAX_BUFFER) {
30
42
  const start = performance.now();
31
43
  return new Promise((resolve) => {
32
- exec(command, {
33
- cwd,
34
- timeout: timeoutMs,
35
- maxBuffer: 10 * 1024 * 1024, // 10 MB
36
- env: { ...process.env, PAGER: 'cat' },
37
- signal,
38
- }, (error, stdout, stderr) => {
39
- const durationMs = Math.round(performance.now() - start);
40
- // error.code is the exit code when the command ran; when the binary
41
- // cannot be spawned Node sets error.code to a STRING ('ENOENT' etc).
42
- // Coerce to a number so the integer output schema is never violated.
43
- const exitCode = typeof error?.code === 'number' ? error.code : (error ? 1 : 0);
44
- const canceled = signal?.aborted === true;
44
+ try {
45
+ exec(command, {
46
+ cwd,
47
+ timeout: timeoutMs,
48
+ maxBuffer,
49
+ env: { ...process.env, PAGER: 'cat' },
50
+ signal,
51
+ }, (error, stdout, stderr) => {
52
+ const durationMs = Math.round(performance.now() - start);
53
+ // error.code is the exit code when the command ran; when the binary
54
+ // cannot be spawned Node sets error.code to a STRING ('ENOENT' etc).
55
+ // Coerce to a number so the integer output schema is never violated.
56
+ const exitCode = typeof error?.code === 'number' ? error.code : (error ? 1 : 0);
57
+ const canceled = signal?.aborted === true;
58
+ // maxBuffer overflow: error.code is the STRING
59
+ // 'ERR_CHILD_PROCESS_STDIO_MAXBUFFER' and stdout/stderr were cut
60
+ // short — flag it instead of reporting a plain exit-1 with silently
61
+ // truncated output. @types/node types ExecException.code as
62
+ // number|undefined, so widen to unknown for the runtime comparison.
63
+ const errorCode = error?.code;
64
+ const truncated = errorCode === 'ERR_CHILD_PROCESS_STDIO_MAXBUFFER';
65
+ resolve({
66
+ command,
67
+ exitCode,
68
+ stdout: stdout ?? '',
69
+ stderr: stderr ?? '',
70
+ // A caller cancellation also kills the child (killed === true), but
71
+ // that is not a timeout — only report timedOut for the deadline path.
72
+ timedOut: error?.killed === true && !canceled,
73
+ canceled,
74
+ durationMs,
75
+ truncated,
76
+ });
77
+ });
78
+ }
79
+ catch (err) {
80
+ // Synchronous exec() throw (invalid command string, e.g. a NUL byte):
81
+ // resolve a structured "never started" failure rather than rejecting.
45
82
  resolve({
46
83
  command,
47
- exitCode,
48
- stdout: stdout ?? '',
49
- stderr: stderr ?? '',
50
- // A caller cancellation also kills the child (killed === true), but
51
- // that is not a timeout — only report timedOut for the deadline path.
52
- timedOut: error?.killed === true && !canceled,
53
- canceled,
54
- durationMs,
84
+ exitCode: 1,
85
+ stdout: '',
86
+ stderr: String(err),
87
+ timedOut: false,
88
+ canceled: false,
89
+ durationMs: Math.round(performance.now() - start),
90
+ truncated: false,
91
+ startError: String(err?.message ?? err),
55
92
  });
56
- });
93
+ }
57
94
  });
58
95
  }
59
96
  /**
@@ -109,6 +146,10 @@ export function registerValidateTool(ctx) {
109
146
  timedOut: { type: 'boolean', required: true },
110
147
  canceled: { type: 'boolean', required: true },
111
148
  durationMs: { type: 'integer', required: true },
149
+ truncated: {
150
+ type: 'boolean',
151
+ description: 'true when command output exceeded the maxBuffer cap and was cut short.',
152
+ },
112
153
  rejectReason: { type: 'string' },
113
154
  },
114
155
  },
@@ -122,6 +163,12 @@ export function registerValidateTool(ctx) {
122
163
  `Duration: ${value.durationMs}ms`,
123
164
  value.timedOut ? '⚠ Timed out' : '',
124
165
  value.canceled ? '⚠ Cancelled before completion' : '',
166
+ // A truncated run still reports its (partial) output — the
167
+ // reader must be told the stream was cut, otherwise the
168
+ // missing tail reads as "no output".
169
+ value.truncated
170
+ ? `⚠ ${value.rejectReason ?? 'Output truncated: maxBuffer limit exceeded.'}`
171
+ : '',
125
172
  '',
126
173
  value.stdout ? `[stdout]\n${value.stdout}` : '',
127
174
  value.stderr ? `[stderr]\n${value.stderr}` : '',
@@ -186,7 +233,51 @@ export function registerValidateTool(ctx) {
186
233
  `Allowed commands: ${predefinedCommands.join(' | ')}`,
187
234
  };
188
235
  }
189
- const result = await runCommand(args.command, projectRoot, timeout, exec.signal);
236
+ const result = await (async () => {
237
+ try {
238
+ return await runCommand(args.command, projectRoot, timeout, exec.signal);
239
+ }
240
+ catch (err) {
241
+ // runCommand resolves its own start failures; this guard exists so
242
+ // NO unexpected rejection can escape `execute` as an unhandled tool
243
+ // crash (the tool contract is a structured result, always).
244
+ return {
245
+ command: args.command,
246
+ exitCode: 1,
247
+ stdout: '',
248
+ stderr: String(err),
249
+ timedOut: false,
250
+ canceled: false,
251
+ durationMs: 0,
252
+ truncated: false,
253
+ startError: String(err?.message ?? err),
254
+ };
255
+ }
256
+ })();
257
+ // exec() refused the command before it ever ran (e.g. a NUL byte in a
258
+ // whitelisted command string): the command never started, so this is a
259
+ // structured failure — exit 1 + a rejectReason naming the cause — not a
260
+ // run that merely exited non-zero.
261
+ if (result.startError !== undefined) {
262
+ return {
263
+ allowed: false,
264
+ command: args.command,
265
+ exitCode: 1,
266
+ stdout: '',
267
+ stderr: result.stderr,
268
+ timedOut: false,
269
+ canceled: false,
270
+ durationMs: result.durationMs,
271
+ rejectReason: `command failed to start: ${result.startError}`,
272
+ };
273
+ }
274
+ if (result.truncated === true) {
275
+ return {
276
+ allowed: true,
277
+ ...result,
278
+ rejectReason: `command output exceeded the ${Math.round(DEFAULT_MAX_BUFFER / (1024 * 1024))} MB maxBuffer limit and was truncated`,
279
+ };
280
+ }
190
281
  return {
191
282
  allowed: true,
192
283
  ...result,
@@ -11,6 +11,8 @@
11
11
  * - F5 — checkpoint summary (resume).
12
12
  * - F6 — nudge channel (steer the next round).
13
13
  * - F7 — append-only decision timeline.
14
+ * - validations — per-round validation outcomes (command / exitCode /
15
+ * allowed) for the run console; see {@link TranscriptValidation}.
14
16
  *
15
17
  * It performs NO I/O and NEVER touches the filesystem — persistence lives in
16
18
  * the `iterate_transcript` tool. Inputs are defensively normalized so a
@@ -31,14 +33,22 @@ const MAX_FINDINGS_PER_THREAD = 100;
31
33
  /** Max global findings kept in the manifest. */
32
34
  const MAX_FINDINGS_TOTAL = 2000;
33
35
  /** Max timeline entries kept (newest wins). */
34
- const MAX_TIMELINE = 500;
36
+ export const MAX_TIMELINE = 500;
35
37
  /** Max applied-fix records kept (newest wins; bounded so a long run cannot
36
38
  * grow the manifest payload without limit). */
37
39
  const MAX_FIXES = 200;
40
+ /** Max validation rows kept (newest wins; bounds a very long run's payload). */
41
+ const MAX_VALIDATIONS = 500;
38
42
  /** Max round number accepted by the builder. A model-authored/manifest-backed
39
43
  * round value is attacker-influenced JSON: `roundStart(1e9)` / `snapshotConvergence`
40
- * would otherwise preallocate arrays of that size and OOM the host. */
41
- const MAX_ROUNDS = 1000;
44
+ * would otherwise preallocate arrays of that size and OOM the host.
45
+ * Exported so the capture/rehydrate FEED side can truncate before feeding
46
+ * (a value above the cap would otherwise be folded into the last slot). */
47
+ export const MAX_ROUNDS = 1000;
48
+ /** Max threads restored per round from a persisted manifest (the live cap plus
49
+ * headroom for the single overflow thread). Shared by {@link ReviewTranscriptBuilder.restoreThread}
50
+ * and the read-side normalizer so both bound hostile manifests identically. */
51
+ export const MAX_THREADS_RESTORED = MAX_THREADS_PER_ROUND * 2 + 1;
42
52
  /** Thresholds applied when reducing a string list under a cap. */
43
53
  function clampStringList(source, cap) {
44
54
  const out = [];
@@ -97,6 +107,40 @@ function normalizeFinding(input) {
97
107
  acknowledged: typeof f.acknowledged === 'boolean' ? f.acknowledged : undefined,
98
108
  };
99
109
  }
110
+ /**
111
+ * Normalize one validation row (`capture` `validations` contract), dropping
112
+ * rows that cannot identify WHAT was run and clamping the rest — the same
113
+ * defensive style as the round/fix normalizers, because these values arrive
114
+ * from a `type:'json'` argument a model authored.
115
+ *
116
+ * - a non-object row, a missing/non-finite round (≤ 0 after flooring), or
117
+ * a missing/blank command → dropped (there is nothing to attribute);
118
+ * - `round` above the builder cap → clamped (never dropped: the row is real);
119
+ * - `exitCode` → integer, or `null` when absent/unknown/not a number;
120
+ * - `allowed` → strict boolean; an ABSENT flag degrades to `false` (fail
121
+ * closed: a row that cannot prove it was allow-listed reads as rejected);
122
+ * - `rejectReason` → trimmed string, omitted when blank.
123
+ */
124
+ function normalizeValidation(input) {
125
+ if (!input || typeof input !== 'object' || Array.isArray(input))
126
+ return null;
127
+ const v = input;
128
+ const rawRound = typeof v.round === 'number' && Number.isFinite(v.round) ? Math.floor(v.round) : 0;
129
+ if (rawRound <= 0)
130
+ return null;
131
+ const command = typeof v.command === 'string' ? v.command.trim() : '';
132
+ if (!command)
133
+ return null;
134
+ const exitCode = typeof v.exitCode === 'number' && Number.isFinite(v.exitCode) ? Math.floor(v.exitCode) : null;
135
+ const rejectReason = typeof v.rejectReason === 'string' && v.rejectReason.trim() ? v.rejectReason.trim() : undefined;
136
+ return {
137
+ round: Math.min(rawRound, MAX_ROUNDS),
138
+ command,
139
+ exitCode,
140
+ allowed: v.allowed === true,
141
+ ...(rejectReason ? { rejectReason } : {}),
142
+ };
143
+ }
100
144
  /**
101
145
  * Append one raw finding to a thread's list, bound to MAX_FINDINGS_PER_THREAD
102
146
  * with NEWEST-WINS eviction (drop the oldest findings once the cap is hit).
@@ -140,6 +184,7 @@ export class ReviewTranscriptBuilder {
140
184
  globalFindings = [];
141
185
  globalSeenKeys = new Set();
142
186
  fixes = [];
187
+ validations = [];
143
188
  checkpoint = null;
144
189
  timeline = [];
145
190
  nudge = null;
@@ -214,6 +259,29 @@ export class ReviewTranscriptBuilder {
214
259
  }
215
260
  this.touch();
216
261
  }
262
+ /**
263
+ * Advance the CURRENT-round marker to `round` WITHOUT creating round rows.
264
+ *
265
+ * `roundStart` pre-allocates empty rows up to `round` (legitimate while a
266
+ * round is opening — reviewers write into those rows). At capture time,
267
+ * though, `roundsExecuted` may legitimately exceed the rounds we actually
268
+ * captured data for: calling `roundStart(roundsExecuted)` there fabricated
269
+ * phantom EMPTY round rows the reviewers never produced. This setter only
270
+ * moves the marker — and only FORWARD (never rewinds past a row capture
271
+ * already recorded) — so `manifest.round` still reports how far the run got
272
+ * while `manifest.rounds` keeps only rounds with captured content.
273
+ */
274
+ advanceRound(round, maxRounds) {
275
+ const r = typeof round === 'number' && Number.isFinite(round) && round > 0
276
+ ? Math.min(Math.floor(round), MAX_ROUNDS)
277
+ : 0;
278
+ if (r > this.round)
279
+ this.round = r;
280
+ if (typeof maxRounds === 'number' && Number.isFinite(maxRounds) && maxRounds >= 0) {
281
+ this.maxRounds = Math.min(Math.floor(maxRounds), MAX_ROUNDS);
282
+ }
283
+ this.touch();
284
+ }
217
285
  // ─── Reviewer threads (F1) ──────────────────────────────────────────────
218
286
  /** Start a reviewer sub-agent's thread for the current round. */
219
287
  reviewerStart(dimension, attempt = 1) {
@@ -274,7 +342,7 @@ export class ReviewTranscriptBuilder {
274
342
  return;
275
343
  // Hostile-manifest guard: keep the live hard bound (with headroom for the
276
344
  // single overflow thread) and newest-first truncate beyond it.
277
- if (live.threads.length >= MAX_THREADS_PER_ROUND * 2 + 1)
345
+ if (live.threads.length >= MAX_THREADS_RESTORED)
278
346
  return;
279
347
  const dim = typeof thread.dimension === 'string' && thread.dimension.trim()
280
348
  ? thread.dimension.trim()
@@ -407,6 +475,23 @@ export class ReviewTranscriptBuilder {
407
475
  }
408
476
  this.touch();
409
477
  }
478
+ // ─── Validations (F6/F8 console) ────────────────────────────────────────
479
+ /**
480
+ * Record one validation command outcome. Normalizes defensively (bad rows
481
+ * are dropped, out-of-cap rounds clamped) and keeps the NEWEST rows under
482
+ * MAX_VALIDATIONS, mirroring {@link fix}. A malformed row can never crash
483
+ * the capture nor leak non-JSON state into the manifest.
484
+ */
485
+ validation(input) {
486
+ const record = normalizeValidation(input);
487
+ if (!record)
488
+ return;
489
+ this.validations.push(record);
490
+ if (this.validations.length > MAX_VALIDATIONS) {
491
+ this.validations.splice(0, this.validations.length - MAX_VALIDATIONS);
492
+ }
493
+ this.touch();
494
+ }
410
495
  // ─── Checkpoint (F5) ────────────────────────────────────────────────────
411
496
  /** Record the current checkpoint summary (null clears it). */
412
497
  recordCheckpoint(state) {
@@ -493,6 +578,7 @@ export class ReviewTranscriptBuilder {
493
578
  // already ≤ MAX_FINDINGS_TOTAL — no post-hoc slice needed.
494
579
  findings: this.globalFindings,
495
580
  fixes: this.fixes,
581
+ validations: this.validations,
496
582
  checkpoint: this.checkpoint,
497
583
  timeline: this.timeline,
498
584
  nudge: this.nudge,
@@ -560,3 +646,180 @@ function dedupePaths(paths) {
560
646
  function isoNow() {
561
647
  return new Date().toISOString();
562
648
  }
649
+ // ─── Read-side normalization ────────────────────────────────────────────────
650
+ /** Clamp an unknown round marker into [0, MAX_ROUNDS] (0 = never recorded). */
651
+ function clampRoundValue(v) {
652
+ return typeof v === 'number' && Number.isFinite(v)
653
+ ? Math.min(Math.max(Math.floor(v), 0), MAX_ROUNDS)
654
+ : 0;
655
+ }
656
+ /** Clamp an unknown non-negative counter (0 when absent/garbage). */
657
+ function clampCountValue(v) {
658
+ return typeof v === 'number' && Number.isFinite(v) && v >= 0 ? Math.floor(v) : 0;
659
+ }
660
+ /** Re-run one persisted thread row through the builder's per-row bounds. */
661
+ function normalizeThreadRow(input) {
662
+ const t = (input && typeof input === 'object' ? input : {});
663
+ const dim = typeof t.dimension === 'string' && t.dimension.trim() ? t.dimension.trim() : 'review';
664
+ const att = typeof t.attempt === 'number' && Number.isFinite(t.attempt) ? Math.floor(t.attempt) : 1;
665
+ const messages = Array.isArray(t.messages) ? t.messages : [];
666
+ const readFiles = Array.isArray(t.readFiles) ? t.readFiles : [];
667
+ const rawFindings = Array.isArray(t.findings) ? t.findings : [];
668
+ const findings = rawFindings
669
+ .map((f) => normalizeFinding(f))
670
+ .filter((f) => f !== null);
671
+ return {
672
+ dimension: dim,
673
+ attempt: att > 0 ? att : 1,
674
+ // Same reductions serialize() applies: messages newest-capped + trimmed,
675
+ // readFiles deduped, per-thread findings newest-capped.
676
+ messages: clampStringList(messages, MAX_MESSAGES_PER_THREAD),
677
+ readFiles: dedupePaths(readFiles),
678
+ findings: findings.length > MAX_FINDINGS_PER_THREAD
679
+ ? findings.slice(-MAX_FINDINGS_PER_THREAD)
680
+ : findings,
681
+ };
682
+ }
683
+ /** Re-run one persisted fix row through the builder's fix() requirements. */
684
+ function normalizeFixRow(input) {
685
+ if (!input || typeof input !== 'object')
686
+ return null;
687
+ const f = input;
688
+ const id = typeof f.id === 'string' ? f.id : '';
689
+ const file = typeof f.file === 'string' ? f.file : '';
690
+ if (!id || !file)
691
+ return null;
692
+ return {
693
+ id,
694
+ file,
695
+ // No fabricated "now" on read: a corrupt/absent timestamp stays blank
696
+ // instead of masquerading as a fix that just happened.
697
+ timestamp: typeof f.timestamp === 'string' ? f.timestamp : '',
698
+ round: clampRoundValue(f.round),
699
+ summary: typeof f.summary === 'string' ? f.summary : '',
700
+ linesAdded: clampCountValue(f.linesAdded),
701
+ linesRemoved: clampCountValue(f.linesRemoved),
702
+ success: f.success !== false,
703
+ };
704
+ }
705
+ /** Re-run one persisted timeline row through the builder's decision() shape. */
706
+ function normalizeTimelineRow(input) {
707
+ if (!input || typeof input !== 'object')
708
+ return null;
709
+ const e = input;
710
+ return {
711
+ timestamp: typeof e.timestamp === 'string' ? e.timestamp : '',
712
+ round: clampRoundValue(e.round),
713
+ type: typeof e.type === 'string' && e.type ? e.type : 'decision',
714
+ data: e.data && typeof e.data === 'object' && !Array.isArray(e.data)
715
+ ? e.data
716
+ : {},
717
+ };
718
+ }
719
+ /** Re-run a persisted checkpoint through the builder's recordCheckpoint rules. */
720
+ function normalizeCheckpointRow(input) {
721
+ if (!input || typeof input !== 'object')
722
+ return null;
723
+ const c = input;
724
+ const round = typeof c.round === 'number' && Number.isFinite(c.round) ? c.round : 0;
725
+ if (round <= 0)
726
+ return null;
727
+ return {
728
+ mode: c.mode === 'dry-run' || c.mode === 'normal' ? c.mode : 'normal',
729
+ round: clampRoundValue(c.round),
730
+ maxRounds: clampCountValue(c.maxRounds),
731
+ fixedCount: clampCountValue(c.fixedCount),
732
+ resumeCount: clampCountValue(c.resumeCount),
733
+ updatedAt: typeof c.updatedAt === 'string' ? c.updatedAt : '',
734
+ };
735
+ }
736
+ /** Re-run a persisted nudge through the builder's setNudge rules. */
737
+ function normalizeNudgeRow(input) {
738
+ if (!input || typeof input !== 'object')
739
+ return null;
740
+ const n = input;
741
+ const text = typeof n.text === 'string' ? n.text.trim() : '';
742
+ if (!text)
743
+ return null;
744
+ return { timestamp: typeof n.timestamp === 'string' ? n.timestamp : '', text };
745
+ }
746
+ /**
747
+ * Defensive read-side normalization of a persisted manifest.
748
+ *
749
+ * `.iterate/transcript.json` is hand-editable JSON handed straight to the
750
+ * client/model by `iterate_transcript.read`: the tool gates the ROOT shape,
751
+ * but row-level bounds (threads, messages, findings, timeline, fixes …) used
752
+ * to pass through untouched, so a hostile or corrupted file could leak an
753
+ * unbounded payload or junk field values (fractional lines, non-string
754
+ * phases, negative counters) that a capture could never have written.
755
+ *
756
+ * Every field is re-run through the SAME normalizers and caps the builder
757
+ * uses at capture time. Deliberately NOT implemented as
758
+ * `rehydrateBuilder(...).serialize()`: that drops fields the builder cannot
759
+ * rebuild (phases, the run identity, the round marker), so a plain read would
760
+ * silently rewrite history. Unknown inputs degrade instead of throwing — this
761
+ * runs after the tool's structural gate but must stay safe on its own.
762
+ */
763
+ export function normalizeManifestBounds(value) {
764
+ const m = (value && typeof value === 'object' ? value : {});
765
+ const rounds = (Array.isArray(m.rounds) ? m.rounds : [])
766
+ .slice(0, MAX_ROUNDS)
767
+ .map((r) => {
768
+ const row = (r && typeof r === 'object' ? r : {});
769
+ const threads = (Array.isArray(row.threads) ? row.threads : [])
770
+ .slice(0, MAX_THREADS_RESTORED)
771
+ .map((t) => normalizeThreadRow(t));
772
+ return { round: Math.max(clampRoundValue(row.round), 1), threads };
773
+ });
774
+ const fixes = (Array.isArray(m.fixes) ? m.fixes : [])
775
+ .map((f) => normalizeFixRow(f))
776
+ .filter((f) => f !== null);
777
+ const timeline = (Array.isArray(m.timeline) ? m.timeline : [])
778
+ .map((e) => normalizeTimelineRow(e))
779
+ .filter((e) => e !== null);
780
+ const globalFindings = (Array.isArray(m.findings) ? m.findings : [])
781
+ .map((f) => normalizeFinding(f))
782
+ .filter((f) => f !== null);
783
+ const convergence = (Array.isArray(m.convergence) ? m.convergence : [])
784
+ .slice(0, MAX_ROUNDS)
785
+ .map((n) => (typeof n === 'number' && Number.isFinite(n) ? n : -1));
786
+ const validations = Array.isArray(m.validations)
787
+ ? m.validations
788
+ .map((v) => normalizeValidation(v))
789
+ .filter((v) => v !== null)
790
+ .slice(-MAX_VALIDATIONS)
791
+ : undefined;
792
+ const policy = m.approval?.policy === 'deny' || m.approval?.policy === 'allow' ? m.approval.policy : 'ask';
793
+ const out = {
794
+ version: typeof m.version === 'number' && Number.isFinite(m.version) ? m.version : TRANSCRIPT_VERSION,
795
+ project: typeof m.project === 'string' ? m.project : '',
796
+ updatedAt: typeof m.updatedAt === 'string' ? m.updatedAt : '',
797
+ active: m.active === true,
798
+ mode: m.mode === 'dry-run' || m.mode === 'normal' ? m.mode : null,
799
+ goal: typeof m.goal === 'string' ? m.goal : '',
800
+ // A phase list is free-form narration names — strings only, bounded.
801
+ phases: clampStringList(Array.isArray(m.phases) ? m.phases : [], 100),
802
+ round: clampRoundValue(m.round),
803
+ maxRounds: clampCountValue(m.maxRounds),
804
+ rounds,
805
+ convergence,
806
+ findings: globalFindings.length > MAX_FINDINGS_TOTAL
807
+ ? globalFindings.slice(-MAX_FINDINGS_TOTAL)
808
+ : globalFindings,
809
+ fixes: fixes.slice(-MAX_FIXES),
810
+ checkpoint: normalizeCheckpointRow(m.checkpoint),
811
+ timeline: timeline.slice(-MAX_TIMELINE),
812
+ nudge: normalizeNudgeRow(m.nudge),
813
+ approval: {
814
+ active: typeof m.approval?.active === 'boolean' ? m.approval.active : policy !== 'allow',
815
+ policy,
816
+ },
817
+ };
818
+ if (validations !== undefined)
819
+ out.validations = validations;
820
+ if (m.taskMode === 'code' || m.taskMode === 'iterate' || m.taskMode === null)
821
+ out.taskMode = m.taskMode;
822
+ if (typeof m.stoppedReason === 'string' || m.stoppedReason === null)
823
+ out.stoppedReason = m.stoppedReason;
824
+ return out;
825
+ }