iterate-plugin 3.9.2 → 3.11.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.
@@ -140,8 +140,10 @@ export function registerHistoryTool(ctx: { tools: { register: (def: ReturnType<t
140
140
  type: {
141
141
  type: 'string',
142
142
  description:
143
- 'Optional entry-type filter: round_start, review_result, atomic_fix, architectural_fix, ' +
144
- 'revert, round_failed, validation, decision, report, resume.',
143
+ 'Optional entry-type (decision kind) filter. This plugin emits round_start, review_result, ' +
144
+ 'atomic_fix, architectural_fix, revert, round_failed, validation, decision, report, resume — ' +
145
+ 'a LABEL list, not a closed set: any kind matching the kernel grammar ' +
146
+ '(`^[a-z][a-z0-9_]{0,31}$`) can be recorded and filtered here.',
145
147
  },
146
148
  since: {
147
149
  type: 'string',
@@ -6,7 +6,6 @@
6
6
  * actually delete.
7
7
  *
8
8
  * Artifacts managed:
9
- * - Decision-log entries older than `retainDays` (default 30, via since).
10
9
  * - Stale checkpoint files (checkpoint.json).
11
10
  * - Fix backups left over from old rounds (backups whose fix-id no longer
12
11
  * appears in the registry).
@@ -18,6 +17,14 @@
18
17
  * - Defense events older than `retainDays` (stale iteration streams that
19
18
  * were never cleared).
20
19
  *
20
+ * Artifacts NOT managed (deliberately):
21
+ * - The decision ledger (`.iterate/decision-log.jsonl`). It is a hash CHAIN since the
22
+ * kernel flip: every entry's `prevEntryHash` is the sha256 of the previous line's
23
+ * bytes, so dropping or renumbering one line invalidates every later one. Prune
24
+ * therefore never rewrites it — `pruneDecisionLog` reports what a cutoff WOULD have
25
+ * removed and names the two legal moves instead. The renumbering rewrite that used to
26
+ * live here is deleted, not hidden behind a flag.
27
+ *
21
28
  * Security model:
22
29
  * - Only operates under the resolved project `.iterate/` directory.
23
30
  * - dryRun=true by default — the caller must explicitly opt into deletion.
@@ -30,16 +37,16 @@ import { join } from 'node:path'
30
37
  import { defineTool } from '@deepseek-ai/dsh-tools'
31
38
  import type { JsonValue } from '@deepseek-ai/dsh-util-values'
32
39
  import { resolveProjectRootForExec } from '../config-loader.ts'
33
- import { writeTextAtomic, writeJsonAtomic } from '../atomic-fs.ts'
40
+ import { writeJsonAtomic } from '../atomic-fs.ts'
34
41
  import { withProjectLock } from '../file-lock.ts'
35
- import { readDecisionEntries, appendDecisionEntry, acquireLogLockChecked, invalidateLogCountCache } from './decision-log.ts'
42
+ import { readDecisionEntries, appendDecisionEntry, decisionLogContestedLine } from './decision-log.ts'
36
43
  import { readRegistry, removeRecord, recomputeRoundCounts } from './fix.ts'
37
44
  import { readExperienceBank, writeExperienceBank } from './experience-store.ts'
38
45
  import { readDefenseEvents, writeDefenseEvents } from './defense-store.ts'
39
46
  import { iterateDir, fixesDir, checkpointPath, fixRegistryPath } from '../paths.ts'
40
47
  import type { DefenseEventStream, ExperienceBank, FixRegistry } from '../types.ts'
41
48
 
42
- /** Default retention for decision-log entries (in days). */
49
+ /** Retention window for the age-based artifacts (checkpoints, defense events). */
43
50
  const DEFAULT_RETAIN_DAYS = 30
44
51
  const MIN_RETAIN_DAYS = 1
45
52
  const MAX_RETAIN_DAYS = 365
@@ -52,9 +59,6 @@ const MAX_RETAIN_DAYS = 365
52
59
  */
53
60
  export const MAX_EXPERIENCE_ENTRIES = 2000
54
61
 
55
- /** How many compare-and-append retries bound the decision-log rewrite race. */
56
- const MAX_LOG_REWRITE_RETRIES = 3
57
-
58
62
  /** Clamp retainDays to a sane range. */
59
63
  export function clampRetainDays(days: number | undefined): number {
60
64
  if (typeof days !== 'number' || !Number.isInteger(days) || days <= 0) {
@@ -163,6 +167,9 @@ export function inspectPrune(
163
167
  retainDays: number,
164
168
  ): {
165
169
  oldLogEntries: number
170
+ /** 1-based line of the first legacy record AFTER the chain began (a contested ledger),
171
+ * or null. This is the only decision-log CONDITION prune can report but not settle. */
172
+ contestedLogLine: number | null
166
173
  hasCheckpoint: boolean
167
174
  checkpointStale: boolean
168
175
  staleBackups: string[]
@@ -180,9 +187,12 @@ export function inspectPrune(
180
187
  } {
181
188
  const cutoff = cutoffTimestamp(retainDays)
182
189
 
183
- // 1. Decision-log entries older than retainDays.
190
+ // 1. Decision-log entries older than retainDays — REPORTED, never removed: the ledger
191
+ // is a hash chain (see `pruneDecisionLog`). `contestedLogLine` is the one ledger
192
+ // condition a human has to settle, so the dry-run preview surfaces it.
184
193
  const entries = readDecisionEntries(projectRoot)
185
194
  const oldLogEntries = entries.filter((e) => e.timestamp < cutoff).length
195
+ const contestedLogLine = decisionLogContestedLine(projectRoot)
186
196
 
187
197
  // 2. Checkpoint presence. A checkpoint is only STALE once it is older than
188
198
  // retainDays — a fresh checkpoint means a run was interrupted recently and
@@ -280,6 +290,7 @@ export function inspectPrune(
280
290
 
281
291
  return {
282
292
  oldLogEntries,
293
+ contestedLogLine,
283
294
  hasCheckpoint,
284
295
  checkpointStale,
285
296
  staleBackups,
@@ -300,91 +311,70 @@ export function inspectPrune(
300
311
  * Returns a detailed report of what was deleted.
301
312
  */
302
313
  /**
303
- * Rewrite the decision log to the entries younger than `cutoff`.
314
+ * What a cutoff WOULD have removed from the decision ledger — and why nothing is removed.
304
315
  *
305
- * The read-before-rewrite is a TOCTOU hot spot: a concurrent worker can append
306
- * a fresh audit line between our read and our atomic rewrite, and an atomic
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
310
- * retry whenever the file grew (a concurrent append slipped in). The loop
311
- * terminates when the file is stable or after `MAX_LOG_REWRITE_RETRIES`, and a
312
- * failure here is surfaced as a structured error instead of truncating the log.
316
+ * THE RENUMBERING REWRITE IS GONE, NOT HIDDEN BEHIND A FLAG. It used to filter the log to
317
+ * the entries younger than `cutoff` and rewrite the whole file (temp + atomic rename, a
318
+ * bounded retry loop, the cross-process lock). Against the kernel's hash chain that is not
319
+ * a destructive-but-honest trim, it is forgery: entry i+1's `prevEntryHash` is the sha256
320
+ * of entry i's BYTES, so dropping line 3 makes every later line's recorded link wrong —
321
+ * and re-emitting the survivors with renumbered `sequence` and recomputed hashes produces
322
+ * a chain that verifies while proving nothing, because it now claims "these decisions were
323
+ * contiguous" about a file somebody deleted the middle of.
313
324
  *
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.
325
+ * So prune reads, reports, and touches nothing. The two legal moves belong to the ledger
326
+ * itself and both are named in the remedy:
319
327
  *
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).
328
+ * 1. keep appending — an all-legacy log migrates onto the chain on its next write
329
+ * (verified before the swap, the original kept beside it at mode 0600) and a healthy
330
+ * chain simply grows; a line is bounded by PAYLOAD_MAX_CANONICAL_LENGTH, not by age;
331
+ * 2. settle a CONTESTED ledger by CHOOSING archive-and-restart
332
+ * (`iterate_decision_log({operation:"archive"})` / `archiveContestedDecisionLog`),
333
+ * which renames the file to `…contested-<stamp>.jsonl` at mode 0600 and lets the next
334
+ * append start a fresh chain. It refuses a healthy or an all-legacy ledger, so it can
335
+ * never become a way to erase continuity by accident.
324
336
  *
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.
337
+ * `cutoff` stays in the signature because the REPORT needs it: `retained` is exactly the
338
+ * number of lines the old code would have deleted, which is what a human weighs when
339
+ * deciding whether a ledger is worth settling.
332
340
  */
333
- export function rewriteDecisionLogKeepingRecent(
341
+ export function pruneDecisionLog(
334
342
  projectRoot: string,
335
343
  cutoff: string,
336
- options: { afterRewrite?: () => void; lock?: { waitMs?: number; staleMs?: number } } = {},
337
- ): { deleted: number; error?: string } {
338
- const logPath = join(iterateDir(projectRoot), 'decision-log.jsonl')
339
- // Take the cross-process log lock for the ENTIRE loop: with the mutex held,
340
- // no concurrent appender can slip a fresh line into the read-before-rewrite
341
- // window, so the first attempt is nearly always the only one. (The retry
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
- }
353
- try {
354
- let deletedTotal = 0
355
- for (let attempt = 0; attempt < MAX_LOG_REWRITE_RETRIES; attempt++) {
356
- try {
357
- const entries = readDecisionEntries(projectRoot)
358
- const kept = entries.filter((e) => e.timestamp >= cutoff)
359
- const deleted = entries.length - kept.length
360
- if (deleted === 0) return { deleted: deletedTotal }
361
- writeTextAtomic(logPath, kept.map((e) => JSON.stringify(e)).join('\n') + '\n')
362
- // The append-side entry-count cache is keyed by path and assumes "same
363
- // byte size ⇒ same count"; the rename above replaced the file with a
364
- // different number of entries, so drop the stale cache or the next
365
- // append can report a wrong entryCount when the sizes coincidentally
366
- // match.
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?.()
373
- // A concurrent appender (cross-process, lock-unavailable path) may have
374
- // landed new lines after our read. If the log grew during the write
375
- // window, re-read and prune again instead of accepting a lost audit trail.
376
- const after = readDecisionEntries(projectRoot)
377
- if (after.length <= kept.length) return { deleted: deletedTotal }
378
- } catch (err) {
379
- return { deleted: deletedTotal, error: `failed to rewrite decision log: ${String(err)}` }
380
- }
381
- }
382
- return { deleted: deletedTotal, error: `failed to rewrite decision log after ${MAX_LOG_REWRITE_RETRIES} attempts` }
383
- } finally {
384
- release()
344
+ ): {
345
+ deleted: number
346
+ retained: number
347
+ contestedLine: number | null
348
+ code: string
349
+ error: string
350
+ remedy: string
351
+ } {
352
+ const entries = readDecisionEntries(projectRoot)
353
+ const retained = entries.filter((e) => e.timestamp < cutoff).length
354
+ const contestedLine = decisionLogContestedLine(projectRoot)
355
+ return {
356
+ deleted: 0,
357
+ retained,
358
+ contestedLine,
359
+ code: 'ITERATE_E_DECISION_LOG_IMMUTABLE',
360
+ error:
361
+ `the decision ledger is a hash chain: ${retained} ` +
362
+ `entr${retained === 1 ? 'y' : 'ies'} older than ${cutoff} are RETAINED, not rewritten`,
363
+ remedy:
364
+ 'a chain cannot be trimmed in place — every later line links to the bytes of the line before it, ' +
365
+ 'so deleting or renumbering one forges the rest. Two legal moves: (1) keep appending: an all-legacy ' +
366
+ 'ledger migrates onto the chain on its next write and a healthy one just grows; (2) if an append ' +
367
+ 'reports ITERATE_E_DECISION_LOG_INTERLEAVED, a human settles it with archive-and-restart — ' +
368
+ 'iterate_decision_log({operation:"archive"}) moves the contested ledger aside losslessly (mode 0600) ' +
369
+ 'and the next append starts a clean chain. Prune never deletes a ledger line.',
385
370
  }
386
371
  }
387
372
 
373
+ /**
374
+ * Actually prune the runtime artifacts (only called when dryRun=false).
375
+ * Returns a detailed report of what was deleted.
376
+ */
377
+
388
378
  /**
389
379
  * Execute the prune: apply every cleanable item reported by `inspectPrune`.
390
380
  * Every step is individually hedged so a single disk failure degrades to a
@@ -396,6 +386,10 @@ export function executePrune(
396
386
  report: ReturnType<typeof inspectPrune>,
397
387
  ): {
398
388
  deletedLogEntries: number
389
+ /** Lines older than the cutoff the ledger KEEPS (a hash chain is not age-trimmed). */
390
+ retainedLogEntries: number
391
+ /** The immutable-ledger refusal: code + what was not done + the two legal moves. */
392
+ decisionLog: { code: string; error: string; remedy: string }
399
393
  deletedCheckpoint: boolean
400
394
  deletedBackups: string[]
401
395
  deletedTemps: string[]
@@ -405,8 +399,13 @@ export function executePrune(
405
399
  errors: string[]
406
400
  } {
407
401
  const cutoff = cutoffTimestamp(retainDays)
402
+ const ledger = pruneDecisionLog(projectRoot, cutoff)
408
403
  const result = {
404
+ // Always 0: a ledger line is never deleted by prune. The field stays because the
405
+ // prune summary (and the audit entry it logs) has always carried it.
409
406
  deletedLogEntries: 0,
407
+ retainedLogEntries: ledger.retained,
408
+ decisionLog: { code: ledger.code, error: ledger.error, remedy: ledger.remedy },
410
409
  deletedCheckpoint: false,
411
410
  deletedBackups: [] as string[],
412
411
  deletedTemps: [] as string[],
@@ -416,15 +415,15 @@ export function executePrune(
416
415
  errors: [] as string[],
417
416
  }
418
417
 
419
- // 1. Rewrite the decision log, keeping only recent entries. Atomic
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.
424
- {
425
- const { deleted, error } = rewriteDecisionLogKeepingRecent(projectRoot, cutoff)
426
- result.deletedLogEntries = deleted
427
- if (error) result.errors.push(error)
418
+ // 1. The decision ledger is READ-ONLY here: nothing is deleted, nothing is rewritten
419
+ // (see `pruneDecisionLog`). The refusal is always reported; a CONTESTED ledger is
420
+ // additionally escalated into `errors`, because that is a broken state only a human can
421
+ // settle — archive-and-restart is offered, never taken.
422
+ if (ledger.contestedLine !== null) {
423
+ result.errors.push(
424
+ `line ${ledger.contestedLine} of the decision ledger is a legacy record appended after the chain ` +
425
+ `began (ITERATE_E_DECISION_LOG_INTERLEAVED). ${ledger.remedy}`,
426
+ )
428
427
  }
429
428
 
430
429
  // 2. Remove checkpoint — ONLY when it is actually stale (older than
@@ -548,9 +547,12 @@ export function registerPruneTool(ctx: { tools: { register: (def: ReturnType<typ
548
547
  description:
549
548
  'Inspect or clean up old iterate runtime artifacts (.iterate/). ' +
550
549
  'Defaults to dry-run (report-only, no deletion). Pass `dryRun: false` to actually prune. ' +
551
- 'Manages: old decision-log entries, stale checkpoints, orphaned fix backups, empty fix rounds, ' +
550
+ 'Manages: stale checkpoints, orphaned fix backups, empty fix rounds, ' +
552
551
  'stray temp files left by crashed atomic writes, experience-bank entries beyond the entry cap, ' +
553
552
  'and stale defense events. ' +
553
+ 'The decision ledger is NEVER trimmed: it is a hash chain, so a removed or renumbered line ' +
554
+ 'would invalidate every later one. Prune reports how many lines a cutoff would have removed ' +
555
+ 'and names the two legal moves (keep appending / human-chosen archive-and-restart). ' +
554
556
  'A non-dry-run appends ONE aggregate summary entry (counts per artifact class) to the decision log.',
555
557
  parameters: {
556
558
  dryRun: {
@@ -588,7 +590,10 @@ export function registerPruneTool(ctx: { tools: { register: (def: ReturnType<typ
588
590
  const registryError = report?.registryError as string | null | undefined
589
591
  const lines = [
590
592
  `[dry-run] prune report (retainDays=${value.retainDays}):`,
591
- ` Decision-log entries to remove: ${report?.oldLogEntries ?? '?'} (of ${report?.totalLogEntries ?? '?'})`,
593
+ ` Decision-log entries older than retainDays: ${report?.oldLogEntries ?? '?'} (of ${report?.totalLogEntries ?? '?'}) — RETAINED, a hash chain is never trimmed`,
594
+ report?.contestedLogLine
595
+ ? ` CONTESTED ledger: line ${report.contestedLogLine} is a legacy record after the chain — settle it with iterate_decision_log({operation:"archive"})`
596
+ : ' Ledger continuity: no interleaved legacy records',
592
597
  ` Checkpoint to delete: ${report?.checkpointStale ? 'yes (stale)' : report?.hasCheckpoint ? 'no (fresh — resume point, kept)' : 'none'}`,
593
598
  registryError
594
599
  ? ` Stale backups to delete: none — ${registryError}`
@@ -604,7 +609,7 @@ export function registerPruneTool(ctx: { tools: { register: (def: ReturnType<typ
604
609
  }
605
610
  const lines = [
606
611
  `Prune complete (retainDays=${value.retainDays}):`,
607
- ` Deleted ${result?.deletedLogEntries ?? 0} old log entries.`,
612
+ ` Decision-log lines deleted: ${result?.deletedLogEntries ?? 0} — ${result?.retainedLogEntries ?? 0} older than the cutoff RETAINED (${(result?.decisionLog as { code?: string } | undefined)?.code ?? 'hash chain'}: never renumbered).`,
608
613
  ` Checkpoint deleted: ${result?.deletedCheckpoint ? 'yes' : 'no'}`,
609
614
  ` Deleted ${(result?.deletedBackups as string[] | undefined)?.length ?? 0} stale backups.`,
610
615
  ` Deleted ${(result?.deletedTemps as string[] | undefined)?.length ?? 0} stray temp files.`,
@@ -652,6 +657,10 @@ export function registerPruneTool(ctx: { tools: { register: (def: ReturnType<typ
652
657
  action: 'prune',
653
658
  retainDays,
654
659
  deletedLogEntries: result.deletedLogEntries,
660
+ retainedLogEntries: result.retainedLogEntries,
661
+ // The ledger itself was not touched; record WHY, so an audit reader can tell
662
+ // "prune deleted nothing" apart from "prune was refused by an immutable chain".
663
+ decisionLogRefusal: result.decisionLog.code,
655
664
  deletedCheckpoint: result.deletedCheckpoint,
656
665
  deletedBackups: result.deletedBackups.length,
657
666
  trimmedEmptyRounds: result.trimmedEmptyRounds,
@@ -31,7 +31,7 @@ import { readQualityGate } from './quality-store.ts'
31
31
  import { readExperienceBank } from './experience-store.ts'
32
32
  import { readDefenseEvents } from './defense-store.ts'
33
33
  import { resultHeadline } from './present.ts'
34
- import { renderRunReportMarkdown, renderRunReportSarif, REPORT_FORMATS } from '../report-render.ts'
34
+ import { renderRunReportMarkdown, renderRunReportSarif, renderRunReportJson, REPORT_FORMATS } from '../report-render.ts'
35
35
 
36
36
  /** Formats accepted by the tool (kept in sync with the shared renderer). */
37
37
  const FORMATS: ReadonlySet<string> = new Set(REPORT_FORMATS)
@@ -73,19 +73,21 @@ export function registerReportTool(ctx: { tools: { register: (def: ReturnType<ty
73
73
  'quality-gate table, findings table, fixes table, per-round validations, defense events, and the ' +
74
74
  'experience bank. `format:"sarif"` renders a SARIF 2.1.0 log for CI / GitHub Code Scanning ' +
75
75
  '(one rule per review dimension; finding severity maps to error/warning/note; findings carry ' +
76
- 'file + line locations and the suggested fix). ' +
76
+ 'file + line locations and the suggested fix). `format:"json"` renders the COMPLETE normalized run ' +
77
+ 'model as machine-readable JSON (findings, fixes, validations, convergence, quality gate, ' +
78
+ 'experience bank, defense events) — the lossless format for CI dashboards and PR bots. ' +
77
79
  'Set `write:true` to also persist the report under `.iterate/report.md` / `.iterate/report.sarif` ' +
78
- 'and get the path back. `lang` selects the report language (zh | en). ' +
80
+ '/ `.iterate/report.json` and get the path back. `lang` selects the report language (zh | en). ' +
79
81
  'Read-only apart from the optional report file; never touches source files.',
80
82
  parameters: {
81
83
  format: {
82
84
  type: 'string',
83
- description: 'Report format: "markdown" (default) or "sarif".',
84
- enum: ['markdown', 'sarif'],
85
+ description: 'Report format: "markdown" (default), "sarif", or "json".',
86
+ enum: ['markdown', 'sarif', 'json'],
85
87
  },
86
88
  write: {
87
89
  type: 'boolean',
88
- description: 'When true, persist the report to `.iterate/report.md` / `.iterate/report.sarif` and return its path. Default: false.',
90
+ description: 'When true, persist the report to `.iterate/report.md` / `.iterate/report.sarif` / `.iterate/report.json` and return its path. Default: false.',
89
91
  },
90
92
  lang: {
91
93
  type: 'string',
@@ -106,10 +108,12 @@ export function registerReportTool(ctx: { tools: { register: (def: ReturnType<ty
106
108
  ok: { type: 'boolean', required: true },
107
109
  kind: { type: 'string' },
108
110
  format: { type: 'string' },
109
- /** The rendered report text (Markdown, or pretty-printed SARIF JSON). */
111
+ /** The rendered report text (Markdown, or pretty-printed SARIF/JSON). */
110
112
  content: { type: 'string' },
111
113
  /** The SARIF log object, when format is "sarif". */
112
114
  sarif: { type: 'json' },
115
+ /** The full normalized run model, when format is "json". */
116
+ json: { type: 'json' },
113
117
  /** Whether a source transcript manifest was found on disk. */
114
118
  source: { type: 'string' },
115
119
  written: { type: 'boolean' },
@@ -161,6 +165,7 @@ export function registerReportTool(ctx: { tools: { register: (def: ReturnType<ty
161
165
  const extra = { qualityGate, experienceBank, defenseEvents }
162
166
  let content: string
163
167
  let sarif: JsonValue | undefined
168
+ let json: JsonValue | undefined
164
169
  let findings = 0
165
170
  try {
166
171
  if (format === 'sarif') {
@@ -170,6 +175,11 @@ export function registerReportTool(ctx: { tools: { register: (def: ReturnType<ty
170
175
  sarif = doc as unknown as JsonValue
171
176
  content = JSON.stringify(doc, null, 2)
172
177
  findings = Array.isArray(doc?.runs?.[0]?.results) ? (doc.runs[0].results as unknown[]).length : 0
178
+ } else if (format === 'json') {
179
+ const doc = renderRunReportJson(manifest, extra)
180
+ json = doc as unknown as JsonValue
181
+ content = JSON.stringify(doc, null, 2)
182
+ findings = doc.findings.length
173
183
  } else {
174
184
  content = renderRunReportMarkdown(manifest, extra, { lang }) as unknown as string
175
185
  findings = countMarkdownFindings(content)
@@ -189,7 +199,7 @@ export function registerReportTool(ctx: { tools: { register: (def: ReturnType<ty
189
199
  try {
190
200
  const dir = iterateDir(projectRoot)
191
201
  mkdirSync(dir, { recursive: true })
192
- reportPath = `${dir}/report.${format === 'sarif' ? 'sarif' : 'md'}`
202
+ reportPath = `${dir}/report.${format === 'sarif' ? 'sarif' : format === 'json' ? 'json' : 'md'}`
193
203
  writeFileSync(reportPath, content, 'utf-8')
194
204
  written = true
195
205
  } catch (err) {
@@ -208,6 +218,7 @@ export function registerReportTool(ctx: { tools: { register: (def: ReturnType<ty
208
218
  format,
209
219
  content,
210
220
  ...(sarif !== undefined ? { sarif } : {}),
221
+ ...(json !== undefined ? { json } : {}),
211
222
  source,
212
223
  written,
213
224
  ...(reportPath ? { reportPath } : {}),
package/src/types.ts CHANGED
@@ -53,21 +53,50 @@ export interface IterateConfig {
53
53
  personalization?: Record<string, unknown>
54
54
  }
55
55
 
56
- /** One entry in the append-only decision log */
56
+ /**
57
+ * The decision kinds THIS plugin's own producers write.
58
+ *
59
+ * DISPLAY AND DOCUMENTATION ONLY — never a validity rule. Membership in this list
60
+ * used to be the gate on `iterate_decision_log` appends, and it disagreed with the
61
+ * Python harness's own list (which rejected `round_failed` and `resume` outright):
62
+ * two shells, two closed sets, one shared ledger. The disagreement WAS the bug.
63
+ * Validity is now the kernel's kind GRAMMAR (`DECISION_KIND_PATTERN`,
64
+ * `^[a-z][a-z0-9_]{0,31}$`) — see `src/tools/decision-log.ts` `isDecisionKind` —
65
+ * deliberately open so adding a kind does not need a kernel release, and so a kind
66
+ * produced by either shell is readable by the other.
67
+ */
68
+ export const KNOWN_DECISION_KINDS = [
69
+ 'round_start',
70
+ 'review_result',
71
+ 'atomic_fix',
72
+ 'architectural_fix',
73
+ 'revert',
74
+ 'validation',
75
+ 'decision',
76
+ 'report',
77
+ 'round_failed',
78
+ 'resume',
79
+ ] as const
80
+
81
+ /** One of the kinds this plugin emits itself (a label, not an exhaustive set). */
82
+ export type KnownDecisionKind = (typeof KNOWN_DECISION_KINDS)[number]
83
+
84
+ /**
85
+ * One entry in the append-only decision log — the loop's RECORD shape.
86
+ *
87
+ * This is what a producer hands to `appendDecisionEntry` and what every reader sees
88
+ * back. It is NOT the shape on disk: the ledger stores the kernel's canonical
89
+ * hash-chained entry, and `decision-log.ts` transcribes on the way in
90
+ * (`type`→`kind`, `data`→`payload`, `timestamp`→`createdAt`) and projects back out
91
+ * on the way read (`projectDecisionRecord`).
92
+ *
93
+ * `type` is an open string (kernel grammar) — see {@link KNOWN_DECISION_KINDS} for
94
+ * what this plugin actually emits.
95
+ */
57
96
  export interface DecisionLogEntry {
58
97
  timestamp: string
59
98
  round: number
60
- type:
61
- | 'round_start'
62
- | 'review_result'
63
- | 'atomic_fix'
64
- | 'architectural_fix'
65
- | 'revert'
66
- | 'validation'
67
- | 'decision'
68
- | 'report'
69
- | 'round_failed'
70
- | 'resume'
99
+ type: string
71
100
  data: Record<string, unknown>
72
101
  }
73
102