iterate-plugin 3.5.6 → 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.
- package/README.md +64 -20
- package/README.zh-CN.md +53 -17
- package/dist/approval-gate.js +143 -23
- package/dist/atomic-fs.js +148 -17
- package/dist/config-loader.js +509 -34
- package/dist/config-write.js +83 -22
- package/dist/evidence.js +141 -87
- package/dist/file-lock.js +165 -0
- package/dist/index.js +54 -28
- package/dist/jobs.js +56 -11
- package/dist/live.js +52 -13
- package/dist/meta-review.js +132 -28
- package/dist/method-scope.js +31 -8
- package/dist/review-scope.js +32 -9
- package/dist/review.js +96 -17
- package/dist/session-hooks.js +55 -13
- package/dist/skill-prompt.js +417 -67
- package/dist/tools/checkpoint.js +85 -19
- package/dist/tools/config.js +44 -2
- package/dist/tools/context.js +22 -5
- package/dist/tools/decision-log.js +122 -27
- package/dist/tools/defense-events.js +90 -31
- package/dist/tools/defense-store.js +28 -3
- package/dist/tools/experience-bank.js +108 -43
- package/dist/tools/experience-store.js +54 -4
- package/dist/tools/fix.js +347 -119
- package/dist/tools/history.js +68 -14
- package/dist/tools/present.js +80 -0
- package/dist/tools/prune.js +156 -53
- package/dist/tools/quality-gate.js +52 -2
- package/dist/tools/quality-store.js +17 -1
- package/dist/tools/review.js +158 -7
- package/dist/tools/transcript.js +173 -36
- package/dist/tools/triage.js +67 -12
- package/dist/tools/validate.js +115 -24
- package/dist/transcript.js +267 -4
- package/lib/client.js +945 -181
- package/lib/parse.js +760 -57
- package/package.json +15 -12
- package/src/approval-gate.ts +160 -23
- package/src/atomic-fs.ts +152 -17
- package/src/client/index.ts +600 -129
- package/src/config-loader.ts +542 -35
- package/src/config-write.ts +108 -26
- package/src/evidence.ts +194 -92
- package/src/file-lock.ts +184 -0
- package/src/index.ts +53 -29
- package/src/jobs.ts +63 -12
- package/src/live.ts +48 -11
- package/src/meta-review.ts +151 -34
- package/src/method-scope.ts +31 -8
- package/src/review-scope.ts +33 -10
- package/src/review.ts +96 -17
- package/src/session-hooks.ts +54 -12
- package/src/skill-prompt.ts +417 -67
- package/src/tools/checkpoint.ts +81 -19
- package/src/tools/config.ts +44 -2
- package/src/tools/context.ts +25 -5
- package/src/tools/decision-log.ts +127 -22
- package/src/tools/defense-events.ts +87 -31
- package/src/tools/defense-store.ts +30 -3
- package/src/tools/experience-bank.ts +112 -44
- package/src/tools/experience-store.ts +54 -5
- package/src/tools/fix.ts +258 -20
- package/src/tools/history.ts +74 -15
- package/src/tools/present.ts +89 -0
- package/src/tools/prune.ts +160 -51
- package/src/tools/quality-gate.ts +57 -2
- package/src/tools/quality-store.ts +18 -1
- package/src/tools/review.ts +155 -6
- package/src/tools/transcript.ts +178 -36
- package/src/tools/triage.ts +70 -13
- package/src/tools/validate.ts +138 -32
- package/src/transcript.ts +279 -4
- package/src/types.ts +29 -1
package/dist/tools/triage.js
CHANGED
|
@@ -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
|
|
16
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
/**
|
|
150
|
-
|
|
151
|
-
|
|
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.
|
|
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,
|
package/dist/tools/validate.js
CHANGED
|
@@ -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
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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:
|
|
49
|
-
stderr:
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
|
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,
|
package/dist/transcript.js
CHANGED
|
@@ -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
|
-
|
|
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 >=
|
|
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
|
+
}
|