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