iterate-plugin 3.9.2 → 3.10.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.
@@ -1,36 +1,103 @@
1
+ /**
2
+ * src/tools/decision-log.ts — the iterate loop's decision ledger.
3
+ *
4
+ * THE WRITE SIDE IS THE KERNEL'S SINCE THIS BATCH. `appendDecisionEntry` no longer
5
+ * serialises the loop's own `{timestamp, round, type, data}` record: it transcribes that
6
+ * record through `iterate-kernel/decision-log-transcribe` and appends a verified
7
+ * hash-chain entry through `iterate-kernel/decision-log` — the same shape the Python
8
+ * harness writes (`iterate_harness/iterate/decision_log.py`) and the same file format the
9
+ * GlassPane fork already runs. Two ecosystems, one format on disk, one chain either side
10
+ * can recompute:
11
+ *
12
+ * line i = canonical JSON of entry i (keys sorted, no whitespace)
13
+ * sequence = i (genesis is 0, strictly +1)
14
+ * prevEntryHash = "" for i = 0, else sha256_hex(line i-1 as written)
15
+ *
16
+ * Chain fields are supplied by the LEDGER (`decisionLogHead` inside the kernel's append),
17
+ * never by the caller — a caller that could pick `sequence` could rewrite history.
18
+ *
19
+ * The history that predates the flip is neither rewritten away nor ignored:
20
+ *
21
+ * - a file whose every line is still a legacy record is MIGRATED ON THE NEXT WRITE: each
22
+ * record transcribed in order using its OWN timestamp (which also mints its entry id, so
23
+ * two runs migrating the same history build the same chain), the new chain verified
24
+ * BEFORE anything is swapped in, and the original bytes kept beside it at mode 0600;
25
+ * - `readDecisionLogDetailed` classifies every line and returns the RECORD VIEW for both
26
+ * shapes (`projectDecisionRecord`), which is what keeps `last_state`, `ci_report`,
27
+ * `html_report`, `replay`, history/checkpoint and the web client working unchanged:
28
+ * transcription rule 3 copies `data` into `payload` in full, so nothing a report reads
29
+ * is lost by the flip;
30
+ * - a log where a legacy line FOLLOWS the chain is CONTESTED: refused, reported, and left
31
+ * for a human. Re-deriving a line that already carries a hash would forge its
32
+ * continuity, so neither the append path nor prune touches it — `archiveContestedDecisionLog`
33
+ * is the only legal move, and it is chosen, never automatic;
34
+ * - a line that is neither shape is SKIPPED by the reader (a partial log beats no log) and
35
+ * counted in `invalidLines` so it never disappears silently.
36
+ *
37
+ * Refusals carry `code` + `remedy` — the kernel's own codes propagate verbatim
38
+ * (`KERNEL_E_*`), plus the two cases only a file's history can produce
39
+ * (`ITERATE_E_DECISION_LOG_INTERLEAVED` / `ITERATE_E_DECISION_LOG_MIGRATION`). They are
40
+ * never a bare Error and never swallowed: `appendDecisionEntry` keeps its old
41
+ * never-throws contract and returns the refusal structured, and the tool boundary answers
42
+ * with the code and the remedy.
43
+ */
44
+
1
45
  import {
2
- appendFileSync,
46
+ chmodSync,
47
+ closeSync,
48
+ fchmodSync,
49
+ fsyncSync,
50
+ openSync,
3
51
  readFileSync,
4
52
  mkdirSync,
5
53
  existsSync,
54
+ renameSync,
6
55
  statSync,
56
+ writeSync,
7
57
  } from 'node:fs'
8
58
  import { join } from 'node:path'
9
59
  import { defineTool } from '@deepseek-ai/dsh-tools'
10
60
  import type { JsonValue } from '@deepseek-ai/dsh-util-values'
61
+ import {
62
+ KernelDecisionLogError,
63
+ appendDecisionLogEntry as kernelAppendLedgerEntry,
64
+ buildDecisionLogEntry as kernelBuildLedgerEntry,
65
+ decisionLogEntryHash as kernelLedgerEntryHash,
66
+ newDecisionEntryId as kernelNewDecisionEntryId,
67
+ serializeDecisionLogEntry as kernelSerializeLedgerEntry,
68
+ verifyDecisionLogText as kernelVerifyLedgerText,
69
+ type DecisionLogDraft,
70
+ } from 'iterate-kernel/decision-log'
71
+ import { DECISION_KIND_PATTERN as KERNEL_DECISION_KIND_PATTERN } from 'iterate-kernel/decision-log-entry'
72
+ import {
73
+ KernelDecisionTranscribeError,
74
+ decisionLogDraftFromRecord as kernelDraftFromRecord,
75
+ isLegacyDecisionRecord as kernelIsLegacyRecord,
76
+ } from 'iterate-kernel/decision-log-transcribe'
11
77
  import { resolveProjectRootForExec } from '../config-loader.ts'
12
78
  import { acquireProjectLockChecked } from '../file-lock.ts'
13
- import type { DecisionLogEntry } from '../types.ts'
79
+ import { KNOWN_DECISION_KINDS, type DecisionLogEntry } from '../types.ts'
14
80
 
15
81
  const LOG_DIR = '.iterate'
16
82
  const LOG_FILE = 'decision-log.jsonl'
17
83
 
84
+ /** Absolute path of a project's ledger. Pure: never creates anything. */
85
+ export function decisionLogPath(projectRoot: string): string {
86
+ return join(projectRoot, LOG_DIR, LOG_FILE)
87
+ }
88
+
18
89
  // ─── Cross-process log mutex ────────────────────────────────────────────────
19
90
  //
20
- // The prune rewrite (temp + atomic rename) can silently discard an audit line
21
- // from a CONCURRENT appender whose fd was opened against the pre-rename inode:
22
- // the appender writes to the unlinked inode, the post-rewrite re-read sees a
23
- // "stable" file, and the fresh entry is lost. In-process safety is guaranteed
24
- // by synchronous I/O, but a second plugin process appending to the same
25
- // project realises the race. The log is serialized with a tiny advisory lock
26
- // file (exclusive create, pid stamped, stale-stealable) so `append` and the
27
- // prune rewrite mutually exclude across processes. Appends are BEST-EFFORT:
28
- // contention timeouts degrade to "proceed unlocked" (an audit line must never
29
- // be lost to a wedged append), never to a crash. The prune REWRITE is the
30
- // opposite — it renames the log and therefore refuses to run when the lock
31
- // cannot be taken (see `rewriteDecisionLogKeepingRecent` in prune.ts).
32
-
33
- /** Lock name shared with the prune rewrite (→ `.iterate/.decision-log.lock`). */
91
+ // Appends from two plugin processes against one project would otherwise interleave
92
+ // inside the kernel's read-head → write → read-back window and break the chain. The
93
+ // kernel DETECTS that (read-back + verify) and refuses to build on the damaged tail;
94
+ // the advisory lock keeps it from happening at all. Appends are BEST-EFFORT on
95
+ // contention: a timeout degrades to "proceed unlocked" (an audit line must never be
96
+ // lost to a wedged append) and the kernel's own detection is the backstop. Nothing
97
+ // here renames the log any more — the prune rewrite that needed the exclusive lock is
98
+ // gone, because renumbering a hash chain is not a thing a ledger can honestly do.
99
+
100
+ /** Lock name shared with the project lock registry (→ `.iterate/.decision-log.lock`). */
34
101
  const LOG_LOCK_NAME = 'decision-log'
