iterate-plugin 3.5.7 → 3.7.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 (79) hide show
  1. package/README.md +97 -22
  2. package/README.zh-CN.md +79 -19
  3. package/dist/approval-gate.js +143 -23
  4. package/dist/atomic-fs.js +148 -17
  5. package/dist/config-loader.js +516 -35
  6. package/dist/config-write.js +83 -22
  7. package/dist/evidence.js +141 -87
  8. package/dist/file-lock.js +193 -0
  9. package/dist/index.js +61 -28
  10. package/dist/jobs.js +56 -11
  11. package/dist/live.js +54 -14
  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 +61 -14
  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 +106 -28
  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/dist/working-directory.js +114 -0
  38. package/lib/client.js +1912 -512
  39. package/lib/parse.js +1001 -103
  40. package/package.json +16 -9
  41. package/src/approval-gate.ts +160 -23
  42. package/src/atomic-fs.ts +152 -17
  43. package/src/client/i18n.ts +517 -0
  44. package/src/client/index.ts +1031 -369
  45. package/src/client/session-cache.ts +54 -0
  46. package/src/config-loader.ts +549 -36
  47. package/src/config-write.ts +108 -26
  48. package/src/evidence.ts +194 -92
  49. package/src/file-lock.ts +211 -0
  50. package/src/index.ts +60 -29
  51. package/src/jobs.ts +63 -12
  52. package/src/live.ts +50 -12
  53. package/src/meta-review.ts +151 -34
  54. package/src/method-scope.ts +31 -8
  55. package/src/review-scope.ts +33 -10
  56. package/src/review.ts +96 -17
  57. package/src/session-hooks.ts +60 -13
  58. package/src/skill-prompt.ts +396 -66
  59. package/src/tools/checkpoint.ts +81 -19
  60. package/src/tools/config.ts +44 -2
  61. package/src/tools/context.ts +25 -5
  62. package/src/tools/decision-log.ts +114 -23
  63. package/src/tools/defense-events.ts +87 -31
  64. package/src/tools/defense-store.ts +30 -3
  65. package/src/tools/experience-bank.ts +112 -44
  66. package/src/tools/experience-store.ts +54 -5
  67. package/src/tools/fix.ts +258 -20
  68. package/src/tools/history.ts +74 -15
  69. package/src/tools/present.ts +89 -0
  70. package/src/tools/prune.ts +154 -51
  71. package/src/tools/quality-gate.ts +57 -2
  72. package/src/tools/quality-store.ts +18 -1
  73. package/src/tools/review.ts +155 -6
  74. package/src/tools/transcript.ts +178 -36
  75. package/src/tools/triage.ts +70 -13
  76. package/src/tools/validate.ts +138 -32
  77. package/src/transcript.ts +279 -4
  78. package/src/types.ts +29 -1
  79. package/src/working-directory.ts +126 -0
@@ -5,6 +5,8 @@ import type { JsonValue } from '@deepseek-ai/dsh-util-values'
5
5
  import yaml from 'js-yaml'
6
6
  import { resolveProjectRootForExec } from '../config-loader.ts'
7
7
  import { writeTextAtomic } from '../atomic-fs.ts'
8
+ import { configBackupSuffix } from '../config-write.ts'
9
+ import { WHOLE_FILE_LINE } from '../evidence.ts'
8
10
  import type { KnownIntentional } from '../types.ts'
9
11
 
10
12
  const CONFIG_FILE = 'iterate.config.yaml'
@@ -16,11 +18,23 @@ const KNOWN_INTENTIONAL_KEY = 'known_intentional'
16
18
  /** Max entries per single `apply` call. */
17
19
  const MAX_ENTRIES = 500
18
20
 
21
+ /**
22
+ * TOTAL cap on `personalization.known_intentional`.
23
+ *
24
+ * `MAX_ENTRIES` only bounds one `apply` payload, so the list itself grew
25
+ * without limit across sessions — every review round then ran
26
+ * `filterKnownIntentional` as O(findings × entries) over an ever-longer list,
27
+ * and the config file (backed up on every write) grew with it. Merges now
28
+ * evict the OLDEST entries (list order is write order — incoming entries are
29
+ * appended) beyond this bound and report how many were dropped.
30
+ */
31
+ export const MAX_TOTAL_KNOWN_INTENTIONAL = 1000
32
+
19
33
  /** How many timestamped config backups are retained (older ones are removed). */
20
34
  export const MAX_TRIAGE_BACKUPS = 5
21
35
 
