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.
- package/board/.claude-plugin/plugin.json +5 -1
- package/board/packages/board/lib/data-readers.mjs +53 -8
- package/board/packages/board/lib/docs.mjs +39 -0
- package/board/packages/board/lib/routes.mjs +107 -0
- package/board/packages/board/public/index.html +438 -18
- package/board/scripts/lib/freshness.mjs +175 -0
- package/board/scripts/lib/gate-tier.mjs +279 -0
- package/board/scripts/lib/pipeline-wake.mjs +120 -0
- package/board/scripts/lib/receipt.mjs +386 -0
- package/board/scripts/lib/stand-down.mjs +147 -0
- package/board/scripts/lib/system-map.mjs +206 -0
- package/dist/detect.js +1 -0
- package/package.json +1 -1
|
@@ -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
|
+
}
|