great-cto 2.96.0 → 2.98.0

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,386 @@
1
+ // The rung above "a second reader agreed": the code that was reviewed is the
2
+ // code that shipped.
3
+ //
4
+ // Every rung of the evidence ladder below this one asks a question about the
5
+ // moment of review — did the stage report, does the artefact exist, does the
6
+ // check still pass, does a second reader agree. None of them says anything
7
+ // about what happened afterwards. `code-reviewer` returns APPROVED over a tree,
8
+ // senior-dev keeps editing, `gate:ship` is approved at 14:20 over one state and
9
+ // the push happens at 17:05 over another, and every rung still reads green
10
+ // because every rung is answering a question about the past.
11
+ //
12
+ // A receipt is a fingerprint of exactly what an agent saw, recorded in its
13
+ // verdict and comparable later. It proves identity and nothing else: that the
14
+ // bytes are the bytes. Whether the reviewer was right is the rung below.
15
+ //
16
+ // Why HEAD alone is not enough
17
+ // ----------------------------
18
+ // An agent almost always reviews a dirty tree — that is what reviewing a change
19
+ // means. Two entirely different working states share a HEAD, so a receipt built
20
+ // from the commit sha would match after any amount of uncommitted editing.
21
+ //
22
+ // Why a per-file map and not one hash
23
+ // -----------------------------------
24
+ // "Something changed since the review" sends a reader looking. "routes.mjs
25
+ // changed after the review that approved it" is the finding. The difference
26
+ // between those two is whether anyone acts on it.
27
+
28
+ import { execFileSync } from 'node:child_process';
29
+ import { createHash } from 'node:crypto';
30
+ import { readFileSync } from 'node:fs';
31
+ import * as fsModule from 'node:fs';
32
+ import { join } from 'node:path';
33
+
34
+ const sha = (s) => createHash('sha256').update(String(s)).digest('hex');
35
+
36
+ function git(args, cwd, { maxBuffer = 32 * 1024 * 1024 } = {}) {
37
+ try {
38
+ return execFileSync('git', args, { cwd, encoding: 'utf8', maxBuffer, stdio: ['ignore', 'pipe', 'ignore'] });
39
+ } catch { return null; }
40
+ }
41
+
42
+ /** A cap, so a receipt for a thousand-file change cannot bloat every verdict line. */
43
+ export const MAX_FILES = 200;
44
+
45
+ /**
46
+ * The state of the tree right now, as something comparable later.
47
+ *
48
+ * `base` names what the change is measured against — the merge-base with the
49
+ * default branch by default, which is "the change under review" rather than
50
+ * "everything that ever happened".
51
+ *
52
+ * Returns `null` outside a git repository rather than a fabricated receipt: a
53
+ * receipt that cannot be built must not look like one that matched.
54
+ */
55
+ export function treeReceipt(cwd = process.cwd(), { base = null, maxFiles = MAX_FILES } = {}) {
56
+ const head = git(['rev-parse', 'HEAD'], cwd)?.trim();
57
+ if (!head) return null;
58
+
59
+ // Uncommitted content, hashed rather than stored: the receipt has to fit on a
60
+ // verdict line, and the question it answers is "the same or not".
61
+ //
62
+ // Untracked files are part of that. `git diff HEAD` does not see them, so a
63
+ // receipt built from the diff alone called a tree clean while an agent was
64
+ // reviewing four brand-new modules — which is most of what a new feature is.
65
+ // Their names and content go into the hash; `--exclude-standard` keeps
66
+ // .gitignore'd build output and node_modules out of it.
67
+ const diff = git(['diff', 'HEAD'], cwd) ?? '';
68
+ const untracked = (git(['ls-files', '--others', '--exclude-standard'], cwd) ?? '')
69
+ .split('\n').map((s) => s.trim()).filter(Boolean);
70
+ const untrackedDigest = untracked.map((p) => `${p}:${fileDigest(cwd, p) ?? '?'}`).join('\n');
71
+ const dirty = (diff.trim() || untrackedDigest) ? sha(`${diff}\n--untracked--\n${untrackedDigest}`) : null;
72
+
73
+ // Which files the change touches. `--diff-filter=d` drops deletions: a file
74
+ // that is gone cannot have a blob sha, and its absence is already visible in
75
+ // the map as a missing key.
76
+ const ref = base || mergeBase(cwd) || 'HEAD';
77
+ const names = [
78
+ ...(git(['diff', '--name-only', '--diff-filter=d', ref], cwd) ?? '')
79
+ .split('\n').map((s) => s.trim()).filter(Boolean),
80
+ // A new file is part of the change under review, and is exactly the kind a
81
+ // reviewer reads most closely.
82
+ ...untracked,
83
+ ];
84
+
85
+ const files = {};
86
+ let truncated = false;
87
+ for (const p of names) {
88
+ if (Object.keys(files).length >= maxFiles) { truncated = true; break; }
89
+ const blob = fileDigest(cwd, p);
90
+ if (blob) files[p] = blob;
91
+ }
92
+
93
+ return { head, dirty, base: ref, files, ...(truncated ? { truncated: true } : {}) };
94
+ }
95
+
96
+ /**
97
+ * The content hash of a path AS IT IS ON DISK, not as it is in the index.
98
+ *
99
+ * `git rev-parse :path` reads the index, which is what was staged rather than
100
+ * what an agent read. `hash-object` on the working file is the thing the
101
+ * reviewer actually saw.
102
+ */
103
+ export function fileDigest(cwd, path) {
104
+ const out = git(['hash-object', '--', path], cwd);
105
+ return out ? out.trim() : null;
106
+ }
107
+
108
+ /** The fork point from the default branch, or null when there isn't one. */
109
+ export function mergeBase(cwd) {
110
+ for (const branch of ['origin/main', 'main', 'origin/master', 'master']) {
111
+ const b = git(['merge-base', 'HEAD', branch], cwd);
112
+ if (b?.trim()) return b.trim();
113
+ }
114
+ return null;
115
+ }
116
+
117
+ /**
118
+ * What changed between a recorded receipt and the state now.
119
+ *
120
+ * Three outcomes, and they are deliberately not two: "matches", "differs", and
121
+ * "cannot tell". A push with no receipt to compare is not the same as a push
122
+ * whose receipt matched, and collapsing them is the defect this whole ladder
123
+ * exists to remove.
124
+ */
125
+ export function compareReceipts(recorded, current, { digest = null, cwd = process.cwd() } = {}) {
126
+ if (!recorded) return { state: 'no-receipt', why: 'the approving verdict carries no receipt — nothing to compare against' };
127
+ if (!current) return { state: 'unreadable', why: 'the current tree state could not be read' };
128
+
129
+ const changed = [];
130
+ const added = [];
131
+ const removed = [];
132
+ const landed = [];
133
+ const before = recorded.files || {};
134
+ const now = current.files || {};
135
+ // Falling out of the CHANGE SET is not the same as being deleted.
136
+ //
137
+ // The file map is the diff against the merge-base, so the moment a reviewed
138
+ // change is committed and the base moves forward, every reviewed file drops
139
+ // out of it. The first version read that as `removed` and would therefore
140
+ // have blocked every push after a release, with the strongest wording it
141
+ // has, about files sitting right there on disk. A gate that fires on the
142
+ // ordinary case is a gate people route around.
143
+ const digestOf = digest || ((path) => fileDigest(cwd, path));
144
+
145
+ for (const [p, d] of Object.entries(before)) {
146
+ if (p in now) {
147
+ if (now[p] !== d) changed.push(p);
148
+ continue;
149
+ }
150
+ // Not in the current change set — ask the disk which of the three it is.
151
+ const onDisk = digestOf(p);
152
+ if (onDisk === null) removed.push(p); // genuinely gone
153
+ else if (onDisk === d) landed.push(p); // committed since; byte-identical to the review
154
+ else changed.push(p); // still here, and edited
155
+ }
156
+ for (const p of Object.keys(now)) if (!(p in before)) added.push(p);
157
+
158
+ // A file the review covered, edited since. This is the finding; the rest is
159
+ // context. `added` in particular is usually ordinary work continuing, not a
160
+ // review being bypassed, and reporting it as one is how a signal dies.
161
+ if (changed.length || removed.length) {
162
+ return { state: 'differs', changed, added, removed, landed,
163
+ why: `${changed.length + removed.length} reviewed file(s) changed after the approval` };
164
+ }
165
+ if (added.length) {
166
+ return { state: 'extended', changed, added, removed, landed,
167
+ why: `${added.length} file(s) were added after the approval; nothing reviewed was altered` };
168
+ }
169
+ const shipped = landed.length ? ` (${landed.length} committed since, unchanged)` : '';
170
+ return { state: 'matches', changed, added, removed, landed,
171
+ why: `every reviewed file is byte-identical to what was approved${shipped}` };
172
+ }
173
+
174
+ /** Lines a human can act on — the paths, not just the count. */
175
+ export function describeDrift(cmp, { max = 10 } = {}) {
176
+ if (!cmp) return '';
177
+ const lines = [cmp.why];
178
+ const show = (label, xs) => {
179
+ for (const p of (xs || []).slice(0, max)) lines.push(` ${label} ${p}`);
180
+ if ((xs || []).length > max) lines.push(` … and ${xs.length - max} more`);
181
+ };
182
+ show('changed:', cmp.changed);
183
+ show('removed:', cmp.removed);
184
+ if (cmp.state === 'extended') show('added: ', cmp.added);
185
+ return lines.join('\n');
186
+ }
187
+
188
+ /**
189
+ * The newest verdict that both APPROVED something and recorded what it saw.
190
+ *
191
+ * Only reviewing stages count. `architect` approving a design says nothing
192
+ * about which bytes shipped, and treating it as an approval of the code would
193
+ * make the check pass for the wrong reason — which is worse than not running.
194
+ */
195
+ export const APPROVING_AGENTS = Object.freeze(['code-reviewer', 'security-officer', 'qa-engineer']);
196
+ const APPROVING_VERDICTS = new Set(['APPROVED', 'PASS', 'PASSED']);
197
+
198
+ export function latestApproval(cwd = process.cwd(), { agents = APPROVING_AGENTS, read = readFileSync } = {}) {
199
+ let best = null;
200
+ for (const agent of agents) {
201
+ let text;
202
+ try {
203
+ text = read(join(cwd, '.great_cto', 'verdicts', `${agent}.log`), 'utf8');
204
+ } catch { continue; }
205
+ for (const line of String(text).split('\n')) {
206
+ if (!line.trim()) continue;
207
+ let rec;
208
+ try { rec = JSON.parse(line); } catch { continue; }
209
+ if (!APPROVING_VERDICTS.has(String(rec.verdict || '').toUpperCase())) continue;
210
+ if (!rec.receipt?.head) continue;
211
+ if (!best || String(rec.ts) > String(best.ts)) best = rec;
212
+ }
213
+ }
214
+ return best;
215
+ }
216
+
217
+ /**
218
+ * Where an operator's acceptance of a drifted state is recorded.
219
+ *
220
+ * Beside the verdicts rather than in them: a verdict is an agent's report of
221
+ * what it found, and this is a human's decision about what to do next. Mixing
222
+ * them would let a reader mistake one for the other, which is the whole thing
223
+ * receipts exist to prevent.
224
+ */
225
+ export const ACCEPT_PATH = '.great_cto/.receipt-accept';
226
+
227
+ /**
228
+ * A stable fingerprint of a tree state — what an acceptance is bound TO.
229
+ *
230
+ * `hashgate`'s formulation, which is better than the one we shipped: an approve
231
+ * button approves an intention, a hash approves a state. Accepting "yes, ship
232
+ * despite the drift" without naming WHICH state leaves an acceptance that
233
+ * survives the next edit — an expiring bypass wearing an approval's clothes.
234
+ */
235
+ export function receiptHash(receipt) {
236
+ if (!receipt) return null;
237
+ // The file map in a fixed order, plus the dirty digest: two trees hash alike
238
+ // exactly when every reviewed file is byte-identical and the uncommitted work
239
+ // is the same.
240
+ const files = Object.keys(receipt.files || {}).sort()
241
+ .map((p) => `${p}:${receipt.files[p]}`).join('\n');
242
+ return sha(`${receipt.head}\n${receipt.dirty ?? '-'}\n${files}`);
243
+ }
244
+
245
+ /**
246
+ * Record that a human accepted this exact state.
247
+ *
248
+ * Single-use by design: an acceptance authorises ONE push. An acceptance that
249
+ * outlives its push is a standing permission, and nobody asked for one.
250
+ */
251
+ export function writeAcceptance(cwd, { hash, why = '', at = Date.now() } = {}) {
252
+ const { writeFileSync, mkdirSync } = requireFs();
253
+ mkdirSync(join(cwd, '.great_cto'), { recursive: true });
254
+ writeFileSync(join(cwd, ACCEPT_PATH), JSON.stringify({ hash, why, at }) + '\n');
255
+ return { hash, why, at };
256
+ }
257
+
258
+ /**
259
+ * The pending acceptance, if it is for the state in front of us.
260
+ *
261
+ * Four answers, not two. "None recorded", "unreadable", "for a different
262
+ * state" and "valid" are different situations, and the third is the one worth
263
+ * naming out loud: it means the tree moved after a human looked at it.
264
+ */
265
+ export function readAcceptance(cwd, currentHash) {
266
+ const { readFileSync } = requireFs();
267
+ let raw;
268
+ try { raw = readFileSync(join(cwd, ACCEPT_PATH), 'utf8'); }
269
+ catch { return { valid: false, why: 'no acceptance recorded' }; }
270
+
271
+ let rec;
272
+ try { rec = JSON.parse(raw.trim()); }
273
+ catch { return { valid: false, unreadable: true, why: 'the acceptance record could not be parsed' }; }
274
+
275
+ if (!rec?.hash) return { valid: false, unreadable: true, why: 'the acceptance record names no state' };
276
+ if (rec.hash !== currentHash) {
277
+ return { valid: false, stale: true, rec,
278
+ why: 'the acceptance names a different state — the tree changed after it was accepted' };
279
+ }
280
+ return { valid: true, rec, why: `accepted at ${new Date(rec.at).toISOString()}` };
281
+ }
282
+
283
+ /** Consume it. One acceptance, one push. */
284
+ export function clearAcceptance(cwd) {
285
+ try { requireFs().rmSync(join(cwd, ACCEPT_PATH), { force: true }); return true; }
286
+ catch { return false; /* an acceptance we cannot clear is re-checked against the hash anyway */ }
287
+ }
288
+
289
+ function requireFs() {
290
+ // Imported lazily so the pure comparison functions above stay usable by
291
+ // callers that hand in their own strings and never touch a disk.
292
+ return fsModule;
293
+ }
294
+
295
+ // ── CLI ─────────────────────────────────────────────────────────────────────
296
+ //
297
+ // `--emit` prints a receipt for the current tree (used by log-verdict.sh).
298
+ // `--check <file>` compares a recorded receipt held in a file.
299
+ // `--verify` compares the newest approving verdict's receipt against now.
300
+
301
+ if (import.meta.url === `file://${process.argv[1]}`) {
302
+ const argv = process.argv.slice(2);
303
+ if (argv.includes('--emit')) {
304
+ const r = treeReceipt(process.cwd());
305
+ process.stdout.write(r ? JSON.stringify(r) : '');
306
+ process.exit(r ? 0 : 1);
307
+ }
308
+ if (argv.includes('--verify')) {
309
+ const cwd = process.cwd();
310
+ const approval = latestApproval(cwd);
311
+ const current = treeReceipt(cwd);
312
+ const cmp = compareReceipts(approval?.receipt ?? null, current);
313
+ if (cmp.state === 'no-receipt') {
314
+ // Not silence. A push with no approval to compare against and a push whose
315
+ // receipt matched are different facts, and only one of them is evidence.
316
+ console.log('receipt: no approving verdict carries a receipt — nothing was verified');
317
+ process.exit(0);
318
+ }
319
+ const who = approval ? `${approval.agent} ${approval.verdict} at ${approval.ts}` : 'an approval';
320
+ console.log(`receipt: against ${who}`);
321
+ console.log(describeDrift(cmp).split('\n').map((l) => ` ${l}`).join('\n'));
322
+
323
+ if (cmp.state !== 'differs') process.exit(0);
324
+
325
+ // Drift, but a human may already have looked at exactly this state.
326
+ const hash = receiptHash(current);
327
+ const acc = readAcceptance(cwd, hash);
328
+ if (acc.valid) {
329
+ console.log(` accepted by the operator for this exact state (${acc.why})`);
330
+ // Consumed here rather than by the caller: whoever asked the question is
331
+ // the one acting on the answer, and an acceptance that survives its own
332
+ // check is a standing permission.
333
+ clearAcceptance(cwd);
334
+ process.exit(0);
335
+ }
336
+ if (acc.stale) console.log(` ${acc.why} — accept again if this state is fine`);
337
+ console.log(` to accept this state: node scripts/lib/receipt.mjs --accept`);
338
+ process.exit(1);
339
+ }
340
+
341
+ // `--accept`: a human says this drifted state is fine to ship.
342
+ //
343
+ // Requires a controlling terminal, and that is the substance rather than a
344
+ // nicety. Our hooks and agents run inside the operator's own shell, so
345
+ // without this "the operator accepted" would mean "something in the agent's
346
+ // session ran a command". Enforcement must not depend on the agent's good
347
+ // behaviour — an agent asked to approve its own work will comply.
348
+ if (argv.includes('--accept')) {
349
+ const cwd = process.cwd();
350
+ const approval = latestApproval(cwd);
351
+ const current = treeReceipt(cwd);
352
+ const cmp = compareReceipts(approval?.receipt ?? null, current);
353
+ if (cmp.state !== 'differs') {
354
+ console.log(`receipt: nothing to accept — ${cmp.why}`);
355
+ process.exit(0);
356
+ }
357
+ console.log(describeDrift(cmp));
358
+ const hash = receiptHash(current);
359
+ console.log(`\nstate: ${hash.slice(0, 16)}…`);
360
+
361
+ if (!process.stdin.isTTY) {
362
+ console.error('\nreceipt: --accept needs a terminal. Run it yourself, in your own shell —');
363
+ console.error('an acceptance from inside an agent session is the agent approving its own work.');
364
+ process.exit(2);
365
+ }
366
+ const { createInterface } = await import('node:readline');
367
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
368
+ const answer = await new Promise((res) => rl.question('\nShip this state anyway? [y/N] ', (a) => { rl.close(); res(a); }));
369
+ if (!/^y(es)?$/i.test(answer.trim())) { console.log('not accepted — nothing recorded.'); process.exit(1); }
370
+
371
+ writeAcceptance(cwd, { hash, why: `drift accepted over ${cmp.changed.length} changed file(s)` });
372
+ console.log('accepted. This authorises ONE push of this exact state; any further edit voids it.');
373
+ process.exit(0);
374
+ }
375
+
376
+ const i = argv.indexOf('--check');
377
+ if (i > -1) {
378
+ const { readFileSync } = await import('node:fs');
379
+ let recorded = null;
380
+ try { recorded = JSON.parse(readFileSync(argv[i + 1], 'utf8')); } catch { /* absent */ }
381
+ const cmp = compareReceipts(recorded, treeReceipt(process.cwd()));
382
+ console.log(describeDrift(cmp));
383
+ process.exit(cmp.state === 'differs' ? 1 : 0);
384
+ }
385
+ console.log(JSON.stringify(treeReceipt(process.cwd()), null, 2));
386
+ }
@@ -0,0 +1,147 @@
1
+ // The record a gate leaves when it stands down instead of waiting.
2
+ //
3
+ // Six days ago `gate-tiering: evidence` started dropping gates to notify-only.
4
+ // `pipelinePosition` returns `ready-to-dispatch` with the gate named in
5
+ // `notified`, and the pipeline proceeds. The board's inbox is assembled from
6
+ // beads and verdicts, so the intent was that the entry still reaches a human.
7
+ //
8
+ // Nothing guaranteed it. If that write failed the stage proceeded anyway and the
9
+ // entry never appeared — and a gate that stood down is then indistinguishable
10
+ // from a gate that stood down and told nobody. That is precisely the defect the
11
+ // tiering feature was built to avoid reintroducing, and it shipped inside it.
12
+ //
13
+ // The invariant, taken from `deepseek-ai/deepseek-harness`, whose approval model
14
+ // is otherwise nothing like ours: **a decision that could not be logged is
15
+ // refused.** Their `approval/asked` + `approval/decided` pair is atomic with the
16
+ // outcome. Ours is one append that must succeed before the pipeline is told it
17
+ // may proceed.
18
+ //
19
+ // Append-only, beside the verdicts, in the shape they use. A notification is an
20
+ // event: overwriting state would lose the sequence, and the sequence is the
21
+ // audit.
22
+ //
23
+ // Fail-closed everywhere. Every path that cannot produce a durable record
24
+ // returns `recorded: false`, and the caller's contract is that this restores the
25
+ // gate. There is no path here that returns success on doubt.
26
+
27
+ import { openSync, writeSync, fsyncSync, closeSync, readFileSync, existsSync, mkdirSync } from 'node:fs';
28
+ import { join, dirname } from 'node:path';
29
+
30
+ export const STAND_DOWN_PATH = join('.great_cto', 'stand-downs.jsonl');
31
+
32
+ /**
33
+ * Write one stand-down record, durably.
34
+ *
35
+ * `fsync` rather than a bare append: `appendFileSync` returning means the bytes
36
+ * reached the OS, not the disk. For a record whose entire purpose is to survive
37
+ * so a human can audit a decision nobody was asked about, "probably written" is
38
+ * the same failure in slower motion.
39
+ *
40
+ * @param {string} cwd
41
+ * @param {{gate: string, agent: string, tier: string, evidence: string, at?: number}} rec
42
+ * @returns {{recorded: boolean, why: string, path?: string}}
43
+ */
44
+ export function recordStandDown(cwd, { gate, agent, tier, evidence, at = null } = {}) {
45
+ // A record that cannot name what stood down is not a record. Refusing here
46
+ // rather than writing a partial line keeps the file's meaning intact: every
47
+ // line in it identifies a specific gate and a specific agent.
48
+ if (!gate || !agent) {
49
+ return { recorded: false, why: 'a stand-down record must name both the gate and the agent' };
50
+ }
51
+ if (at === null) {
52
+ // Injected, never read from the clock here — the callers in this repository
53
+ // all resolve one `now` at startup so a run is reproducible, and a module
54
+ // that reaches for Date.now() quietly opts out of that.
55
+ return { recorded: false, why: 'no timestamp supplied — the record must say when' };
56
+ }
57
+
58
+ const path = join(cwd, STAND_DOWN_PATH);
59
+ let fd = null;
60
+ try {
61
+ mkdirSync(dirname(path), { recursive: true });
62
+ const line = JSON.stringify({
63
+ v: 1,
64
+ ts: new Date(at).toISOString(),
65
+ gate,
66
+ agent,
67
+ tier: tier || 'unknown',
68
+ evidence: evidence || '(none recorded)',
69
+ }) + '\n';
70
+ fd = openSync(path, 'a');
71
+ writeSync(fd, line);
72
+ fsyncSync(fd);
73
+ return { recorded: true, why: `recorded gate:${gate} standing down for ${agent}`, path };
74
+ } catch (e) {
75
+ // The real error, not a guess about it. A catch that invents its cause is
76
+ // how a ReferenceError once reported itself as a missing build.
77
+ return { recorded: false, why: `could not write ${STAND_DOWN_PATH}: ${String(e?.message || e)}` };
78
+ } finally {
79
+ if (fd !== null) { try { closeSync(fd); } catch { /* the record is already fsynced */ } }
80
+ }
81
+ }
82
+
83
+ /**
84
+ * A recorder bound to one project, in the shape `pipelinePosition` injects.
85
+ *
86
+ * The position lib is forbidden to write (ARCH-pipeline-position S2, with a test
87
+ * asserting it performs no fs-write). So the write lives here and is handed in,
88
+ * which also means the default in that lib can be "no recorder", and no recorder
89
+ * means no stand-down.
90
+ */
91
+ export function standDownRecorder(cwd, { at = null } = {}) {
92
+ return (rec) => recordStandDown(cwd, { ...rec, at: rec?.at ?? at });
93
+ }
94
+
95
+ /**
96
+ * The records, newest last.
97
+ *
98
+ * A file that cannot be read returns `null` rather than `[]`. "I could not look"
99
+ * and "I looked and there were none" are different answers, and an empty array
100
+ * for both is the shape this module exists to remove.
101
+ *
102
+ * @returns {Array<object>|null}
103
+ */
104
+ export function readStandDowns(cwd, { limit = 0 } = {}) {
105
+ const path = join(cwd, STAND_DOWN_PATH);
106
+ if (!existsSync(path)) return [];
107
+ let text;
108
+ try { text = readFileSync(path, 'utf8'); }
109
+ catch { return null; }
110
+ const out = [];
111
+ for (const line of text.split('\n')) {
112
+ if (!line.trim()) continue;
113
+ try { out.push(JSON.parse(line)); } catch { /* a torn line is not a record */ }
114
+ }
115
+ return limit > 0 ? out.slice(-limit) : out;
116
+ }
117
+
118
+ // ── CLI ─────────────────────────────────────────────────────────────────────
119
+ //
120
+ // node scripts/lib/stand-down.mjs [--limit N] [--json]
121
+ //
122
+ // "Which gates stopped asking me, and on what evidence" — the question the
123
+ // board's inbox answers for gates that waited, and nothing answered for gates
124
+ // that did not.
125
+
126
+ if (import.meta.url === `file://${process.argv[1]}`) {
127
+ const argv = process.argv.slice(2);
128
+ const li = argv.indexOf('--limit');
129
+ const limit = li !== -1 ? Number(argv[li + 1]) : 0;
130
+
131
+ const rows = readStandDowns(process.cwd(), { limit: Number.isFinite(limit) ? limit : 0 });
132
+ if (rows === null) {
133
+ console.error(`stand-down: ${STAND_DOWN_PATH} exists but could not be read — that is not "no stand-downs"`);
134
+ process.exit(2);
135
+ }
136
+ if (argv.includes('--json')) { console.log(JSON.stringify(rows, null, 2)); process.exit(0); }
137
+
138
+ if (!rows.length) {
139
+ console.log('stand-down: no gate has stood down on this project.');
140
+ process.exit(0);
141
+ }
142
+ console.log(`stand-down: ${rows.length} gate(s) proceeded without being asked\n`);
143
+ for (const r of rows) {
144
+ console.log(` ${r.ts} gate:${r.gate} ${r.agent} [${r.tier}]`);
145
+ console.log(` ${r.evidence}`);
146
+ }
147
+ }