35
102
 
36
103
  /**
@@ -49,17 +116,14 @@ export function acquireLogLock(
49
116
  }
50
117
 
51
118
  /**
52
- * {@link acquireLogLock} plus the one thing the prune rewrite needs: whether
53
- * the lock was actually TAKEN. Appends stay best-effort on contention (an
54
- * audit line must not be lost to a wedged append), but the rewrite renames the
55
- * log under any unlocked concurrent appender — it must refuse to run without
56
- * the mutex rather than silently dropping an audit line.
119
+ * {@link acquireLogLock} plus whether the lock was actually TAKEN. Appends stay
120
+ * best-effort on contention; a caller that would mutate the ledger's identity (there is
121
+ * no such caller in this module any more — archive-and-restart renames an UNVERIFIABLE
122
+ * file only) needs the difference.
57
123
  *
58
- * Delegates to the shared `acquireProjectLockChecked` with lock name
59
- * `decision-log`: the decision-log mutex IS that generic project lock. This
60
- * module used to hand-roll an identical copy of the acquire/steal/wait logic,
61
- * and the two stale/wait parameter sets could drift apart — one implementation,
62
- * one set of defaults, one ownership-checked release.
124
+ * Delegates to the shared `acquireProjectLockChecked` with lock name `decision-log`:
125
+ * the decision-log mutex IS that generic project lock. One implementation, one set of
126
+ * defaults, one ownership-checked release.
63
127
  */
