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
@@ -21,16 +21,18 @@
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
27
 
27
- import { existsSync, readdirSync, rmSync, unlinkSync, statSync } from 'node:fs'
28
+ import { existsSync, readdirSync, readFileSync, rmSync, unlinkSync, statSync } from 'node:fs'
28
29
  import { join } from 'node:path'
29
30
  import { defineTool } from '@deepseek-ai/dsh-tools'
30
31
  import type { JsonValue } from '@deepseek-ai/dsh-util-values'
31
32
  import { resolveProjectRootForExec } from '../config-loader.ts'
32
33
  import { writeTextAtomic, writeJsonAtomic } from '../atomic-fs.ts'
33
- import { readDecisionEntries, appendDecisionEntry, acquireLogLock, invalidateLogCountCache } from './decision-log.ts'
34
+ import { withProjectLock } from '../file-lock.ts'
35
+ import { readDecisionEntries, appendDecisionEntry, acquireLogLockChecked, invalidateLogCountCache } from './decision-log.ts'
34
36
  import { readRegistry, removeRecord, recomputeRoundCounts } from './fix.ts'
35
37
  import { readExperienceBank, writeExperienceBank } from './experience-store.ts'
36
38
  import { readDefenseEvents, writeDefenseEvents } from './defense-store.ts'
