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