64
128
  export function acquireLogLockChecked(
65
129
  projectRoot: string,
@@ -129,82 +193,527 @@ export function recountEntries(filePath: string): { count: number; ok: boolean }
129
193
 
130
194
  /**
131
195
  * Drop the cached entry count for a log file. The cache is keyed by the log
132
- * path and assumes "same byte size ⇒ same entry count"; a prune rewrite
133
- * (temp + atomic rename) replaces the file with a DIFFERENT number of entries,
134
- * so only a stale-cache invalidation keeps the fast path honest. Without it, a
135
- * rewrite that lands on the exact same byte size as the previous append would
136
- * make the next append report a wrong `count`.
196
+ * path and assumes "same byte size ⇒ same entry count"; any event that replaces
197
+ * the file wholesale (a migration, an archive-and-restart) lands a DIFFERENT
198
+ * number of entries under the same path, so only an explicit invalidation keeps
199
+ * the fast path honest.
137
200
  */
138
201
  export function invalidateLogCountCache(filePath: string): void {
139
202
  entryCountCache.delete(filePath)
140
203
  }
141
204
 
142
- /** All valid DecisionLogEntry `type` values (must stay in sync with Types). */
143
- const VALID_ENTRY_TYPES = new Set<DecisionLogEntry['type']>([
144
- 'round_start',
145
- 'review_result',
146
- 'atomic_fix',
147
- 'architectural_fix',
148
- 'revert',
149
- 'round_failed',
150
- 'validation',
151
- 'decision',
152
- 'report',
153
- 'resume',
154
- ])
205
+ // ─── Refusals: code + remedy, never a bare Error ────────────────────────────
155
206
 
156
207
  /**
157
- * Validate a candidate (type, round, data) triple for an append operation.
158
- * Returns an error string on failure, or null when the entry is well-formed.
208
+ * Harness-level refusal codes. The kernel's own codes (`KERNEL_E_*`) propagate
209
+ * unchanged; these two name the cases only a file's HISTORY can produce, so a caller can
210
+ * tell "this ledger is broken" from "a legacy record and the chain are interleaved and I
211
+ * will not guess which side owns the tail". Names mirror the Python side verbatim.
159
212
  */
160
- function validateEntryInput(type: unknown, round: unknown, data: unknown): string | null {
161
- if (typeof type !== 'string' || !VALID_ENTRY_TYPES.has(type as DecisionLogEntry['type'])) {
162
- return `type must be one of: ${[...VALID_ENTRY_TYPES].join(', ')}.`
213
+ export const DECISION_LOG_ERROR_CODES = {
214
+ interleaved: 'ITERATE_E_DECISION_LOG_INTERLEAVED',
215
+ migration: 'ITERATE_E_DECISION_LOG_MIGRATION',
216
+ } as const
217
+
218
+ export type DecisionLogErrorCode = (typeof DECISION_LOG_ERROR_CODES)[keyof typeof DECISION_LOG_ERROR_CODES]
219
+
220
+ /** A decision that must not be recorded, carrying a code and an executable remedy. */
221
+ export class DecisionLogWriteRefusal extends Error {
222
+ readonly code: string
223
+ readonly remedy: string
224
+
225
+ constructor(code: string, message: string, remedy: string) {
226
+ super(`${code}: ${message}`)
227
+ this.name = 'DecisionLogWriteRefusal'
228
+ this.code = code
229
+ this.remedy = remedy
163
230
  }
164
- if (typeof round !== 'number' || !Number.isInteger(round) || round < 1) {
165
- return 'round must be a positive integer.'
231
+ }
232
+
233
+ /**
234
+ * Everything "this decision cannot be recorded" can raise, so a tool boundary can answer
235
+ * with a code and a remedy instead of letting an exception reach the agent as a crash. The
236
+ * kernel's own classes are in here too: their `code`/`remedy` attributes are the same shape
237
+ * on purpose.
238
+ */
239
+ export const DECISION_LOG_REFUSALS = [
240
+ DecisionLogWriteRefusal,
241
+ KernelDecisionLogError,
242
+ KernelDecisionTranscribeError,
243
+ ] as const
244
+
245
+ /** Flatten any refusal (ours or the kernel's) into the structured form callers expect. */
246
+ function refusalFields(error: unknown): { error: string; code: string; remedy: string } {
247
+ if (
248
+ error instanceof DecisionLogWriteRefusal ||
249
+ error instanceof KernelDecisionLogError ||
250
+ error instanceof KernelDecisionTranscribeError
251
+ ) {
252
+ const carried = error as unknown as { message?: unknown; code?: unknown; remedy?: unknown }
253
+ return {
254
+ error: typeof carried.message === 'string' ? carried.message : String(error),
255
+ code: typeof carried.code === 'string' ? carried.code : 'ITERATE_E_DECISION_LOG_UNKNOWN',
256
+ remedy: typeof carried.remedy === 'string'
257
+ ? carried.remedy
258
+ : 'preserve the ledger file and inspect it before writing again',
259
+ }
166
260
  }
167
- if (data !== undefined && data !== null && typeof data !== 'object') {
168
- return 'data must be an object (or omitted).'
261
+ return {
262
+ error: `decision log write failed: ${String(error)}`,
263
+ code: 'ITERATE_E_DECISION_LOG_WRITE',
264
+ remedy: 'report this as a failed write instead of continuing as if the decision had been logged',
169
265
  }
170
- return null
266
+ }
267
+
268
+ /**
269
+ * Whether `type` is a decision kind the kernel can carry — SHAPE, not membership.
270
+ *
271
+ * The pattern is the kernel's own export (`DECISION_KIND_PATTERN`,
272
+ * `iterate-kernel/decision-log-entry`), so this module cannot drift from the contract it
273
+ * implements; `test/decision-log.test.ts` pins the pattern's source text against the
274
+ * string both shells were coded from, which is what ends the 8-vs-10 kind divergence.
275
+ */
276
+ export const DECISION_KIND_PATTERN: RegExp = KERNEL_DECISION_KIND_PATTERN
277
+
278
+ export function isDecisionKind(value: unknown): value is string {
279
+ return typeof value === 'string' && DECISION_KIND_PATTERN.test(value)
280
+ }
281
+
282
+ // ─── Line classification and the record projection ──────────────────────────
283
+
284
+ const CANONICAL_REQUIRED_KEYS = ['entryId', 'sequence', 'prevEntryHash', 'summary', 'outcome', 'createdAt'] as const
285
+
286
+ export type DecisionLogLineKind = 'legacy' | 'canonical' | 'unknown'
287
+
288
+ /**
289
+ * `"legacy"` | `"canonical"` | `"unknown"` for one decoded JSONL line.
290
+ *
291
+ * Shape-based, like the kernel's `isLegacyDecisionRecord`: guessing "it has a timestamp"
292
+ * would misclassify the fork's own ledger lines, which also carry one. Mirrors Python's
293
+ * `_classify_line` exactly.
294
+ */
295
+ export function classifyDecisionLine(decoded: unknown): DecisionLogLineKind {
296
+ if (decoded === null || typeof decoded !== 'object' || Array.isArray(decoded)) return 'unknown'
297
+ const record = decoded as Record<string, unknown>
298
+ if (Object.keys(record).length === 0) return 'unknown'
299
+ if (kernelIsLegacyRecord(record)) return 'legacy'
300
+ const keys = new Set(Object.keys(record))
301
+ return CANONICAL_REQUIRED_KEYS.every((k) => keys.has(k)) ? 'canonical' : 'unknown'
302
+ }
303
+
304
+ /** Every non-empty line of a ledger, paired with its decoded object (`null` when it does not decode). */
305
+ function decodedLines(filePath: string): Array<{ raw: string; decoded: unknown }> {
306
+ let text: string
307
+ try {
308
+ text = readFileSync(filePath, 'utf-8')
309
+ } catch {
310
+ return []
311
+ }
312
+ const out: Array<{ raw: string; decoded: unknown }> = []
313
+ for (const raw of text.split('\n')) {
314
+ if (raw.trim().length === 0) continue
315
+ let decoded: unknown = null
316
+ try {
317
+ decoded = JSON.parse(raw)
318
+ } catch {
319
+ decoded = null
320
+ }
321
+ out.push({ raw, decoded })
322
+ }
323
+ return out
324
+ }
325
+
326
+ /** The 1-based line number of the first legacy record AFTER the chain began, else `null`. */
327
+ export function contestedLedgerLine(filePath: string): number | null {
328
+ const kinds = decodedLines(filePath).map((l) => classifyDecisionLine(l.decoded))
329
+ const firstCanonical = kinds.indexOf('canonical')
330
+ if (firstCanonical === -1) return null
331
+ const trailing = kinds.findIndex((kind, index) => index > firstCanonical && kind === 'legacy')
332
+ return trailing === -1 ? null : trailing + 1
333
+ }
334
+
335
+ /** Project one line (legacy record or canonical entry) into the loop's record shape. */
336
+ export function projectDecisionRecord(decoded: unknown): DecisionLogEntry | null {
337
+ if (decoded === null || typeof decoded !== 'object' || Array.isArray(decoded)) return null
338
+ const line = decoded as Record<string, unknown>
339
+ const kind = classifyDecisionLine(line)
340
+
341
+ if (kind === 'legacy') {
342
+ const data = line['data']
343
+ return {
344
+ timestamp: typeof line['timestamp'] === 'string' ? line['timestamp'] : '',
345
+ round: typeof line['round'] === 'number' ? line['round'] : 0,
346
+ type: typeof line['type'] === 'string' ? line['type'] : '',
347
+ data: data !== null && typeof data === 'object' && !Array.isArray(data)
348
+ ? { ...(data as Record<string, unknown>) }
349
+ : {},
350
+ }
351
+ }
352
+
353
+ if (kind !== 'canonical') return null
354
+ // The inverse of the kernel's transcription on the fields readers actually use:
355
+ // createdAt→timestamp, kind→type, payload→data (rule 3 copied `data` in full, so a
356
+ // report that read a finding list before the flip reads the same list after it).
357
+ const payload = line['payload']
358
+ const round = typeof line['round'] === 'number' ? line['round'] : 0
359
+ return {
360
+ timestamp: typeof line['createdAt'] === 'string' ? line['createdAt'] : '',
361
+ round,
362
+ type: typeof line['kind'] === 'string' ? line['kind'] : '',
363
+ data: payload !== null && typeof payload === 'object' && !Array.isArray(payload)
364
+ ? { ...(payload as Record<string, unknown>) }
365
+ : {},
366
+ }
367
+ }
368
+
369
+ // ─── Timestamps and drafts ──────────────────────────────────────────────────
370
+
371
+ /**
372
+ * ISO-8601 with an EXPLICIT zone — the same rule both shells transcribe against
373
+ * (`kernel_decision_record.ISO_8601_WITH_ZONE` on the Python side, rule 4 of
374
+ * `iterate-kernel/decision-log-transcribe` on the kernel's).
375
+ *
376
+ * A PATTERN and not `Date.parse`: JS happily guesses US month/day order for a spelling
377
+ * like `11/10/2026 02:28` that Python refuses outright. A ledger whose dates depend on
378
+ * which runtime converted them is not evidence, and the two halves of this contract
379
+ * would migrate the same history two different ways.
380
+ */
381
+ const ISO_8601_WITH_ZONE = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.(\d{1,9}))?(Z|[+-]\d{2}:\d{2})$/
382
+
383
+ /** Milliseconds since the epoch for a zone-stamped ISO-8601 instant, or `null`. */
384
+ export function timestampMs(raw: unknown): number | null {
385
+ if (typeof raw !== 'string' || raw.length === 0) return null
386
+ const m = ISO_8601_WITH_ZONE.exec(raw)
387
+ if (!m) return null
388
+ const [, y, mo, d, h, mi, s, frac, zone] = m
389
+ if (y === undefined || mo === undefined || d === undefined || h === undefined ||
390
+ mi === undefined || s === undefined || zone === undefined) return null
391
+ const millis = (frac ?? '').slice(0, 3).padEnd(3, '0')
392
+ const epochMs = Date.UTC(Number(y), Number(mo) - 1, Number(d), Number(h), Number(mi), Number(s), Number(millis))
393
+ if (Number.isNaN(epochMs)) return null
394
+ if (zone === 'Z') return epochMs
395
+ const sign = zone[0] === '+' ? 1 : -1
396
+ const offsetMinutes = sign * (Number(zone.slice(1, 3)) * 60 + Number(zone.slice(4, 6)))
397
+ return epochMs - offsetMinutes * 60_000
398
+ }
399
+
400
+ /** Transcribe one record into a ledger draft, with an entry id minted from its OWN instant. */
401
+ function draftFor(record: DecisionLogEntry, context: string): DecisionLogDraft {
402
+ const epochMs = timestampMs(record.timestamp)
403
+ if (epochMs === null) {
404
+ throw new DecisionLogWriteRefusal(
405
+ DECISION_LOG_ERROR_CODES.migration,
406
+ `a decision dated ${JSON.stringify(record.timestamp)} cannot be entered into the ledger (${context})`,
407
+ 'the kernel accepts ISO-8601 with an explicit zone; fix the producer\'s timestamp — ' +
408
+ 'an entry the ledger cannot date is un-auditable, and minting an id from this machine\'s ' +
409
+ 'clock would make the same history hash two different ways',
410
+ )
411
+ }
412
+ // The id is time-ordered from the RECORD's own timestamp (never `Date.now()`), so two
413
+ // runs migrating the same legacy history build the same chain and get the same hashes.
414
+ return kernelDraftFromRecord(
415
+ { timestamp: record.timestamp, round: record.round, type: record.type, data: record.data },
416
+ kernelNewDecisionEntryId(epochMs),
417
+ )
418
+ }
419
+
420
+ // ─── Migration and the contested state ──────────────────────────────────────
421
+
422
+ /** Write `bytes` to a fresh file at mode 0600, refusing to clobber (O_EXCL + explicit chmod). */
423
+ function writeExclusive0600(path: string, bytes: string | Uint8Array): void {
424
+ // The mode argument is masked by the process umask, so `0o600` alone is not the mode it
425
+ // looks like; fchmod after the open states it outright.
426
+ const fd = openSync(path, 'wx', 0o600)
427
+ try {
428
+ fchmodSync(fd, 0o600)
429
+ const buffer = typeof bytes === 'string' ? Buffer.from(bytes, 'utf-8') : Buffer.from(bytes)
430
+ let written = 0
431
+ while (written < buffer.length) written += writeSync(fd, buffer, written)
432
+ fsyncSync(fd)
433
+ } finally {
434
+ closeSync(fd)
435
+ }
436
+ }
437
+
438
+ /** `20261020T010203004567Z` — sortable, zone-stamped, collision-unlikely suffix for archives/backups. */
439
+ function utcStamp(now: Date): string {
440
+ return now.toISOString().replace(/[-:]/g, '').replace(/\./g, '')
441
+ }
442
+
443
+ /**
444
+ * Rewrite an all-legacy log as a verified chain, keeping the original bytes.
445
+ *
446
+ * Refuses rather than improvises: any record the kernel will not transcribe stops the
447
+ * whole migration, because a ledger that silently omits the line it could not read is a
448
+ * ledger whose gaps are invisible. The new text is verified BEFORE it replaces anything
449
+ * (same order the Python side uses: build → verify → backup → swap).
450
+ */
451
+ function migrateToLedger(filePath: string, records: DecisionLogEntry[]): void {
452
+ let sequence = 0
453
+ let prevHash = ''
454
+ const lines: string[] = []
455
+ for (let index = 0; index < records.length; index++) {
456
+ const record = records[index] as DecisionLogEntry
457
+ try {
458
+ const entry = kernelBuildLedgerEntry(draftFor(record, `line ${index + 1}`), {
459
+ sequence,
460
+ prevEntryHash: prevHash,
461
+ })
462
+ prevHash = kernelLedgerEntryHash(entry)
463
+ sequence += 1
464
+ lines.push(kernelSerializeLedgerEntry(entry))
465
+ } catch (error) {
466
+ if (error instanceof DecisionLogWriteRefusal) throw error
467
+ const { code, error: message, remedy } = refusalFields(error)
468
+ throw new DecisionLogWriteRefusal(
469
+ code === 'ITERATE_E_DECISION_LOG_MIGRATION' ? code : DECISION_LOG_ERROR_CODES.migration,
470
+ `line ${index + 1} of ${filePath} cannot be transcribed into the ledger: ${message}`,
471
+ `the ledger will not start on top of a record it cannot read (${code}) — preserve the file, ` +
472
+ `repair that line's timestamp/payload/kind, then write again. ${remedy}`,
473
+ )
474
+ }
475
+ }
476
+
477
+ const body = `${lines.map((line) => `${line}\n`).join('')}`
478
+ const check = kernelVerifyLedgerText(body)
479
+ if (!check.ok || check.entries.length !== records.length) {
480
+ throw new DecisionLogWriteRefusal(
481
+ DECISION_LOG_ERROR_CODES.migration,
482
+ `the migrated chain does not verify (${check.reason ?? 'unknown reason'})`,
483
+ `nothing was written; keep ${filePath} as it is and investigate line ${check.firstBrokenLine ?? '?'}`,
484
+ )
485
+ }
486
+
487
+ // Verified first, backup second, swap last: an interrupted migration can always be
488
+ // replayed from the 0600 original, and the file on disk is never an unverified chain.
489
+ let original: Buffer
490
+ try {
491
+ original = readFileSync(filePath)
492
+ } catch (error) {
493
+ throw new DecisionLogWriteRefusal(
494
+ DECISION_LOG_ERROR_CODES.migration,
495
+ `the legacy ledger could not be read for backup: ${String(error)}`,
496
+ `nothing was written; fix access to ${filePath} — this is evidence, so repair it rather than deleting it`,
497
+ )
498
+ }
499
+ const backup = `${filePath.replace(/\.jsonl$/, '')}.pre-ledger-${utcStamp(new Date())}.jsonl.bak`
500
+ try {
501
+ writeExclusive0600(backup, original)
502
+ const temp = `${filePath}.migrating-${process.pid}`
503
+ writeExclusive0600(temp, body)
504
+ renameSync(temp, filePath)
505
+ } catch (error) {
506
+ throw new DecisionLogWriteRefusal(
507
+ DECISION_LOG_ERROR_CODES.migration,
508
+ `the migrated chain could not be swapped in: ${String(error)}`,
509
+ `the original is preserved at ${backup}; nothing was lost — repair the directory and write again`,
510
+ )
511
+ } finally {
512
+ invalidateLogCountCache(filePath)
513
+ }
514
+ }
515
+
516
+ /**
517
+ * Move a ledger whose legacy lines follow the chain aside, without losing a byte.
518
+ *
519
+ * This is the HUMAN's action after the `…INTERLEAVED` refusal, and it is the only safe
520
+ * one: the chain cannot be renumbered and the foreign lines cannot be left inside a file
521
+ * the ledger has to verify. The archive keeps the original bytes at mode 0600 under a name
522
+ * that says what it is, and the next `appendDecisionEntry` starts a fresh chain — a
523
+ * restart of continuity somebody chose, rather than a silent repair this module could not
524
+ * do honestly.
525
+ *
526
+ * Refuses when the log is NOT contested, because otherwise this would be a way to erase a
527
+ * healthy chain by accident; an all-legacy log is refused too, because it migrates.
528
+ */
529
+ export function archiveContestedDecisionLog(projectRoot: string): string {
530
+ const filePath = decisionLogPath(projectRoot)
531
+ const contested = contestedLedgerLine(filePath)
532
+ if (contested === null) {
533
+ const kinds = decodedLines(filePath).map((l) => classifyDecisionLine(l.decoded))
534
+ const allLegacy = kinds.length > 0 && kinds.every((k) => k === 'legacy')
535
+ throw new DecisionLogWriteRefusal(
536
+ DECISION_LOG_ERROR_CODES.interleaved,
537
+ `${filePath} is not a contested ledger`,
538
+ allLegacy
539
+ ? 'this log is ALL legacy records and nothing has to be settled: it migrates onto the chain on the next write, keeping a 0600 backup of the original'
540
+ : 'archiving is only for a log whose legacy lines FOLLOW the chain (the first one is none here): a healthy ledger would lose its continuity to this call',
541
+ )
542
+ }
543
+ const archive = `${filePath.replace(/\.jsonl$/, '')}.contested-${utcStamp(new Date())}.jsonl`
544
+ try {
545
+ renameSync(filePath, archive)
546
+ chmodSync(archive, 0o600)
547
+ } catch (error) {
548
+ throw new DecisionLogWriteRefusal(
549
+ DECISION_LOG_ERROR_CODES.interleaved,
550
+ `the contested ledger could not be moved aside: ${String(error)}`,
551
+ `nothing was deleted; ${filePath} is still in place — fix the directory permissions and choose archive-and-restart again`,
552
+ )
553
+ } finally {
554
+ invalidateLogCountCache(filePath)
555
+ }
556
+ return archive
557
+ }
558
+
559
+ /** 1-based line of the first legacy record after the chain began, or `null` (exported for tools/tests). */
560
+ export function decisionLogContestedLine(projectRoot: string): number | null {
561
+ return contestedLedgerLine(decisionLogPath(projectRoot))
171
562
  }
172
563
 
173
564
  /**
174
565
  * Resolve the log file path, creating the directory if needed.
566
+ *
567
+ * A directory this function creates is set to `0700` with an explicit `chmod`: the ledger
568
+ * refuses a group/other-writable parent (anyone could replace the whole chain) and
569
+ * `mkdir`'s mode argument is masked by the process umask, so passing `0o700` alone would
570
+ * not be the mode it looks like.
571
+ *
572
+ * An ALREADY-EXISTING directory is only touched when it is group/other-WRITABLE, and then
573
+ * just enough to clear those two write bits: that is the condition the kernel's own
574
+ * location check refuses on, and the directory may also have been created by the project
575
+ * lock helper (which mkdirs `.iterate/` itself), so "fresh" is not the only path to a
576
+ * writable parent. Anything else — an owner's deliberate 0750 — is left alone.
175
577
  */
176
578
  function logPath(projectRoot: string): string {
177
579
  const dir = join(projectRoot, LOG_DIR)
178
580
  if (!existsSync(dir)) {
179
581
  mkdirSync(dir, { recursive: true })
582
+ try {
583
+ chmodSync(dir, 0o700)
584
+ } catch {
585
+ // A chmod we cannot perform is reported by the kernel's own location check on the
586
+ // next append (mode is still whatever the umask allowed); it must not become a
587
+ // crash here, before the ledger has even been looked at.
588
+ }
589
+ return join(dir, LOG_FILE)
590
+ }
591
+ try {
592
+ const mode = statSync(dir).mode & 0o777
593
+ if ((mode & 0o022) !== 0) chmodSync(dir, mode & ~0o022)
594
+ } catch {
595
+ // Same reasoning as above: unreadable/unchmodable is the ledger's problem to report,
596
+ // not this function's to crash on.
180
597
  }
181
598
  return join(dir, LOG_FILE)
182
599
  }
183
600
 
184
601
  /**
185
- * Append one entry to the decision log (JSONL format).
186
- * Returns the entry count after appending. Never throws — a disk failure is
187
- * surfaced through `error` so callers (fix/prune) can report the audit-trail
188
- * miss without failing the mutation they already performed.
602
+ * Migrate an all-legacy ledger and refuse a contested one, so the next kernel append
603
+ * lands on a chain this file can verify. Any refusal is a `DecisionLogWriteRefusal`.
604
+ */
605
+ function prepareLedger(filePath: string): void {
606
+ const lines = decodedLines(filePath)
607
+ const kinds = lines.map((l) => classifyDecisionLine(l.decoded))
608
+ if (lines.length > 0 && kinds.every((k) => k === 'legacy')) {
609
+ migrateToLedger(
610
+ filePath,
611
+ lines.map((l) => l.decoded as DecisionLogEntry),
612
+ )
613
+ }
614
+ const contested = contestedLedgerLine(filePath)
615
+ if (contested !== null) {
616
+ throw new DecisionLogWriteRefusal(
617
+ DECISION_LOG_ERROR_CODES.interleaved,
618
+ `line ${contested} of ${filePath} is a legacy record appended after the chain began`,
619
+ 'this cannot be repaired in place: re-deriving a line that already carries a hash forges it, ' +
620
+ 'and leaving it means the ledger stays unverifiable. Either flip the writer that produced it, ' +
621
+ 'or archive the contested log losslessly and start the chain again — ' +
622
+ '`iterate_decision_log({operation:"archive"})` / `archiveContestedDecisionLog(projectRoot)`. ' +
623
+ 'Nothing is deleted either way.',
624
+ )
625
+ }
626
+ }
627
+
628
+ /**
629
+ * The structured form every `appendDecisionEntry` failure takes.
630
+ *
631
+ * The `failed to append decision log` prefix is the contract callers (fix, prune, the tool
632
+ * boundary, the F8 tests) have always matched on; the kernel's own message rides inside it
633
+ * together with its code and remedy, so a refusal is both greppable and actionable — never
634
+ * a bare Error, never swallowed.
635
+ */
636
+ function appendFailure(err: unknown, path: string): {
637
+ count: number
638
+ path: string
639
+ error: string
640
+ code: string
641
+ remedy: string
642
+ } {
643
+ const refusal = refusalFields(err)
644
+ return {
645
+ count: 0,
646
+ path,
647
+ error: `failed to append decision log: ${refusal.error}`,
648
+ code: refusal.code,
649
+ remedy: refusal.remedy,
650
+ }
651
+ }
652
+
653
+ /**
654
+ * Append one decision to the ledger as a canonical, hash-chained entry.
655
+ *
656
+ * Returns `{ count, path }` — `count` counts every readable line, legacy or canonical,
657
+ * because that is what the reports iterate over. NEVER throws: a refusal (bad kind, an
658
+ * un-migratable legacy line, a contested log, a broken tail, a torn write) comes back as
659
+ * `{ error, code, remedy }`, and a disk failure is surfaced the same way so callers
660
+ * (fix/prune) can report the audit-trail miss without failing the mutation they already
661
+ * performed.
662
+ *
663
+ * The chain fields (`sequence`, `prevEntryHash`) are supplied by the LEDGER through the
664
+ * kernel's append, never by the caller; the kernel reads back and re-verifies what it
665
+ * wrote, so an interleaved second writer is DETECTED and reported rather than silently
666
+ * chained onto.
189
667
  */
190
- export function appendDecisionEntry(projectRoot: string, entry: DecisionLogEntry): { count: number; path: string; error?: string } {
191
- // Serialize with the cross-process log lock so an append can never interleave
192
- // with a prune rewrite (which renames the file under us).
668
+ export function appendDecisionEntry(
669
+ projectRoot: string,
670
+ entry: DecisionLogEntry,
671
+ ): { count: number; path: string; error?: string; code?: string; remedy?: string } {
672
+ // Kind grammar BEFORE any disk access: a refusal must not leave a state directory
673
+ // behind as the only evidence that something was attempted.
674
+ if (!isDecisionKind(entry.type)) {
675
+ return {
676
+ count: 0,
677
+ path: decisionLogPath(projectRoot),
678
+ ...refusalFields(
679
+ new DecisionLogWriteRefusal(
680
+ 'KERNEL_E_DECISION_KIND',
681
+ `${JSON.stringify(entry.type)} is not a decision kind the kernel can carry`,
682
+ 'a kind is lowercase snake_case up to 32 characters (`^[a-z][a-z0-9_]{0,31}$`); ' +
683
+ 'it is not renamed or case-folded, because two ecosystems must be able to compare it. ' +
684
+ `This plugin's own kinds are ${KNOWN_DECISION_KINDS.join(', ')} — that list is a label, not a gate.`,
685
+ ),
686
+ ),
687
+ }
688
+ }
689
+
690
+ // The ledger directory is resolved BEFORE the lock is taken: `acquireProjectLockChecked`
691
+ // mkdirs `.iterate/` itself, and if it got there first the directory would carry
692
+ // whatever mode the umask produced instead of the 0700 this ledger asks for. A mkdir
693
+ // failure (EACCES, ENOTDIR when an ancestor is a regular file) is reported in the same
694
+ // structured form — `appendDecisionEntry` must never throw out of a caller that already
695
+ // performed its mutation.
696
+ let filePath: string
697
+ try {
698
+ filePath = logPath(projectRoot)
699
+ } catch (err) {
700
+ return appendFailure(err, decisionLogPath(projectRoot))
701
+ }
702
+
193
703
  const release = acquireLogLock(projectRoot)
194
704
  try {
195
- // EVERYTHING that can throw — including `logPath`'s mkdirSync (EACCES when
196
- // `.iterate` cannot be created, ENOTDIR when an ancestor is a regular file)
197
- // and the statSync probes — sits inside the inner try: callers run this
198
- // AFTER their own mutations and only handle the structured form, so an
199
- // escaping exception would abort the whole iterate_fix/iterate_prune call.
200
705
  try {
201
- const filePath = logPath(projectRoot)
202
- const line = JSON.stringify(entry) + '\n'
203
- const prevSize = existsSync(filePath) ? statSync(filePath).size : 0
204
- appendFileSync(filePath, line, 'utf-8')
205
- // Count entries via the size/count cache: when nothing else changed the
206
- // file since our last append (same byte size), count is just +1 — no full
207
- // re-read. Falls back to a full re-count whenever the cache is stale.
706
+ prepareLedger(filePath)
707
+ const draft = draftFor(entry, 'this append')
708
+ const appended = kernelAppendLedgerEntry(filePath, draft)
709
+ // The kernel wrote `bytesWritten` (line + newline) and verified the read-back. The
710
+ // size the file had BEFORE this append is therefore today's size minus that line —
711
+ // which is what the count cache keys on.
712
+ const afterSize = statSync(filePath).size
713
+ const prevSize = Math.max(0, afterSize - appended.bytesWritten)
714
+ // Count entries via the size/count cache: when nothing else changed the file since
715
+ // our last append (same byte size), count is just +1 — no full re-read. Falls back
716
+ // to a full re-count whenever the cache is stale.
208
717
  const cached = cacheGet(filePath)
209
718
  let count: number
210
719
  if (cached && cached.size === prevSize) {
@@ -212,19 +721,18 @@ export function appendDecisionEntry(projectRoot: string, entry: DecisionLogEntry
212
721
  } else {
213
722
  const recounted = recountEntries(filePath)
214
723
  if (!recounted.ok) {
215
- // The append LANDED but the read-back failed (file vanished /
216
- // replaced mid-call). Report the best count we actually know —
217
- // cached value or 0 — and DO NOT touch the cache: caching a failed
218
- // read's fallback under the new byte size would poison every later
219
- // append's count.
724
+ // The append LANDED but the read-back failed (file vanished / replaced
725
+ // mid-call). Report the best count we actually know and DO NOT touch the
726
+ // cache: caching a failed read under the new byte size would poison every
727
+ // later append's count.
220
728
  return { count: recounted.count, path: filePath }
221
729
  }
222
730
  count = recounted.count
223
731
  }
224
- cacheSet(filePath, { size: existsSync(filePath) ? statSync(filePath).size : prevSize, count })
732
+ cacheSet(filePath, { size: afterSize, count })
225
733
  return { count, path: filePath }
226
734
  } catch (err) {
227
- return { count: 0, path: join(projectRoot, LOG_DIR, LOG_FILE), error: `failed to append decision log: ${String(err)}` }
735
+ return appendFailure(err, filePath)
228
736
  }
229
737
  } finally {
230
738
  release()
@@ -241,39 +749,26 @@ export interface DecisionLogRead {
241
749
  }
242
750
 
243
751
  /**
244
- * Read all entries from the decision log, plus the count of corrupt lines.
245
- * A single corrupt line (partial write, hand-edit) is SKIPPED, not fatal —
246
- * one bad line must never empty the whole history for every reader — but it
247
- * is counted in `invalidLines` so it never disappears silently.
752
+ * Read all entries from the decision log, plus the count of unreadable lines.
753
+ *
754
+ * Both shapes are PROJECTED to the loop's record view (`projectDecisionRecord`), so every
755
+ * reader keeps the fields it has always read. A canonical line is projected, NOT verified:
756
+ * this reader answers "what did the loop decide", and `verifyDecisionLedger` is what
757
+ * answers "is this ledger intact" — a report must not be silenced by a broken chain link.
758
+ * A line that decodes to NEITHER shape is skipped, never fatal, but counted.
248
759
  */
249
760
  export function readDecisionLogDetailed(projectRoot: string): DecisionLogRead {
250
- const filePath = join(projectRoot, LOG_DIR, LOG_FILE)
761
+ const filePath = decisionLogPath(projectRoot)
251
762
  if (!existsSync(filePath)) return { entries: [], invalidLines: 0 }
252
- let content: string
253
- try {
254
- content = readFileSync(filePath, 'utf-8')
255
- } catch {
256
- return { entries: [], invalidLines: 0 }
257
- }
258
763
  const entries: DecisionLogEntry[] = []
259
764
  let invalidLines = 0
260
- for (const line of content.split('\n')) {
261
- const trimmed = line.trim()
262
- if (trimmed.length === 0) continue
263
- try {
264
- const parsed: unknown = JSON.parse(trimmed)
265
- if (
266
- parsed === null || typeof parsed !== 'object' ||
267
- typeof (parsed as { timestamp?: unknown }).timestamp !== 'string' ||
268
- typeof (parsed as { type?: unknown }).type !== 'string'
269
- ) {
270
- invalidLines += 1
271
- continue
272
- }
273
- entries.push(parsed as DecisionLogEntry)
274
- } catch {
765
+ for (const line of decodedLines(filePath)) {
766
+ const view = projectDecisionRecord(line.decoded)
767
+ if (view === null) {
275
768
  invalidLines += 1
769
+ continue
276
770
  }
771
+ entries.push(view)
277
772
  }
278
773
  return { entries, invalidLines }
279
774
  }
@@ -287,48 +782,80 @@ export function readDecisionEntries(projectRoot: string): DecisionLogEntry[] {
287
782
  return readDecisionLogDetailed(projectRoot).entries
288
783
  }
289
784
 
785
+ /**
786
+ * Verify the ledger's hash chain (the kernel's own read-back). A report should not need
787
+ * this; an auditor does. A missing file is an empty chain, not an error.
788
+ */
789
+ export function verifyDecisionLedger(projectRoot: string): {
790
+ ok: boolean
791
+ entries: number
792
+ firstBrokenLine?: number
793
+ brokenKind?: 'corrupt' | 'chain'
794
+ reason?: string
795
+ error?: string
796
+ code?: string
797
+ remedy?: string
798
+ } {
799
+ const filePath = decisionLogPath(projectRoot)
800
+ try {
801
+ const lines = decodedLines(filePath)
802
+ const body = `${lines.map((l) => l.raw).join('\n')}${lines.length > 0 ? '\n' : ''}`
803
+ const verified = kernelVerifyLedgerText(body)
804
+ return {
805
+ ok: verified.ok,
806
+ entries: verified.entries.length,
807
+ firstBrokenLine: verified.firstBrokenLine,
808
+ brokenKind: verified.brokenKind,
809
+ reason: verified.reason,
810
+ }
811
+ } catch (err) {
812
+ return { ok: false, entries: 0, ...refusalFields(err) }
813
+ }
814
+ }
815
+
290
816
  /**
291
817
  * Register the `iterate_decision_log` tool.
292
- * Append-only decision log stored in .iterate/decision-log.jsonl.
293
- * Supports `append` and `read` operations.
818
+ * Append-only decision ledger stored in .iterate/decision-log.jsonl as a kernel
819
+ * hash chain. Supports `append`, `read`, `verify` and the human-chosen `archive`.
294
820
  */
295
821
  export function registerDecisionLogTool(ctx: { tools: { register: (def: ReturnType<typeof defineTool>) => void } }): void {
296
822
  ctx.tools.register(
297
823
  defineTool({
298
824
  name: 'iterate_decision_log',
299
- // `read` never writes; `append` extends the audit log → only read joins
300
- // a parallel dispatch group.
301
- isConcurrencySafe: (args) => (args as { operation?: unknown }).operation === 'read',
825
+ // `read`/`verify` never write; `append`/`archive` extend (or settle) the audit
826
+ // ledger → only read joins a parallel dispatch group.
827
+ isConcurrencySafe: (args) => {
828
+ const operation = (args as { operation?: unknown }).operation
829
+ return operation === 'read' || operation === 'verify'
830
+ },
302
831
  description:
303
- 'Append-only decision log for the iterate loop. ' +
304
- 'Use `append` to record a round start, review finding, fix, validation result, or decision. ' +
305
- 'Use `read` to retrieve all entries for review. ' +
832
+ 'Append-only decision ledger for the iterate loop, stored as a hash-chained JSONL ledger ' +
833
+ '(each line is canonical JSON; `prevEntryHash` is the sha256 of the previous line). ' +
834
+ 'Use `append` to record a round start, review finding, fix, validation result, or decision — ' +
835
+ 'the chain fields come from the ledger, never from the caller. ' +
836
+ 'Use `read` to retrieve the loop\'s record view of every entry, `verify` to check the chain. ' +
837
+ 'Use `archive` ONLY when an append reports ITERATE_E_DECISION_LOG_INTERLEAVED: it moves the ' +
838
+ 'contested ledger aside losslessly (0600) so the next append starts a clean chain. ' +
839
+ 'The ledger can never be renumbered, edited or age-trimmed in place. ' +
306
840
  'The log is stored in .iterate/decision-log.jsonl and persists across sessions.',
307
841
 
308
842
  parameters: {
309
843
  operation: {
310
844
  type: 'string',
311
845
  required: true,
312
- description: '"append" to add an entry, "read" to retrieve all entries.',
313
- enum: ['append', 'read'],
846
+ description:
847
+ '"append" to add an entry, "read" to retrieve all entries, "verify" to check the ' +
848
+ 'hash chain, "archive" to move a CONTESTED ledger aside and restart the chain.',
849
+ enum: ['append', 'read', 'verify', 'archive'],
314
850
  },
315
851
  type: {
316
852
  type: 'string',
317
853
  description:
318
- 'Entry type (required for append): round_start, review_result, atomic_fix, ' +
319
- 'architectural_fix, revert, round_failed, validation, decision, report, resume.',
320
- enum: [
321
- 'round_start',
322
- 'review_result',
323
- 'atomic_fix',
324
- 'architectural_fix',
325
- 'revert',
326
- 'round_failed',
327
- 'validation',
328
- 'decision',
329
- 'report',
330
- 'resume',
331
- ],
854
+ 'Decision kind (required for append): lowercase snake_case, at most 32 characters ' +
855
+ '(`^[a-z][a-z0-9_]{0,31}$`). This plugin emits ' +
856
+ `${KNOWN_DECISION_KINDS.join(', ')} — that is a label list, not a closed set: a ` +
857
+ 'well-formed kind either shell produces is accepted, because the kernel owns the ' +
858
+ 'GRAMMAR and each ecosystem owns its vocabulary.',
332
859
  },
333
860
  round: {
334
861
  type: 'integer',
@@ -336,7 +863,7 @@ export function registerDecisionLogTool(ctx: { tools: { register: (def: ReturnTy
336
863
  },
337
864
  data: {
338
865
  type: 'json',
339
- description: 'Entry payload as JSON object (required for append).',
866
+ description: 'Entry payload as JSON object (required for append). Copied into the entry\'s `payload` in full.',
340
867
  },
341
868
  path: {
342
869
  type: 'string',
@@ -356,7 +883,11 @@ export function registerDecisionLogTool(ctx: { tools: { register: (def: ReturnTy
356
883
  entries: { type: 'json' },
357
884
  success: { type: 'boolean' },
358
885
  entry: { type: 'json' },
886
+ archivePath: { type: 'string' },
887
+ chain: { type: 'json' },
359
888
  error: { type: 'string' },
889
+ code: { type: 'string' },
890
+ remedy: { type: 'string' },
360
891
  },
361
892
  },
362
893
  render: (_args, value) => [
@@ -377,30 +908,44 @@ export function registerDecisionLogTool(ctx: { tools: { register: (def: ReturnTy
377
908
  operation: 'read',
378
909
  entryCount: entries.length,
379
910
  invalidLines,
380
- logPath: join(projectRoot, LOG_DIR, LOG_FILE),
911
+ logPath: decisionLogPath(projectRoot),
381
912
  entries: entries as unknown as JsonValue,
382
913
  }
383
914
  }
384
915
 
916
+ if (args.operation === 'verify') {
917
+ const chain = verifyDecisionLedger(projectRoot)
918
+ return {
919
+ operation: 'verify',
920
+ success: chain.ok,
921
+ entryCount: chain.entries,
922
+ logPath: decisionLogPath(projectRoot),
923
+ chain: chain as unknown as JsonValue,
924
+ ...(chain.error !== undefined ? { error: chain.error, code: chain.code, remedy: chain.remedy } : {}),
925
+ }
926
+ }
927
+
928
+ if (args.operation === 'archive') {
929
+ try {
930
+ const archivePath = archiveContestedDecisionLog(projectRoot)
931
+ return { operation: 'archive', success: true, archivePath, logPath: decisionLogPath(projectRoot) }
932
+ } catch (err) {
933
+ return { operation: 'archive', success: false, ...refusalFields(err) }
934
+ }
935
+ }
936
+
385
937
  if (args.operation === 'append') {
386
- const invalid = validateEntryInput(args.type, args.round, args.data)
938
+ const invalid = validateAppendInput(args.type, args.round, args.data)
387
939
  if (invalid !== null) {
388
- return {
389
- operation: 'append',
390
- error: invalid,
391
- }
940
+ return { operation: 'append', success: false, ...invalid }
392
941
  }
393
- const type = args.type as DecisionLogEntry['type']
394
- const round = args.round as number
395
- const data = args.data === undefined || args.data === null
396
- ? {}
397
- : args.data as Record<string, unknown>
398
-
399
942
  const entry: DecisionLogEntry = {
400
943
  timestamp: new Date().toISOString(),
401
- round,
402
- type,
403
- data,
944
+ round: args.round as number,
945
+ type: args.type as string,
946
+ data: args.data === undefined || args.data === null
947
+ ? {}
948
+ : args.data as Record<string, unknown>,
404
949
  }
405
950
 
406
951
  const result = appendDecisionEntry(projectRoot, entry)
@@ -411,6 +956,8 @@ export function registerDecisionLogTool(ctx: { tools: { register: (def: ReturnTy
411
956
  entryCount: 0,
412
957
  logPath: result.path,
413
958
  error: result.error,
959
+ code: result.code,
960
+ remedy: result.remedy,
414
961
  }
415
962
  }
416
963
  return {
@@ -424,9 +971,50 @@ export function registerDecisionLogTool(ctx: { tools: { register: (def: ReturnTy
424
971
 
425
972
  return {
426
973
  operation: args.operation,
427
- error: `Unknown operation "${args.operation}". Use "append" or "read".`,
974
+ error: `Unknown operation "${args.operation}". Use "append", "read", "verify" or "archive".`,
428
975
  }
429
976
  },
430
977
  }),
431
978
  )
432
- }
979
+ }
980
+
981
+ /**
982
+ * Validate a candidate (type, round, data) triple for an append operation.
983
+ * Returns a refusal (code + remedy) on failure, or null when the entry is well-formed.
984
+ *
985
+ * `type` is checked against the KERNEL GRAMMAR here, not a list: an unknown-but-well-formed
986
+ * kind is a decision this shell has not seen, not a malformed one.
987
+ */
988
+ function validateAppendInput(
989
+ type: unknown,
990
+ round: unknown,
991
+ data: unknown,
992
+ ): { error: string; code: string; remedy: string } | null {
993
+ if (!isDecisionKind(type)) {
994
+ return refusalFields(
995
+ new DecisionLogWriteRefusal(
996
+ 'KERNEL_E_DECISION_KIND',
997
+ `${JSON.stringify(type)} is not a decision kind the kernel can carry`,
998
+ 'a kind is lowercase snake_case up to 32 characters (`^[a-z][a-z0-9_]{0,31}$`); it is not ' +
999
+ `renamed or case-folded, because two ecosystems must be able to compare it. This plugin emits ` +
1000
+ `${KNOWN_DECISION_KINDS.join(', ')} and a new well-formed kind needs no release.`,
1001
+ ),
1002
+ )
1003
+ }
1004
+ if (typeof round !== 'number' || !Number.isInteger(round) || round < 1) {
1005
+ return {
1006
+ code: 'ITERATE_E_DECISION_ROUND',
1007
+ error: 'round must be a positive integer.',
1008
+ remedy: 'pass the round the loop is on; the kernel allows 0 and above, this tool requires 1+ ' +
1009
+ 'so a report can locate the decision in a run',
1010
+ }
1011
+ }
1012
+ if (data !== undefined && data !== null && (typeof data !== 'object' || Array.isArray(data))) {
1013
+ return {
1014
+ code: 'KERNEL_E_DECISION_RECORD',
1015
+ error: 'data must be an object (or omitted).',
1016
+ remedy: 'pass the payload as a JSON object; transcription copies it into `payload` in full',
1017
+ }
1018
+ }
1019
+ return null
1020
+ }