@@ -97,12 +99,20 @@ export function sweepExperienceBank(bank: ExperienceBank, cap: number): { bank:
97
99
  const size = Number.isFinite(cap) && (cap as number) >= 0 ? Math.floor(cap) : MAX_EXPERIENCE_ENTRIES
98
100
  const total = bank.entries.length
99
101
  if (total <= size) return { bank, removed: 0 }
100
- const kept = [...bank.entries]
101
- .sort((a, b) => (b.timestamp ?? '').localeCompare(a.timestamp ?? ''))
102
- .slice(0, size)
103
- // Preserve the original (insertion) order of the surviving entries.
104
- const keptIds = new Set(kept.map((e) => e.id))
105
- const ordered = bank.entries.filter((e) => keptIds.has(e.id))
102
+ // Keep by POSITION, not by id: a Set of kept ids lets duplicate ids (a
103
+ // hand-edited or double-written bank) defeat the cap — every original entry
104
+ // sharing a kept id would survive, so `removed` stayed 0 and the cap never
105
+ // applied. Rank positions newest-first (ties fall back to the original index
106
+ // so equal timestamps keep insertion order, as before), keep the first `size`
107
+ // positions, then re-emit the survivors in insertion order.
108
+ const ranked = bank.entries.map((_, i) => i)
109
+ ranked.sort((a, b) => {
110
+ const ta = bank.entries[a]?.timestamp ?? ''
111
+ const tb = bank.entries[b]?.timestamp ?? ''
112
+ return tb.localeCompare(ta) || a - b
113
+ })
114
+ const keep = new Set(ranked.slice(0, size))
115
+ const ordered = bank.entries.filter((_, i) => keep.has(i))
106
116
  return {
107
117
  bank: { ...bank, entries: ordered, lastUpdated: new Date().toISOString() },
108
118
  removed: total - ordered.length,
@@ -160,6 +170,9 @@ export function inspectPrune(
160
170
  emptyRounds: number[]
161
171
  totalLogEntries: number
162
172
  registryRounds: number
173
+ /** Non-null when the fix registry file is PRESENT but unreadable/corrupt —
174
+ * stale-backup deletion must then be refused (see `executePrune`). */
175
+ registryError: string | null
163
176
  totalExperiences: number
164
177
  experienceOversize: number
165
178
  totalDefenseEvents: number
@@ -187,6 +200,26 @@ export function inspectPrune(
187
200
  }
188
201
 
189
202
  // 3. Stale fix backups: .bak files whose fix-id prefix is not in the registry.
203
+ //
204
+ // `readRegistry` returns an EMPTY registry for BOTH "file absent" and "file
205
+ // present but corrupt" — and for the corrupt case an empty active-id set
206
+ // would classify EVERY backup stale, so a single dryRun:false prune would
207
+ // delete the entire rollback safety net. Read the registry file directly to
208
+ // distinguish the two: absent ⇒ backups are orphaned (stale, delete as
209
+ // before); present-but-unreadable ⇒ set `registryError`, classify nothing,
210
+ // and let `executePrune`/the render surface the refusal.
211
+ const registryFile = fixRegistryPath(projectRoot)
212
+ let registryError: string | null = null
213
+ if (existsSync(registryFile)) {
214
+ try {
215
+ const parsed: unknown = JSON.parse(readFileSync(registryFile, 'utf-8'))
216
+ if (!parsed || typeof parsed !== 'object' || !Array.isArray((parsed as { rounds?: unknown }).rounds)) {
217
+ registryError = `refusing to delete fix backups: fix registry is present but unreadable (invalid shape): ${registryFile}`
218
+ }
219
+ } catch (err) {
220
+ registryError = `refusing to delete fix backups: fix registry is present but unreadable: ${String(err)} (${registryFile})`
221
+ }
222
+ }
190
223
  const registry = readRegistry(projectRoot)
191
224
  const activeIds = new Set<string>()
192
225
  for (const r of registry.rounds) {
@@ -204,14 +237,16 @@ export function inspectPrune(
204
237
  } catch {
205
238
  fixEntries = []
206
239
  }
207
- for (const entry of fixEntries) {
208
- if (!entry.endsWith('.bak')) continue
209
- // Extract the fix-id prefix (up to the first underscore after the id).
210
- // e.g. "fix-abc123_2026-08-17T00-00-00-000Z.bak" → "fix-abc123"
211
- const match = entry.match(/^(fix-[a-z0-9]+)_/)
212
- const id = match?.[1]
213
- if (id && !activeIds.has(id)) {
214
- staleBackups.push(entry)
240
+ if (registryError === null) {
241
+ for (const entry of fixEntries) {
242
+ if (!entry.endsWith('.bak')) continue
243
+ // Extract the fix-id prefix (up to the first underscore after the id).
244
+ // e.g. "fix-abc123_2026-08-17T00-00-00-000Z.bak" → "fix-abc123"
245
+ const match = entry.match(/^(fix-[a-z0-9]+)_/)
246
+ const id = match?.[1]
247
+ if (id && !activeIds.has(id)) {
248
+ staleBackups.push(entry)
249
+ }
215
250
  }
216
251
  }
217
252
 
@@ -252,6 +287,7 @@ export function inspectPrune(
252
287
  emptyRounds,
253
288
  totalLogEntries: entries.length,
254
289
  registryRounds: registry.rounds.length,
290
+ registryError,
255
291
  totalExperiences: bank.entries.length,
256
292
  experienceOversize: sweptBank.removed,
257
293
  totalDefenseEvents: defenseStream.events.length,
@@ -268,28 +304,60 @@ export function inspectPrune(
268
304
  *
269
305
  * The read-before-rewrite is a TOCTOU hot spot: a concurrent worker can append
270
306
  * a fresh audit line between our read and our atomic rewrite, and an atomic
271
- * rename would silently discard it. So instead of a single read+write we use a
272
- * bounded compare-and-append loop — after each rewrite we re-read the log and
307
+ * rename would silently discard it. So instead of a single read+write we take
308
+ * the cross-process log mutex for the whole loop AND keep a bounded
309
+ * compare-and-append loop behind it — after each rewrite we re-read the log and
273
310
  * retry whenever the file grew (a concurrent append slipped in). The loop
274
311
  * terminates when the file is stable or after `MAX_LOG_REWRITE_RETRIES`, and a
275
312
  * failure here is surfaced as a structured error instead of truncating the log.
276
313
  *
277
- * @returns the number of entries removed (best-effort) or 0 on error.
314
+ * Lock required: when the mutex cannot be taken (another process is appending
315
+ * and does not release within the wait budget) the rewrite is REFUSED with a
316
+ * structured error rather than proceeding unlocked — an unlocked rename can
317
+ * drop a concurrent appender's audit line (its fd points at the pre-rename
318
+ * inode) with no way to detect the loss afterwards.
319
+ *
320
+ * `deleted` is the TOTAL across every attempt: attempt 1 may already have
321
+ * removed entries before a retry is needed, and reporting only the final
322
+ * attempt's slice would under-count the audit entry (the pre-fix code returned
323
+ * `deleted: 0` on the exhaustion path even after real deletions).
324
+ *
325
+ * @param options.afterRewrite test hook run after each successful rewrite —
326
+ * simulates a concurrent appender landing lines mid-loop (production passes
327
+ * no hook).
328
+ * @param options.lock lock wait/stale budgets (tests pass short waits so the
329
+ * lock-unavailable refusal is exercised in milliseconds, not 5s).
330
+ * @returns the number of entries removed across all attempts, or 0 with an
331
+ * `error` when nothing needed removing or the rewrite was refused/failed.
278
332
  */
279
- function rewriteDecisionLogKeepingRecent(projectRoot: string, cutoff: string): { deleted: number; error?: string } {
333
+ export function rewriteDecisionLogKeepingRecent(
334
+ projectRoot: string,
335
+ cutoff: string,
336
+ options: { afterRewrite?: () => void; lock?: { waitMs?: number; staleMs?: number } } = {},
337
+ ): { deleted: number; error?: string } {
280
338
  const logPath = join(iterateDir(projectRoot), 'decision-log.jsonl')
281
339
  // Take the cross-process log lock for the ENTIRE loop: with the mutex held,
282
340
  // no concurrent appender can slip a fresh line into the read-before-rewrite
283
341
  // window, so the first attempt is nearly always the only one. (The retry
284
- // loop stays as defense-in-depth for the lock-unavailable path.)
285
- const release = acquireLogLock(projectRoot)
342
+ // loop stays as defense-in-depth for a line landing via the afterRewrite
343
+ // hook or an already-running appender's buffered write.)
344
+ const { acquired, release } = acquireLogLockChecked(projectRoot, options.lock)
345
+ if (!acquired) {
346
+ return {
347
+ deleted: 0,
348
+ error:
349
+ 'failed to acquire the decision-log lock — refusing to rewrite the log unlocked ' +
350
+ '(a concurrent appender could lose an audit line). Retry after the other operation finishes.',
351
+ }
352
+ }
286
353
  try {
354
+ let deletedTotal = 0
287
355
  for (let attempt = 0; attempt < MAX_LOG_REWRITE_RETRIES; attempt++) {
288
356
  try {
289
357
  const entries = readDecisionEntries(projectRoot)
290
358
  const kept = entries.filter((e) => e.timestamp >= cutoff)
291
359
  const deleted = entries.length - kept.length
292
- if (deleted === 0) return { deleted: 0 }
360
+ if (deleted === 0) return { deleted: deletedTotal }
293
361
  writeTextAtomic(logPath, kept.map((e) => JSON.stringify(e)).join('\n') + '\n')
294
362
  // The append-side entry-count cache is keyed by path and assumes "same
295
363
  // byte size ⇒ same count"; the rename above replaced the file with a
@@ -297,16 +365,21 @@ function rewriteDecisionLogKeepingRecent(projectRoot: string, cutoff: string): {
297
365
  // append can report a wrong entryCount when the sizes coincidentally
298
366
  // match.
299
367
  invalidateLogCountCache(logPath)
368
+ // Accumulate THIS attempt's deletions before the re-read: whatever the
369
+ // later attempts (or the exhaustion path) report, these entries are
370
+ // already gone from disk and must count toward the audit total.
371
+ deletedTotal += deleted
372
+ options.afterRewrite?.()
300
373
  // A concurrent appender (cross-process, lock-unavailable path) may have
301
374
  // landed new lines after our read. If the log grew during the write
302
375
  // window, re-read and prune again instead of accepting a lost audit trail.
303
376
  const after = readDecisionEntries(projectRoot)
304
- if (after.length <= kept.length) return { deleted }
377
+ if (after.length <= kept.length) return { deleted: deletedTotal }
305
378
  } catch (err) {
306
- return { deleted: 0, error: `failed to rewrite decision log: ${String(err)}` }
379
+ return { deleted: deletedTotal, error: `failed to rewrite decision log: ${String(err)}` }
307
380
  }
308
381
  }
309
- return { deleted: 0, error: `failed to rewrite decision log after ${MAX_LOG_REWRITE_RETRIES} attempts` }
382
+ return { deleted: deletedTotal, error: `failed to rewrite decision log after ${MAX_LOG_REWRITE_RETRIES} attempts` }
310
383
  } finally {
311
384
  release()
312
385
  }
@@ -344,9 +417,10 @@ export function executePrune(
344
417
  }
345
418
 
346
419
  // 1. Rewrite the decision log, keeping only recent entries. Atomic
347
- // (temp + rename) so a crash mid-write can never truncate the log; the
348
- // bounded compare-and-append loop guards against a concurrent appender
349
- // slipping a fresh audit line into the read-before-rewrite window.
420
+ // (temp + rename) under the cross-process log mutex — REFUSED with a
421
+ // structured error when the lock cannot be taken (an unlocked rename could
422
+ // drop a concurrent appender's audit line); the bounded compare-and-append
423
+ // loop behind it guards the remaining read-before-rewrite window.
350
424
  {
351
425
  const { deleted, error } = rewriteDecisionLogKeepingRecent(projectRoot, cutoff)
352
426
  result.deletedLogEntries = deleted
@@ -365,30 +439,41 @@ export function executePrune(
365
439
  }
366
440
  }
367
441
 
368
- // 3. Delete stale backups.
369
- for (const bak of report.staleBackups) {
370
- try {
371
- unlinkSync(join(fixesDir(projectRoot), bak))
372
- result.deletedBackups.push(bak)
373
- } catch (err) {
374
- result.errors.push(`failed to delete backup ${bak}: ${String(err)}`)
442
+ // 3. Delete stale backups — REFUSED outright when the registry is present
443
+ // but unreadable: without a readable active-id set every backup would have
444
+ // classified stale, and one dryRun:false prune would wipe the whole rollback
445
+ // safety net. Surface the refusal as a structured error instead.
446
+ if (report.registryError) {
447
+ result.errors.push(report.registryError)
448
+ } else {
449
+ for (const bak of report.staleBackups) {
450
+ try {
451
+ unlinkSync(join(fixesDir(projectRoot), bak))
452
+ result.deletedBackups.push(bak)
453
+ } catch (err) {
454
+ result.errors.push(`failed to delete backup ${bak}: ${String(err)}`)
455
+ }
375
456
  }
376
457
  }
377
458
 
378
- // 4. Trim empty rounds from the registry.
459
+ // 4. Trim empty rounds from the registry (locked read-modify-write: the
460
+ // registry read and rewrite must not interleave with another process's
461
+ // fix/rollback or the update is lost — see src/file-lock.ts).
379
462
  if (report.emptyRounds.length > 0) {
380
463
  try {
381
- let registry = readRegistry(projectRoot)
382
- const emptyRoundNos = new Set(report.emptyRounds)
383
- // Drop whole empty rounds (records.length === 0) instead of only
384
- // removing their records — an empty round has no records to remove, so
385
- // the old loop was a no-op that still reported trimmedEmptyRounds.
386
- registry = {
387
- ...registry,
388
- rounds: registry.rounds.filter((r) => !emptyRoundNos.has(r.round) || (r.records?.length ?? 0) > 0),
389
- }
390
- registry = recomputeRoundCounts(registry)
391
- writeJsonAtomic(fixRegistryPath(projectRoot), registry)
464
+ withProjectLock(projectRoot, 'fix-registry', () => {
465
+ let registry = readRegistry(projectRoot)
466
+ const emptyRoundNos = new Set(report.emptyRounds)
467
+ // Drop whole empty rounds (records.length === 0) instead of only
468
+ // removing their records — an empty round has no records to remove, so
469
+ // the old loop was a no-op that still reported trimmedEmptyRounds.
470
+ registry = {
471
+ ...registry,
472
+ rounds: registry.rounds.filter((r) => !emptyRoundNos.has(r.round) || (r.records?.length ?? 0) > 0),
473
+ }
474
+ registry = recomputeRoundCounts(registry)
475
+ writeJsonAtomic(fixRegistryPath(projectRoot), registry)
476
+ })
392
477
  result.trimmedEmptyRounds = report.emptyRounds.length
393
478
  } catch (err) {
394
479
  result.errors.push(`failed to trim empty rounds: ${String(err)}`)
@@ -445,13 +530,28 @@ export function registerPruneTool(ctx: { tools: { register: (def: ReturnType<typ
445
530
  ctx.tools.register(
446
531
  defineTool({
447
532
  name: 'iterate_prune',
533
+ // Pending-call card (#12): say up front whether this sweep is the default
534
+ // report-only preview or a REAL deletion — that distinction is prune's
535
+ // entire safety model, so it must be visible before the call runs. Pure:
536
+ // derived from args only.
537
+ presentCall: (args) => {
538
+ const a = args as { dryRun?: unknown }
539
+ const deleting = a.dryRun === false
540
+ return {
541
+ card: 'generic',
542
+ title: deleting
543
+ ? 'Prune stale .iterate/ artifacts (delete)'
544
+ : 'Prune preview (dry run — nothing deleted)',
545
+ kind: deleting ? 'delete' : 'read',
546
+ }
547
+ },
448
548
  description:
449
549
  'Inspect or clean up old iterate runtime artifacts (.iterate/). ' +
450
550
  'Defaults to dry-run (report-only, no deletion). Pass `dryRun: false` to actually prune. ' +
451
551
  'Manages: old decision-log entries, stale checkpoints, orphaned fix backups, empty fix rounds, ' +
452
552
  'stray temp files left by crashed atomic writes, experience-bank entries beyond the entry cap, ' +
453
553
  'and stale defense events. ' +
454
- 'Each deletion is logged to the decision log.',
554
+ 'A non-dry-run appends ONE aggregate summary entry (counts per artifact class) to the decision log.',
455
555
  parameters: {
456
556
  dryRun: {
457
557
  type: 'boolean',
@@ -485,11 +585,14 @@ export function registerPruneTool(ctx: { tools: { register: (def: ReturnType<typ
485
585
  const report = value.report as Record<string, unknown> | undefined
486
586
  const result = value.result as Record<string, unknown> | undefined
487
587
  if (value.dryRun) {
588
+ const registryError = report?.registryError as string | null | undefined
488
589
  const lines = [
489
590
  `[dry-run] prune report (retainDays=${value.retainDays}):`,
490
591
  ` Decision-log entries to remove: ${report?.oldLogEntries ?? '?'} (of ${report?.totalLogEntries ?? '?'})`,
491
592
  ` Checkpoint to delete: ${report?.checkpointStale ? 'yes (stale)' : report?.hasCheckpoint ? 'no (fresh — resume point, kept)' : 'none'}`,
492
- ` Stale backups to delete: ${(report?.staleBackups as string[] | undefined)?.length ?? 0}`,
593
+ registryError
594
+ ? ` Stale backups to delete: none — ${registryError}`
595
+ : ` Stale backups to delete: ${(report?.staleBackups as string[] | undefined)?.length ?? 0}`,
493
596
  ` Stray temp files to delete: ${(report?.staleTemps as string[] | undefined)?.length ?? 0}`,
494
597
  ` Empty rounds to trim: ${(report?.emptyRounds as number[] | undefined)?.length ?? 0}`,
495
598
  ` Experience entries to drop (over ${report?.totalExperiences ?? '?'} cap): ${report?.experienceOversize ?? 0} of ${report?.totalExperiences ?? '?'}`,
@@ -11,6 +11,7 @@ import { defineTool } from '@deepseek-ai/dsh-tools'
11
11
  import type { JsonValue } from '@deepseek-ai/dsh-util-values'
12
12
  import { resolveProjectRootForExec } from '../config-loader.ts'
13
13
  import { readQualityGate, writeQualityGate, computeQualityGate, clearQualityGate } from './quality-store.ts'
14
+ import { resultHeadline } from './present.ts'
14
15
  import type { QualityGateSnapshot } from '../types.ts'
15
16
 
16
17
  /** Valid severity values (kept in sync with QualityGateSnapshot counting). */
@@ -68,6 +69,14 @@ export function registerQualityGateTool(ctx: { tools: { register: (def: ReturnTy
68
69
  ctx.tools.register(
69
70
  defineTool({
70
71
  name: 'iterate_quality_gate',
72
+ // Result card (#12): the certificate render already opens with its
73
+ // headline ("✓ Quality Gate: PASS (score: 92)") — surface that as the
74
+ // card title so a completed gate query isn't a bare blob. Pure: reads
75
+ // only the rendered result (replay-safe); failures decline the card.
76
+ presentResult: (_args, result) => {
77
+ const title = resultHeadline(result)
78
+ return title ? { card: 'generic', title } : undefined
79
+ },
71
80
  // `read` never writes; `compute` persists the snapshot → only read
72
81
  // joins a parallel dispatch group.
73
82
  isConcurrencySafe: (args) => (args as { operation?: unknown }).operation === 'read',
@@ -88,12 +97,14 @@ export function registerQualityGateTool(ctx: { tools: { register: (def: ReturnTy
88
97
  dimensions: {
89
98
  type: 'array',
90
99
  items: { type: 'string' },
91
- description: 'Dimensions to gate (required for compute).',
100
+ description: 'Dimensions to gate (required and must be non-empty for compute).',
92
101
  },
93
102
  findings: {
94
103
  type: 'json',
95
104
  description:
96
- 'Findings array (required for compute). Each item: { dimension, severity (critical|high|medium|low), file, line? }.',
105
+ 'Findings array for compute (optional — defaults to []). Each item: ' +
106
+ '{ dimension, severity (critical|high|medium|low), file, line? }. Findings whose dimension ' +
107
+ 'is not listed in `dimensions` are excluded from scoring and reported as warnings.',
97
108
  },
98
109
  validationResults: {
99
110
  type: 'json',
@@ -124,6 +135,11 @@ export function registerQualityGateTool(ctx: { tools: { register: (def: ReturnTy
124
135
  kind: { type: 'string' },
125
136
  operation: { type: 'string' },
126
137
  snapshot: { type: 'json' },
138
+ warnings: {
139
+ type: 'array',
140
+ items: { type: 'string' },
141
+ description: 'compute only: findings excluded from scoring because their dimension is not gated.',
142
+ },
127
143
  error: { type: 'string' },
128
144
  },
129
145
  },
@@ -148,6 +164,9 @@ export function registerQualityGateTool(ctx: { tools: { register: (def: ReturnTy
148
164
  if (snapshot.failReason) {
149
165
  lines.push('', `Fail Reason: ${snapshot.failReason}`)
150
166
  }
167
+ if (Array.isArray(value.warnings) && value.warnings.length > 0) {
168
+ lines.push('', 'Warnings:', ...value.warnings.map((w) => ` ! ${String(w)}`))
169
+ }
151
170
  if (operation === 'compute') {
152
171
  lines.push('', 'Quality gate snapshot computed and persisted.')
153
172
  }
@@ -182,6 +201,21 @@ export function registerQualityGateTool(ctx: { tools: { register: (def: ReturnTy
182
201
  const dimensions = Array.isArray(args.dimensions)
183
202
  ? args.dimensions.filter((d): d is string => typeof d === 'string' && d.length > 0)
184
203
  : []
204
+ // A compute without any gateable dimension would score an empty
205
+ // dimension list at overallScore 0 and PERSIST a fabricated FAIL
206
+ // certificate for a review that was simply never scoped. Refuse
207
+ // instead: no snapshot is written, and the caller gets a structured
208
+ // error naming the missing argument.
209
+ if (dimensions.length === 0) {
210
+ return {
211
+ ok: false,
212
+ kind: 'quality_gate',
213
+ operation: 'compute',
214
+ error:
215
+ 'dimensions is required (and must be a non-empty array of dimension names) for operation "compute" — ' +
216
+ 'without dimensions there is nothing to gate and no certificate can be computed.',
217
+ }
218
+ }
185
219
  const findings = Array.isArray(args.findings) ? args.findings.filter(isValidFinding) : []
186
220
  const validationResults = Array.isArray(args.validationResults)
187
221
  ? args.validationResults.filter(isValidValidationResult)
@@ -189,6 +223,24 @@ export function registerQualityGateTool(ctx: { tools: { register: (def: ReturnTy
189
223
  const findingsByRound = sanitizeRoundSeries(args.findingsByRound)
190
224
  const fixedByDimension = sanitizeNumberMap(args.fixedByDimension)
191
225
 
226
+ // Findings in dimensions outside the gated list are silently skipped
227
+ // by computeQualityGate (they never touch any dimension score) — a
228
+ // critical one still fails the gate globally, everything else just
229
+ // vanishes from the numbers. Surface those dimensions as warnings so
230
+ // the caller knows the score does NOT cover them; scoring is
231
+ // deliberately left unchanged.
232
+ const gated = new Set(dimensions)
233
+ const ungatedCounts = new Map<string, number>()
234
+ for (const finding of findings) {
235
+ if (!gated.has(finding.dimension)) {
236
+ ungatedCounts.set(finding.dimension, (ungatedCounts.get(finding.dimension) ?? 0) + 1)
237
+ }
238
+ }
239
+ const warnings = [...ungatedCounts].map(
240
+ ([dimension, count]) =>
241
+ `finding dimension "${dimension}" is not in the gated dimensions and was excluded from scoring (${count} finding${count === 1 ? '' : 's'})`,
242
+ )
243
+
192
244
  const snapshot = computeQualityGate({
193
245
  dimensions,
194
246
  findings,
@@ -205,6 +257,9 @@ export function registerQualityGateTool(ctx: { tools: { register: (def: ReturnTy
205
257
  kind: 'quality_gate',
206
258
  operation: 'compute',
207
259
  snapshot: snapshot as unknown as JsonValue,
260
+ // Typed by the output schema as string[] — do not widen this to
261
+ // JsonValue or the execute return type stops matching the schema.
262
+ warnings,
208
263
  }
209
264
  }
210
265
 
@@ -67,6 +67,11 @@ export function readQualityGate(projectRoot: string): QualityGateSnapshot {
67
67
  })
68
68
  : []
69
69
  return {
70
+ // Snapshot-level fallback timestamp: display-only metadata — the gate
71
+ // is a SINGLE snapshot file (overwritten wholesale), and nothing sorts
72
+ // or evicts by `timestamp`, so a missing/corrupt value degrading to
73
+ // "now" cannot affect retention or ordering. (Reaffirmed from the
74
+ // earlier review: comment only, behavior unchanged.)
70
75
  timestamp: typeof parsed.timestamp === 'string' ? parsed.timestamp : emptySnapshot().timestamp,
71
76
  overallStatus: status,
72
77
  overallScore: num(parsed.overallScore),
@@ -191,6 +196,12 @@ export function computeQualityGate(opts: {
191
196
  }
192
197
 
193
198
  for (const finding of findings) {
199
+ // Findings in dimensions outside the gated list land here with no matching
200
+ // `dimensionStats` entry and are skipped — they never touch any dimension
201
+ // score (only a global critical still fails the gate). Scoring stays
202
+ // unchanged by contract; the tool layer surfaces the excluded dimensions
203
+ // as `warnings` so the numbers are never silently incomplete
204
+ // (see src/tools/quality-gate.ts).
194
205
  const stats = dimensionStats[finding.dimension]
195
206
  if (stats) {
196
207
  stats.count++
@@ -207,7 +218,13 @@ export function computeQualityGate(opts: {
207
218
  // Score: 100 - (critical*30 + high*15 + medium*5 + low*1), capped at 0
208
219
  const penalty = stats.critical * 30 + stats.high * 15 + stats.medium * 5 + stats.low * 1
209
220
  const score = Math.max(0, 100 - penalty)
210
- const status: 'pass' | 'warn' | 'fail' = score >= 80 ? 'pass' : score >= 50 ? 'warn' : 'fail'
221
+ // Arithmetic alone would grade a single critical as 100-30=70 → 'warn',
222
+ // while the snapshot's overall status is 'fail' (hasCritical below) — a
223
+ // certificate that contradicts itself ("WARN" on the dimension that made
224
+ // the gate fail). Any critical finding is a hard FAIL for that dimension,
225
+ // regardless of its score; non-critical dimensions keep the score bands.
226
+ const status: 'pass' | 'warn' | 'fail' =
227
+ stats.critical > 0 ? 'fail' : score >= 80 ? 'pass' : score >= 50 ? 'warn' : 'fail'
211
228
  const series = findingsByRound?.[dim]
212
229
  const convergenceRate = convergenceRateFor(Array.isArray(series) ? series : undefined, stats.count)
213
230