iterate-plugin 3.9.1 → 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.
- package/README.md +5 -4
- package/README.zh-CN.md +1 -1
- package/dist/tools/decision-log.js +628 -144
- package/dist/tools/history.js +4 -2
- package/dist/tools/prune.js +85 -97
- package/dist/tools/transcript.js +23 -14
- package/dist/types.js +24 -1
- package/package.json +20 -10
- package/src/tools/decision-log.ts +736 -148
- package/src/tools/history.ts +4 -2
- package/src/tools/prune.ts +104 -95
- package/src/tools/transcript.ts +21 -13
- package/src/types.ts +41 -12
|
@@ -1,25 +1,72 @@
|
|
|
1
|
-
|
|
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
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
|
|
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
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
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
|
-
*
|
|
44
|
-
*
|
|
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";
|
|
108
|
-
*
|
|
109
|
-
*
|
|
110
|
-
*
|
|
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
|
-
|
|
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
|
-
*
|
|
131
|
-
*
|
|
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
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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
|
-
|
|
138
|
-
|
|
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
|
-
|
|
141
|
-
return
|
|
250
|
+
catch {
|
|
251
|
+
return [];
|
|
142
252
|
}
|
|
143
|
-
|
|
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
|
-
*
|
|
157
|
-
*
|
|
158
|
-
|
|
159
|
-
|
|
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
|
-
//
|
|
163
|
-
//
|
|
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
|
-
|
|
173
|
-
const
|
|
174
|
-
const
|
|
175
|
-
|
|
176
|
-
//
|
|
177
|
-
//
|
|
178
|
-
|
|
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
|
-
//
|
|
189
|
-
//
|
|
190
|
-
//
|
|
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:
|
|
622
|
+
cacheSet(filePath, { size: afterSize, count });
|
|
197
623
|
return { count, path: filePath };
|
|
198
624
|
}
|
|
199
625
|
catch (err) {
|
|
200
|
-
return
|
|
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
|
|
209
|
-
*
|
|
210
|
-
*
|
|
211
|
-
*
|
|
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 =
|
|
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
|
|
227
|
-
const
|
|
228
|
-
if (
|
|
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
|
|
257
|
-
* Supports `append` and `
|
|
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
|
|
263
|
-
// a parallel dispatch group.
|
|
264
|
-
isConcurrencySafe: (args) =>
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
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
|
-
|
|
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: '
|
|
279
|
-
'
|
|
280
|
-
|
|
281
|
-
'
|
|
282
|
-
'
|
|
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:
|
|
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 =
|
|
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 "
|
|
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
|
+
}
|