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.
- package/.claude-plugin/plugin.json +1 -1
- package/bin/baton-pane.mjs +62 -9
- package/bin/baton.mjs +8 -2
- package/bin/connect.mjs +44 -4
- package/bin/crew/open.mjs +54 -5
- package/bin/crew-runner.mjs +98 -12
- package/bin/doctor.mjs +61 -1
- package/bin/drill-report.mjs +80 -0
- package/bin/drill-report.test.mjs +146 -0
- package/bin/drill-seams.mjs +157 -0
- package/bin/drill-surface.mjs +157 -54
- package/bin/duty.mjs +68 -1
- package/bin/write-handoff.mjs +7 -1
- package/deploy/setup.sh +38 -0
- package/hooks/lib/handoff.mjs +158 -2
- package/hooks/sessionstart.mjs +10 -0
- package/lib/duty-nudges.mjs +48 -10
- package/lib/state/apply.mjs +170 -0
- package/lib/state/derive.mjs +229 -0
- package/lib/state/gate.mjs +355 -0
- package/lib/state/migrate.mjs +126 -0
- package/lib/state/promote.mjs +122 -0
- package/lib/state/schema.mjs +216 -0
- package/lib/state/store.mjs +442 -0
- package/lib/state/validate.mjs +231 -0
- package/package.json +1 -1
|
@@ -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
|
+
}
|