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,229 @@
1
+ // Trantor State P4 — derive (TDD §4.5). Build a WorkingState for a handoff that has no sidecar
2
+ // behind it, from two sources that are both already there: git's account of the worktree, and the
3
+ // model's own STATE block in the handoff it just wrote.
4
+ //
5
+ // Why this exists at all: nothing writes a `state/` sidecar until the P6 runner applies patches
6
+ // (Phase 2a). A Phase-1 writer that only READ a sidecar would read a file that does not exist,
7
+ // every handoff would carry `state: null`, and the Phase-1 exit gate — ten real handoffs carrying
8
+ // a schema-valid STATE — could not go green until the phase after it shipped.
9
+ //
10
+ // The rule that keeps this honest: A DERIVED STATE CARRIES NO CREDIT. Every path is
11
+ // `verified: false`, `verify` is empty, and neither is reachable from the patch (both are
12
+ // harness-written, §4.2's write matrix). So a derived state satisfies neither route (a) — the gate
13
+ // went green this turn — nor route (b) — every named path is credited — and the successor has to
14
+ // re-earn its evidence before anything moves to `done`. That is the honest outcome, not a
15
+ // limitation: no gate ran, so there is nothing to inherit.
16
+ //
17
+ // The model's block goes through `applyTurn`, not through a second parser. That is the whole
18
+ // design: the write matrix, the caps, the evidence marker and the verified-done rule then hold on
19
+ // the handoff path exactly as they hold on the runner path, for free, instead of being a promise a
20
+ // second parser would have to keep and would eventually break.
21
+ import { execFileSync } from "node:child_process";
22
+ import { CAPS, LISTS, emptyState, stateError } from "./schema.mjs";
23
+ import { applyTurn } from "./apply.mjs";
24
+ import { gitTouched } from "./store.mjs";
25
+
26
+ /** The line every derived state carries, so the successor reads the absence of credit as a fact
27
+ * rather than inferring it from an empty `verify`. */
28
+ export const DERIVED_NOTE = "derived: no gate ran at this handoff — every path is uncredited and nothing here is evidence";
29
+
30
+ /** `git diff --name-only HEAD`, NUL-separated. `-z` for the same reason the gate uses it: without
31
+ * it git C-quotes any path with a non-ASCII byte and `café.mjs` arrives as `caf\303\251.mjs`, a
32
+ * path that does not exist (#6901). */
33
+ export function gitDiffPaths(cwd) {
34
+ if (!cwd) return [];
35
+ try {
36
+ const out = execFileSync("git", ["diff", "--name-only", "-z", "HEAD"], {
37
+ cwd, encoding: "utf8", maxBuffer: 16 * 1024 * 1024, stdio: ["pipe", "pipe", "pipe"],
38
+ });
39
+ return out.split("\0").filter(Boolean);
40
+ } catch {
41
+ return []; // no git, no repo, or no HEAD yet — a derived state without files is still valid
42
+ }
43
+ }
44
+
45
+ /**
46
+ * Ground truth for `files`: every path the worktree has changed, `touched: true` and
47
+ * `verified: false` without exception. These are harness facts, so they ride `ctx.files` into
48
+ * applyTurn's runtime pass rather than the patch — a patch cannot write `files` at all.
49
+ * @returns {Record<string, { touched: boolean, verified: boolean }>}
50
+ */
51
+ export function deriveFiles(worktree) {
52
+ const out = {};
53
+ for (const p of [...gitTouched(worktree), ...gitDiffPaths(worktree)]) {
54
+ // A path over CAPS.PATH cannot be a key in a schema-valid state, and truncating it would
55
+ // credit a file that is not there. Drop it; `files_count` is not the place to hide it either,
56
+ // because nothing was compacted.
57
+ if (!p || p.length > CAPS.PATH) continue;
58
+ out[p] = { touched: true, verified: false };
59
+ }
60
+ return out;
61
+ }
62
+
63
+ /** Which of the handoff's sections a heading opens. Anchored at the front on purpose: the sections
64
+ * the Stop path asks for ("TASK (what we're doing + the goal)", "OPEN THREADS & NEXT STEPS") lead
65
+ * with their keyword and mention other keywords later in the same line. */
66
+ function sectionOf(heading) {
67
+ const h = String(heading).trim().toLowerCase().replace(/^[^a-z]+/, "");
68
+ if (/^(task|goal|objective)/.test(h)) return "task";
69
+ if (/^(state|status|progress so far)/.test(h)) return "state";
70
+ if (/^(done|completed|shipped|landed|delivered)/.test(h)) return "done";
71
+ if (/^(in[ -]?progress|in[ -]?flight|wip|doing|current)/.test(h)) return "in_flight";
72
+ if (/^(next|open thread|todo|to do|remaining|follow[ -]?up|upcoming)/.test(h)) return "next";
73
+ if (/^(blocker|blocked|risk)/.test(h)) return "blockers";
74
+ return "other";
75
+ }
76
+
77
+ /** A heading line: `## STATE`, `**STATE**`, or a bare `STATE:` / `STATE (done / in-progress)`. */
78
+ function headingText(line) {
79
+ const hash = /^\s{0,3}#{1,6}\s+(.+?)\s*$/.exec(line);
80
+ if (hash) return hash[1].replace(/[*_`]/g, "");
81
+ const bold = /^\s{0,3}\*\*(.+?)\*\*\s*:?\s*$/.exec(line);
82
+ if (bold) return bold[1];
83
+ const bare = /^([A-Z][A-Z0-9 &'’(),./-]{2,})\s*:?\s*$/.exec(line);
84
+ if (bare) return bare[1];
85
+ return "";
86
+ }
87
+
88
+ const BULLET = /^\s*(?:[-*+•]|\d+[.)])\s+(.+)$/;
89
+ /** An inline label on a bullet overrides the section it sits in — "- done: wired the promoter". */
90
+ const INLINE_LABEL = /^(?:\*\*)?(done|completed|shipped|in[ -]?progress|in[ -]?flight|wip|doing|next|todo|blocked|blocker)(?:\*\*)?\s*[:\-–—]\s+/i;
91
+
92
+ /** The bucket a label word names. */
93
+ function bucketOfLabel(word) {
94
+ const w = String(word).toLowerCase().replace(/[ -]/g, "");
95
+ if (["done", "completed", "shipped"].includes(w)) return "done";
96
+ if (["inprogress", "inflight", "wip", "doing"].includes(w)) return "in_flight";
97
+ if (["next", "todo"].includes(w)) return "next";
98
+ return "blockers";
99
+ }
100
+
101
+ /** Leading status glyphs carry the same meaning as a label and are stripped with it. */
102
+ function glyphBucket(text) {
103
+ if (/^(?:✅|✔️?|☑️?|\[x\]|\[X\])\s*/.test(text)) return "done";
104
+ if (/^(?:⛔|❌|🚫|🛑)\s*/.test(text)) return "blockers";
105
+ if (/^(?:🚧|🔄|▶️?)\s*/.test(text)) return "in_flight";
106
+ return "";
107
+ }
108
+
109
+ /** Strip markdown emphasis, glyphs and a trailing colon so the item reads as one claim. */
110
+ function cleanItem(raw) {
111
+ return String(raw)
112
+ .replace(/^(?:✅|✔️?|☑️?|\[[ xX]\]|⛔|❌|🚫|🛑|🚧|🔄|▶️?)\s*/, "")
113
+ .replace(/\*\*/g, "")
114
+ .replace(/^[*_`]+|[*_`]+$/g, "")
115
+ .replace(/\s+/g, " ")
116
+ .replace(/[:;,]$/, "")
117
+ .trim();
118
+ }
119
+
120
+ /**
121
+ * Read the model's own handoff into the four lists. Bullets only: a paragraph in a STATE section
122
+ * is prose about the work, not an item, and turning it into one would fill the successor's state
123
+ * with sentences it cannot address by id.
124
+ *
125
+ * An unlabelled bullet inside STATE lands in `in_flight`, never `done`. That default is the
126
+ * conservative one on purpose — "the work is still open" costs the successor one re-check, while
127
+ * "the work is finished" is a claim nothing verified.
128
+ * @returns {{ done: string[], in_flight: string[], next: string[], blockers: string[], task: string }}
129
+ */
130
+ export function parseHandoffState(text) {
131
+ const out = { done: [], in_flight: [], next: [], blockers: [], task: "" };
132
+
133
+ let section = "other";
134
+ let bucket = "";
135
+ // Anything that is not handoff markdown simply has no headings and no bullets, so it parses to
136
+ // four empty lists — the same answer a null summary gives, without a shape check standing in for
137
+ // a boundary this function does not have.
138
+ for (const line of String(text ?? "").split(/\r?\n/)) {
139
+ const heading = headingText(line);
140
+ if (heading) {
141
+ section = sectionOf(heading);
142
+ bucket = LISTS.includes(section) ? section : section === "state" ? "in_flight" : "";
143
+ continue;
144
+ }
145
+ const bullet = BULLET.exec(line);
146
+ if (section === "task") {
147
+ const t = cleanItem(bullet ? bullet[1] : line);
148
+ if (t && !out.task) out.task = t;
149
+ continue;
150
+ }
151
+ if (!bullet || !bucket) continue;
152
+
153
+ let body = bullet[1].trim();
154
+ let target = bucket;
155
+ const glyph = glyphBucket(body);
156
+ if (glyph) target = glyph;
157
+ const labelled = INLINE_LABEL.exec(body.replace(/^(?:✅|✔️?|☑️?|\[[ xX]\]|⛔|❌|🚫|🛑|🚧|🔄|▶️?)\s*/, ""));
158
+ if (labelled) {
159
+ target = bucketOfLabel(labelled[1]);
160
+ body = body.replace(/^(?:✅|✔️?|☑️?|\[[ xX]\]|⛔|❌|🚫|🛑|🚧|🔄|▶️?)\s*/, "").slice(labelled[0].length);
161
+ }
162
+ const item = cleanItem(body);
163
+ if (item) out[target].push(item);
164
+ }
165
+ return out;
166
+ }
167
+
168
+ const ID_PREFIX = { done: "d", in_flight: "f", next: "n", blockers: "b" };
169
+
170
+ /**
171
+ * The parsed lists as `add` ops. Over-CAPS.LIST lines are dropped HERE, with a notes line naming
172
+ * the count, rather than being sent into applyTurn where a working-list overflow is a rejection
173
+ * that would cost every item in the patch (§3.1). Named beats silent, and both beat losing the lot.
174
+ * @returns {{ ops: object[], dropped: string[] }}
175
+ */
176
+ function itemOps(parsed) {
177
+ const ops = [];
178
+ const dropped = [];
179
+ for (const list of LISTS) {
180
+ const lines = parsed[list];
181
+ if (lines.length > CAPS.LIST) {
182
+ dropped.push(`derived: ${lines.length - CAPS.LIST} ${list} line(s) over CAPS.LIST (${CAPS.LIST}) were not carried`);
183
+ }
184
+ lines.slice(0, CAPS.LIST).forEach((text, n) => {
185
+ ops.push({ add: { list, item: { id: `${ID_PREFIX[list]}${n + 1}`, text } } });
186
+ });
187
+ }
188
+ return { ops, dropped };
189
+ }
190
+
191
+ /**
192
+ * Build a WorkingState for a handoff with no sidecar behind it.
193
+ *
194
+ * `card` and `task` come from the record and the card title; `files` from git; the four lists from
195
+ * the handoff's own STATE block, through applyTurn. A rejected patch does not cost the state: the
196
+ * git-derived files, the task and one notes line naming the rejection code still make a
197
+ * schema-valid state, which is exactly what the Phase-1 gate measures.
198
+ *
199
+ * @returns {object|null} a state the validator accepts, or null — never a half-built one.
200
+ */
201
+ export function deriveState({ project, seat, card, worktree, handoffText, cardTitle, now, by } = {}) {
202
+ // The bus names a seat `<seat>:<project>`. A caller that passes the two halves separately (the
203
+ // sidecar path's components) gets the same `cursor.by` the runner path would have written.
204
+ const bare = String(seat || "");
205
+ const author = String(by || (bare && project && !bare.includes(":") ? `${bare}:${project}` : bare));
206
+ const base = emptyState(Number.isInteger(card) && card >= 0 ? card : 0, author);
207
+ const ctx = {
208
+ now: Number.isInteger(now) ? now : Math.floor(Date.now() / 1000),
209
+ by: author,
210
+ files: deriveFiles(worktree),
211
+ };
212
+
213
+ const parsed = parseHandoffState(handoffText);
214
+ const task = String(cardTitle || parsed.task || "").replace(/\s+/g, " ").trim().slice(0, CAPS.TASK);
215
+ const { ops, dropped } = itemOps(parsed);
216
+
217
+ const setTask = task ? [{ set: { field: "task", value: task } }] : [];
218
+ const notes = (lines) => ({ set: { field: "notes", value: [DERIVED_NOTE, ...lines].join("\n") } });
219
+
220
+ let r = applyTurn(base, { patch: [...setTask, notes(dropped), ...ops], action: { continue: true } }, ctx);
221
+ if (!r.ok) {
222
+ const why = `derived: STATE block rejected (${r.code} at ${r.at}) — items dropped, files and task kept`;
223
+ r = applyTurn(base, { patch: [...setTask, notes([why])], action: { continue: true } }, ctx);
224
+ }
225
+ if (!r.ok) return null;
226
+ // The contract is "something the validator accepts, or nothing at all". applyTurn should never
227
+ // hand back an invalid state; if it ever does, a derived handoff is the wrong place to find out.
228
+ return stateError(r.state) === "" ? r.state : null;
229
+ }
@@ -0,0 +1,355 @@
1
+ // Trantor State P5.5 — the gate (TDD §4.8).
2
+ //
3
+ // runGate(spec, opts) is the lazy half of the evidence pipeline: the driver calls it once at the
4
+ // `move → done` boundary to cure NEEDS_GATE, and the shape it returns feeds ctx.gate_attempted
5
+ // and ctx.files on the retry. It NEVER touches state — it computes and returns; apply.mjs stage 5
6
+ // lands the memo through the single apply point.
7
+ //
8
+ // runGate({ items, paths }, { cwd }) ->
9
+ // { verify, files, coverage, cmd, exit, ms, tail, memo, memoHit }
10
+ //
11
+ // The three invariants this file exists to hold:
12
+ // 1. The memo keys on tree CONTENT (scratch-index `git write-tree`), never on
13
+ // `git status --porcelain` — porcelain records that a path is modified, never what is in
14
+ // it, so a re-edit of an ALREADY-modified file is byte-identical to porcelain and a stale
15
+ // green would be reused as evidence for a done-move (the bust test in test-gate.mjs).
16
+ // 2. Scoped resolves BEFORE scripts.test. scripts.test here is the full suite; a seat running
17
+ // the whole thing collides with sibling seats on fixed ports. Three reviewers hit this.
18
+ // 3. `verified` means "a gate covering this path passed" (R11): on scoped coverage, touched
19
+ // paths OUTSIDE the scope come back verified:false — a scoped suite is not evidence about
20
+ // files it never loaded.
21
+ import { spawnSync } from "node:child_process";
22
+ import { existsSync, mkdirSync, readFileSync, mkdtempSync, readdirSync, writeFileSync } from "node:fs";
23
+ import { tmpdir } from "node:os";
24
+ import { join, resolve } from "node:path";
25
+ import { CAPS } from "./schema.mjs";
26
+
27
+ const DEFAULT_MAX_MS = 300_000;
28
+ /** GNU timeout's convention: a timebox kill is a RED gate, never a missing one. */
29
+ export const TIMED_OUT_EXIT = 124;
30
+
31
+ const sh = (cmd, args, opts = {}) => {
32
+ const r = spawnSync(cmd, args, { encoding: "utf8", ...opts });
33
+ return {
34
+ status: r.status,
35
+ timedOut: r.error?.code === "ABORT_ERR" || (r.error?.code === "ETIMEDOUT"),
36
+ stdout: r.stdout || "",
37
+ stderr: r.stderr || "",
38
+ };
39
+ };
40
+
41
+ /** Last `CAPS.OBS_TOKENS` bytes of combined output — the seat's next observation starts here. */
42
+ const tailOf = (...outs) => {
43
+ const t = outs.filter(Boolean).join("\n").trim();
44
+ return t.length > CAPS.OBS_TOKENS ? t.slice(-CAPS.OBS_TOKENS) : t;
45
+ };
46
+
47
+ /**
48
+ * Command resolution (§4.8), first match wins. Exported for direct tests — resolution is pure.
49
+ *
50
+ * TRANTOR_STATE_GATE (explicit, per project)
51
+ * → the SCOPED form `node test/run.mjs --only <subsystem>`, when every path in spec.paths sits
52
+ * under one subsystem that has a sibling suite dir (lib/state/* ↔ test/state/) — BEFORE
53
+ * scripts.test, deliberately: scripts.test is the full suite and seats must never run it
54
+ * → package.json scripts.test
55
+ * → none.
56
+ *
57
+ * A subsystem for a path is any directory segment `<name>` of the path for which
58
+ * `<cwd>/test/<name>/` exists and contains suites. Build command resolves the same way:
59
+ * TRANTOR_STATE_BUILD → scripts.typecheck → scripts.build → none.
60
+ */
61
+ export function resolveGateCommand(spec = {}, { cwd = process.cwd(), env = process.env } = {}) {
62
+ if (env.TRANTOR_STATE_GATE) {
63
+ return { kind: "explicit", cmd: env.TRANTOR_STATE_GATE };
64
+ }
65
+ const subsystem = scopedSubsystem(spec.paths || [], { cwd });
66
+ if (subsystem) {
67
+ return { kind: "scoped", subsystem, cmd: `node test/run.mjs --only ${subsystem}` };
68
+ }
69
+ const pkg = readPkg(cwd);
70
+ if (pkg?.scripts?.test) return { kind: "scripts.test", cmd: `npm test` };
71
+ return { kind: "none", cmd: null };
72
+ }
73
+
74
+ export function resolveBuildCommand({ cwd = process.cwd(), env = process.env } = {}) {
75
+ if (env.TRANTOR_STATE_BUILD) return { kind: "explicit", cmd: env.TRANTOR_STATE_BUILD };
76
+ const pkg = readPkg(cwd);
77
+ if (pkg?.scripts?.typecheck) return { kind: "scripts.typecheck", cmd: "npm run typecheck" };
78
+ if (pkg?.scripts?.build) return { kind: "scripts.build", cmd: "npm run build" };
79
+ return { kind: "none", cmd: null };
80
+ }
81
+
82
+ function readPkg(cwd) {
83
+ try {
84
+ return JSON.parse(readFileSync(join(cwd, "package.json"), "utf8"));
85
+ } catch {
86
+ return null;
87
+ }
88
+ }
89
+
90
+ /** Idempotently add `entry` to the repo's local excludes (never a tracked file, so it cannot
91
+ * change the tree or a diff — it only changes what `add -A` picks up). */
92
+ function ensureIgnored(cwd, entry) {
93
+ const p = sh("git", ["rev-parse", "--git-path", "info/exclude"], { cwd });
94
+ const f = p.status === 0 && p.stdout.trim() ? resolve(cwd, p.stdout.trim()) : null;
95
+ if (!f) return;
96
+ let cur = "";
97
+ try { cur = readFileSync(f, "utf8"); } catch { /* first write */ }
98
+ if (!cur.split("\n").includes(entry)) {
99
+ try {
100
+ mkdirSync(dirname(f), { recursive: true });
101
+ writeFileSync(f, `${cur}${cur.endsWith("\n") || !cur ? "" : "\n"}${entry}\n`);
102
+ } catch { /* read-only git dir: the add below may then fold the scratch in — the memo
103
+ misses every time, which is the safe direction (re-run, never stale green). */ }
104
+ }
105
+ }
106
+
107
+ function hasSuites(cwd, sub) {
108
+ const dir = join(cwd, "test", sub);
109
+ if (!existsSync(dir)) return false;
110
+ try {
111
+ return existsSync(join(dir, "test.mjs")) ||
112
+ readdirSync(dir).some(f => /^test-[\w.-]+\.(mjs|sh)$/.test(f));
113
+ } catch {
114
+ return false;
115
+ }
116
+ }
117
+
118
+ /** The one subsystem all paths share, or null. `lib/state/gate.mjs` → "state" when test/state/ has suites. */
119
+ function scopedSubsystem(paths, opts) {
120
+ if (!paths.length) return null;
121
+ let shared = null;
122
+ for (const p of paths) {
123
+ const sub = subsystemOf(p, opts);
124
+ if (!sub) return null; // a path no suite dir covers → scope cannot place the work
125
+ if (shared === null) shared = sub;
126
+ else if (shared !== sub) return null; // spans two subsystems → not one scoped suite
127
+ }
128
+ return shared;
129
+ }
130
+
131
+ /** Shallowest directory segment of `p` that has a sibling suite dir — the subsystem a path
132
+ * belongs to, or null. ONE rule for both resolution and credits, so the lane that ran and the
133
+ * lane that gets credited can never drift apart. */
134
+ function subsystemOf(p, { cwd }) {
135
+ const segs = p.split("/");
136
+ return segs.slice(0, -1).find(s => s && s !== ".." && hasSuites(cwd, s)) || null;
137
+ }
138
+
139
+ /**
140
+ * Tree CONTENT hash: HEAD sha + the worktree's tree sha, computed against a SCRATCH index so the
141
+ * seat's real `.git/index` is never touched. `git add -A` into the scratch index folds in
142
+ * untracked files, so a brand-new test file busts the memo too. The scratch dir defaults to
143
+ * `<cwd>/.agent-bus-out/` (gitignored, same filesystem) and falls back to a temp dir when that
144
+ * is not writable.
145
+ */
146
+ export function contentHash(cwd) {
147
+ const scratchDir = join(cwd, ".agent-bus-out");
148
+ let dir = scratchDir;
149
+ let outside = false;
150
+ try {
151
+ mkdirSync(scratchDir, { recursive: true });
152
+ } catch {
153
+ dir = mkdtempSync(join(tmpdir(), "trantor-gate-"));
154
+ outside = true;
155
+ }
156
+ // The design's premise is that the scratch dir is gitignored; make that true locally (idempotent,
157
+ // repo-local `.git/info/exclude` — `--git-path` resolves the right git dir inside linked
158
+ // worktrees too). Unignored, `add -A` would fold the index file's own timestamp-varying bytes
159
+ // into the tree and the memo could never hit. The dir is NOT touched with a pathspec: an
160
+ // explicit `:(exclude)` naming an ignored path makes git REFUSE the add outright.
161
+ if (!outside) {
162
+ ensureIgnored(cwd, ".agent-bus-out/");
163
+ }
164
+ const env = { ...process.env, GIT_INDEX_FILE: join(dir, "gate-index") };
165
+ const add = sh("git", ["add", "-A"], { cwd, env });
166
+ if (add.status !== 0) return null;
167
+ const tree = sh("git", ["write-tree"], { cwd, env });
168
+ if (tree.status !== 0) return null;
169
+ const head = sh("git", ["rev-parse", "HEAD"], { cwd, env });
170
+ return `${head.stdout.trim()}+${tree.stdout.trim()}`;
171
+ }
172
+
173
+ /** Paths the worktree has actually changed vs HEAD — the TOUCHED set (tier 1's fact).
174
+ * `-uall` so an untracked DIRECTORY expands to the files inside it (`?? bin/` would hide the
175
+ * path a credit must name). Porcelain is fine HERE: touched is about which paths differ, not
176
+ * about evidence. The memo hash above is where content, not status letters, is the only key. */
177
+ export function touchedPaths(cwd) {
178
+ // -z, and it is not a style preference. WITHOUT it git C-quotes any path with a non-ASCII byte,
179
+ // a space-with-quote, or a backslash: `café.mjs` arrives as `"caf\303\251.mjs"`, and stripping
180
+ // the surrounding quotes leaves `caf\303\251.mjs` — a path that does not exist. The credit a
181
+ // seat names would then never match its file, so route (b) would silently never grant evidence
182
+ // for that path. It fails closed rather than granting a false credit, which is why nothing but a
183
+ // deliberate test catches it. -z emits literal NUL-separated paths with no quoting at all.
184
+ const out = sh("git", ["status", "--porcelain", "-z", "-uall"], { cwd });
185
+ if (out.status !== 0) return [];
186
+ const fields = out.stdout.split("\0").filter(Boolean);
187
+ const paths = [];
188
+ for (let i = 0; i < fields.length; i++) {
189
+ const entry = fields[i];
190
+ if (entry.length < 4) continue;
191
+ paths.push(entry.slice(3));
192
+ // A rename or copy emits its ORIGINAL path as the next NUL-terminated field. Consume it so it
193
+ // is not mistaken for a status entry, and count it too: the old path changed as surely as the
194
+ // new one did.
195
+ if (entry[0] === "R" || entry[0] === "C" || entry[1] === "R" || entry[1] === "C") {
196
+ const src = fields[++i];
197
+ if (src) paths.push(src);
198
+ }
199
+ }
200
+ return paths;
201
+ }
202
+
203
+ /** In scope for a `scoped:test/<sub>` credit = the path maps to the same subsystem the scoped
204
+ * suite covers, by the one rule above. Paths outside stay verified:false (R11). */
205
+ const inScope = (p, sub, opts) => (sub ? subsystemOf(p, opts) === sub : true);
206
+
207
+ /**
208
+ * Run the gate. `spec` is the `gate` field NEEDS_GATE carried (`{ items, paths }`). `opts`:
209
+ * cwd — the seat worktree (default process.cwd())
210
+ * env — overrides process.env (tests inject TRANTOR_STATE_GATE without leaking)
211
+ * memo — the prior ext._gate record `{ hash, verify, coverage, ts, ... }`, if any
212
+ * maxMs — GATE_MAX_MS, default 300000; a timeout is a RED gate (exit 124)
213
+ *
214
+ * Returns `{ verify, files, coverage, cmd, exit, ms, tail, memo, memoHit }`:
215
+ * verify — `{ tested, cmd, exit }` (+ `built` only when a build command resolved and ran).
216
+ * `observed` is NEVER set here: a test runner is not an observation.
217
+ * files — every touched path: credited ones `{ touched, verified:true, hash }` (blob sha,
218
+ * what tier 1 later expires against), everything else `{ touched, verified:false }`.
219
+ * memo — the record the driver should land in ext._gate via ctx.gate.
220
+ * memoHit — true when the recorded GREEN result was reused with no spawn.
221
+ *
222
+ * A RED gate (test fails, build fails, slop-gate fails, or timeout) credits no path and carries
223
+ * cmd/exit/tail for UNVERIFIED_DONE. This function returns no rejection codes of its own — the
224
+ * NEEDS_GATE/UNVERIFIED_DONE split belongs to the core (ctx.gate_attempted).
225
+ */
226
+ export function runGate(spec = {}, opts = {}) {
227
+ const cwd = resolve(opts.cwd || process.cwd());
228
+ const env = { ...process.env, ...opts.env };
229
+ const maxMs = opts.maxMs ?? (Number(env.GATE_MAX_MS) || DEFAULT_MAX_MS);
230
+ const t0 = Date.now();
231
+
232
+ // ---- resolve first: the command is part of the memo key, not just its output ----
233
+ const build = resolveBuildCommand({ cwd, env });
234
+ const gateCmd = resolveGateCommand(spec, { cwd, env });
235
+
236
+ // ---- the memo is keyed on CONTENT, and checked before anything spawns ----
237
+ // Same HEAD + same tree bytes + SAME GATE COMMAND as the green run. The command is in the key
238
+ // because a different TRANTOR_STATE_GATE on unchanged bytes is a different gate — reusing the
239
+ // old green there would be exactly the stale-green-as-evidence bug this file exists to prevent.
240
+ const hash = contentHash(cwd);
241
+ if (hash && opts.memo && opts.memo.hash === hash &&
242
+ opts.memo.cmd === gateCmd.cmd && opts.memo.verify?.tested === true) {
243
+ // Same HEAD + same tree bytes as the green run: reuse, no spawn, no slop re-run. The touched
244
+ // set is identical too (same diff), so the recorded files map is still the truth.
245
+ return {
246
+ verify: { ...opts.memo.verify },
247
+ files: JSON.parse(JSON.stringify(opts.memo.files || {})),
248
+ coverage: opts.memo.coverage,
249
+ cmd: opts.memo.cmd,
250
+ exit: opts.memo.exit ?? 0,
251
+ ms: Date.now() - t0,
252
+ tail: opts.memo.tail ?? "",
253
+ memo: opts.memo,
254
+ memoHit: true,
255
+ };
256
+ }
257
+
258
+ // ---- touched set, before running anything (git is ground truth, not testimony) ----
259
+ const touched = touchedPaths(cwd);
260
+
261
+ // ---- run: build (if any) → gate command → slop-gate (always, when present) ----
262
+ let exit = 0;
263
+ let tail = "";
264
+ const verify = { tested: false };
265
+ let coverage = "project";
266
+ const runCmdLine = (cmdline) => {
267
+ // A resolved command is a shell line from a trusted source (repo scripts / operator env),
268
+ // never seat input — spec.paths only SELECTS among them.
269
+ const r = sh("sh", ["-c", cmdline], { cwd, env, timeout: maxMs, killSignal: "SIGTERM" });
270
+ return r;
271
+ };
272
+
273
+ if (build.cmd) {
274
+ const b = runCmdLine(build.cmd);
275
+ verify.built = b.status === 0 && !b.timedOut;
276
+ if (b.timedOut) { exit = exit || TIMED_OUT_EXIT; tail += `\n[build] TIMED OUT after ${maxMs}ms\n` + b.stdout + b.stderr; }
277
+ else if (b.status !== 0) { exit = exit || (b.status ?? 1); tail += `\n[build exit ${b.status}]\n` + b.stdout + b.stderr; }
278
+ }
279
+
280
+ if (gateCmd.kind === "none") {
281
+ exit = exit || 1;
282
+ tail += "\n[gate] no test command resolved (TRANTOR_STATE_GATE, scoped form, scripts.test all missed)\n";
283
+ verify.cmd = null;
284
+ verify.exit = exit;
285
+ } else {
286
+ verify.cmd = gateCmd.cmd;
287
+ coverage = gateCmd.kind === "scoped" ? `scoped:test/${gateCmd.subsystem}` : "project";
288
+ const g = runCmdLine(gateCmd.cmd);
289
+ if (g.timedOut) {
290
+ verify.exit = TIMED_OUT_EXIT;
291
+ exit = exit || TIMED_OUT_EXIT; // a timeout is a RED gate, not a missing one
292
+ tail += `\n[gate] TIMED OUT after ${maxMs}ms: ${gateCmd.cmd}\n` + g.stdout + g.stderr;
293
+ } else {
294
+ verify.exit = g.status ?? 1;
295
+ exit = exit || (g.status ?? 1);
296
+ tail += `\n[gate exit ${g.status ?? 1}] ${gateCmd.cmd}\n` + g.stdout + g.stderr;
297
+ }
298
+ }
299
+
300
+ // ---- slop-gate, always, when this repo has one: no card reaches done with it red ----
301
+ const slopPath = join(cwd, "bin", "slop-gate.mjs");
302
+ if (existsSync(slopPath)) {
303
+ const s = sh(process.execPath, [slopPath], { cwd, env, timeout: maxMs });
304
+ if (s.timedOut) {
305
+ exit = exit || TIMED_OUT_EXIT;
306
+ tail += `\n[slop-gate] TIMED OUT after ${maxMs}ms\n` + s.stdout + s.stderr;
307
+ } else if (s.status !== 0) {
308
+ exit = exit || (s.status ?? 1); // a green suite with red slop is still a RED gate
309
+ tail += `\n[slop-gate exit ${s.status}]\n` + s.stdout + s.stderr;
310
+ }
311
+ }
312
+
313
+ // The design's literal rule — computed AFTER everything that can redden the gate (suite, build,
314
+ // slop). In the none-case above exit is forced non-zero, so exit===0 already implies a test ran.
315
+ verify.tested = exit === 0;
316
+
317
+ // ---- credits (R11): only paths a gate of this coverage actually passed ----
318
+ const scopeSub = coverage === "project" ? null : coverage.slice("scoped:test/".length);
319
+ const files = {};
320
+ if (verify.tested && exit === 0) {
321
+ for (const p of touched) {
322
+ if (!inScope(p, scopeSub, { cwd })) { files[p] = { touched: true, verified: false }; continue; }
323
+ const h = sh("git", ["hash-object", p], { cwd });
324
+ files[p] = h.status === 0
325
+ ? { touched: true, verified: true, hash: h.stdout.trim() }
326
+ : { touched: true, verified: false };
327
+ }
328
+ } else {
329
+ for (const p of touched) files[p] = { touched: true, verified: false }; // red credits nothing
330
+ }
331
+
332
+ const ms = Date.now() - t0;
333
+ const memo = {
334
+ hash,
335
+ verify: { ...verify },
336
+ files: { ...files },
337
+ coverage,
338
+ cmd: verify.cmd,
339
+ exit,
340
+ ms,
341
+ tail: tailOf(tail),
342
+ ts: Date.now(),
343
+ };
344
+ return {
345
+ verify: memo.verify,
346
+ files: memo.files,
347
+ coverage,
348
+ cmd: verify.cmd,
349
+ exit,
350
+ ms,
351
+ tail: memo.tail,
352
+ memo,
353
+ memoHit: false,
354
+ };
355
+ }