trantor 0.18.49 → 0.18.50

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.
@@ -32,17 +32,15 @@ export function dutyEscalations(messages) {
32
32
  export function readDutyNudgeState(path) {
33
33
  try {
34
34
  const parsed = JSON.parse(readFileSync(path, "utf8"));
35
- return parsed?.nudged instanceof Object && !Array.isArray(parsed.nudged)
36
- ? parsed
37
- : { version: 1, nudged: {} };
35
+ if (!(parsed?.nudged instanceof Object) || Array.isArray(parsed.nudged)) throw new Error("invalid nudged state");
36
+ if (!(parsed.planned instanceof Object) || Array.isArray(parsed.planned)) parsed.planned = {};
37
+ return parsed;
38
38
  } catch {
39
- return { version: 1, nudged: {} };
39
+ return { version: 1, nudged: {}, planned: {} };
40
40
  }
41
41
  }
42
42
 
43
- export function planDutyNudges(messages, statePath) {
44
- const state = readDutyNudgeState(statePath);
45
- const items = dutyEscalations(messages).filter(item => !state.nudged[item.id]);
43
+ function buildPlan(items, owner = "") {
46
44
  const targets = [];
47
45
  for (const item of items) {
48
46
  let target = targets.find(candidate => candidate.recipient === item.recipient);
@@ -52,7 +50,37 @@ export function planDutyNudges(messages, statePath) {
52
50
  }
53
51
  if (!target.ids.includes(item.id)) target.ids.push(item.id);
54
52
  }
55
- return { items, targets };
53
+ return { items, targets, owner };
54
+ }
55
+
56
+ export function planDutyNudges(messages, statePath) {
57
+ const state = readDutyNudgeState(statePath);
58
+ const items = dutyEscalations(messages).filter(item => !state.nudged[item.id] && !state.planned[item.id]);
59
+ return buildPlan(items);
60
+ }
61
+
62
+ function processAlive(pid) {
63
+ if (!Number.isInteger(pid) || pid <= 0) return false;
64
+ try { process.kill(pid, 0); return true; } catch { return false; }
65
+ }
66
+
67
+ export async function claimDutyNudges({ messages, statePath, owner, pid = process.pid, now = Date.now() }) {
68
+ let plan = buildPlan([], owner);
69
+ await withStateLock(statePath, state => {
70
+ for (const [id, claim] of Object.entries(state.planned)) {
71
+ if (now - Number(claim?.plannedAt || 0) > 30 * 60 * 1000 || !processAlive(Number(claim?.pid || 0))) {
72
+ delete state.planned[id];
73
+ }
74
+ }
75
+ const items = dutyEscalations(messages).filter(item => !state.nudged[item.id] && !state.planned[item.id]);
76
+ plan = buildPlan(items, owner);
77
+ for (const item of items) {
78
+ state.planned[item.id] = {
79
+ owner, pid, recipient: item.recipient, project: item.project, plannedAt: now,
80
+ };
81
+ }
82
+ });
83
+ return plan;
56
84
  }
57
85
 
58
86
  export function dutyNudgeDirective(plan) {
@@ -93,7 +121,8 @@ export function observedDutyNudgeIds(transcriptDir, sinceMs) {
93
121
  for (const use of uses) {
94
122
  const input = use.input || {};
95
123
  const text = String(input.message || input.content || "");
96
- if (!text.startsWith("Trantor delivery nudge from the duty seat:")) continue;
124
+ if (!text.startsWith("Trantor delivery nudge from the duty seat:")
125
+ || !text.endsWith("This nudge carries no message content; the signed bus messages are the source of truth.")) continue;
97
126
  for (const id of idsIn(text)) ids.add(id);
98
127
  }
99
128
  }
@@ -106,7 +135,7 @@ function writeDutyNudgeState(path, state) {
106
135
  const entries = Object.entries(state.nudged)
107
136
  .sort((a, b) => Number(b[1]?.nudgedAt || 0) - Number(a[1]?.nudgedAt || 0))
108
137
  .slice(0, 5000);
109
- const next = { version: 1, nudged: Object.fromEntries(entries) };
138
+ const next = { version: 1, nudged: Object.fromEntries(entries), planned: state.planned || {} };
110
139
  const temporary = `${path}.${process.pid}.tmp`;
111
140
  writeFileSync(temporary, `${JSON.stringify(next, null, 2)}\n`, { mode: 0o600 });
112
141
  renameSync(temporary, path);
@@ -136,6 +165,7 @@ export async function recordDutyNudges({ plan, observedIds, statePath, now = Dat
136
165
  await withStateLock(statePath, state => {
137
166
  for (const item of nudged) {
138
167
  state.nudged[item.id] = { recipient: item.recipient, project: item.project, nudgedAt: now };
168
+ delete state.planned[item.id];
139
169
  }
140
170
  });
141
171
  return nudged;
@@ -148,5 +178,13 @@ export async function auditDutyNudges({ plan, observedIds, statePath, reportFail
148
178
  ids: target.ids.filter(id => !observedIds.has(id)),
149
179
  })).filter(target => target.ids.length);
150
180
  for (const target of missing) await reportFailure(target);
181
+ if (missing.length) {
182
+ const missingIds = new Set(missing.flatMap(target => target.ids));
183
+ await withStateLock(statePath, state => {
184
+ for (const id of missingIds) {
185
+ if (state.planned[id]?.owner === plan.owner) delete state.planned[id];
186
+ }
187
+ });
188
+ }
151
189
  return { missing, nudged };
152
190
  }
@@ -0,0 +1,170 @@
1
+ /* oxlint-disable anti-slop/no-runtime-typeof -- SAFETY: ctx carries harness facts from the driver and set() merges model-supplied values; both are decoded here at the boundary rather than trusted. */
2
+ // Trantor State — apply (TDD §4.2, stages 4-5). Validate, then apply to a CLONE, then the runtime
3
+ // pass. Pure: `ctx` carries the clock and the harness facts, so this function is a function of its
4
+ // arguments and nothing else.
5
+ //
6
+ // One apply point per turn is the whole crash-safety argument (§4.4): a turn killed mid-flight has
7
+ // never partially applied a patch, because the patch is applied to a clone that is only returned
8
+ // when every op passed.
9
+ import { CAPS, LISTS, COMPACTING_LISTS, RUNTIME_EXT_KEYS, cloneState } from "./schema.mjs";
10
+ import { validateTurn } from "./validate.mjs";
11
+
12
+ /** Deep-merge one level, the `set` semantics of §3.2: null deletes an optional key. */
13
+ function setField(state, field, value) {
14
+ const [root, sub] = String(field).split(".");
15
+ if (sub === undefined) {
16
+ if (value === null && root === "ext") { state.ext = {}; return; }
17
+ state[root] = value;
18
+ return;
19
+ }
20
+ if (value === null) { delete state[root][sub]; return; }
21
+ const cur = state[root][sub];
22
+ const mergeable = typeof cur === "object" && cur !== null && !Array.isArray(cur)
23
+ && typeof value === "object" && value !== null && !Array.isArray(value);
24
+ state[root][sub] = mergeable ? { ...cur, ...value } : value;
25
+ }
26
+
27
+ /** Keep `notes` under CAPS.NOTES by evicting whole lines from the FRONT — the tail is the recent
28
+ * half, and a mid-string elision is #6528, the failure this whole schema exists to prevent. */
29
+ function capNotes(notes) {
30
+ if (Buffer.byteLength(notes, "utf8") <= CAPS.NOTES) return notes;
31
+ const lines = notes.split("\n");
32
+ while (lines.length > 1 && Buffer.byteLength(lines.join("\n"), "utf8") > CAPS.NOTES) lines.shift();
33
+ let out = lines.join("\n");
34
+ while (out.length && Buffer.byteLength(out, "utf8") > CAPS.NOTES) out = out.slice(1);
35
+ return out;
36
+ }
37
+
38
+ /**
39
+ * The five-stage pipeline. `turn` is the TurnResult `{ patch, action }` — §4.8's driver snippet
40
+ * calls this slot `patch`, which is the same object under a shorter name.
41
+ *
42
+ * ctx: { now, by, verify, files, gate_attempted, gate }
43
+ * - `verify` and `files` are harness facts from a gate that actually ran, never testimony.
44
+ * - `gate_attempted` splits NEEDS_GATE from UNVERIFIED_DONE (§4.8).
45
+ * - `gate` is the memo record runGate returned, written to ext._gate here so it lands through
46
+ * the single apply point rather than as a side effect somewhere else.
47
+ *
48
+ * @returns {{ ok: true, state: object, promoted: object[] }
49
+ * | { ok: false, code: string, at: string, message: string }}
50
+ */
51
+ export function applyTurn(state, turn, ctx = {}) {
52
+ const v = validateTurn(state, turn, ctx);
53
+ if (!v.ok) return v;
54
+
55
+ // ---- stage 4: apply, in order, on a structural clone ----
56
+ const next = cloneState(state);
57
+ for (const op of v.ops) {
58
+ if (op.set) { setField(next, op.set.field, op.set.value); continue; }
59
+ if (op.add) { next[op.add.list].push(op.add.item); continue; }
60
+ if (op.remove) {
61
+ next[op.remove.list] = next[op.remove.list].filter(i => i.id !== op.remove.id);
62
+ continue;
63
+ }
64
+ const { id, from, to } = op.move;
65
+ const at = next[from].findIndex(i => i.id === id);
66
+ const [item] = next[from].splice(at, 1);
67
+ next[to].push(item);
68
+ }
69
+
70
+ // ---- stage 5: the runtime pass. Not model-visible, not model-writable. ----
71
+
72
+ // `verify` is REWRITTEN wholesale, never merged: it describes the gate that ran THIS turn, or
73
+ // nothing. That is what makes "evidence at the current rev" enforceable without a rev stamp on
74
+ // every field (§4.2).
75
+ next.verify = ctx.verify && typeof ctx.verify === "object" ? { ...ctx.verify } : {};
76
+
77
+ // Harness file facts: tier 1 sets `touched` from git, the gate sets `verified` + `hash`, and
78
+ // tier 1 EXPIRES a credit whose bytes have moved (R12). Setting evidence is the gate's
79
+ // privilege; expiring it belongs to the only thing that runs every turn.
80
+ for (const [path, fact] of Object.entries(ctx.files || {})) {
81
+ const cur = next.files[path] || { touched: false, verified: false };
82
+ const merged = { ...cur };
83
+ if (fact.touched !== undefined) merged.touched = fact.touched;
84
+ if (fact.verified !== undefined) merged.verified = fact.verified;
85
+ if (fact.blast_radius !== undefined) merged.blast_radius = fact.blast_radius;
86
+ if (fact.hash !== undefined) merged.hash = fact.hash;
87
+ if (merged.verified === false) delete merged.hash;
88
+ next.files[path] = merged;
89
+ }
90
+
91
+ if (ctx.gate && typeof ctx.gate === "object") next.ext._gate = { ...ctx.gate };
92
+ if (ctx.promoted !== undefined) next.ext._promoted = ctx.promoted;
93
+
94
+ // ---- compaction: history compacts, working lists never reach here (they were rejected) ----
95
+ for (const list of COMPACTING_LISTS) {
96
+ if (next[list].length > CAPS.LIST) {
97
+ const overflow = next[list].length - CAPS.LIST;
98
+ next[list] = next[list].slice(overflow);
99
+ if (list === "done") next.done_count += overflow;
100
+ }
101
+ }
102
+ next.done_count = Math.max(next.done_count, 0);
103
+ const paths = Object.keys(next.files);
104
+ if (paths.length > CAPS.FILES) {
105
+ // Drop the least interesting first: untouched and uncredited paths carry no evidence.
106
+ const rank = (p) => (next.files[p].verified ? 2 : next.files[p].touched ? 1 : 0);
107
+ const doomed = paths.sort((a, b) => rank(a) - rank(b)).slice(0, paths.length - CAPS.FILES);
108
+ for (const p of doomed) delete next.files[p];
109
+ next.files_count += doomed.length;
110
+ }
111
+
112
+ // ---- cap enforcement on content ----
113
+ next.task = next.task.slice(0, CAPS.TASK);
114
+ next.notes = capNotes(next.notes);
115
+ const extKeys = Object.keys(next.ext).filter(k => !RUNTIME_EXT_KEYS.includes(k));
116
+ for (const k of extKeys.slice(CAPS.EXT_KEYS)) delete next.ext[k];
117
+ let extBytes = Buffer.byteLength(JSON.stringify(
118
+ Object.fromEntries(Object.entries(next.ext).filter(([k]) => !RUNTIME_EXT_KEYS.includes(k))),
119
+ ), "utf8");
120
+ for (const k of extKeys.slice(0, CAPS.EXT_KEYS).reverse()) {
121
+ if (extBytes <= CAPS.EXT_BYTES) break;
122
+ extBytes -= Buffer.byteLength(JSON.stringify({ [k]: next.ext[k] }), "utf8");
123
+ delete next.ext[k];
124
+ }
125
+
126
+ next.cursor = {
127
+ turn: state.cursor.turn + 1,
128
+ ts: Number.isInteger(ctx.now) ? ctx.now : state.cursor.ts,
129
+ by: typeof ctx.by === "string" ? ctx.by : state.cursor.by,
130
+ };
131
+ next.rev = state.rev + 1;
132
+
133
+ return { ok: true, state: next, promoted: promotionPlan(state, next) };
134
+ }
135
+
136
+ /**
137
+ * The delta the board is allowed to see (TDD §4.7): what shipped, what is blocking, what the
138
+ * evidence says. `in_flight`/`next` are scratch and promote nothing — that churn is #6669's
139
+ * lesson. The promoter (P3) composes these into at most one card note per turn.
140
+ * @returns {{ kind: string, text: string }[]}
141
+ */
142
+ export function promotionPlan(before, after) {
143
+ const out = [];
144
+ const wasDone = new Set(before.done.map(i => i.id));
145
+ for (const item of after.done) {
146
+ if (wasDone.has(item.id)) continue;
147
+ const ev = item.paths?.length ? ` (${item.paths.join(", ")})` : "";
148
+ out.push({ kind: "done", text: `${item.text}${ev}` });
149
+ }
150
+ const wasBlocked = new Map(before.blockers.map(i => [i.id, i]));
151
+ for (const item of after.blockers) {
152
+ if (!wasBlocked.has(item.id)) out.push({ kind: "blocker_added", text: item.text });
153
+ }
154
+ const nowBlocked = new Set(after.blockers.map(i => i.id));
155
+ for (const item of before.blockers) {
156
+ if (!nowBlocked.has(item.id)) out.push({ kind: "blocker_cleared", text: item.text });
157
+ }
158
+ for (const field of ["built", "tested", "observed"]) {
159
+ if (after.verify[field] === true && before.verify[field] !== true) {
160
+ const cmd = after.verify.cmd ? ` — ${after.verify.cmd}` : "";
161
+ out.push({ kind: "verify", text: `${field}${cmd}` });
162
+ }
163
+ }
164
+ return out;
165
+ }
166
+
167
+ /** Every id currently in the state, for the "arrays change only by id" property. */
168
+ export function allIds(state) {
169
+ return LISTS.flatMap(l => state[l].map(i => i.id));
170
+ }
@@ -0,0 +1,126 @@
1
+ /* oxlint-disable anti-slop/no-runtime-typeof -- SAFETY: migration reads objects written by an OLDER schema version off disk — the type of any field is exactly what cannot be assumed, which is why a mismatch is salvaged into notes instead of coerced. */
2
+ // Trantor State — migration (TDD §4.3). A field the newer schema does not know is never dropped:
3
+ // it is appended to `notes` as `migrated:<path>=<json>`. That is #6528 generalised, and it is the
4
+ // single rule that makes the whole scheme safe to version.
5
+ import { CAPS, CURRENT_VERSION, ERR, KNOWN_FIELDS, emptyState, stateError } from "./schema.mjs";
6
+
7
+ /** Append `migrated:` lines to notes, oldest evicted first when the cap bites. */
8
+ function noteMigrated(state, lines) {
9
+ if (!lines.length) return state;
10
+ const joined = [state.notes, ...lines].filter(Boolean).join("\n");
11
+ // Truncate to fit CAPS.NOTES by dropping from the front — the newest migrated line is the one
12
+ // most likely to matter, and a mid-line elision is exactly the bug this rule exists to prevent.
13
+ const all = joined.split("\n");
14
+ while (all.length > 1 && Buffer.byteLength(all.join("\n"), "utf8") > CAPS.NOTES) all.shift();
15
+ state.notes = all.join("\n").slice(-CAPS.NOTES);
16
+ return state;
17
+ }
18
+
19
+ /**
20
+ * Sweep every key the v3 schema does not define into `notes`. Returns the swept state.
21
+ */
22
+ function absorbUnknown(state) {
23
+ const lines = [];
24
+ for (const key of Object.keys(state)) {
25
+ if (KNOWN_FIELDS.includes(key)) continue;
26
+ lines.push(`migrated:${key}=${JSON.stringify(state[key])}`);
27
+ delete state[key];
28
+ }
29
+ return noteMigrated(state, lines);
30
+ }
31
+
32
+ /** v2 → v3: `rev`, the two compaction counters and FileFact.hash are new; everything else carries
33
+ * over by name. Unknown v2 keys go to notes rather than being dropped. */
34
+ function up2to3(v2) {
35
+ const out = { ...emptyState(0) };
36
+ // A value of the wrong TYPE is not silently coerced to a default — that is field loss wearing a
37
+ // helpful face, and zero-field-loss means zero. It keeps the default and the original value goes
38
+ // to notes, exactly like an unknown key.
39
+ const salvaged = [];
40
+ const carry = (key, value, typeOk) => {
41
+ if (value === undefined) return false;
42
+ if (typeOk) return true;
43
+ salvaged.push(`migrated:${key}=${JSON.stringify(value)}`);
44
+ return false;
45
+ };
46
+ if (carry("card", v2.card, Number.isInteger(v2.card))) out.card = v2.card;
47
+ for (const key of ["task", "notes"]) {
48
+ if (carry(key, v2[key], typeof v2[key] === "string")) out[key] = v2[key];
49
+ }
50
+ for (const list of ["done", "in_flight", "next", "blockers"]) {
51
+ if (Array.isArray(v2[list])) {
52
+ out[list] = v2[list]
53
+ .filter(i => i && typeof i.id === "string" && typeof i.text === "string")
54
+ .map(i => (Array.isArray(i.paths) ? { id: i.id, text: i.text, paths: i.paths } : { id: i.id, text: i.text }));
55
+ }
56
+ }
57
+ if (v2.files && typeof v2.files === "object") {
58
+ for (const [p, f] of Object.entries(v2.files)) {
59
+ if (!f || typeof f !== "object") continue;
60
+ out.files[p] = { touched: f.touched === true, verified: f.verified === true };
61
+ if (Number.isInteger(f.blast_radius)) out.files[p].blast_radius = f.blast_radius;
62
+ }
63
+ }
64
+ if (v2.verify && typeof v2.verify === "object") out.verify = { ...v2.verify };
65
+ if (v2.ext && typeof v2.ext === "object") out.ext = { ...v2.ext };
66
+ if (v2.cursor && typeof v2.cursor === "object") {
67
+ for (const [k, isOk] of [["turn", Number.isInteger(v2.cursor.turn)], ["ts", Number.isInteger(v2.cursor.ts)], ["by", typeof v2.cursor.by === "string"]]) {
68
+ if (carry(`cursor.${k}`, v2.cursor[k], isOk)) out.cursor[k] = v2.cursor[k];
69
+ }
70
+ }
71
+ if (carry("done_count", v2.done_count, Number.isInteger(v2.done_count))) out.done_count = v2.done_count;
72
+ if (carry("files_count", v2.files_count, Number.isInteger(v2.files_count))) out.files_count = v2.files_count;
73
+
74
+ // Carry the unknowns across BEFORE absorbing, so they are swept from the v3 object and land in
75
+ // notes rather than vanishing with the v2 shell.
76
+ const carried = { ...out };
77
+ for (const key of Object.keys(v2)) {
78
+ if (KNOWN_FIELDS.includes(key) || key === "schema_version") continue;
79
+ carried[key] = v2[key];
80
+ }
81
+ return noteMigrated(absorbUnknown(carried), salvaged);
82
+ }
83
+
84
+ /** Applied in sequence on read until schema_version matches CURRENT. */
85
+ export const MIGRATIONS = { 2: up2to3 };
86
+
87
+ /**
88
+ * Migrate an object of any known version up to v3.
89
+ * An unmigratable object returns MIGRATE_FAILED; the STORE (P1) is what keeps the original file
90
+ * untouched under `.v<n>.json` rather than half-upgrading it — this function only reports.
91
+ * @returns {{ ok: true, state: object, migrated: boolean } | { ok: false, code: string, at: string, message: string }}
92
+ */
93
+ export function migrate(obj) {
94
+ if (typeof obj !== "object" || obj === null || Array.isArray(obj)) {
95
+ return { ok: false, code: ERR.MIGRATE_FAILED, at: "state", message: "state must be an object" };
96
+ }
97
+ let cur = structuredClone(obj);
98
+ let steps = 0;
99
+ const from = cur.schema_version;
100
+ if (!Number.isInteger(from) || from < 1 || from > CURRENT_VERSION) {
101
+ return {
102
+ ok: false, code: ERR.MIGRATE_FAILED, at: "schema_version",
103
+ message: `no migration path from schema_version ${JSON.stringify(from)} to ${CURRENT_VERSION}`,
104
+ };
105
+ }
106
+ while (cur.schema_version !== CURRENT_VERSION) {
107
+ const step = MIGRATIONS[cur.schema_version];
108
+ if (!step) {
109
+ return {
110
+ ok: false, code: ERR.MIGRATE_FAILED, at: `v${cur.schema_version}`,
111
+ message: `no migration registered for schema_version ${cur.schema_version}`,
112
+ };
113
+ }
114
+ cur = step(cur);
115
+ cur.schema_version = CURRENT_VERSION;
116
+ if (++steps > CURRENT_VERSION) {
117
+ return { ok: false, code: ERR.MIGRATE_FAILED, at: "migrate", message: "migration did not converge" };
118
+ }
119
+ }
120
+ if (steps === 0) cur = absorbUnknown(cur);
121
+ const why = stateError(cur);
122
+ if (why) {
123
+ return { ok: false, code: ERR.MIGRATE_FAILED, at: "state", message: `migrated object is not schema-valid: ${why}` };
124
+ }
125
+ return { ok: true, state: cur, migrated: steps > 0 };
126
+ }
@@ -0,0 +1,216 @@
1
+ /* oxlint-disable anti-slop/no-runtime-typeof -- SAFETY: this file IS the I/O boundary decoder. A WorkingState is JSON read off disk and a TurnResult is untrusted model output; stateError/itemError/isAction are the parse step that establishes the contract every other module then relies on, so the typeof checks here are the boundary, not a substitute for one. */
2
+ // Trantor State — the wire contract (TDD §3). One place for the shapes, the caps, the write
3
+ // matrix and the evidence marker; validate.mjs and apply.mjs hold behaviour, this file holds
4
+ // facts. Nothing here reads the disk or the clock.
5
+ //
6
+ // The whole package is dark at P0: nothing in the running product imports it yet.
7
+
8
+ /** @typedef {{ id: string, text: string, paths?: string[] }} Item // id <= CAPS.ID, text <= CAPS.ITEM */
9
+ /** @typedef {{ touched: boolean, verified: boolean, hash?: string, blast_radius?: number }} FileFact
10
+ * // every field harness-written; `hash` = the path's blob sha when the gate credited it (TDD §4.2) */
11
+
12
+ /** @typedef {{
13
+ * schema_version: 3,
14
+ * card: number,
15
+ * task: string,
16
+ * done: Item[], in_flight: Item[], next: Item[], blockers: Item[],
17
+ * done_count: number, files_count: number,
18
+ * files: Record<string, FileFact>,
19
+ * verify: { built?: boolean, tested?: boolean, observed?: boolean, cmd?: string, exit?: number },
20
+ * notes: string,
21
+ * ext: Record<string, unknown>,
22
+ * cursor: { turn: number, ts: number, by: string },
23
+ * rev: number
24
+ * }} WorkingState */
25
+
26
+ /** @typedef {{ tool: string, input: object } | { done: true } | { ask: string } | { continue: true }} Action */
27
+ /** @typedef {{ patch: Op[], action: Action }} TurnResult */
28
+ /** @typedef {{ set: { field: string, value: unknown } }
29
+ * | { add: { list: ListName, item: Item } }
30
+ * | { remove: { list: ListName, id: string } }
31
+ * | { move: { id: string, from: ListName, to: ListName } }} Op */
32
+
33
+ /** @typedef {{ kind: "done"|"blocker_added"|"blocker_cleared"|"verify", text: string }} Promotion */
34
+
35
+ export const CURRENT_VERSION = 3;
36
+
37
+ /** Caps — one table, one place (TDD §3.1). A driver that wants a cap imports this. */
38
+ export const CAPS = {
39
+ ID: 32, // chars per item id — ids render into the prompt and key every array op
40
+ ITEM: 240, // chars per item PROSE, after the evidence marker is extracted
41
+ ITEM_PATHS: 8, // evidence paths per item
42
+ PATH: 400, // chars per files key
43
+ NOTES: 2048, // bytes of the notes tail
44
+ LIST: 40, // items per list; `done` overflow compacts into done_count, the
45
+ // working lists reject instead (TDD §3.1)
46
+ FILES: 200, // paths; overflow compacts into files_count
47
+ EXT_KEYS: 8, EXT_BYTES: 4096,
48
+ TAIL_TOKENS: 2000, OBS_TOKENS: 4000,
49
+ TASK: 400,
50
+ };
51
+
52
+ /** The four lists a patch may address. */
53
+ export const LISTS = ["done", "in_flight", "next", "blockers"];
54
+
55
+ /** Append-only history: overflow COMPACTS into a `_count`. Everything else in LISTS is a working
56
+ * list, where overflow is a CAP rejection — dropping the oldest blocker silently is the worst
57
+ * thing this object could do (TDD §3.1). */
58
+ export const COMPACTING_LISTS = ["done"];
59
+
60
+ /**
61
+ * The evidence marker, made structural (TDD §3). A model writes it inline —
62
+ * "wire the promoter @lib/state/promote.mjs,test/state/test-promote.mjs"
63
+ * — and the validator extracts it into `paths` BEFORE any cap is applied. This regex lives here
64
+ * and nowhere else; a second copy is how the two halves drift apart.
65
+ */
66
+ export const EVIDENCE_MARKER = /\s+@((?:[\w./-]+)(?:,[\w./-]+)*)\s*$/;
67
+
68
+ /** Rejection codes the pure core returns (TDD §3.3). `STALE` is deliberately absent: it is the
69
+ * CAS outcome of the store's commit(), which no branch in here can produce. */
70
+ export const ERR = {
71
+ SCHEMA: "SCHEMA",
72
+ READONLY_FIELD: "READONLY_FIELD",
73
+ CAP: "CAP",
74
+ UNKNOWN_LIST: "UNKNOWN_LIST",
75
+ UNKNOWN_FIELD: "UNKNOWN_FIELD",
76
+ DUP_ID: "DUP_ID",
77
+ NO_SUCH_ID: "NO_SUCH_ID",
78
+ UNVERIFIED_DONE: "UNVERIFIED_DONE",
79
+ NEEDS_GATE: "NEEDS_GATE",
80
+ BAD_ACTION: "BAD_ACTION",
81
+ MIGRATE_FAILED: "MIGRATE_FAILED",
82
+ };
83
+
84
+ /** Malformed OUTPUT — the seat failed to speak the grammar. These count against the §7.3 invalid-
85
+ * patch budget and the circuit breaker. */
86
+ export const MALFORMED_CODES = [
87
+ ERR.SCHEMA, ERR.BAD_ACTION, ERR.READONLY_FIELD, ERR.CAP,
88
+ ERR.UNKNOWN_FIELD, ERR.UNKNOWN_LIST, ERR.DUP_ID, ERR.NO_SUCH_ID,
89
+ ];
90
+
91
+ /** Well-formed patch, wrong CODE — the system working. Never counted against the budget, or the
92
+ * breaker trips on the healthiest seat there is: one writing perfect patches against a failing
93
+ * test (TDD §7.3). */
94
+ export const EVIDENCE_CODES = [ERR.UNVERIFIED_DONE, ERR.NEEDS_GATE];
95
+
96
+ /** Every field a `set` op may write. Anything else that IS a schema field is READONLY_FIELD;
97
+ * anything the schema does not define at all is UNKNOWN_FIELD (TDD §4.2 stage 3). */
98
+ export const SETTABLE_FIELDS = ["task", "notes", "ext"];
99
+
100
+ /** Harness/runtime fields, never model-writable (TDD §2 / §4.2 write matrix). `files` is listed
101
+ * whole because every FileFact field — touched, verified, hash, blast_radius — is harness-written:
102
+ * git is ground truth for the first, the gate for the rest. */
103
+ export const READONLY_FIELDS = [
104
+ "verify", "files", "card", "schema_version", "cursor", "done_count", "files_count", "rev",
105
+ ];
106
+
107
+ /** Runtime-owned `ext` keys (TDD §4.7, §4.8). Model-unwritable like the rest of the matrix. */
108
+ export const RUNTIME_EXT_KEYS = ["_gate", "_promoted"];
109
+
110
+ /** Every field name the v3 schema defines. A `set` naming anything outside this is UNKNOWN_FIELD:
111
+ * an unknown field quietly accepted is the write matrix ceasing to be enforceable, because
112
+ * tomorrow's real field arrives as today's typo. */
113
+ export const KNOWN_FIELDS = [
114
+ "schema_version", "card", "task", ...LISTS, "done_count", "files_count",
115
+ "files", "verify", "notes", "ext", "cursor", "rev",
116
+ ];
117
+
118
+ /** A blank, schema-valid v3 state. */
119
+ export function emptyState(card = 0, by = "") {
120
+ return {
121
+ schema_version: CURRENT_VERSION,
122
+ card,
123
+ task: "",
124
+ done: [], in_flight: [], next: [], blockers: [],
125
+ done_count: 0, files_count: 0,
126
+ files: {},
127
+ verify: {},
128
+ notes: "",
129
+ ext: {},
130
+ cursor: { turn: 0, ts: 0, by },
131
+ rev: 0,
132
+ };
133
+ }
134
+
135
+ const isObj = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
136
+ const isStr = (v) => typeof v === "string";
137
+ const isInt = (v) => typeof v === "number" && Number.isInteger(v);
138
+
139
+ /** Shape-check one Item. Returns an error string, or "" when it is well-formed. */
140
+ export function itemError(item) {
141
+ if (!isObj(item)) return "item must be an object";
142
+ if (!isStr(item.id) || !item.id) return "item.id must be a non-empty string";
143
+ if (!isStr(item.text)) return "item.text must be a string";
144
+ if (item.paths !== undefined) {
145
+ if (!Array.isArray(item.paths) || !item.paths.every(isStr)) return "item.paths must be string[]";
146
+ }
147
+ for (const k of Object.keys(item)) {
148
+ if (!["id", "text", "paths"].includes(k)) return `item has unknown key "${k}"`;
149
+ }
150
+ return "";
151
+ }
152
+
153
+ /**
154
+ * Shape-check a whole WorkingState. This is what "re-validates against the schema" means in the
155
+ * Phase-0 property test, so it is deliberately total: it never throws, it returns the first
156
+ * problem it finds as a string, and "" means valid.
157
+ */
158
+ export function stateError(s) {
159
+ if (!isObj(s)) return "state must be an object";
160
+ if (s.schema_version !== CURRENT_VERSION) return `schema_version must be ${CURRENT_VERSION}`;
161
+ if (!isInt(s.card)) return "card must be an integer";
162
+ if (!isStr(s.task)) return "task must be a string";
163
+ if (s.task.length > CAPS.TASK) return "task exceeds CAPS.TASK";
164
+ for (const list of LISTS) {
165
+ if (!Array.isArray(s[list])) return `${list} must be an array`;
166
+ if (s[list].length > CAPS.LIST) return `${list} exceeds CAPS.LIST`;
167
+ for (const item of s[list]) {
168
+ const e = itemError(item);
169
+ if (e) return `${list}: ${e}`;
170
+ if (item.id.length > CAPS.ID) return `${list}: item.id exceeds CAPS.ID`;
171
+ if (item.text.length > CAPS.ITEM) return `${list}: item.text exceeds CAPS.ITEM`;
172
+ if (item.paths && item.paths.length > CAPS.ITEM_PATHS) return `${list}: item.paths exceeds CAPS.ITEM_PATHS`;
173
+ }
174
+ }
175
+ if (!isInt(s.done_count) || s.done_count < 0) return "done_count must be a non-negative integer";
176
+ if (!isInt(s.files_count) || s.files_count < 0) return "files_count must be a non-negative integer";
177
+ if (!isObj(s.files)) return "files must be an object";
178
+ if (Object.keys(s.files).length > CAPS.FILES) return "files exceeds CAPS.FILES";
179
+ for (const [p, f] of Object.entries(s.files)) {
180
+ if (p.length > CAPS.PATH) return `files["${p}"] key exceeds CAPS.PATH`;
181
+ if (!isObj(f)) return `files["${p}"] must be an object`;
182
+ if (typeof f.touched !== "boolean") return `files["${p}"].touched must be a boolean`;
183
+ if (typeof f.verified !== "boolean") return `files["${p}"].verified must be a boolean`;
184
+ if (f.hash !== undefined && !isStr(f.hash)) return `files["${p}"].hash must be a string`;
185
+ if (f.blast_radius !== undefined && !isInt(f.blast_radius)) return `files["${p}"].blast_radius must be an integer`;
186
+ }
187
+ if (!isObj(s.verify)) return "verify must be an object";
188
+ if (!isStr(s.notes)) return "notes must be a string";
189
+ if (Buffer.byteLength(s.notes, "utf8") > CAPS.NOTES) return "notes exceeds CAPS.NOTES";
190
+ if (!isObj(s.ext)) return "ext must be an object";
191
+ if (Object.keys(s.ext).filter(k => !RUNTIME_EXT_KEYS.includes(k)).length > CAPS.EXT_KEYS) return "ext exceeds CAPS.EXT_KEYS";
192
+ if (!isObj(s.cursor)) return "cursor must be an object";
193
+ if (!isInt(s.cursor.turn)) return "cursor.turn must be an integer";
194
+ if (!isInt(s.cursor.ts)) return "cursor.ts must be an integer";
195
+ if (!isStr(s.cursor.by)) return "cursor.by must be a string";
196
+ if (!isInt(s.rev) || s.rev < 0) return "rev must be a non-negative integer";
197
+ for (const k of Object.keys(s)) {
198
+ if (!KNOWN_FIELDS.includes(k)) return `state has unknown key "${k}"`;
199
+ }
200
+ return "";
201
+ }
202
+
203
+ /** The one Action shape check. Missing or unrecognised → BAD_ACTION at the caller (TDD §4.2). */
204
+ export function isAction(a) {
205
+ if (!isObj(a)) return false;
206
+ if (a.done === true) return Object.keys(a).length === 1;
207
+ if (a.continue === true) return Object.keys(a).length === 1;
208
+ if (isStr(a.ask)) return Object.keys(a).length === 1;
209
+ if (isStr(a.tool)) return isObj(a.input) && Object.keys(a).length === 2;
210
+ return false;
211
+ }
212
+
213
+ /** Structural clone, so an aborted patch can never leave a half-applied state behind. */
214
+ export function cloneState(s) {
215
+ return structuredClone(s);
216
+ }