trantor 0.18.49 → 0.18.51

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.
@@ -0,0 +1,442 @@
1
+ // Trantor State P1 — the store (TDD §4.4). Where a WorkingState lives, how it is written without
2
+ // ever being torn, how two writers are kept off each other without a lock file, and what a turn
3
+ // that was killed mid-flight leaves behind.
4
+ //
5
+ // Three things this file deliberately does NOT do, each of them a temptation:
6
+ //
7
+ // 1. It does not replay the journal to recover. The design admits exactly ONE apply point per
8
+ // turn, after the CLI has exited, so a killed turn has never half-applied a patch: the
9
+ // on-disk state is the last good state, whole. What the dead turn lost is its OBSERVATIONS,
10
+ // so recovery reconstructs from ground truth (git), never from `<sidecar>.ops.jsonl`. The
11
+ // journal is forensics and test replay, and saying so out loud is the point — recovery from a
12
+ // journal would need the journal write and the state write to be one atomic act, which they
13
+ // are not, and we are not claiming a durability we do not have.
14
+ // 2. It does not take a lock. Concurrency is a compare-and-swap on `rev`. A lock file in
15
+ // ~/.agent-bus outlives the process holding it, and this project has already paid for that.
16
+ // 3. It does not retry a STALE commit. Curing STALE is the driver's job (re-read, re-run
17
+ // applyTurn on the fresh state — safe because ops are id-addressed), and a store that retried
18
+ // quietly would be making a policy decision it cannot see the consequences of.
19
+ //
20
+ // The whole package is dark: nothing in the running product imports it yet.
21
+ import {
22
+ existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync,
23
+ } from "node:fs";
24
+ import { execFileSync } from "node:child_process";
25
+ import { randomBytes } from "node:crypto";
26
+ import { join } from "node:path";
27
+ import { homedir } from "node:os";
28
+ import { busDir } from "../project.mjs";
29
+ import { CAPS, ERR, emptyState, stateError } from "./schema.mjs";
30
+ import { migrate } from "./migrate.mjs";
31
+
32
+ /** The CAS-conflict outcome. The one code this file owns; no branch of validate.mjs produces it. */
33
+ export const STALE = "STALE";
34
+
35
+ /** Journal ring size — accepted patches kept for forensics and test replay (TDD §4.4). */
36
+ export const JOURNAL_LINES = 200;
37
+
38
+ /** A sidecar for a card in a terminal status becomes collectable after this. */
39
+ export const GC_AGE_MS = 14 * 24 * 60 * 60 * 1000;
40
+
41
+ /**
42
+ * Sanitise one path component. Session ids carry `:`, so the seat is folded exactly as
43
+ * hooks/ask-sidecar.mjs folds its own: everything outside [A-Za-z0-9._-] becomes `_`, and the
44
+ * three names that are not names — "", "." and ".." — are rejected rather than folded, because
45
+ * each of them addresses a directory rather than a file in it.
46
+ * @returns {string|null} the safe component, or null when there is no safe reading of the input
47
+ */
48
+ export function sanitizeComponent(raw) {
49
+ const s = String(raw ?? "").trim().replace(/[^A-Za-z0-9._-]/g, "_");
50
+ if (!s || s === "." || s === "..") return null;
51
+ return s;
52
+ }
53
+
54
+ /** `busDir()/state/<project>` — alongside asks/, handoffs/, claims/. */
55
+ export function stateDir(project) {
56
+ const p = sanitizeComponent(project);
57
+ return p ? join(busDir(), "state", p) : null;
58
+ }
59
+
60
+ /**
61
+ * The sidecar path, per SEAT-CARD rather than per seat: a seat that switches cards must not
62
+ * inherit the previous card's in-flight list, and a card handed to another seat starts clean.
63
+ * @returns {string|null} null when the seat, card or project has no safe path form
64
+ */
65
+ export function statePath(seat, card, project) {
66
+ const dir = stateDir(project);
67
+ const s = sanitizeComponent(seat);
68
+ if (!dir || !s || !Number.isInteger(card) || card < 0) return null;
69
+ return join(dir, `${s}--${card}.json`);
70
+ }
71
+
72
+ /** The ops journal that sits beside a sidecar. Forensics and test replay ONLY (TDD §4.4). */
73
+ export function opsPath(seat, card, project) {
74
+ const p = statePath(seat, card, project);
75
+ return p ? `${p.slice(0, -".json".length)}.ops.jsonl` : null;
76
+ }
77
+
78
+ /**
79
+ * The cut marker bin/crew-runner.mjs writes when the shell's own time box kills a turn. The runner
80
+ * builds it from homedir() directly, so a test (or any run with AGENT_BUS_DIR set) has to look in
81
+ * both places or it would be testing a path production never writes.
82
+ */
83
+ export function turncutPaths(agent, project) {
84
+ const a = sanitizeComponent(agent), p = sanitizeComponent(project);
85
+ if (!a || !p) return [];
86
+ const name = `turncut-${a}-${p}`;
87
+ return [...new Set([join(busDir(), name), join(homedir(), ".agent-bus", name)])];
88
+ }
89
+
90
+ /** Atomic by the house pattern: write a temp in the SAME directory, then rename over the target. */
91
+ function writeAtomic(path, body) {
92
+ const dir = path.slice(0, path.lastIndexOf("/"));
93
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
94
+ const tmp = `${path}.${process.pid}.${randomBytes(4).toString("hex")}.tmp`;
95
+ try {
96
+ writeFileSync(tmp, body, { mode: 0o600 });
97
+ renameSync(tmp, path);
98
+ return true;
99
+ } catch {
100
+ try { rmSync(tmp, { force: true }); } catch { /* the temp is already gone */ }
101
+ return false;
102
+ }
103
+ }
104
+
105
+ function git(args, cwd) {
106
+ try {
107
+ return execFileSync("git", args, { cwd, encoding: "utf8", maxBuffer: 16 * 1024 * 1024, stdio: ["pipe", "pipe", "pipe"] });
108
+ } catch {
109
+ return null; // no git, no repo, or a path git refuses — recovery degrades, never throws
110
+ }
111
+ }
112
+
113
+ /**
114
+ * Every path git says has changed in the worktree, from `git status` in its NUL-separated form.
115
+ * `-z` is not a stylistic preference: the human-readable `--short` C-quotes any path with an
116
+ * unusual byte in it, and a recovery that mis-parses a quoted path credits `touched` to a file
117
+ * that does not exist. `-uall` because an untracked directory renders as one entry otherwise, and
118
+ * the caller needs files.
119
+ *
120
+ * A rename yields TWO paths — the new one and the original — and both are touched: the original
121
+ * because it is gone, the new one because it is new.
122
+ */
123
+ export function gitTouched(cwd) {
124
+ const out = git(["status", "--porcelain", "-z", "-uall"], cwd);
125
+ if (out === null) return [];
126
+ const fields = out.split("\0").filter(f => f !== "");
127
+ const paths = [];
128
+ for (let i = 0; i < fields.length; i++) {
129
+ const entry = fields[i];
130
+ if (entry.length < 4) continue; // "XY path" — anything shorter is not an entry
131
+ const xy = entry.slice(0, 2);
132
+ paths.push(entry.slice(3));
133
+ // R/C entries are followed by their source path as its own NUL-terminated field.
134
+ if ((xy[0] === "R" || xy[0] === "C" || xy[1] === "R" || xy[1] === "C") && fields[i + 1] !== undefined) {
135
+ paths.push(fields[++i]);
136
+ }
137
+ }
138
+ return [...new Set(paths)];
139
+ }
140
+
141
+ /**
142
+ * Blob sha per path, by `git hash-object` — the same computation the gate used when it recorded
143
+ * `files[p].hash`, which is the whole point: a credit is compared against the tool that minted it,
144
+ * not against a lookalike. A path that no longer exists maps to null, and a null clears the credit
145
+ * exactly as a moved sha does.
146
+ * @returns {Map<string, string|null>}
147
+ */
148
+ export function hashPaths(paths, cwd) {
149
+ const out = new Map();
150
+ const live = [];
151
+ for (const p of paths) {
152
+ if (!existsSync(join(cwd, p))) { out.set(p, null); continue; }
153
+ // `--stdin-paths` is newline-delimited, so a path containing a newline cannot ride the batch.
154
+ if (p.includes("\n")) {
155
+ const one = git(["hash-object", "--", p], cwd);
156
+ out.set(p, one ? one.trim() : null);
157
+ continue;
158
+ }
159
+ live.push(p);
160
+ }
161
+ if (!live.length) return out;
162
+ let res = null;
163
+ try {
164
+ res = execFileSync("git", ["hash-object", "--stdin-paths"], {
165
+ cwd, encoding: "utf8", input: `${live.join("\n")}\n`, maxBuffer: 16 * 1024 * 1024,
166
+ stdio: ["pipe", "pipe", "pipe"],
167
+ });
168
+ } catch {
169
+ res = null;
170
+ }
171
+ const shas = res === null ? [] : res.trim().split("\n").filter(Boolean);
172
+ // A short batch means git stopped early; the paths it never reached get null rather than a
173
+ // borrowed sha from the wrong file, which would keep a credit alive on unexamined bytes.
174
+ for (let i = 0; i < live.length; i++) out.set(live[i], shas[i] ?? null);
175
+ return out;
176
+ }
177
+
178
+ /**
179
+ * Append `line` to a notes tail, evicting whole lines from the front until it fits CAPS.NOTES.
180
+ * The eviction is line-wise on purpose: a mid-line elision is #6528, the bug that started all of
181
+ * this. apply.mjs and migrate.mjs each hold their own copy of this loop for their own tail; a
182
+ * shared helper in the pure core would be the right home for all three and is a P0 change, so it
183
+ * is asked for over the bus rather than made here under a second hat.
184
+ */
185
+ function appendNote(notes, line) {
186
+ const all = [notes, line].filter(Boolean).join("\n").split("\n");
187
+ while (all.length > 1 && Buffer.byteLength(all.join("\n"), "utf8") > CAPS.NOTES) all.shift();
188
+ let outStr = all.join("\n");
189
+ while (outStr.length && Buffer.byteLength(outStr, "utf8") > CAPS.NOTES) outStr = outStr.slice(1);
190
+ return outStr;
191
+ }
192
+
193
+ /**
194
+ * Rebuild what a cut turn observed, from ground truth (TDD §4.4).
195
+ *
196
+ * Two halves, and the second is the one an earlier draft got wrong:
197
+ * - `touched` is re-derived from `git status`, because the dead turn's observations are what was
198
+ * lost, not its state.
199
+ * - every path still carrying `verified: true` is RE-HASHED and its credit CLEARED wherever the
200
+ * blob sha has moved off `files[p].hash` (or the file is gone). A cut turn is exactly the
201
+ * moment a file changes under a credit, so "leaves `verified` alone" was the stale-credit hole
202
+ * (R12) with a crash in front of it: the next `move → done` would pass route (b) on a green
203
+ * describing code that no longer exists, and because the evidence is stale-PRESENT rather than
204
+ * absent, NEEDS_GATE never fires and no memo hash can save it.
205
+ *
206
+ * Pure with respect to the sidecar: it reads git and returns a new state. Persisting it is
207
+ * readState's job, so the repair survives a turn that dies before its own commit.
208
+ *
209
+ * @param {object} state
210
+ * @param {{ cwd?: string, turn?: number }} opts
211
+ * @returns {{ state: object, cleared: string[], touched: string[] }}
212
+ */
213
+ export function recover(state, opts = {}) {
214
+ const cwd = opts.cwd || process.cwd();
215
+ const next = structuredClone(state);
216
+ const touched = gitTouched(cwd);
217
+ for (const p of touched) {
218
+ if (p.length > CAPS.PATH) continue; // a path the schema cannot hold is not a fact it can carry
219
+ const cur = next.files[p] || { touched: false, verified: false };
220
+ next.files[p] = { ...cur, touched: true };
221
+ }
222
+
223
+ const credited = Object.keys(next.files).filter(p => next.files[p].verified === true);
224
+ const shas = hashPaths(credited, cwd);
225
+ const cleared = [];
226
+ for (const p of credited) {
227
+ // `hash` is a string or absent by the schema (the state was decoded by migrate() on read), and
228
+ // `sha` is a 40-char blob sha or null, so an absent record can never accidentally compare equal.
229
+ const sha = shas.get(p) ?? null;
230
+ if (sha !== null && sha === next.files[p].hash) continue;
231
+ const kept = { ...next.files[p], verified: false };
232
+ delete kept.hash;
233
+ next.files[p] = kept;
234
+ cleared.push(p);
235
+ }
236
+
237
+ const turn = Number.isInteger(opts.turn) ? opts.turn : next.cursor.turn;
238
+ next.notes = appendNote(
239
+ next.notes,
240
+ `recovered: turn ${turn} was cut; touched paths re-derived from git; ${cleared.length} stale verifications cleared`,
241
+ );
242
+ return { state: next, cleared, touched };
243
+ }
244
+
245
+ /**
246
+ * Read + migrate + recover — the left edge of the §4.1 diagram.
247
+ *
248
+ * @param {string} seat
249
+ * @param {number} card
250
+ * @param {{ project: string, cwd?: string, by?: string, agent?: string, recover?: boolean }} opts
251
+ * @returns {{ ok: true, state: object, path: string, created: boolean, migrated: boolean,
252
+ * recovered: null | { turn: number, cleared: string[], persisted: boolean } }
253
+ * | { ok: false, code: string, at: string, message: string }}
254
+ */
255
+ export function readState(seat, card, opts = {}) {
256
+ const path = statePath(seat, card, opts.project);
257
+ if (!path) {
258
+ return { ok: false, code: ERR.SCHEMA, at: "path", message: `no safe sidecar path for seat ${JSON.stringify(seat)} card ${JSON.stringify(card)} project ${JSON.stringify(opts.project)}` };
259
+ }
260
+ const by = String(opts.by ?? seat ?? "");
261
+ if (!existsSync(path)) {
262
+ return { ok: true, state: emptyState(card, by), path, created: true, migrated: false, recovered: null };
263
+ }
264
+
265
+ let raw;
266
+ try { raw = readFileSync(path, "utf8"); }
267
+ catch (e) { return { ok: false, code: ERR.MIGRATE_FAILED, at: "read", message: `sidecar unreadable: ${e.message}` }; }
268
+
269
+ let parsed;
270
+ try { parsed = JSON.parse(raw); }
271
+ catch (e) {
272
+ preserve(path, raw, null);
273
+ return { ok: false, code: ERR.MIGRATE_FAILED, at: "parse", message: `sidecar is not JSON: ${e.message}` };
274
+ }
275
+
276
+ const m = migrate(parsed);
277
+ if (!m.ok) {
278
+ // The original is kept, untouched, beside itself — a half-upgraded sidecar is worse than a
279
+ // loud one, and the read stays loud on every retry until someone looks (TDD §4.3).
280
+ preserve(path, raw, parsed?.schema_version);
281
+ return m;
282
+ }
283
+
284
+ let state = m.state;
285
+ let recovered = null;
286
+ const markers = opts.recover === false ? [] : turncutPaths(opts.agent ?? seat, opts.project).filter(existsSync);
287
+ if (markers.length) {
288
+ const r = recover(state, { cwd: opts.cwd, turn: state.cursor.turn });
289
+ state = r.state;
290
+ // Persist before clearing the marker: a repair the next turn cannot see is a repair that did
291
+ // not happen, and if this write fails the marker stays and the next read tries again.
292
+ // `rev` is deliberately NOT bumped. The repair restores observations the dead turn already
293
+ // owned, so the caller's expectedRev must still match the disk when it commits its own turn;
294
+ // bumping here would hand every recovered turn a STALE it did nothing to earn.
295
+ const persisted = writeAtomic(path, `${JSON.stringify(state)}\n`);
296
+ if (persisted) for (const f of markers) { try { rmSync(f, { force: true }); } catch { /* another writer got it */ } }
297
+ recovered = { turn: state.cursor.turn, cleared: r.cleared, persisted };
298
+ }
299
+ return { ok: true, state, path, created: false, migrated: m.migrated, recovered };
300
+ }
301
+
302
+ /** Keep a copy of a sidecar we refused to upgrade, at `<name>.v<n>.json`. First copy wins. */
303
+ function preserve(path, raw, version) {
304
+ const n = Number.isInteger(version) ? version : "unknown";
305
+ const aside = `${path.slice(0, -".json".length)}.v${n}.json`;
306
+ if (existsSync(aside)) return;
307
+ writeAtomic(aside, raw);
308
+ }
309
+
310
+ /**
311
+ * Compare-and-swap write (TDD §4.4). `expectedRev` is the rev the state was READ at; applyTurn has
312
+ * already bumped `state.rev` to expectedRev + 1.
313
+ *
314
+ * The re-read is the whole mechanism: if the on-disk rev has moved, someone else (the seat's own
315
+ * turn, or a `trantor state` repair) wrote between the read and here, and this state was computed
316
+ * against bytes that no longer exist. Returning STALE honestly is the contract; the driver cures it
317
+ * by re-reading and re-running applyTurn, which is safe because ops are id-addressed and the
318
+ * failure modes it would then hit (DUP_ID, NO_SUCH_ID) are exactly the ones re-application means.
319
+ *
320
+ * @param {object} state
321
+ * @param {number} expectedRev
322
+ * @param {{ seat: string, card: number, project: string, ops?: object[] }} opts
323
+ * @returns {{ ok: true, rev: number, path: string } | { ok: false, code: string, at: string, message: string, rev?: number }}
324
+ */
325
+ export function commit(state, expectedRev, opts = {}) {
326
+ const path = statePath(opts.seat, opts.card ?? state?.card, opts.project);
327
+ if (!path) {
328
+ return { ok: false, code: ERR.SCHEMA, at: "path", message: `no safe sidecar path for seat ${JSON.stringify(opts.seat)} project ${JSON.stringify(opts.project)}` };
329
+ }
330
+ const why = stateError(state);
331
+ if (why) {
332
+ // A store that writes an invalid state has thrown away the only guarantee a reader has.
333
+ return { ok: false, code: ERR.SCHEMA, at: "state", message: `refusing to commit an invalid state: ${why}` };
334
+ }
335
+
336
+ const onDisk = diskRev(path);
337
+ if (onDisk !== expectedRev) {
338
+ return {
339
+ ok: false, code: STALE, at: "rev", rev: onDisk,
340
+ message: `state moved under this turn: expected rev ${expectedRev}, on disk ${onDisk}. Re-read and re-run applyTurn on the fresh state.`,
341
+ };
342
+ }
343
+
344
+ if (!writeAtomic(path, `${JSON.stringify(state)}\n`)) {
345
+ return { ok: false, code: ERR.SCHEMA, at: "write", message: `could not write ${path}` };
346
+ }
347
+ if (Array.isArray(opts.ops) && opts.ops.length) {
348
+ appendJournal(opts.seat, opts.card ?? state.card, opts.project, {
349
+ ts: Date.now(), rev: state.rev, turn: state.cursor.turn, by: state.cursor.by, ops: opts.ops,
350
+ });
351
+ }
352
+ return { ok: true, rev: state.rev, path };
353
+ }
354
+
355
+ /** The rev currently on disk. A sidecar that is missing, unreadable or unparseable reads as 0 —
356
+ * the same rev an empty state carries, so a first write matches and a later one does not. */
357
+ function diskRev(path) {
358
+ try {
359
+ const cur = JSON.parse(readFileSync(path, "utf8"));
360
+ return Number.isInteger(cur?.rev) ? cur.rev : 0;
361
+ } catch {
362
+ return 0;
363
+ }
364
+ }
365
+
366
+ /**
367
+ * Append one accepted patch to the journal ring. FORENSICS AND TEST REPLAY ONLY — nothing in
368
+ * recovery reads this file, and §4.4 says so out loud so a reviewer can check the claim.
369
+ */
370
+ export function appendJournal(seat, card, project, entry) {
371
+ const path = opsPath(seat, card, project);
372
+ if (!path) return false;
373
+ let lines = [];
374
+ try { lines = readFileSync(path, "utf8").split("\n").filter(Boolean); } catch { lines = []; }
375
+ lines.push(JSON.stringify(entry));
376
+ if (lines.length > JOURNAL_LINES) lines = lines.slice(lines.length - JOURNAL_LINES);
377
+ return writeAtomic(path, `${lines.join("\n")}\n`);
378
+ }
379
+
380
+ /** Read the journal back. For a forensics tool or a replay test, never for recovery. */
381
+ export function readJournal(seat, card, project) {
382
+ const path = opsPath(seat, card, project);
383
+ if (!path) return [];
384
+ let raw;
385
+ try { raw = readFileSync(path, "utf8"); } catch { return []; }
386
+ const out = [];
387
+ for (const line of raw.split("\n")) {
388
+ if (!line) continue;
389
+ try { out.push(JSON.parse(line)); } catch { /* a torn tail is a forensics artefact, not a fault */ }
390
+ }
391
+ return out;
392
+ }
393
+
394
+ /**
395
+ * Collect sidecars whose card is in a terminal status and whose file has not moved in GC_AGE_MS.
396
+ * The store does not know what "terminal" means — that is the board's word — so the caller passes
397
+ * the predicate. Nothing is removed unless `apply` is set: the default answer to "which of these
398
+ * could go" is a list, not a deletion.
399
+ *
400
+ * @param {{ project: string, isTerminal: (card: number) => boolean, now?: number,
401
+ * maxAgeMs?: number, apply?: boolean }} opts
402
+ * @returns {{ candidates: { path: string, seat: string, card: number, ageMs: number }[], removed: string[] }}
403
+ */
404
+ export function gcSidecars(opts = {}) {
405
+ const dir = stateDir(opts.project);
406
+ const isTerminal = opts.isTerminal || (() => false);
407
+ const now = Number.isInteger(opts.now) ? opts.now : Date.now();
408
+ const maxAgeMs = Number.isInteger(opts.maxAgeMs) ? opts.maxAgeMs : GC_AGE_MS;
409
+ const candidates = [], removed = [];
410
+ if (!dir || !existsSync(dir)) return { candidates, removed };
411
+
412
+ for (const name of readdirSync(dir)) {
413
+ const m = /^(.+)--(\d+)\.json$/.exec(name);
414
+ if (!m) continue; // .ops.jsonl and preserved .v<n>.json are not sidecars
415
+ const path = join(dir, name);
416
+ let ageMs;
417
+ try { ageMs = now - statSync(path).mtimeMs; } catch { continue; }
418
+ if (ageMs < maxAgeMs) continue;
419
+ const card = Number(m[2]);
420
+ if (!isTerminal(card)) continue;
421
+ candidates.push({ path, seat: m[1], card, ageMs });
422
+ }
423
+ if (!opts.apply) return { candidates, removed };
424
+
425
+ for (const c of candidates) {
426
+ const stem = c.path.slice(0, -".json".length);
427
+ for (const f of [c.path, `${stem}.ops.jsonl`, ...preservedFor(dir, stem)]) {
428
+ try { if (existsSync(f)) { rmSync(f, { force: true }); removed.push(f); } } catch { /* leave it for the next sweep */ }
429
+ }
430
+ }
431
+ return { candidates, removed };
432
+ }
433
+
434
+ /** The `.v<n>.json` copies preserved beside one sidecar, so gc takes the whole set or none of it. */
435
+ function preservedFor(dir, stem) {
436
+ const prefix = `${stem.slice(dir.length + 1)}.v`;
437
+ try {
438
+ return readdirSync(dir).filter(n => n.startsWith(prefix) && n.endsWith(".json")).map(n => join(dir, n));
439
+ } catch {
440
+ return [];
441
+ }
442
+ }
@@ -0,0 +1,231 @@
1
+ /* oxlint-disable anti-slop/no-runtime-typeof -- SAFETY: the validator's whole job is decoding an untrusted TurnResult at the boundary — a model may emit any JSON at all, and every typeof here is that parse, returning a named rejection rather than narrowing a value that was never established. */
2
+ // Trantor State — the validator (TDD §4.2, stages 1-3). Pure and total: same arguments, same
3
+ // answer, no disk, no clock, no throw. Rejection is a first-class RETURN, not an exception,
4
+ // because the rejection text is fed back to the seat as its next observation.
5
+ import {
6
+ CAPS, LISTS, COMPACTING_LISTS, ERR, EVIDENCE_MARKER, SETTABLE_FIELDS, READONLY_FIELDS,
7
+ RUNTIME_EXT_KEYS, KNOWN_FIELDS, itemError, isAction,
8
+ } from "./schema.mjs";
9
+
10
+ const reject = (code, at, message) => ({ ok: false, code, at, message });
11
+
12
+ /** Which top-level field a `set` path addresses: "ext.foo" → "ext". */
13
+ const rootOf = (field) => String(field).split(".")[0];
14
+
15
+ /**
16
+ * Pull the evidence marker out of an item's text BEFORE any cap is applied, and merge it with any
17
+ * `paths` the patch supplied directly (TDD §3: the two routes converge on the same field).
18
+ * Order is the whole point: extract, then cap, so a long text can never truncate away the paths
19
+ * route (b) depends on.
20
+ * @returns {{ ok: true, item: object } | { ok: false, reason: string }}
21
+ */
22
+ export function extractEvidence(item) {
23
+ const marked = EVIDENCE_MARKER.exec(item.text);
24
+ const fromMarker = marked ? marked[1].split(",").filter(Boolean) : [];
25
+ const prose = marked ? item.text.slice(0, marked.index) : item.text;
26
+ const paths = [...new Set([...(item.paths || []), ...fromMarker])];
27
+
28
+ // A malformed marker is rejected naming the marker, never accepted-and-trimmed into silence.
29
+ if (paths.length > CAPS.ITEM_PATHS) {
30
+ return { ok: false, reason: `evidence marker carries ${paths.length} paths, CAPS.ITEM_PATHS is ${CAPS.ITEM_PATHS}` };
31
+ }
32
+ for (const p of paths) {
33
+ if (p.length > CAPS.PATH) return { ok: false, reason: `evidence path exceeds CAPS.PATH (${CAPS.PATH}): "${p.slice(0, 60)}…"` };
34
+ if (p.startsWith("/") || p.split("/").includes("..")) {
35
+ return { ok: false, reason: `evidence path escapes the worktree: "${p}"` };
36
+ }
37
+ }
38
+
39
+ const out = { id: item.id, text: prose.slice(0, CAPS.ITEM) };
40
+ if (paths.length) out.paths = paths;
41
+ return { ok: true, item: out };
42
+ }
43
+
44
+ /**
45
+ * Does this item have the evidence a `move → done` needs (TDD §4.2)?
46
+ * Route (a): the gate went green this turn. Route (b): every path it names is credited.
47
+ */
48
+ export function hasEvidence(state, item) {
49
+ if (state.verify.tested === true && state.verify.exit === 0) return true;
50
+ const paths = item.paths || [];
51
+ if (!paths.length) return false;
52
+ return paths.every(p => state.files[p]?.verified === true);
53
+ }
54
+
55
+ /**
56
+ * Stages 1-3. On success returns the normalised ops (markers extracted, prose capped) for apply.mjs
57
+ * to run; on failure the rejection the seat will read.
58
+ *
59
+ * `ctx.gate_attempted` is what splits NEEDS_GATE from UNVERIFIED_DONE, and it is the difference
60
+ * between "no gate has run" and "a gate ran and came back red" — two causes that look identical in
61
+ * the state. Without it the core returns NEEDS_GATE both times and §4.8's one-retry bound is false.
62
+ */
63
+ export function validateTurn(state, turn, ctx = {}) {
64
+ // ---- stage 1: shape ----
65
+ if (typeof turn !== "object" || turn === null || Array.isArray(turn)) {
66
+ return reject(ERR.SCHEMA, "turn", "TurnResult must be an object with { patch, action }");
67
+ }
68
+ if (!Array.isArray(turn.patch)) {
69
+ return reject(ERR.SCHEMA, "turn.patch", "TurnResult.patch must be an array of ops");
70
+ }
71
+ if (!isAction(turn.action)) {
72
+ return reject(ERR.BAD_ACTION, "turn.action",
73
+ "every turn must act, finish, or ask: one of { tool, input } | { done: true } | { ask } | { continue: true }");
74
+ }
75
+
76
+ const OPERATORS = ["set", "add", "remove", "move"];
77
+ const normalised = [];
78
+
79
+ // Lengths are simulated as we go, so an `add` that would overflow a working list is caught
80
+ // before anything is applied — all-or-nothing is what makes "no valid patch corrupts state"
81
+ // testable in the first place.
82
+ const lengths = Object.fromEntries(LISTS.map(l => [l, state[l].length]));
83
+ // id → which list holds it, and id → the item itself. Both are simulated forward through the
84
+ // patch so ops later in the same patch see what the earlier ones did.
85
+ const listOf = new Map();
86
+ const itemOf = new Map();
87
+ for (const l of LISTS) for (const it of state[l]) { listOf.set(it.id, l); itemOf.set(it.id, it); }
88
+ let taskSet = state.task !== "";
89
+
90
+ for (let n = 0; n < turn.patch.length; n++) {
91
+ const op = turn.patch[n];
92
+ if (typeof op !== "object" || op === null || Array.isArray(op)) {
93
+ return reject(ERR.SCHEMA, `patch[${n}]`, "each op must be an object");
94
+ }
95
+ const keys = Object.keys(op).filter(k => OPERATORS.includes(k));
96
+ if (keys.length !== 1 || Object.keys(op).length !== 1) {
97
+ return reject(ERR.SCHEMA, `patch[${n}]`,
98
+ `each op has exactly one operator key (${OPERATORS.join(" | ")}); got [${Object.keys(op).join(", ")}]`);
99
+ }
100
+ const kind = keys[0];
101
+ const body = op[kind];
102
+ if (typeof body !== "object" || body === null || Array.isArray(body)) {
103
+ return reject(ERR.SCHEMA, `patch[${n}].${kind}`, `${kind} takes an object`);
104
+ }
105
+
106
+ if (kind === "set") {
107
+ const { field, value } = body;
108
+ if (typeof field !== "string" || !field) {
109
+ return reject(ERR.SCHEMA, `patch[${n}].set`, "set.field must be a non-empty string");
110
+ }
111
+ const root = rootOf(field);
112
+ // ---- stage 2: the write matrix. One table lookup, and it is the whole "a seat marks its
113
+ // own work verified" hole.
114
+ if (READONLY_FIELDS.includes(root)) {
115
+ return reject(ERR.READONLY_FIELD, `set:${field}`,
116
+ `${field} is harness-written and never model-writable. Evidence comes from a gate that ran, not from the patch.`);
117
+ }
118
+ if (root === "ext" && RUNTIME_EXT_KEYS.includes(field.split(".")[1])) {
119
+ return reject(ERR.READONLY_FIELD, `set:${field}`, `ext.${field.split(".")[1]} is runtime-owned`);
120
+ }
121
+ if (LISTS.includes(root)) {
122
+ return reject(ERR.READONLY_FIELD, `set:${field}`,
123
+ `${root} changes by add/remove/move only, never by set — arrays change only by id`);
124
+ }
125
+ if (!KNOWN_FIELDS.includes(root)) {
126
+ return reject(ERR.UNKNOWN_FIELD, `set:${field}`,
127
+ `the schema defines no field "${root}". An unknown field is a rejection, never a silently created key.`);
128
+ }
129
+ if (!SETTABLE_FIELDS.includes(root)) {
130
+ return reject(ERR.READONLY_FIELD, `set:${field}`, `${field} is not settable`);
131
+ }
132
+ if (root === "task") {
133
+ if (field !== "task") return reject(ERR.UNKNOWN_FIELD, `set:${field}`, "task is a scalar; set it whole");
134
+ if (typeof value !== "string") return reject(ERR.SCHEMA, `set:${field}`, "task must be a string");
135
+ if (taskSet) return reject(ERR.READONLY_FIELD, "set:task", "task is set-once: it is writable only while empty");
136
+ if (value.length > CAPS.TASK) return reject(ERR.CAP, "set:task", `task exceeds CAPS.TASK (${CAPS.TASK})`);
137
+ taskSet = value !== "";
138
+ }
139
+ if (root === "notes") {
140
+ if (typeof value !== "string") return reject(ERR.SCHEMA, `set:${field}`, "notes must be a string");
141
+ }
142
+ normalised.push({ set: { field, value } });
143
+ continue;
144
+ }
145
+
146
+ if (kind === "add") {
147
+ const { list, item } = body;
148
+ if (!LISTS.includes(list)) {
149
+ return reject(ERR.UNKNOWN_LIST, `patch[${n}].add`, `no list "${list}"; one of ${LISTS.join(", ")}`);
150
+ }
151
+ const why = itemError(item);
152
+ if (why) return reject(ERR.SCHEMA, `add:${list}`, why);
153
+ if (item.id.length > CAPS.ID) {
154
+ return reject(ERR.CAP, `add:${item.id.slice(0, 16)}…`,
155
+ `item.id exceeds CAPS.ID (${CAPS.ID}). Over-long ids are rejected, not trimmed — a trimmed id no longer addresses the item the next op names.`);
156
+ }
157
+ if (listOf.has(item.id)) return reject(ERR.DUP_ID, `add:${item.id}`, `id "${item.id}" already exists`);
158
+ const ev = extractEvidence(item);
159
+ if (!ev.ok) return reject(ERR.CAP, `add:${item.id}`, ev.reason);
160
+ if (lengths[list] + 1 > CAPS.LIST) {
161
+ if (!COMPACTING_LISTS.includes(list)) {
162
+ return reject(ERR.CAP, `add:${list}`,
163
+ `${list} is at CAPS.LIST (${CAPS.LIST}) and is a working list: overflow is a rejection, not a silent drop`);
164
+ }
165
+ }
166
+ listOf.set(item.id, list);
167
+ itemOf.set(item.id, ev.item);
168
+ lengths[list]++;
169
+ normalised.push({ add: { list, item: ev.item } });
170
+ continue;
171
+ }
172
+
173
+ if (kind === "remove") {
174
+ const { list, id } = body;
175
+ if (!LISTS.includes(list)) {
176
+ return reject(ERR.UNKNOWN_LIST, `patch[${n}].remove`, `no list "${list}"; one of ${LISTS.join(", ")}`);
177
+ }
178
+ if (typeof id !== "string" || !id) return reject(ERR.SCHEMA, `patch[${n}].remove`, "remove.id must be a non-empty string");
179
+ if (listOf.get(id) !== list) {
180
+ return reject(ERR.NO_SUCH_ID, `remove:${id}`,
181
+ listOf.has(id) ? `item "${id}" is in ${listOf.get(id)}, not ${list}` : `no item "${id}" to remove`);
182
+ }
183
+ listOf.delete(id);
184
+ itemOf.delete(id);
185
+ lengths[list]--;
186
+ normalised.push({ remove: { list, id } });
187
+ continue;
188
+ }
189
+
190
+ // move
191
+ const { id, from, to } = body;
192
+ if (!LISTS.includes(from) || !LISTS.includes(to)) {
193
+ return reject(ERR.UNKNOWN_LIST, `patch[${n}].move`, `move needs two known lists; got "${from}" → "${to}"`);
194
+ }
195
+ if (typeof id !== "string" || !id) return reject(ERR.SCHEMA, `patch[${n}].move`, "move.id must be a non-empty string");
196
+ if (listOf.get(id) !== from) {
197
+ return reject(ERR.NO_SUCH_ID, `move:${id}`,
198
+ listOf.has(id) ? `item "${id}" is in ${listOf.get(id)}, not ${from}` : `no item "${id}" to move`);
199
+ }
200
+ if (lengths[to] + 1 > CAPS.LIST && !COMPACTING_LISTS.includes(to)) {
201
+ return reject(ERR.CAP, `move:${id}`, `${to} is at CAPS.LIST (${CAPS.LIST})`);
202
+ }
203
+ if (to === "done") {
204
+ const item = itemOf.get(id);
205
+ if (item && !hasEvidence(state, item)) {
206
+ // The split that makes §4.8's one-retry bound a fact: a gate that ran and failed looks
207
+ // exactly like a gate that never ran, unless the driver says which happened.
208
+ if (ctx.gate_attempted) {
209
+ const g = ctx.gate_attempted;
210
+ return {
211
+ ...reject(ERR.UNVERIFIED_DONE, `move:${id}`,
212
+ `move ${id} → done rejected: the gate ran and did not pass (${g.cmd || "gate"}, exit ${g.exit}). ` +
213
+ `Fix the failure, then move it.`),
214
+ gate: { cmd: g.cmd, exit: g.exit, tail: g.tail },
215
+ };
216
+ }
217
+ return {
218
+ ...reject(ERR.NEEDS_GATE, `move:${id}`,
219
+ `move ${id} → done needs evidence: no gate has run at this state. Run it, then re-apply.`),
220
+ gate: { items: [id], paths: item.paths || [] },
221
+ };
222
+ }
223
+ }
224
+ listOf.set(id, to);
225
+ lengths[from]--;
226
+ lengths[to]++;
227
+ normalised.push({ move: { id, from, to } });
228
+ }
229
+
230
+ return { ok: true, ops: normalised };
231
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trantor",
3
- "version": "0.18.49",
3
+ "version": "0.18.51",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "trantor": "bin/cli.mjs"