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.
@@ -18,7 +18,13 @@ import { fileURLToPath } from "node:url";
18
18
  import { deriveSubagentManifest } from "../../lib/subagent-manifest.mjs";
19
19
  import { signedPost } from "./api.mjs";
20
20
  import { loadAutonomy, resolveAutonomy } from "../../lib/autonomy.mjs";
21
- import { resolveProject, orchSessionsPath } from "../../lib/project.mjs";
21
+ import { resolveProject, orchSessionsPath, hostId } from "../../lib/project.mjs";
22
+ // Trantor State (TDD §4.5). Dark behind TRANTOR_STATE_HANDOFF: these are imported unconditionally
23
+ // because they are pure modules with no side effects at load, and a lazy import would make
24
+ // attachState async on a path that is deliberately synchronous.
25
+ import { statePath, readState } from "../../lib/state/store.mjs";
26
+ import { stateError } from "../../lib/state/schema.mjs";
27
+ import { deriveState } from "../../lib/state/derive.mjs";
22
28
 
23
29
  // Writer and reader MUST resolve the same directory — see lib/project.mjs busDir(). This used to
24
30
  // honour only RELAY_DATA_DIR while the reader honoured neither override.
@@ -247,7 +253,13 @@ export function lastRowMidTurn(transcriptPath) {
247
253
  const c = r?.message?.content;
248
254
  if (r.type === "assistant") {
249
255
  const blocks = Array.isArray(c) ? c : [];
250
- if (blocks.some(b => b?.type === "tool_use")) return true; // a result is still owed
256
+ const calls = blocks.filter(b => b?.type === "tool_use");
257
+ // #6668: a session parked in a LONE relay_wait is at its boundary. The wait is not work
258
+ // in flight — everything the turn did is already on disk, and the tool returns only when
259
+ // the bus speaks. Reading it as mid-turn armed the baton for the 17-minute boundary wait
260
+ // on a turn that never ends on its own; the pre-kill idle gate's deadline is what ends it.
261
+ if (calls.length && calls.every(isRelayWaitCall)) return false;
262
+ if (calls.length) return true; // a result is still owed
251
263
  return false; // text-only → turn said its piece
252
264
  }
253
265
  // user row: #6528 follow-up — a trailing user row of ANY kind means in flight. A
@@ -263,6 +275,42 @@ export function lastRowMidTurn(transcriptPath) {
263
275
  export function turnInFlight(transcriptPath) {
264
276
  return subagentsActive(transcriptPath) || lastRowMidTurn(transcriptPath);
265
277
  }
278
+ // The relay MCP's wait tool, by any server prefix (mcp__plugin_trantor_relay__relay_wait,
279
+ // mcp__trantor__relay_wait, a bare relay_wait in a fixture).
280
+ function isRelayWaitCall(block) {
281
+ return /(^|__)relay_wait$/.test(String(block?.name || ""));
282
+ }
283
+
284
+ // ---- does the transcript's session still have a process? (#6668) ------------------------------
285
+ // Claude Code registers every live session in ~/.claude/sessions/<pid>.json ({pid, sessionId,
286
+ // cwd, ...}) and removes the file at exit. The 09-07 12:35 chain armed on a transcript whose
287
+ // session had exited at 12:16: the transcript's last row was a tool_result ("Connection closed"),
288
+ // so the boundary gate read it as mid-turn and waited on a turn that no process would ever end.
289
+ // A session with no live process IS at its boundary — its record can be written now.
290
+ // "live" an entry names this session and its pid answers kill -0
291
+ // "dead" the registry is in use (some other session is live) and none of its live entries
292
+ // name this session
293
+ // "unknown" no registry, or nothing in it is alive — say nothing, the boundary gate decides
294
+ // The "dead" verdict needs another LIVE entry on purpose: a Claude Code too old to keep the
295
+ // registry must not turn every mid-turn handoff into an immediate write.
296
+ export function sessionProcessState(sessionId, { home = homedir() } = {}) {
297
+ if (!sessionId) return "unknown";
298
+ let files;
299
+ try { files = readdirSync(join(home, ".claude", "sessions")).filter(f => f.endsWith(".json")); } catch { return "unknown"; }
300
+ let anyLive = false;
301
+ for (const f of files) {
302
+ let entry;
303
+ try { entry = JSON.parse(readFileSync(join(home, ".claude", "sessions", f), "utf8")); } catch { continue; }
304
+ const pid = Number(entry?.pid) || Number(basename(f, ".json")) || 0;
305
+ if (!(pid > 0) || !pidAlive(pid)) continue;
306
+ anyLive = true;
307
+ if (String(entry?.sessionId || "") === sessionId) return "live";
308
+ }
309
+ return anyLive ? "dead" : "unknown";
310
+ }
311
+ function pidAlive(pid) {
312
+ try { process.kill(pid, 0); return true; } catch (e) { return e?.code === "EPERM"; }
313
+ }
266
314
 
267
315
  // ---- whole-session summary --------------------------------------------------
268
316
  function collectTurns(transcriptPath) {
@@ -406,6 +454,109 @@ export function capSummary(text, cap = 4096) {
406
454
  return head + elide + tail;
407
455
  }
408
456
 
457
+ // ---------------------------------------------------------------------------------------------
458
+ // Trantor State — the structured field on the record (TDD §4.5). `summary` keeps being written
459
+ // exactly as it is today; `state` rides beside it, validated on write and NEVER capped. That is
460
+ // the whole fix for #6528: capSummary's mid-string elision ate the STATE section of the prose, and
461
+ // the structured field cannot lose a member because the lossy operation is not applied to it.
462
+ // ---------------------------------------------------------------------------------------------
463
+
464
+ /** Dark by default. The prose path is untouched either way; this flag only decides whether the
465
+ * structured field is built and rendered (TDD §4.5, "Fallback"). */
466
+ export function stateHandoffEnabled(env = process.env) {
467
+ return ["1", "true", "on", "yes"].includes(String(env.TRANTOR_STATE_HANDOFF || "").toLowerCase());
468
+ }
469
+
470
+ /** The bus id of the seat writing this handoff — the same resolution sessionstart.mjs uses, so a
471
+ * sidecar written under the runner's seat name is the one this path reads back. */
472
+ export function resolveSeat(projectName, env = process.env) {
473
+ return env.RELAY_SESSION || (env.RELAY_AGENT ? `${env.RELAY_AGENT}:${projectName}` : `${hostId()}:${projectName}`);
474
+ }
475
+
476
+ /**
477
+ * Which card this handoff belongs to. `TRANTOR_CARD` wins — the crew runner knows the answer for
478
+ * certain and a lookup cannot beat being told. Otherwise ask the hub for this seat's newest open
479
+ * card, on the same 2s best-effort budget as the verify-gates fetch: a hub that is down costs the
480
+ * handoff a card number, never the handoff.
481
+ */
482
+ export function resolveHandoffCard({ projectName, seat, env = process.env } = {}) {
483
+ const told = Number(env.TRANTOR_CARD);
484
+ if (Number.isInteger(told) && told > 0) return told;
485
+ try {
486
+ const out = execSync(`curl -s --max-time 2 ${JSON.stringify(relayUrl() + "/tasks?project=" + encodeURIComponent(projectName))}`, { encoding: "utf8", timeout: 2500 });
487
+ const tasks = JSON.parse(out).tasks || [];
488
+ const mine = tasks
489
+ .filter(t => t && Number.isInteger(t.id) && t.assignee === seat && ["doing", "testing"].includes(t.status))
490
+ .sort((a, b) => (a.status === b.status ? (b.updated || b.ts || 0) - (a.updated || a.ts || 0) : a.status === "doing" ? -1 : 1));
491
+ return mine.length ? mine[0].id : 0;
492
+ } catch { return 0; }
493
+ }
494
+
495
+ /**
496
+ * Attach the structured working state to a handoff record, from two sources in order:
497
+ * 1. the sidecar, when one exists (Phase 2a and after) — read, migrated, validated;
498
+ * 2. derived from git + the model's own STATE block, when none does (every Phase-1 handoff).
499
+ *
500
+ * Invalid or underivable state attaches `null` and logs. It NEVER blocks a handoff: a session at
501
+ * the context wall losing its baton because a state object would not validate is a far worse
502
+ * failure than a successor reading prose, which is exactly what it read before this field existed.
503
+ * @returns {object|null} the attached state
504
+ */
505
+ export function attachState(rec, { project, seat, card, worktree, env = process.env } = {}) {
506
+ if (!stateHandoffEnabled(env)) return null;
507
+ try {
508
+ const name = project || rec?.projectName || "";
509
+ const who = seat || resolveSeat(name, env);
510
+ const no = Number.isInteger(card) ? card : 0;
511
+ const cwd = worktree || rec?.project || "";
512
+
513
+ let state = null;
514
+ const sidecar = statePath(who, no, name);
515
+ if (sidecar && existsSync(sidecar)) {
516
+ const r = readState(who, no, { project: name, cwd, recover: false });
517
+ if (!r.ok) throw new Error(`sidecar rejected: ${r.code} at ${r.at} — ${r.message}`);
518
+ state = r.state;
519
+ } else {
520
+ state = deriveState({ project: name, seat: who, card: no, worktree: cwd, handoffText: rec?.summary || "" });
521
+ }
522
+
523
+ const why = state ? stateError(state) : "no state could be derived";
524
+ if (why) throw new Error(why);
525
+ rec.state = state;
526
+ return state;
527
+ } catch (e) {
528
+ process.stderr.write(`[trantor] handoff state skipped: ${e?.message || e}\n`);
529
+ rec.state = null;
530
+ return null;
531
+ }
532
+ }
533
+
534
+ /** The state as the successor reads it: one compact block, bounded by the schema's own caps, with
535
+ * the absence of credit stated rather than implied. */
536
+ export function renderStateBlock(state) {
537
+ const line = (items) => items.map(i => `${i.id} ${i.text}${i.paths?.length ? ` [${i.paths.join(", ")}]` : ""}`).join("; ");
538
+ const rows = [];
539
+ if (state?.task) rows.push(`task: ${state.task}`);
540
+ for (const [list, label] of [["done", "done"], ["in_flight", "in flight"], ["next", "next"], ["blockers", "blockers"]]) {
541
+ const items = state?.[list] || [];
542
+ if (!items.length) continue;
543
+ const more = list === "done" && state.done_count ? ` (+${state.done_count} compacted)` : "";
544
+ rows.push(`${label} (${items.length}${more}): ${line(items)}`);
545
+ }
546
+ const files = state?.files || {};
547
+ const paths = Object.keys(files);
548
+ const verified = paths.filter(p => files[p].verified === true);
549
+ if (paths.length) rows.push(`files: ${paths.length} touched, ${verified.length} verified${verified.length ? ` — ${verified.join(", ")}` : ""}`);
550
+ const verify = Object.entries(state?.verify || {});
551
+ if (verify.length) rows.push(`verify: ${verify.map(([k, v]) => `${k}=${v}`).join(" ")}`);
552
+ if (!rows.length) return ""; // nothing to render is not a block with a warning in it
553
+ if (!verified.length) {
554
+ rows.push("NO PATH IS VERIFIED HERE — no gate ran at the handoff. Nothing in this block is evidence: re-earn it before you move anything to done.");
555
+ }
556
+ if (state.notes) rows.push(`notes: ${state.notes}`);
557
+ return rows.join("\n");
558
+ }
559
+
409
560
  // How fresh a model-authored handoff must be before an automatic digest DEFERS to it: 15 minutes.
410
561
  // Older than that, the state it describes has likely moved on — compose fresh.
411
562
  const FRESH_HANDOFF_SEC = 15 * 60;
@@ -522,6 +673,11 @@ export function writeHandoff({ projectDir, sessionId, transcript, trigger, summa
522
673
  // The §5 machine's ledger: every transition appends here via appendHandoffState.
523
674
  states: [{ state: "written", ts: Number(stamp) || 0, by: sessionId || "" }],
524
675
  };
676
+ // The structured field (TDD §4.5), dark behind TRANTOR_STATE_HANDOFF. It is attached AFTER the
677
+ // record is built because it reads `summary` — the model's own STATE block is one of its two
678
+ // sources — and BEFORE the write, so the field lands in the same file the successor loads.
679
+ const seat = resolveSeat(projectName);
680
+ attachState(record, { project: projectName, seat, card: resolveHandoffCard({ projectName, seat }), worktree: projectDir });
525
681
  const file = join(HANDOFF_DIR, `${record.id}.json`);
526
682
  writeFileSync(file, JSON.stringify(record, null, 2));
527
683
  supersedeOlderHandoffs(projectName, record.id);
@@ -14,6 +14,7 @@ import { fileURLToPath } from "node:url";
14
14
  import { resolveProject, hostId, resolveHubInfo, knownProjects, nonSeatReason, handoffDir, readOrchSession, writeOrchSession } from "../lib/project.mjs";
15
15
  import { formatSubagentManifest } from "../lib/subagent-manifest.mjs";
16
16
  import { updateAvailable, maybeNotifyDesktop, readConfig } from "./lib/update-check.mjs";
17
+ import { renderStateBlock } from "./lib/handoff.mjs";
17
18
  import { maybeCheckBalances } from "./lib/balance-check.mjs";
18
19
  import { getJSON, signedGet, signedPost, loadIdentity } from "./lib/api.mjs";
19
20
  import { ledgerPaths, ensureStart, anchorCursor, writeCursor } from "./lib/inbox-ledger.mjs";
@@ -569,6 +570,15 @@ try {
569
570
  }
570
571
  additionalContext += `\n`;
571
572
  }
573
+ // The structured working state (TDD §4.5), AFTER the recap instruction and above the prose: it
574
+ // is bounded by the schema's own caps, so unlike the summary it cannot have lost a member to an
575
+ // elision (#6528). A record without one — every handoff until the flag is on — renders nothing
576
+ // and the successor sees exactly today's prose handoff.
577
+ const stateBlock = renderStateBlock(handoff.state);
578
+ if (stateBlock) {
579
+ additionalContext += `## Working state (structured, card #${sanitize(String(handoff.state.card || 0))}, turn ${sanitize(String(handoff.state.cursor?.turn ?? 0))})\n`;
580
+ additionalContext += `${sanitize(stateBlock)}\n\n`;
581
+ }
572
582
  additionalContext += `## Handoff summary\n${sanitize(capHandoffSummary(handoff))}\n`;
573
583
  if (handoff.gitStatus) additionalContext += `\n## Git working-tree at handoff\n\`\`\`\n${sanitize(handoff.gitStatus)}\n\`\`\`\n`;
574
584
  // Sub-agent manifest: LIVE-primary, snapshot-as-fallback. The prior session may have had
@@ -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
+ }