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
@@ -24,16 +24,29 @@ export function clampHistoryLimit(limit) {
24
24
  * Filter + cap decision-log entries. Pure, unit-tested.
25
25
  * Returns the newest `limit` matching entries plus the total match count
26
26
  * (before the cap), so callers can tell when the result was truncated.
27
+ *
28
+ * `round` keeps only entries logged for that round; `file` keeps only entries
29
+ * whose `data.file` names that fixed file. Invalid `round`/`file` inputs are
30
+ * ignored (same convention as `type`/`since`), never treated as a match-nothing
31
+ * filter by accident.
27
32
  */
28
33
  export function filterDecisionEntries(entries, opts) {
29
34
  const type = typeof opts.type === 'string' && opts.type ? opts.type : undefined;
30
35
  const since = typeof opts.since === 'string' && opts.since ? opts.since : undefined;
36
+ const round = typeof opts.round === 'number' && Number.isInteger(opts.round) && opts.round >= 0
37
+ ? opts.round
38
+ : undefined;
39
+ const file = typeof opts.file === 'string' && opts.file ? opts.file : undefined;
31
40
  const limit = clampHistoryLimit(opts.limit);
32
41
  const matching = (Array.isArray(entries) ? entries : []).filter((e) => {
33
42
  if (type && e.type !== type)
34
43
  return false;
35
44
  if (since && e.timestamp <= since)
36
45
  return false;
46
+ if (round !== undefined && e.round !== round)
47
+ return false;
48
+ if (file && (!e.data || e.data.file !== file))
49
+ return false;
37
50
  return true;
38
51
  });
39
52
  return {
@@ -42,15 +55,44 @@ export function filterDecisionEntries(entries, opts) {
42
55
  limit,
43
56
  };
44
57
  }
45
- /** Per-round fix counts + totals from a fix registry. Pure, unit-tested. */
46
- export function summarizeFixRegistry(registry) {
47
- const rounds = (registry.rounds ?? []).map((r) => ({
48
- round: r.round,
49
- // Coerce defensively so a hand-edited registry round missing either count
50
- // can never propagate NaN into the integer output fields.
51
- fixedCount: Number(r.fixedCount) || 0,
52
- failedCount: Number(r.failedCount) || 0,
53
- }));
58
+ /**
59
+ * Per-round fix counts + totals from a fix registry. Pure, unit-tested.
60
+ *
61
+ * `opts.round` narrows the view to one round. `opts.file` narrows each round to
62
+ * the records that fixed that file: the stored per-round counts cover every
63
+ * file and cannot be subsetted, so a file-scoped summary RECOMPUTES the counts
64
+ * from the kept records (mirroring `recomputeRoundCounts`), and rounds left
65
+ * without any matching record are dropped entirely — an all-zero placeholder
66
+ * round would falsely suggest the file was touched that round.
67
+ */
68
+ export function summarizeFixRegistry(registry, opts = {}) {
69
+ const roundFilter = typeof opts.round === 'number' && Number.isInteger(opts.round) && opts.round >= 0
70
+ ? opts.round
71
+ : undefined;
72
+ const fileFilter = typeof opts.file === 'string' && opts.file ? opts.file : undefined;
73
+ const rounds = (registry.rounds ?? [])
74
+ .filter((r) => roundFilter === undefined || r.round === roundFilter)
75
+ .flatMap((r) => {
76
+ if (fileFilter === undefined) {
77
+ // Coerce defensively so a hand-edited registry round missing either count
78
+ // can never propagate NaN into the integer output fields.
79
+ return [{
80
+ round: r.round,
81
+ fixedCount: Number(r.fixedCount) || 0,
82
+ failedCount: Number(r.failedCount) || 0,
83
+ }];
84
+ }
85
+ const kept = (r.records ?? []).filter((rec) => rec && rec.finding && rec.finding.file === fileFilter);
86
+ if (kept.length === 0)
87
+ return [];
88
+ // Recompute from the kept records: a rolled-back (success:false) record
89
+ // counts as failed, matching the registry's own recomputeRoundCounts.
90
+ return [{
91
+ round: r.round,
92
+ fixedCount: kept.filter((rec) => rec.success).length,
93
+ failedCount: kept.filter((rec) => !rec.success).length,
94
+ }];
95
+ });
54
96
  return {
55
97
  totalFixed: rounds.reduce((s, r) => s + r.fixedCount, 0),
56
98
  totalFailed: rounds.reduce((s, r) => s + r.failedCount, 0),
@@ -60,8 +102,9 @@ export function summarizeFixRegistry(registry) {
60
102
  }
61
103
  /**
62
104
  * Register the `iterate_history` tool.
63
- * Reads the decision log (optionally filtered by type / since / limit) and a
64
- * fix-registry summary. Read-only; never modifies the filesystem.
105
+ * Reads the decision log (optionally filtered by type / since / round / file /
106
+ * limit) and a fix-registry summary (scoped by the same round / file filters).
107
+ * Read-only; never modifies the filesystem.
65
108
  */
66
109
  export function registerHistoryTool(ctx) {
67
110
  ctx.tools.register(defineTool({
@@ -70,7 +113,8 @@ export function registerHistoryTool(ctx) {
70
113
  // dispatch group alongside other read-only sibling calls.
71
114
  isConcurrencySafe: () => true,
72
115
  description: 'Read the iteration history: decision-log entries (optionally filtered by entry `type`, `since` ' +
73
- 'timestamp, and a `limit`) plus a summary of the fix registry (per-round fixed/failed counts). ' +
116
+ 'timestamp, `round`, fixed `file`, and a `limit`) plus a summary of the fix registry (per-round ' +
117
+ 'fixed/failed counts, scoped by the same round/file filters). ' +
74
118
  'Read-only — use it to review what the run did, audit a log, or inspect fixes.',
75
119
  parameters: {
76
120
  type: {
@@ -82,6 +126,16 @@ export function registerHistoryTool(ctx) {
82
126
  type: 'string',
83
127
  description: 'Optional ISO timestamp; only entries AFTER this timestamp are returned.',
84
128
  },
129
+ round: {
130
+ type: 'integer',
131
+ description: 'Optional round number; only decision-log entries logged for this round are returned, ' +
132
+ 'and the fix summary is scoped to this round.',
133
+ },
134
+ file: {
135
+ type: 'string',
136
+ description: 'Optional fixed-file path; only decision-log entries whose `data.file` matches are returned, ' +
137
+ 'and the fix summary counts only fixes applied to that file.',
138
+ },
85
139
  limit: {
86
140
  type: 'integer',
87
141
  description: `Max entries to return (default: ${DEFAULT_LIMIT}, cap: ${MAX_LIMIT}). Returns the newest window in chronological order.`,
@@ -127,8 +181,8 @@ export function registerHistoryTool(ctx) {
127
181
  if (!resolved.ok)
128
182
  return { ok: false, kind: 'history', error: resolved.reason };
129
183
  const projectRoot = resolved.root;
130
- const { entries, filteredCount, limit } = filterDecisionEntries(readDecisionEntries(projectRoot), { type: args.type, since: args.since, limit: args.limit });
131
- const fixes = summarizeFixRegistry(readRegistry(projectRoot));
184
+ const { entries, filteredCount, limit } = filterDecisionEntries(readDecisionEntries(projectRoot), { type: args.type, since: args.since, limit: args.limit, round: args.round, file: args.file });
185
+ const fixes = summarizeFixRegistry(readRegistry(projectRoot), { round: args.round, file: args.file });
132
186
  return {
133
187
  ok: true,
134
188
  kind: 'history',
@@ -0,0 +1,80 @@
1
+ /**
2
+ * src/tools/present.ts — shared `presentResult` helpers for the iterate tools (#12).
3
+ *
4
+ * dsh presents a completed call through `ToolDefinition.presentResult(args,
5
+ * result)` when the tool supplies one; the return value is a `ToolResultView`
6
+ * (a `card`-tagged render intent from `@deepseek-ai/dsh-tools`). These helpers
7
+ * read ONLY the durable result projection handed to the presenter
8
+ * (`result.content` / `result.isError`) — they never re-execute anything, so a
9
+ * live call and a session-log replay produce the identical card.
10
+ *
11
+ * House rules for every presenter built on this module:
12
+ * - Pure / replay-safe: depend on `args` + `result` only.
13
+ * - Defensive: any shape it cannot read returns `undefined`, which makes the
14
+ * UI fall back to the default presentation (pending title + raw result)
15
+ * instead of a wrong or half-guessed card.
16
+ * - No invented copy: card text is derived from the render output the tool
17
+ * itself produced.
18
+ */
19
+ /**
20
+ * Concatenated text of a completed call's rendered content, or `undefined`
21
+ * when there is none. Failure results are never summarized — the presenter
22
+ * declines the card and the UI shows the raw error.
23
+ */
24
+ export function renderedText(result) {
25
+ if (!result || result.isError === true || !Array.isArray(result.content))
26
+ return undefined;
27
+ const parts = [];
28
+ for (const block of result.content) {
29
+ if (block && typeof block === 'object' && block.type === 'text' && typeof block.text === 'string') {
30
+ parts.push(block.text);
31
+ }
32
+ }
33
+ return parts.length > 0 ? parts.join('\n') : undefined;
34
+ }
35
+ /**
36
+ * First non-empty line of the rendered content — the card headline for a
37
+ * `presentResult` (#12). Long lines are clamped so a card header stays a
38
+ * header. `undefined` (failure / no text) means "decline the card".
39
+ */
40
+ export function resultHeadline(result, maxLength = 160) {
41
+ const text = renderedText(result);
42
+ if (!text)
43
+ return undefined;
44
+ const line = text.split('\n').find((l) => l.trim().length > 0);
45
+ if (!line)
46
+ return undefined;
47
+ const trimmed = line.trim();
48
+ return trimmed.length > maxLength ? `${trimmed.slice(0, maxLength - 1)}…` : trimmed;
49
+ }
50
+ /**
51
+ * Parse a tool's rendered content as JSON — for tools whose `render` emits
52
+ * `JSON.stringify(value, null, 2)` verbatim (review, checkpoint). Returns a
53
+ * plain record, or `undefined` when the content is not an object-shaped JSON
54
+ * document (the presenter then declines the card rather than guess).
55
+ */
56
+ export function parseRenderedJson(result) {
57
+ const text = renderedText(result);
58
+ if (!text)
59
+ return undefined;
60
+ let parsed;
61
+ try {
62
+ parsed = JSON.parse(text);
63
+ }
64
+ catch {
65
+ return undefined;
66
+ }
67
+ return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
68
+ ? parsed
69
+ : undefined;
70
+ }
71
+ /** Narrow an unknown value to a plain record (arrays/null/primitives → undefined). */
72
+ export function asRecord(value) {
73
+ return value && typeof value === 'object' && !Array.isArray(value)
74
+ ? value
75
+ : undefined;
76
+ }
77
+ /** Finite-number narrow for defensively reading fields off a parsed result. */
78
+ export function asNumber(value) {
79
+ return typeof value === 'number' && Number.isFinite(value) ? value : undefined;
80
+ }
@@ -21,14 +21,16 @@
21
21
  * Security model:
22
22
  * - Only operates under the resolved project `.iterate/` directory.
23
23
  * - dryRun=true by default — the caller must explicitly opt into deletion.
24
- * - Each deletion is logged to the decision log (when not dry-run).
24
+ * - Each non-dry-run appends ONE aggregate summary entry to the decision
25
+ * log (counts per artifact class) — not one log line per deleted file.
25
26
  */
26
- import { existsSync, readdirSync, rmSync, unlinkSync, statSync } from 'node:fs';
27
+ import { existsSync, readdirSync, readFileSync, rmSync, unlinkSync, statSync } from 'node:fs';
27
28
  import { join } from 'node:path';
28
29
  import { defineTool } from '@deepseek-ai/dsh-tools';
29
30
  import { resolveProjectRootForExec } from "../config-loader.js";
30
31
  import { writeTextAtomic, writeJsonAtomic } from "../atomic-fs.js";
31
- import { readDecisionEntries, appendDecisionEntry, acquireLogLock, invalidateLogCountCache } from "./decision-log.js";
32
+ import { withProjectLock } from "../file-lock.js";
33
+ import { readDecisionEntries, appendDecisionEntry, acquireLogLockChecked, invalidateLogCountCache } from "./decision-log.js";
32
34
  import { readRegistry, recomputeRoundCounts } from "./fix.js";
33
35
  import { readExperienceBank, writeExperienceBank } from "./experience-store.js";
34
36
  import { readDefenseEvents, writeDefenseEvents } from "./defense-store.js";
@@ -91,12 +93,20 @@ export function sweepExperienceBank(bank, cap) {
91
93
  const total = bank.entries.length;
92
94
  if (total <= size)
93
95
  return { bank, removed: 0 };
94
- const kept = [...bank.entries]
95
- .sort((a, b) => (b.timestamp ?? '').localeCompare(a.timestamp ?? ''))
96
- .slice(0, size);
97
- // Preserve the original (insertion) order of the surviving entries.
98
- const keptIds = new Set(kept.map((e) => e.id));
99
- const ordered = bank.entries.filter((e) => keptIds.has(e.id));
96
+ // Keep by POSITION, not by id: a Set of kept ids lets duplicate ids (a
97
+ // hand-edited or double-written bank) defeat the cap — every original entry
98
+ // sharing a kept id would survive, so `removed` stayed 0 and the cap never
99
+ // applied. Rank positions newest-first (ties fall back to the original index
100
+ // so equal timestamps keep insertion order, as before), keep the first `size`
101
+ // positions, then re-emit the survivors in insertion order.
102
+ const ranked = bank.entries.map((_, i) => i);
103
+ ranked.sort((a, b) => {
104
+ const ta = bank.entries[a]?.timestamp ?? '';
105
+ const tb = bank.entries[b]?.timestamp ?? '';
106
+ return tb.localeCompare(ta) || a - b;
107
+ });
108
+ const keep = new Set(ranked.slice(0, size));
109
+ const ordered = bank.entries.filter((_, i) => keep.has(i));
100
110
  return {
101
111
  bank: { ...bank, entries: ordered, lastUpdated: new Date().toISOString() },
102
112
  removed: total - ordered.length,
@@ -160,6 +170,27 @@ export function inspectPrune(projectRoot, retainDays) {
160
170
  }
161
171
  }
162
172
  // 3. Stale fix backups: .bak files whose fix-id prefix is not in the registry.
173
+ //
174
+ // `readRegistry` returns an EMPTY registry for BOTH "file absent" and "file
175
+ // present but corrupt" — and for the corrupt case an empty active-id set
176
+ // would classify EVERY backup stale, so a single dryRun:false prune would
177
+ // delete the entire rollback safety net. Read the registry file directly to
178
+ // distinguish the two: absent ⇒ backups are orphaned (stale, delete as
179
+ // before); present-but-unreadable ⇒ set `registryError`, classify nothing,
180
+ // and let `executePrune`/the render surface the refusal.
181
+ const registryFile = fixRegistryPath(projectRoot);
182
+ let registryError = null;
183
+ if (existsSync(registryFile)) {
184
+ try {
185
+ const parsed = JSON.parse(readFileSync(registryFile, 'utf-8'));
186
+ if (!parsed || typeof parsed !== 'object' || !Array.isArray(parsed.rounds)) {
187
+ registryError = `refusing to delete fix backups: fix registry is present but unreadable (invalid shape): ${registryFile}`;
188
+ }
189
+ }
190
+ catch (err) {
191
+ registryError = `refusing to delete fix backups: fix registry is present but unreadable: ${String(err)} (${registryFile})`;
192
+ }
193
+ }
163
194
  const registry = readRegistry(projectRoot);
164
195
  const activeIds = new Set();
165
196
  for (const r of registry.rounds) {
@@ -179,15 +210,17 @@ export function inspectPrune(projectRoot, retainDays) {
179
210
  catch {
180
211
  fixEntries = [];
181
212
  }
182
- for (const entry of fixEntries) {
183
- if (!entry.endsWith('.bak'))
184
- continue;
185
- // Extract the fix-id prefix (up to the first underscore after the id).
186
- // e.g. "fix-abc123_2026-08-17T00-00-00-000Z.bak" → "fix-abc123"
187
- const match = entry.match(/^(fix-[a-z0-9]+)_/);
188
- const id = match?.[1];
189
- if (id && !activeIds.has(id)) {
190
- staleBackups.push(entry);
213
+ if (registryError === null) {
214
+ for (const entry of fixEntries) {
215
+ if (!entry.endsWith('.bak'))
216
+ continue;
217
+ // Extract the fix-id prefix (up to the first underscore after the id).
218
+ // e.g. "fix-abc123_2026-08-17T00-00-00-000Z.bak" → "fix-abc123"
219
+ const match = entry.match(/^(fix-[a-z0-9]+)_/);
220
+ const id = match?.[1];
221
+ if (id && !activeIds.has(id)) {
222
+ staleBackups.push(entry);
223
+ }
191
224
  }
192
225
  }
193
226
  // 4. Empty rounds (rounds with 0 records).
@@ -224,6 +257,7 @@ export function inspectPrune(projectRoot, retainDays) {
224
257
  emptyRounds,
225
258
  totalLogEntries: entries.length,
226
259
  registryRounds: registry.rounds.length,
260
+ registryError,
227
261
  totalExperiences: bank.entries.length,
228
262
  experienceOversize: sweptBank.removed,
229
263
  totalDefenseEvents: defenseStream.events.length,
@@ -239,29 +273,56 @@ export function inspectPrune(projectRoot, retainDays) {
239
273
  *
240
274
  * The read-before-rewrite is a TOCTOU hot spot: a concurrent worker can append
241
275
  * a fresh audit line between our read and our atomic rewrite, and an atomic
242
- * rename would silently discard it. So instead of a single read+write we use a
243
- * bounded compare-and-append loop — after each rewrite we re-read the log and
276
+ * rename would silently discard it. So instead of a single read+write we take
277
+ * the cross-process log mutex for the whole loop AND keep a bounded
278
+ * compare-and-append loop behind it — after each rewrite we re-read the log and
244
279
  * retry whenever the file grew (a concurrent append slipped in). The loop
245
280
  * terminates when the file is stable or after `MAX_LOG_REWRITE_RETRIES`, and a
246
281
  * failure here is surfaced as a structured error instead of truncating the log.
247
282
  *
248
- * @returns the number of entries removed (best-effort) or 0 on error.
283
+ * Lock required: when the mutex cannot be taken (another process is appending
284
+ * and does not release within the wait budget) the rewrite is REFUSED with a
285
+ * structured error rather than proceeding unlocked — an unlocked rename can
286
+ * drop a concurrent appender's audit line (its fd points at the pre-rename
287
+ * inode) with no way to detect the loss afterwards.
288
+ *
289
+ * `deleted` is the TOTAL across every attempt: attempt 1 may already have
290
+ * removed entries before a retry is needed, and reporting only the final
291
+ * attempt's slice would under-count the audit entry (the pre-fix code returned
292
+ * `deleted: 0` on the exhaustion path even after real deletions).
293
+ *
294
+ * @param options.afterRewrite test hook run after each successful rewrite —
295
+ * simulates a concurrent appender landing lines mid-loop (production passes
296
+ * no hook).
297
+ * @param options.lock lock wait/stale budgets (tests pass short waits so the
298
+ * lock-unavailable refusal is exercised in milliseconds, not 5s).
299
+ * @returns the number of entries removed across all attempts, or 0 with an
300
+ * `error` when nothing needed removing or the rewrite was refused/failed.
249
301
  */
250
- function rewriteDecisionLogKeepingRecent(projectRoot, cutoff) {
302
+ export function rewriteDecisionLogKeepingRecent(projectRoot, cutoff, options = {}) {
251
303
  const logPath = join(iterateDir(projectRoot), 'decision-log.jsonl');
252
304
  // Take the cross-process log lock for the ENTIRE loop: with the mutex held,
253
305
  // no concurrent appender can slip a fresh line into the read-before-rewrite
254
306
  // window, so the first attempt is nearly always the only one. (The retry
255
- // loop stays as defense-in-depth for the lock-unavailable path.)
256
- const release = acquireLogLock(projectRoot);
307
+ // loop stays as defense-in-depth for a line landing via the afterRewrite
308
+ // hook or an already-running appender's buffered write.)
309
+ const { acquired, release } = acquireLogLockChecked(projectRoot, options.lock);
310
+ if (!acquired) {
311
+ return {
312
+ deleted: 0,
313
+ error: 'failed to acquire the decision-log lock — refusing to rewrite the log unlocked ' +
314
+ '(a concurrent appender could lose an audit line). Retry after the other operation finishes.',
315
+ };
316
+ }
257
317
  try {
318
+ let deletedTotal = 0;
258
319
  for (let attempt = 0; attempt < MAX_LOG_REWRITE_RETRIES; attempt++) {
259
320
  try {
260
321
  const entries = readDecisionEntries(projectRoot);
261
322
  const kept = entries.filter((e) => e.timestamp >= cutoff);
262
323
  const deleted = entries.length - kept.length;
263
324
  if (deleted === 0)
264
- return { deleted: 0 };
325
+ return { deleted: deletedTotal };
265
326
  writeTextAtomic(logPath, kept.map((e) => JSON.stringify(e)).join('\n') + '\n');
266
327
  // The append-side entry-count cache is keyed by path and assumes "same
267
328
  // byte size ⇒ same count"; the rename above replaced the file with a
@@ -269,18 +330,23 @@ function rewriteDecisionLogKeepingRecent(projectRoot, cutoff) {
269
330
  // append can report a wrong entryCount when the sizes coincidentally
270
331
  // match.
271
332
  invalidateLogCountCache(logPath);
333
+ // Accumulate THIS attempt's deletions before the re-read: whatever the
334
+ // later attempts (or the exhaustion path) report, these entries are
335
+ // already gone from disk and must count toward the audit total.
336
+ deletedTotal += deleted;
337
+ options.afterRewrite?.();
272
338
  // A concurrent appender (cross-process, lock-unavailable path) may have
273
339
  // landed new lines after our read. If the log grew during the write
274
340
  // window, re-read and prune again instead of accepting a lost audit trail.
275
341
  const after = readDecisionEntries(projectRoot);
276
342
  if (after.length <= kept.length)
277
- return { deleted };
343
+ return { deleted: deletedTotal };
278
344
  }
279
345
  catch (err) {
280
- return { deleted: 0, error: `failed to rewrite decision log: ${String(err)}` };
346
+ return { deleted: deletedTotal, error: `failed to rewrite decision log: ${String(err)}` };
281
347
  }
282
348
  }
283
- return { deleted: 0, error: `failed to rewrite decision log after ${MAX_LOG_REWRITE_RETRIES} attempts` };
349
+ return { deleted: deletedTotal, error: `failed to rewrite decision log after ${MAX_LOG_REWRITE_RETRIES} attempts` };
284
350
  }
285
351
  finally {
286
352
  release();
@@ -304,9 +370,10 @@ export function executePrune(projectRoot, retainDays, report) {
304
370
  errors: [],
305
371
  };
306
372
  // 1. Rewrite the decision log, keeping only recent entries. Atomic
307
- // (temp + rename) so a crash mid-write can never truncate the log; the
308
- // bounded compare-and-append loop guards against a concurrent appender
309
- // slipping a fresh audit line into the read-before-rewrite window.
373
+ // (temp + rename) under the cross-process log mutex — REFUSED with a
374
+ // structured error when the lock cannot be taken (an unlocked rename could
375
+ // drop a concurrent appender's audit line); the bounded compare-and-append
376
+ // loop behind it guards the remaining read-before-rewrite window.
310
377
  {
311
378
  const { deleted, error } = rewriteDecisionLogKeepingRecent(projectRoot, cutoff);
312
379
  result.deletedLogEntries = deleted;
@@ -325,30 +392,42 @@ export function executePrune(projectRoot, retainDays, report) {
325
392
  result.errors.push(`failed to remove checkpoint: ${String(err)}`);
326
393
  }
327
394
  }
328
- // 3. Delete stale backups.
329
- for (const bak of report.staleBackups) {
330
- try {
331
- unlinkSync(join(fixesDir(projectRoot), bak));
332
- result.deletedBackups.push(bak);
333
- }
334
- catch (err) {
335
- result.errors.push(`failed to delete backup ${bak}: ${String(err)}`);
395
+ // 3. Delete stale backups — REFUSED outright when the registry is present
396
+ // but unreadable: without a readable active-id set every backup would have
397
+ // classified stale, and one dryRun:false prune would wipe the whole rollback
398
+ // safety net. Surface the refusal as a structured error instead.
399
+ if (report.registryError) {
400
+ result.errors.push(report.registryError);
401
+ }
402
+ else {
403
+ for (const bak of report.staleBackups) {
404
+ try {
405
+ unlinkSync(join(fixesDir(projectRoot), bak));
406
+ result.deletedBackups.push(bak);
407
+ }
408
+ catch (err) {
409
+ result.errors.push(`failed to delete backup ${bak}: ${String(err)}`);
410
+ }
336
411
  }
337
412
  }
338
- // 4. Trim empty rounds from the registry.
413
+ // 4. Trim empty rounds from the registry (locked read-modify-write: the
414
+ // registry read and rewrite must not interleave with another process's
415
+ // fix/rollback or the update is lost — see src/file-lock.ts).
339
416
  if (report.emptyRounds.length > 0) {
340
417
  try {
341
- let registry = readRegistry(projectRoot);
342
- const emptyRoundNos = new Set(report.emptyRounds);
343
- // Drop whole empty rounds (records.length === 0) instead of only
344
- // removing their records — an empty round has no records to remove, so
345
- // the old loop was a no-op that still reported trimmedEmptyRounds.
346
- registry = {
347
- ...registry,
348
- rounds: registry.rounds.filter((r) => !emptyRoundNos.has(r.round) || (r.records?.length ?? 0) > 0),
349
- };
350
- registry = recomputeRoundCounts(registry);
351
- writeJsonAtomic(fixRegistryPath(projectRoot), registry);
418
+ withProjectLock(projectRoot, 'fix-registry', () => {
419
+ let registry = readRegistry(projectRoot);
420
+ const emptyRoundNos = new Set(report.emptyRounds);
421
+ // Drop whole empty rounds (records.length === 0) instead of only
422
+ // removing their records — an empty round has no records to remove, so
423
+ // the old loop was a no-op that still reported trimmedEmptyRounds.
424
+ registry = {
425
+ ...registry,
426
+ rounds: registry.rounds.filter((r) => !emptyRoundNos.has(r.round) || (r.records?.length ?? 0) > 0),
427
+ };
428
+ registry = recomputeRoundCounts(registry);
429
+ writeJsonAtomic(fixRegistryPath(projectRoot), registry);
430
+ });
352
431
  result.trimmedEmptyRounds = report.emptyRounds.length;
353
432
  }
354
433
  catch (err) {
@@ -403,12 +482,27 @@ export function executePrune(projectRoot, retainDays, report) {
403
482
  export function registerPruneTool(ctx) {
404
483
  ctx.tools.register(defineTool({
405
484
  name: 'iterate_prune',
485
+ // Pending-call card (#12): say up front whether this sweep is the default
486
+ // report-only preview or a REAL deletion — that distinction is prune's
487
+ // entire safety model, so it must be visible before the call runs. Pure:
488
+ // derived from args only.
489
+ presentCall: (args) => {
490
+ const a = args;
491
+ const deleting = a.dryRun === false;
492
+ return {
493
+ card: 'generic',
494
+ title: deleting
495
+ ? 'Prune stale .iterate/ artifacts (delete)'
496
+ : 'Prune preview (dry run — nothing deleted)',
497
+ kind: deleting ? 'delete' : 'read',
498
+ };
499
+ },
406
500
  description: 'Inspect or clean up old iterate runtime artifacts (.iterate/). ' +
407
501
  'Defaults to dry-run (report-only, no deletion). Pass `dryRun: false` to actually prune. ' +
408
502
  'Manages: old decision-log entries, stale checkpoints, orphaned fix backups, empty fix rounds, ' +
409
503
  'stray temp files left by crashed atomic writes, experience-bank entries beyond the entry cap, ' +
410
504
  'and stale defense events. ' +
411
- 'Each deletion is logged to the decision log.',
505
+ 'A non-dry-run appends ONE aggregate summary entry (counts per artifact class) to the decision log.',
412
506
  parameters: {
413
507
  dryRun: {
414
508
  type: 'boolean',
@@ -442,11 +536,14 @@ export function registerPruneTool(ctx) {
442
536
  const report = value.report;
443
537
  const result = value.result;
444
538
  if (value.dryRun) {
539
+ const registryError = report?.registryError;
445
540
  const lines = [
446
541
  `[dry-run] prune report (retainDays=${value.retainDays}):`,
447
542
  ` Decision-log entries to remove: ${report?.oldLogEntries ?? '?'} (of ${report?.totalLogEntries ?? '?'})`,
448
543
  ` Checkpoint to delete: ${report?.checkpointStale ? 'yes (stale)' : report?.hasCheckpoint ? 'no (fresh — resume point, kept)' : 'none'}`,
449
- ` Stale backups to delete: ${report?.staleBackups?.length ?? 0}`,
544
+ registryError
545
+ ? ` Stale backups to delete: none — ${registryError}`
546
+ : ` Stale backups to delete: ${report?.staleBackups?.length ?? 0}`,
450
547
  ` Stray temp files to delete: ${report?.staleTemps?.length ?? 0}`,
451
548
  ` Empty rounds to trim: ${report?.emptyRounds?.length ?? 0}`,
452
549
  ` Experience entries to drop (over ${report?.totalExperiences ?? '?'} cap): ${report?.experienceOversize ?? 0} of ${report?.totalExperiences ?? '?'}`,
@@ -9,6 +9,7 @@
9
9
  import { defineTool } from '@deepseek-ai/dsh-tools';
10
10
  import { resolveProjectRootForExec } from "../config-loader.js";
11
11
  import { readQualityGate, writeQualityGate, computeQualityGate, clearQualityGate } from "./quality-store.js";
12
+ import { resultHeadline } from "./present.js";
12
13
  /** Valid severity values (kept in sync with QualityGateSnapshot counting). */
13
14
  const VALID_SEVERITIES = new Set(['critical', 'high', 'medium', 'low']);
14
15
  /** Validate a single finding object; returns true when well-formed. */
@@ -62,6 +63,14 @@ function sanitizeRoundSeries(raw) {
62
63
  export function registerQualityGateTool(ctx) {
63
64
  ctx.tools.register(defineTool({
64
65
  name: 'iterate_quality_gate',
66
+ // Result card (#12): the certificate render already opens with its
67
+ // headline ("✓ Quality Gate: PASS (score: 92)") — surface that as the
68
+ // card title so a completed gate query isn't a bare blob. Pure: reads
69
+ // only the rendered result (replay-safe); failures decline the card.
70
+ presentResult: (_args, result) => {
71
+ const title = resultHeadline(result);
72
+ return title ? { card: 'generic', title } : undefined;
73
+ },
65
74
  // `read` never writes; `compute` persists the snapshot → only read
66
75
  // joins a parallel dispatch group.
67
76
  isConcurrencySafe: (args) => args.operation === 'read',
@@ -81,11 +90,13 @@ export function registerQualityGateTool(ctx) {
81
90
  dimensions: {
82
91
  type: 'array',
83
92
  items: { type: 'string' },
84
- description: 'Dimensions to gate (required for compute).',
93
+ description: 'Dimensions to gate (required and must be non-empty for compute).',
85
94
  },
86
95
  findings: {
87
96
  type: 'json',
88
- description: 'Findings array (required for compute). Each item: { dimension, severity (critical|high|medium|low), file, line? }.',
97
+ description: 'Findings array for compute (optional — defaults to []). Each item: ' +
98
+ '{ dimension, severity (critical|high|medium|low), file, line? }. Findings whose dimension ' +
99
+ 'is not listed in `dimensions` are excluded from scoring and reported as warnings.',
89
100
  },
90
101
  validationResults: {
91
102
  type: 'json',
@@ -114,6 +125,11 @@ export function registerQualityGateTool(ctx) {
114
125
  kind: { type: 'string' },
115
126
  operation: { type: 'string' },
116
127
  snapshot: { type: 'json' },
128
+ warnings: {
129
+ type: 'array',
130
+ items: { type: 'string' },
131
+ description: 'compute only: findings excluded from scoring because their dimension is not gated.',
132
+ },
117
133
  error: { type: 'string' },
118
134
  },
119
135
  },
@@ -139,6 +155,9 @@ export function registerQualityGateTool(ctx) {
139
155
  if (snapshot.failReason) {
140
156
  lines.push('', `Fail Reason: ${snapshot.failReason}`);
141
157
  }
158
+ if (Array.isArray(value.warnings) && value.warnings.length > 0) {
159
+ lines.push('', 'Warnings:', ...value.warnings.map((w) => ` ! ${String(w)}`));
160
+ }
142
161
  if (operation === 'compute') {
143
162
  lines.push('', 'Quality gate snapshot computed and persisted.');
144
163
  }
@@ -170,12 +189,40 @@ export function registerQualityGateTool(ctx) {
170
189
  const dimensions = Array.isArray(args.dimensions)
171
190
  ? args.dimensions.filter((d) => typeof d === 'string' && d.length > 0)
172
191
  : [];
192
+ // A compute without any gateable dimension would score an empty
193
+ // dimension list at overallScore 0 and PERSIST a fabricated FAIL
194
+ // certificate for a review that was simply never scoped. Refuse
195
+ // instead: no snapshot is written, and the caller gets a structured
196
+ // error naming the missing argument.
197
+ if (dimensions.length === 0) {
198
+ return {
199
+ ok: false,
200
+ kind: 'quality_gate',
201
+ operation: 'compute',
202
+ error: 'dimensions is required (and must be a non-empty array of dimension names) for operation "compute" — ' +
203
+ 'without dimensions there is nothing to gate and no certificate can be computed.',
204
+ };
205
+ }
173
206
  const findings = Array.isArray(args.findings) ? args.findings.filter(isValidFinding) : [];
174
207
  const validationResults = Array.isArray(args.validationResults)
175
208
  ? args.validationResults.filter(isValidValidationResult)
176
209
  : undefined;
177
210
  const findingsByRound = sanitizeRoundSeries(args.findingsByRound);
178
211
  const fixedByDimension = sanitizeNumberMap(args.fixedByDimension);
212
+ // Findings in dimensions outside the gated list are silently skipped
213
+ // by computeQualityGate (they never touch any dimension score) — a
214
+ // critical one still fails the gate globally, everything else just
215
+ // vanishes from the numbers. Surface those dimensions as warnings so
216
+ // the caller knows the score does NOT cover them; scoring is
217
+ // deliberately left unchanged.
218
+ const gated = new Set(dimensions);
219
+ const ungatedCounts = new Map();
220
+ for (const finding of findings) {
221
+ if (!gated.has(finding.dimension)) {
222
+ ungatedCounts.set(finding.dimension, (ungatedCounts.get(finding.dimension) ?? 0) + 1);
223
+ }
224
+ }
225
+ const warnings = [...ungatedCounts].map(([dimension, count]) => `finding dimension "${dimension}" is not in the gated dimensions and was excluded from scoring (${count} finding${count === 1 ? '' : 's'})`);
179
226
  const snapshot = computeQualityGate({
180
227
  dimensions,
181
228
  findings,
@@ -192,6 +239,9 @@ export function registerQualityGateTool(ctx) {
192
239
  kind: 'quality_gate',
193
240
  operation: 'compute',
194
241
  snapshot: snapshot,
242
+ // Typed by the output schema as string[] — do not widen this to
243
+ // JsonValue or the execute return type stops matching the schema.
244
+ warnings,
195
245
  };
196
246
  }
197
247
  const snapshot = readQualityGate(projectRoot);