22
- /** Whole-file marker line (matches review.ts filterKnownIntentional semantics). */
23
- const WHOLE_FILE_LINE = 0
36
+ /** Whole-file marker line — the shared constant from evidence.ts (also used by
37
+ * review-scope/filterKnownIntentional), so every consumer agrees on `0`. */
24
38
 
25
39
  // ─── Pure helpers (exported for unit tests) ─────────────────────────────────
26
40
 
@@ -96,16 +110,20 @@ export function entryKey(entry: KnownIntentional): string {
96
110
  /**
97
111
  * Merge incoming entries into the existing known-intentional list.
98
112
  * Existing entries are never mutated; incoming entries whose key already
99
- * exists are skipped. Returns the merged list plus add/skip counts.
113
+ * exists are skipped. The result is then bounded by
114
+ * {@link MAX_TOTAL_KNOWN_INTENTIONAL}: the list is append-ordered, so when a
115
+ * merge overflows, the entries at the FRONT (the oldest ones) are evicted and
116
+ * counted in `dropped` — the caller reports that number instead of silently
117
+ * losing verdicts.
100
118
  *
101
119
  * @param {KnownIntentional[]} existing
102
120
  * @param {KnownIntentional[]} incoming
103
- * @returns {{ merged: KnownIntentional[], added: number, skipped: number }}
121
+ * @returns {{ merged: KnownIntentional[], added: number, skipped: number, dropped: number }}
104
122
  */
105
123
  export function mergeKnownIntentional(
106
124
  existing: KnownIntentional[],
107
125
  incoming: KnownIntentional[],
108
- ): { merged: KnownIntentional[]; added: number; skipped: number } {
126
+ ): { merged: KnownIntentional[]; added: number; skipped: number; dropped: number } {
109
127
  const seen = new Set<string>()
110
128
  const merged: KnownIntentional[] = []
111
129
  for (const entry of existing) {
@@ -127,7 +145,16 @@ export function mergeKnownIntentional(
127
145
  merged.push(entry)
128
146
  added++
129
147
  }
130
- return { merged, added, skipped }
148
+ // Total bound: evict oldest-first. Incoming entries sit at the END of
149
+ // `merged`, so they survive — a session cannot evict its own fresh verdicts
150
+ // by overflowing the list, and one apply adds at most MAX_ENTRIES entries,
151
+ // which bounds how far a single merge can overshoot the cap.
152
+ let dropped = 0
153
+ if (merged.length > MAX_TOTAL_KNOWN_INTENTIONAL) {
154
+ dropped = merged.length - MAX_TOTAL_KNOWN_INTENTIONAL
155
+ merged.splice(0, dropped)
156
+ }
157
+ return { merged, added, skipped, dropped }
131
158
  }
132
159
 
133
160
  /**
@@ -169,10 +196,15 @@ export function readKnownIntentional(
169
196
  )
170
197
  }
171
198
 
172
- /** Build a filesystem-safe backup suffix from the current time. */
173
- export function backupSuffix(now = new Date()): string {
174
- return now.toISOString().replace(/[:.]/g, '-')
175
- }
199
+ /**
200
+ * Build a filesystem-safe backup suffix from the current time.
201
+ *
202
+ * Re-exported from config-write: BOTH writers back up `iterate.config.yaml`,
203
+ * and the shared implementation carries the same-millisecond collision guard
204
+ * (two backups created in the same ms used to produce the identical
205
+ * `config.bak-…` path, so the second silently overwrote the first).
206
+ */
207
+ export const backupSuffix = configBackupSuffix
176
208
 
177
209
  /**
178
210
  * Bound the timestamped config backups: after a fresh one is written, delete
@@ -226,6 +258,8 @@ function applyEntries(
226
258
  ok: true
227
259
  added: number
228
260
  skipped: number
261
+ /** Entries evicted from the FRONT of the list because the total cap was hit. */
262
+ dropped: number
229
263
  count: number
230
264
  configPath: string
231
265
  backupPath: string | null
@@ -242,7 +276,7 @@ function applyEntries(
242
276
  return { ok: false, error: `Failed to read config: ${String(err)}` }
243
277
  }
244
278
  const existing = readKnownIntentional(config)
245
- const { merged, added, skipped } = mergeKnownIntentional(existing, incoming)
279
+ const { merged, added, skipped, dropped } = mergeKnownIntentional(existing, incoming)
246
280
  const nextConfig = buildConfigWithKnownIntentional(config, merged)
247
281
 
248
282
  const hadFile = existsSync(configPath)
@@ -283,7 +317,7 @@ function applyEntries(
283
317
  // project never collects an unbounded pile of config snapshots.
284
318
  if (backupPath) pruneOldConfigBackups(configPath)
285
319
 
286
- return { ok: true, added, skipped, count: merged.length, configPath, backupPath }
320
+ return { ok: true, added, skipped, dropped, count: merged.length, configPath, backupPath }
287
321
  }
288
322
 
289
323
  /**
@@ -304,6 +338,22 @@ export function registerTriageTool(ctx: { tools: { register: (def: ReturnType<ty
304
338
  ctx.tools.register(
305
339
  defineTool({
306
340
  name: 'iterate_triage',
341
+ // Pending-call card (#12): `apply` rewrites iterate.config.yaml (with
342
+ // backup + rollback) — it must read as a config write, not a read, and
343
+ // name how many entries it will merge. `list` is a plain read and keeps
344
+ // the default presentation. Pure: derived from args only.
345
+ presentCall: (args) => {
346
+ const a = args as { operation?: unknown; entries?: unknown }
347
+ if (a.operation !== 'apply') return undefined
348
+ const count = Array.isArray(a.entries) ? a.entries.length : 0
349
+ return {
350
+ card: 'generic',
351
+ title: count > 0
352
+ ? `Apply ${count} known_intentional ${count === 1 ? 'entry' : 'entries'} to iterate.config.yaml`
353
+ : 'Apply known_intentional entries to iterate.config.yaml',
354
+ kind: 'edit',
355
+ }
356
+ },
307
357
  // `list` is a pure config read; `apply` rewrites iterate.config.yaml →
308
358
  // only list joins a parallel dispatch group.
309
359
  isConcurrencySafe: (args) => (args as { operation?: unknown }).operation === 'list',
@@ -311,7 +361,8 @@ export function registerTriageTool(ctx: { tools: { register: (def: ReturnType<ty
311
361
  'Manage `personalization.known_intentional` entries in iterate.config.yaml. ' +
312
362
  'Use `apply` to write back triage verdicts (entries where the reviewer said "known intentional") so ' +
313
363
  'future review rounds filter them out. Entries are deduped by file|dimension|line and the config is ' +
314
- 'backed up before writing. Use `list` to read the current entries. ' +
364
+ 'backed up before writing. The list is capped at 1000 entries — when a merge overflows it, the OLDEST ' +
365
+ 'entries are evicted and the result reports `dropped`. Use `list` to read the current entries. ' +
315
366
  'The client browser cannot write files, so this tool is the write-back channel for the triage panel.',
316
367
  parameters: {
317
368
  operation: {
@@ -342,6 +393,8 @@ export function registerTriageTool(ctx: { tools: { register: (def: ReturnType<ty
342
393
  operation: { type: 'string', required: true },
343
394
  added: { type: 'integer' },
344
395
  skipped: { type: 'integer' },
396
+ /** Entries evicted from the FRONT of the list (total cap hit). */
397
+ dropped: { type: 'integer' },
345
398
  count: { type: 'integer' },
346
399
  path: { type: 'string' },
347
400
  backupPath: { oneOf: [{ type: 'string' }, { type: 'null' }] },
@@ -403,6 +456,10 @@ export function registerTriageTool(ctx: { tools: { register: (def: ReturnType<ty
403
456
  operation: 'apply',
404
457
  added: result.added,
405
458
  skipped: result.skipped,
459
+ // Surface the overflow count: a merge that hit the total cap
460
+ // evicted the OLDEST entries, and the caller must be able to see
461
+ // how many verdicts were dropped instead of silently losing them.
462
+ dropped: result.dropped,
406
463
  count: result.count,
407
464
  path: result.configPath,
408
465
  backupPath: result.backupPath ?? null,
@@ -13,6 +13,9 @@ const DEFAULT_TIMEOUT_MS = 120_000
13
13
  * pin the tool open indefinitely via an unbounded `timeout` argument. */
14
14
  const MAX_TIMEOUT_MS = 600_000
15
15
 
16
+ /** Default cap on a validation command's captured stdout+stderr (10 MB). */
17
+ const DEFAULT_MAX_BUFFER = 10 * 1024 * 1024
18
+
16
19
  /**
17
20
  * Clamp a caller-supplied timeout (ms) to a sane range.
18
21
  * Non-finite / non-positive values fall back to the default; any value above
@@ -25,6 +28,20 @@ export function clampTimeout(ms: number | undefined): number {
25
28
  return Math.min(ms, MAX_TIMEOUT_MS)
26
29
  }
27
30
 
31
+ /**
32
+ * Result of {@link runCommand}: a plain {@link ValidationResult} plus two run
33
+ * anomalies the tool layer must surface but the shared `ValidationResult`
34
+ * DTO (src/types.ts) has no field for.
35
+ * - `truncated` — output hit the `maxBuffer` cap; stdout/stderr were cut.
36
+ * - `startError` — `exec()` refused to spawn the command (it throws
37
+ * SYNCHRONOUSLY for e.g. a NUL byte in the command string); the command
38
+ * never ran at all.
39
+ */
40
+ export type RunCommandResult = ValidationResult & {
41
+ truncated?: boolean
42
+ startError?: string
43
+ }
44
+
28
45
  /**
29
46
  * Run a single shell command with timeout and return structured results.
30
47
  * Pure function (no side effects beyond the exec call).
@@ -34,44 +51,79 @@ export function clampTimeout(ms: number | undefined): number {
34
51
  * instead of pinning the dispatch open until the timeout elapses. The result
35
52
  * distinguishes "cancelled by the caller" (`canceled: true`) from "ran past
36
53
  * `timeoutMs`" (`timedOut: true`).
54
+ *
55
+ * `maxBuffer` is injectable for tests (defaults to the production 10 MB cap);
56
+ * hitting it sets `truncated: true` so callers can report the cut output
57
+ * instead of silently returning a partial stream.
58
+ *
59
+ * Never rejects: `exec()` validates its arguments synchronously and can THROW
60
+ * before registering its callback (a NUL byte in the command raises
61
+ * ERR_INVALID_ARG_VALUE). Inside a promise executor that throw would reject
62
+ * the promise and escape `execute` unguarded, so it is caught and resolved as
63
+ * a `startError` result instead.
37
64
  */
38
65
  export async function runCommand(
39
66
  command: string,
40
67
  cwd: string,
41
68
  timeoutMs: number,
42
69
  signal?: AbortSignal,
43
- ): Promise<ValidationResult> {
70
+ maxBuffer: number = DEFAULT_MAX_BUFFER,
71
+ ): Promise<RunCommandResult> {
44
72
  const start = performance.now()
45
- return new Promise<ValidationResult>((resolve) => {
46
- exec(
47
- command,
48
- {
49
- cwd,
50
- timeout: timeoutMs,
51
- maxBuffer: 10 * 1024 * 1024, // 10 MB
52
- env: { ...process.env, PAGER: 'cat' },
53
- signal,
54
- },
55
- (error, stdout, stderr) => {
56
- const durationMs = Math.round(performance.now() - start)
57
- // error.code is the exit code when the command ran; when the binary
58
- // cannot be spawned Node sets error.code to a STRING ('ENOENT' etc).
59
- // Coerce to a number so the integer output schema is never violated.
60
- const exitCode = typeof error?.code === 'number' ? error.code : (error ? 1 : 0)
61
- const canceled = signal?.aborted === true
62
- resolve({
63
- command,
64
- exitCode,
65
- stdout: stdout ?? '',
66
- stderr: stderr ?? '',
67
- // A caller cancellation also kills the child (killed === true), but
68
- // that is not a timeout — only report timedOut for the deadline path.
69
- timedOut: error?.killed === true && !canceled,
70
- canceled,
71
- durationMs,
72
- })
73
- },
74
- )
73
+ return new Promise<RunCommandResult>((resolve) => {
74
+ try {
75
+ exec(
76
+ command,
77
+ {
78
+ cwd,
79
+ timeout: timeoutMs,
80
+ maxBuffer,
81
+ env: { ...process.env, PAGER: 'cat' },
82
+ signal,
83
+ },
84
+ (error, stdout, stderr) => {
85
+ const durationMs = Math.round(performance.now() - start)
86
+ // error.code is the exit code when the command ran; when the binary
87
+ // cannot be spawned Node sets error.code to a STRING ('ENOENT' etc).
88
+ // Coerce to a number so the integer output schema is never violated.
89
+ const exitCode = typeof error?.code === 'number' ? error.code : (error ? 1 : 0)
90
+ const canceled = signal?.aborted === true
91
+ // maxBuffer overflow: error.code is the STRING
92
+ // 'ERR_CHILD_PROCESS_STDIO_MAXBUFFER' and stdout/stderr were cut
93
+ // short — flag it instead of reporting a plain exit-1 with silently
94
+ // truncated output. @types/node types ExecException.code as
95
+ // number|undefined, so widen to unknown for the runtime comparison.
96
+ const errorCode: unknown = error?.code
97
+ const truncated = errorCode === 'ERR_CHILD_PROCESS_STDIO_MAXBUFFER'
98
+ resolve({
99
+ command,
100
+ exitCode,
101
+ stdout: stdout ?? '',
102
+ stderr: stderr ?? '',
103
+ // A caller cancellation also kills the child (killed === true), but
104
+ // that is not a timeout — only report timedOut for the deadline path.
105
+ timedOut: error?.killed === true && !canceled,
106
+ canceled,
107
+ durationMs,
108
+ truncated,
109
+ })
110
+ },
111
+ )
112
+ } catch (err) {
113
+ // Synchronous exec() throw (invalid command string, e.g. a NUL byte):
114
+ // resolve a structured "never started" failure rather than rejecting.
115
+ resolve({
116
+ command,
117
+ exitCode: 1,
118
+ stdout: '',
119
+ stderr: String(err),
120
+ timedOut: false,
121
+ canceled: false,
122
+ durationMs: Math.round(performance.now() - start),
123
+ truncated: false,
124
+ startError: String((err as Error)?.message ?? err),
125
+ })
126
+ }
75
127
  })
76
128
  }
77
129
 
@@ -131,6 +183,10 @@ export function registerValidateTool(ctx: { tools: { register: (def: ReturnType<
131
183
  timedOut: { type: 'boolean', required: true },
132
184
  canceled: { type: 'boolean', required: true },
133
185
  durationMs: { type: 'integer', required: true },
186
+ truncated: {
187
+ type: 'boolean',
188
+ description: 'true when command output exceeded the maxBuffer cap and was cut short.',
189
+ },
134
190
  rejectReason: { type: 'string' },
135
191
  },
136
192
  },
@@ -144,6 +200,12 @@ export function registerValidateTool(ctx: { tools: { register: (def: ReturnType<
144
200
  `Duration: ${value.durationMs}ms`,
145
201
  value.timedOut ? '⚠ Timed out' : '',
146
202
  value.canceled ? '⚠ Cancelled before completion' : '',
203
+ // A truncated run still reports its (partial) output — the
204
+ // reader must be told the stream was cut, otherwise the
205
+ // missing tail reads as "no output".
206
+ value.truncated
207
+ ? `⚠ ${value.rejectReason ?? 'Output truncated: maxBuffer limit exceeded.'}`
208
+ : '',
147
209
  '',
148
210
  value.stdout ? `[stdout]\n${value.stdout}` : '',
149
211
  value.stderr ? `[stderr]\n${value.stderr}` : '',
@@ -213,7 +275,51 @@ export function registerValidateTool(ctx: { tools: { register: (def: ReturnType<
213
275
  }
214
276
  }
215
277
 
216
- const result = await runCommand(args.command, projectRoot, timeout, exec.signal)
278
+ const result = await (async (): Promise<RunCommandResult> => {
279
+ try {
280
+ return await runCommand(args.command, projectRoot, timeout, exec.signal)
281
+ } catch (err) {
282
+ // runCommand resolves its own start failures; this guard exists so
283
+ // NO unexpected rejection can escape `execute` as an unhandled tool
284
+ // crash (the tool contract is a structured result, always).
285
+ return {
286
+ command: args.command,
287
+ exitCode: 1,
288
+ stdout: '',
289
+ stderr: String(err),
290
+ timedOut: false,
291
+ canceled: false,
292
+ durationMs: 0,
293
+ truncated: false,
294
+ startError: String((err as Error)?.message ?? err),
295
+ }
296
+ }
297
+ })()
298
+
299
+ // exec() refused the command before it ever ran (e.g. a NUL byte in a
300
+ // whitelisted command string): the command never started, so this is a
301
+ // structured failure — exit 1 + a rejectReason naming the cause — not a
302
+ // run that merely exited non-zero.
303
+ if (result.startError !== undefined) {
304
+ return {
305
+ allowed: false,
306
+ command: args.command,
307
+ exitCode: 1,
308
+ stdout: '',
309
+ stderr: result.stderr,
310
+ timedOut: false,
311
+ canceled: false,
312
+ durationMs: result.durationMs,
313
+ rejectReason: `command failed to start: ${result.startError}`,
314
+ }
315
+ }
316
+ if (result.truncated === true) {
317
+ return {
318
+ allowed: true,
319
+ ...result,
320
+ rejectReason: `command output exceeded the ${Math.round(DEFAULT_MAX_BUFFER / (1024 * 1024))} MB maxBuffer limit and was truncated`,
321
+ }
322
+ }
217
323
  return {
218
324
  allowed: true,
219
325
  ...result,