great-cto 3.75.4 → 3.75.6
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/assets/statusline.mjs +7 -4
- package/board/.claude-plugin/plugin.json +1 -1
- package/board/agents/architect.md +19 -1
- package/board/agents/code-reviewer.md +50 -42
- package/board/agents/design-advisor.md +13 -2
- package/board/agents/devops.md +40 -1
- package/board/agents/qa-engineer.md +33 -0
- package/board/agents/security-officer.md +15 -7
- package/board/agents/senior-dev.md +16 -0
- package/board/packages/board/lib/bd-read-worker.mjs +2 -1
- package/board/packages/board/lib/receipt-read-worker.mjs +2 -1
- package/board/scripts/hooks/auto-attach-reviewers.mjs +2 -2
- package/board/scripts/lib/affected-tests.mjs +2 -2
- package/board/scripts/lib/agent-events.mjs +2 -1
- package/board/scripts/lib/agent-usage.mjs +2 -1
- package/board/scripts/lib/approval-level.mjs +3 -1
- package/board/scripts/lib/board-watch.mjs +2 -2
- package/board/scripts/lib/change-tier.mjs +2 -1
- package/board/scripts/lib/cost-meter.mjs +2 -2
- package/board/scripts/lib/cross-model-review.mjs +2 -2
- package/board/scripts/lib/dag-metric.mjs +2 -1
- package/board/scripts/lib/gate-plan.mjs +2 -2
- package/board/scripts/lib/gate-tier.mjs +2 -1
- package/board/scripts/lib/is-main.mjs +24 -0
- package/board/scripts/lib/judge-model.mjs +2 -1
- package/board/scripts/lib/pipeline-health.mjs +2 -1
- package/board/scripts/lib/receipt.mjs +138 -19
- package/board/scripts/lib/second-opinion.mjs +2 -1
- package/board/scripts/lib/session-usage.mjs +2 -1
- package/board/scripts/lib/stack-capabilities.mjs +3 -1
- package/board/scripts/lib/stand-down.mjs +2 -1
- package/board/scripts/lib/system-map.mjs +2 -1
- package/board/scripts/lib/verdict-record.mjs +15 -3
- package/board/scripts/lib/verdict-seal.mjs +95 -0
- package/board/scripts/skill-lint.mjs +2 -1
- package/package.json +1 -1
- package/postinstall.mjs +7 -2
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
import { readFileSync } from 'node:fs';
|
|
18
18
|
import { homedir } from 'node:os';
|
|
19
19
|
import { join } from 'node:path';
|
|
20
|
+
import { isMainModule } from './is-main.mjs';
|
|
20
21
|
|
|
21
22
|
/**
|
|
22
23
|
* Default list prices, USD per 1M tokens.
|
|
@@ -281,6 +282,5 @@ function main(argv) {
|
|
|
281
282
|
process.stdout.write(String(round4(cost)));
|
|
282
283
|
}
|
|
283
284
|
|
|
284
|
-
|
|
285
|
-
const isMain = process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1];
|
|
285
|
+
const isMain = isMainModule(import.meta.url);
|
|
286
286
|
if (isMain) main(process.argv.slice(2));
|
|
@@ -14,7 +14,6 @@
|
|
|
14
14
|
// GREAT_CTO_CROSS_REVIEW_MODEL=google/gemini-2.5-pro node ... --diff -
|
|
15
15
|
|
|
16
16
|
import { readFileSync, lstatSync, openSync, closeSync, fstatSync, readSync, constants } from 'node:fs';
|
|
17
|
-
import { fileURLToPath } from 'node:url';
|
|
18
17
|
import { execFileSync } from 'node:child_process';
|
|
19
18
|
import { costForUsage, round4, resolvePrice } from './cost-meter.mjs';
|
|
20
19
|
import { resolveSecondOpinion, codexReview } from './second-opinion.mjs';
|
|
@@ -22,6 +21,7 @@ import { principalError } from './provider-exhaustion.mjs';
|
|
|
22
21
|
import { existsSync, appendFileSync, mkdirSync } from 'node:fs';
|
|
23
22
|
import { join, resolve, parse, sep } from 'node:path';
|
|
24
23
|
import { reviewInputBinding, parseLegacyReview } from './review-contract.mjs';
|
|
24
|
+
import { isMainModule } from './is-main.mjs';
|
|
25
25
|
|
|
26
26
|
/**
|
|
27
27
|
* Exit codes. The first version had two: 0 for PASS and 1 for everything else —
|
|
@@ -333,5 +333,5 @@ async function main(argv) {
|
|
|
333
333
|
process.exit(verdict === 'BLOCK' ? EXIT.BLOCK : EXIT.PASS);
|
|
334
334
|
}
|
|
335
335
|
|
|
336
|
-
const isMain =
|
|
336
|
+
const isMain = isMainModule(import.meta.url);
|
|
337
337
|
if (isMain) main(process.argv.slice(2)).catch(e => { console.error('FATAL:', e.message); process.exit(EXIT.SKIPPED); });
|
|
@@ -32,6 +32,7 @@
|
|
|
32
32
|
*/
|
|
33
33
|
|
|
34
34
|
import { createHash } from 'node:crypto';
|
|
35
|
+
import { isMainModule } from './is-main.mjs';
|
|
35
36
|
|
|
36
37
|
const isLeafScore = (v) => typeof v === 'number' && Number.isFinite(v) && v >= 0 && v <= 1;
|
|
37
38
|
|
|
@@ -292,6 +293,6 @@ async function main(argv) {
|
|
|
292
293
|
return result.score === null ? 1 : 0;
|
|
293
294
|
}
|
|
294
295
|
|
|
295
|
-
if (import.meta.url
|
|
296
|
+
if (isMainModule(import.meta.url)) {
|
|
296
297
|
main(process.argv.slice(2)).then((c) => { process.exitCode = c; });
|
|
297
298
|
}
|
|
@@ -20,13 +20,13 @@
|
|
|
20
20
|
|
|
21
21
|
import { execFileSync } from 'node:child_process';
|
|
22
22
|
import { readFileSync } from 'node:fs';
|
|
23
|
-
import { fileURLToPath } from 'node:url';
|
|
24
23
|
|
|
25
24
|
import { classify } from './change-tier.mjs';
|
|
26
25
|
import { selectJudgeModel } from './judge-model.mjs';
|
|
27
26
|
// Generated from the canonical CLI policy, but shipped in Git too: marketplace
|
|
28
27
|
// installs do not contain the gitignored CLI build.
|
|
29
28
|
import { effectiveGates } from './gate-policy.mjs';
|
|
29
|
+
import { isMainModule } from './is-main.mjs';
|
|
30
30
|
|
|
31
31
|
/** Parse archetype + project_size out of a .great_cto/PROJECT.md body. */
|
|
32
32
|
export function parseProject(text) {
|
|
@@ -90,7 +90,7 @@ function readProject() {
|
|
|
90
90
|
return { archetype: 'greenfield', size: 'medium' };
|
|
91
91
|
}
|
|
92
92
|
|
|
93
|
-
if (import.meta.url
|
|
93
|
+
if (isMainModule(import.meta.url)) {
|
|
94
94
|
const argv = process.argv.slice(2);
|
|
95
95
|
const opt = (name) => { const i = argv.indexOf(name); return i >= 0 ? argv[i + 1] : null; };
|
|
96
96
|
const json = argv.includes('--json');
|
|
@@ -41,6 +41,7 @@
|
|
|
41
41
|
* the machine, costs money, or cannot be undone needs a human in the loop, and
|
|
42
42
|
* "it scored well" is not the same as "a human decided".
|
|
43
43
|
*/
|
|
44
|
+
import { isMainModule } from './is-main.mjs';
|
|
44
45
|
export const CLASS_A = Object.freeze(['devops', 'infra-provisioner']);
|
|
45
46
|
|
|
46
47
|
/** The run shape a tier decision may be based on. Anything else is not evidence. */
|
|
@@ -243,7 +244,7 @@ export async function notifyOnlyForProject(cwd = process.cwd()) {
|
|
|
243
244
|
// Prints who qualifies and, for everyone else, the specific reason — because
|
|
244
245
|
// "inconclusive" and "fixture-inlined" are different work.
|
|
245
246
|
|
|
246
|
-
if (import.meta.url
|
|
247
|
+
if (isMainModule(import.meta.url)) {
|
|
247
248
|
const { readFileSync, existsSync } = await import('node:fs');
|
|
248
249
|
const { join, dirname } = await import('node:path');
|
|
249
250
|
const { fileURLToPath } = await import('node:url');
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// scripts/lib/is-main.mjs — was this module started as the program, or imported?
|
|
2
|
+
//
|
|
3
|
+
// great_cto-hu2l. The scripts used to answer that by comparing process.argv[1]
|
|
4
|
+
// with import.meta.url as strings. Node resolves the module URL through
|
|
5
|
+
// symlinks and leaves argv[1] as typed, so the two disagree whenever the path
|
|
6
|
+
// crosses a link: macOS /var → /private/var and /tmp → /private/tmp, a
|
|
7
|
+
// symlinked home, the plugin cache links under ~/.claude/plugins/cache. The CLI
|
|
8
|
+
// then ran nothing and exited 0, and a caller that checks only the exit code
|
|
9
|
+
// took that for success.
|
|
10
|
+
//
|
|
11
|
+
// Both sides go through realpath. A path that cannot be resolved is not this
|
|
12
|
+
// module, so a failed realpath answers false instead of guessing from strings.
|
|
13
|
+
// tests/main-guard-scan.test.mjs refuses a new guard that skips this.
|
|
14
|
+
import { realpathSync } from 'node:fs';
|
|
15
|
+
import { fileURLToPath } from 'node:url';
|
|
16
|
+
|
|
17
|
+
export function isMainModule(importMetaUrl, argv1 = process.argv[1]) {
|
|
18
|
+
if (!argv1 || !importMetaUrl) return false;
|
|
19
|
+
try {
|
|
20
|
+
return realpathSync(argv1) === realpathSync(fileURLToPath(importMetaUrl));
|
|
21
|
+
} catch {
|
|
22
|
+
return false;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
//
|
|
12
12
|
// Pairs with change-tier.mjs (the tier) + gate-plan.mjs (per-change plan) + ADR-004.
|
|
13
13
|
|
|
14
|
+
import { isMainModule } from './is-main.mjs';
|
|
14
15
|
export const DEFAULT_FRONTIER_JUDGE = 'claude-opus-5';
|
|
15
16
|
export const DEFAULT_CHEAP_JUDGE = 'claude-haiku-4-5';
|
|
16
17
|
|
|
@@ -36,7 +37,7 @@ export function selectJudgeModel(changeTier, opts = {}) {
|
|
|
36
37
|
}
|
|
37
38
|
|
|
38
39
|
// CLI: node scripts/lib/judge-model.mjs T1 → prints the model + reason
|
|
39
|
-
if (import.meta.url
|
|
40
|
+
if (isMainModule(import.meta.url)) {
|
|
40
41
|
const r = selectJudgeModel(process.argv[2]);
|
|
41
42
|
process.stdout.write(`${r.tier} model=${r.model} human=${r.human}\n ${r.reason}\n`);
|
|
42
43
|
}
|
|
@@ -21,6 +21,7 @@ import { existsSync, readFileSync, readdirSync } from 'node:fs';
|
|
|
21
21
|
import { join, dirname } from 'node:path';
|
|
22
22
|
import { fileURLToPath } from 'node:url';
|
|
23
23
|
import os from 'node:os';
|
|
24
|
+
import { isMainModule } from './is-main.mjs';
|
|
24
25
|
|
|
25
26
|
const REPO = join(dirname(fileURLToPath(import.meta.url)), '..', '..');
|
|
26
27
|
|
|
@@ -105,7 +106,7 @@ export function auditPipelineHealth({ registry = join(os.homedir(), '.great_cto'
|
|
|
105
106
|
//
|
|
106
107
|
// node scripts/lib/pipeline-health.mjs [--strict] [--json]
|
|
107
108
|
|
|
108
|
-
if (import.meta.url
|
|
109
|
+
if (isMainModule(import.meta.url)) {
|
|
109
110
|
const r = auditPipelineHealth();
|
|
110
111
|
if (process.argv.includes('--json')) {
|
|
111
112
|
console.log(JSON.stringify(r, null, 2));
|
|
@@ -30,7 +30,8 @@ import { createHash } from 'node:crypto';
|
|
|
30
30
|
import { readFileSync } from 'node:fs';
|
|
31
31
|
import * as fsModule from 'node:fs';
|
|
32
32
|
import { dirname, join, resolve } from 'node:path';
|
|
33
|
-
import {
|
|
33
|
+
import { isMainModule } from './is-main.mjs';
|
|
34
|
+
import { checkVerdictSeal, checkAcceptanceSeal, sealAcceptance, loadSealKey } from './verdict-seal.mjs';
|
|
34
35
|
|
|
35
36
|
const sha = (s) => createHash('sha256').update(String(s)).digest('hex');
|
|
36
37
|
|
|
@@ -196,6 +197,12 @@ export function treeReceipt(cwd = process.cwd(), { base = null, maxFiles = MAX_F
|
|
|
196
197
|
}
|
|
197
198
|
const changedOutput = git(['diff', '--name-only', '--diff-filter=d', ref, '--', ':/', ...pathspecExclude], cwd);
|
|
198
199
|
if (changedOutput === null) return null;
|
|
200
|
+
// Deletions, kept apart: a file deleted after an approval changes what ships, and
|
|
201
|
+
// the file map — which holds content digests — cannot hold a file that is gone.
|
|
202
|
+
const deletedOutput = git(['diff', '--name-only', '--diff-filter=D', ref, '--', ':/', ...pathspecExclude], cwd);
|
|
203
|
+
if (deletedOutput === null) return null;
|
|
204
|
+
const allDeleted = deletedOutput.split('\n').map((x) => x.trim()).filter(Boolean).sort();
|
|
205
|
+
const deleted = allDeleted.slice(0, maxFiles);
|
|
199
206
|
const names = [
|
|
200
207
|
...changedOutput
|
|
201
208
|
.split('\n').map((s) => s.trim()).filter(Boolean),
|
|
@@ -213,7 +220,9 @@ export function treeReceipt(cwd = process.cwd(), { base = null, maxFiles = MAX_F
|
|
|
213
220
|
files[p] = blob;
|
|
214
221
|
}
|
|
215
222
|
|
|
216
|
-
|
|
223
|
+
// A cut deletion list is a partial receipt like a cut file map: say so.
|
|
224
|
+
if (allDeleted.length > maxFiles) truncated = true;
|
|
225
|
+
return { head, dirty, base: ref, base_from: baseFrom, files, deleted, ...(truncated ? { truncated: true } : {}) };
|
|
217
226
|
}
|
|
218
227
|
|
|
219
228
|
/**
|
|
@@ -289,6 +298,33 @@ export function mergeBase(cwd) {
|
|
|
289
298
|
* whose receipt matched, and collapsing them is the defect this whole ladder
|
|
290
299
|
* exists to remove.
|
|
291
300
|
*/
|
|
301
|
+
// Inside .great_cto/, only what DECIDES behaviour is compared: PROJECT.md (project_size
|
|
302
|
+
// and the tier, which decide the stages a deploy checks), exceptions/ (signed
|
|
303
|
+
// overrides), configuration files (*.toml, *.yml — security-allowlist.yml is one),
|
|
304
|
+
// security-signals.log (it raises the tier from baseline), slo-budget-current.md
|
|
305
|
+
// (an exhausted budget blocks a deploy) and env.sh (sourced into the deploy shell).
|
|
306
|
+
// Deleting or editing any of them after an approval is drift.
|
|
307
|
+
// Everything else there is what the pipeline writes while it runs — verdicts, logs,
|
|
308
|
+
// lessons, the review marker, handoffs, an acceptance — and in a project that does
|
|
309
|
+
// not gitignore .great_cto/ (init does not) each of those, written AFTER an approval,
|
|
310
|
+
// made the approval drift from itself, so a deploy could never pass.
|
|
311
|
+
//
|
|
312
|
+
// A list of what counts, not of what to ignore. The first fix listed the writers and
|
|
313
|
+
// missed three (review-marker.json, .last-auto-learn, the learner's file); the writers grow
|
|
314
|
+
// with every hook, the configuration does not.
|
|
315
|
+
const COUNTED_STATE = /^(PROJECT\.md|exceptions(\/|$)|security-signals\.log$|slo-budget-current\.md$|env\.sh$|[^/]+\.(toml|ya?ml)$)/;
|
|
316
|
+
export function isPipelineOutput(p) {
|
|
317
|
+
if (!p.startsWith('.great_cto/')) return false;
|
|
318
|
+
return !COUNTED_STATE.test(p.slice('.great_cto/'.length));
|
|
319
|
+
}
|
|
320
|
+
/** The top-level entries of .great_cto/ that are pipeline output, as paths to exclude. */
|
|
321
|
+
export function pipelineOutputPaths(cwd) {
|
|
322
|
+
let names;
|
|
323
|
+
try { names = requireFs().readdirSync(join(cwd, '.great_cto')); } catch { return []; }
|
|
324
|
+
return names.filter((n) => !n.includes(':') && isPipelineOutput(`.great_cto/${n}`)).map((n) => `.great_cto/${n}`);
|
|
325
|
+
}
|
|
326
|
+
const codeOnly = (files) => Object.fromEntries(Object.entries(files || {}).filter(([p]) => !isPipelineOutput(p)));
|
|
327
|
+
|
|
292
328
|
export function compareReceipts(recorded, current, { digest = null, cwd = process.cwd() } = {}) {
|
|
293
329
|
if (!recorded) return { state: 'no-receipt', why: 'the approving verdict carries no receipt — nothing to compare against' };
|
|
294
330
|
if (!current) return { state: 'unreadable', why: 'the current tree state could not be read' };
|
|
@@ -297,8 +333,8 @@ export function compareReceipts(recorded, current, { digest = null, cwd = proces
|
|
|
297
333
|
const added = [];
|
|
298
334
|
const removed = [];
|
|
299
335
|
const landed = [];
|
|
300
|
-
const before = recorded.files
|
|
301
|
-
const now = current.files
|
|
336
|
+
const before = codeOnly(recorded.files);
|
|
337
|
+
const now = codeOnly(current.files);
|
|
302
338
|
// Falling out of the CHANGE SET is not the same as being deleted.
|
|
303
339
|
//
|
|
304
340
|
// The file map is the diff against the merge-base, so the moment a reviewed
|
|
@@ -321,6 +357,12 @@ export function compareReceipts(recorded, current, { digest = null, cwd = proces
|
|
|
321
357
|
else changed.push(p); // still here, and edited
|
|
322
358
|
}
|
|
323
359
|
for (const p of Object.keys(now)) if (!(p in before)) added.push(p);
|
|
360
|
+
// Deleted since the approval. Only when the recorded receipt says what was deleted
|
|
361
|
+
// then; one from before this field cannot tell a new deletion from the change's own.
|
|
362
|
+
if (Array.isArray(recorded.deleted)) {
|
|
363
|
+
const was = new Set(recorded.deleted);
|
|
364
|
+
for (const p of current.deleted || []) if (!was.has(p) && !isPipelineOutput(p) && !removed.includes(p)) removed.push(p);
|
|
365
|
+
}
|
|
324
366
|
|
|
325
367
|
// A file the review covered, edited since. This is the finding; the rest is
|
|
326
368
|
// context. `added` in particular is usually ordinary work continuing, not a
|
|
@@ -386,6 +428,9 @@ export function latestApproval(cwd = process.cwd(), { agents = APPROVING_AGENTS,
|
|
|
386
428
|
try { rec = JSON.parse(line); } catch { continue; }
|
|
387
429
|
if (!APPROVING_VERDICTS.has(String(rec.verdict || '').toUpperCase())) continue;
|
|
388
430
|
if (!rec.receipt?.head) continue;
|
|
431
|
+
// A line belongs to the log it sits in. A code-reviewer line copied into
|
|
432
|
+
// qa-engineer.log carries a valid seal, for the wrong stage (review of 3.75.6).
|
|
433
|
+
if (rec.agent !== agent) continue;
|
|
389
434
|
if (!best || String(rec.ts) > String(best.ts)) best = rec;
|
|
390
435
|
}
|
|
391
436
|
}
|
|
@@ -426,11 +471,13 @@ export function receiptHash(receipt) {
|
|
|
426
471
|
* Single-use by design: an acceptance authorises ONE push. An acceptance that
|
|
427
472
|
* outlives its push is a standing permission, and nobody asked for one.
|
|
428
473
|
*/
|
|
429
|
-
export function writeAcceptance(cwd, { hash, why = '', at = Date.now() } = {}) {
|
|
474
|
+
export function writeAcceptance(cwd, { hash, why = '', at = Date.now(), agent = null, seal = sealAcceptance } = {}) {
|
|
430
475
|
const { writeFileSync, mkdirSync } = requireFs();
|
|
431
476
|
mkdirSync(join(cwd, '.great_cto'), { recursive: true });
|
|
432
|
-
|
|
433
|
-
|
|
477
|
+
const rec = { hash, why, at, ...(agent ? { agent } : {}) };
|
|
478
|
+
const s = seal ? seal(rec) : null;
|
|
479
|
+
writeFileSync(join(cwd, ACCEPT_PATH), JSON.stringify({ ...rec, ...(s ? { seal: s } : {}) }) + '\n');
|
|
480
|
+
return { hash, why, at, agent };
|
|
434
481
|
}
|
|
435
482
|
|
|
436
483
|
/**
|
|
@@ -440,7 +487,7 @@ export function writeAcceptance(cwd, { hash, why = '', at = Date.now() } = {}) {
|
|
|
440
487
|
* state" and "valid" are different situations, and the third is the one worth
|
|
441
488
|
* naming out loud: it means the tree moved after a human looked at it.
|
|
442
489
|
*/
|
|
443
|
-
export function readAcceptance(cwd, currentHash) {
|
|
490
|
+
export function readAcceptance(cwd, currentHash, { agent = null } = {}) {
|
|
444
491
|
const { readFileSync } = requireFs();
|
|
445
492
|
let raw;
|
|
446
493
|
try { raw = readFileSync(join(cwd, ACCEPT_PATH), 'utf8'); }
|
|
@@ -451,10 +498,25 @@ export function readAcceptance(cwd, currentHash) {
|
|
|
451
498
|
catch { return { valid: false, unreadable: true, why: 'the acceptance record could not be parsed' }; }
|
|
452
499
|
|
|
453
500
|
if (!rec?.hash) return { valid: false, unreadable: true, why: 'the acceptance record names no state' };
|
|
501
|
+
// great_cto-dv3b: an acceptance not written by `--accept` is not one. A hand-
|
|
502
|
+
// written file carrying the right hash read as "accepted by the operator".
|
|
503
|
+
const sealed = checkAcceptanceSeal(rec);
|
|
504
|
+
if (sealed !== 'valid') {
|
|
505
|
+
return { valid: false, stale: true, rec,
|
|
506
|
+
why: sealed === 'unsealed' ? 'the acceptance carries no seal — it was not written by receipt.mjs --accept'
|
|
507
|
+
: sealed === 'invalid' ? 'the acceptance seal does not match — the file was written or edited outside receipt.mjs --accept'
|
|
508
|
+
: 'the acceptance cannot be checked on this machine (no seal key)' };
|
|
509
|
+
}
|
|
454
510
|
if (rec.hash !== currentHash) {
|
|
455
511
|
return { valid: false, stale: true, rec,
|
|
456
512
|
why: 'the acceptance names a different state — the tree changed after it was accepted' };
|
|
457
513
|
}
|
|
514
|
+
// An acceptance given while looking at one stage's drift does not cover another's:
|
|
515
|
+
// the operator saw code-reviewer's files, not qa-engineer's.
|
|
516
|
+
if (agent && rec.agent !== agent) {
|
|
517
|
+
return { valid: false, stale: true, rec,
|
|
518
|
+
why: `the acceptance was given for ${rec.agent || 'the whole tree'}, not for ${agent} — accept ${agent}'s drift separately` };
|
|
519
|
+
}
|
|
458
520
|
return { valid: true, rec, why: `accepted at ${new Date(rec.at).toISOString()}` };
|
|
459
521
|
}
|
|
460
522
|
|
|
@@ -475,6 +537,12 @@ function requireFs() {
|
|
|
475
537
|
// `--emit` prints a receipt for the current tree (used by log-verdict.sh).
|
|
476
538
|
// `--check <file>` compares a recorded receipt held in a file.
|
|
477
539
|
// `--verify` compares the newest approving verdict's receipt against now.
|
|
540
|
+
// `--agent <name>` narrows it to one approving stage. The newest approval of
|
|
541
|
+
// any stage is the wrong question before a deploy: a review of today's tree
|
|
542
|
+
// must not stand in for a QA pass on yesterday's.
|
|
543
|
+
// `--require` is the deploy question: "no approval carries a receipt" exits 3,
|
|
544
|
+
// not 0, and files ADDED since the approval count as drift. Before a push they
|
|
545
|
+
// are work continuing; before a deploy they are code nobody reviewed, shipping.
|
|
478
546
|
|
|
479
547
|
function currentForReceipt(cwd, receipt) {
|
|
480
548
|
// Persisted evidence is data, not Git options or a new revision expression.
|
|
@@ -487,10 +555,13 @@ function currentForReceipt(cwd, receipt) {
|
|
|
487
555
|
base = receipt.head;
|
|
488
556
|
}
|
|
489
557
|
if (base != null && (typeof base !== 'string' || !/^[0-9a-f]{7,64}$/i.test(base))) return null;
|
|
490
|
-
|
|
558
|
+
// Without the pipeline's own state: an approval, an acceptance and a verdict all
|
|
559
|
+
// write under .great_cto/, and in a project that does not ignore it each write
|
|
560
|
+
// would change the state it is about (an acceptance voiding itself on write).
|
|
561
|
+
return treeReceipt(cwd, { base: base || null, exclude: pipelineOutputPaths(cwd) });
|
|
491
562
|
}
|
|
492
563
|
|
|
493
|
-
if (
|
|
564
|
+
if (isMainModule(import.meta.url)) {
|
|
494
565
|
const argv = process.argv.slice(2);
|
|
495
566
|
if (argv.includes('--state-dir')) {
|
|
496
567
|
console.log(verdictStateDir());
|
|
@@ -510,27 +581,63 @@ if (process.argv[1] && fileURLToPath(import.meta.url) === resolve(process.argv[1
|
|
|
510
581
|
process.stdout.write(r ? JSON.stringify(r) : '');
|
|
511
582
|
process.exit(r ? 0 : 1);
|
|
512
583
|
}
|
|
584
|
+
const agentsArg = () => {
|
|
585
|
+
const i = argv.indexOf('--agent');
|
|
586
|
+
if (i < 0) return APPROVING_AGENTS;
|
|
587
|
+
const name = argv[i + 1];
|
|
588
|
+
if (!APPROVING_AGENTS.includes(name)) {
|
|
589
|
+
console.error(`receipt: --agent must be one of ${APPROVING_AGENTS.join(', ')}, got ${name}`);
|
|
590
|
+
process.exit(2);
|
|
591
|
+
}
|
|
592
|
+
return [name];
|
|
593
|
+
};
|
|
513
594
|
if (argv.includes('--verify')) {
|
|
514
595
|
const cwd = process.cwd();
|
|
515
|
-
const
|
|
596
|
+
const agents = agentsArg();
|
|
597
|
+
const approval = latestApproval(cwd, { agents });
|
|
516
598
|
const current = currentForReceipt(cwd, approval?.receipt);
|
|
517
599
|
const cmp = compareReceipts(approval?.receipt ?? null, current);
|
|
518
600
|
if (cmp.state === 'no-receipt') {
|
|
519
601
|
// Not silence. A push with no approval to compare against and a push whose
|
|
520
602
|
// receipt matched are different facts, and only one of them is evidence.
|
|
521
|
-
|
|
522
|
-
|
|
603
|
+
const whom = agents.length === 1 ? `${agents[0]} verdict` : 'verdict';
|
|
604
|
+
console.log(`receipt: no approving ${whom} carries a receipt — nothing was verified`);
|
|
605
|
+
process.exit(argv.includes('--require') ? 3 : 0);
|
|
523
606
|
}
|
|
524
607
|
const who = approval ? `${approval.agent} ${approval.verdict} at ${approval.ts}` : 'an approval';
|
|
525
608
|
console.log(`receipt: against ${who}`);
|
|
526
609
|
console.log(describeDrift(cmp).split('\n').map((l) => ` ${l}`).join('\n'));
|
|
527
610
|
|
|
528
611
|
if (cmp.state === 'unreadable') process.exit(2);
|
|
529
|
-
|
|
612
|
+
const requireMode = argv.includes('--require');
|
|
613
|
+
// great_cto-dv3b: the deploy question trusts only a sealed approval. A newest
|
|
614
|
+
// line whose seal does not match was written or edited outside log-verdict.sh:
|
|
615
|
+
// exit 4, a block. One with no seal (older than 3.75.6, or a machine without the
|
|
616
|
+
// key) is not verified: exit 3, said, never passed as verified.
|
|
617
|
+
if (requireMode) {
|
|
618
|
+
const sealed = checkVerdictSeal(approval);
|
|
619
|
+
if (sealed === 'invalid') {
|
|
620
|
+
console.log(` the ${approval.agent} approval's seal does not match — the line was written or edited outside log-verdict.sh`);
|
|
621
|
+
process.exit(4);
|
|
622
|
+
}
|
|
623
|
+
if (sealed !== 'valid') {
|
|
624
|
+
console.log(` the ${approval.agent} approval is ${sealed === 'unsealed' ? 'not sealed (written before 3.75.6, or not by log-verdict.sh)' : 'not checkable here (no seal key on this machine)'} — freshness not verified`);
|
|
625
|
+
process.exit(3);
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
// A receipt capped at MAX_FILES lists only part of the change, so a file past
|
|
629
|
+
// the cap could change unseen. For the deploy question that is not a match.
|
|
630
|
+
if (requireMode && (approval?.receipt?.truncated || current?.truncated)) {
|
|
631
|
+
console.log(` the receipt lists only the first ${MAX_FILES} files of this change — a match cannot be shown; re-run the stage on a smaller change`);
|
|
632
|
+
process.exit(2);
|
|
633
|
+
}
|
|
634
|
+
const drifted = cmp.state === 'differs' || (cmp.state === 'extended' && requireMode);
|
|
635
|
+
if (!drifted) process.exit(0);
|
|
530
636
|
|
|
531
637
|
// Drift, but a human may already have looked at exactly this state.
|
|
532
638
|
const hash = receiptHash(current);
|
|
533
|
-
const
|
|
639
|
+
const narrowed = agents.length === 1 ? agents[0] : null;
|
|
640
|
+
const acc = readAcceptance(cwd, hash, { agent: narrowed });
|
|
534
641
|
if (acc.valid) {
|
|
535
642
|
console.log(` accepted by the operator for this exact state (${acc.why})`);
|
|
536
643
|
// Consumed here rather than by the caller: whoever asked the question is
|
|
@@ -540,7 +647,7 @@ if (process.argv[1] && fileURLToPath(import.meta.url) === resolve(process.argv[1
|
|
|
540
647
|
process.exit(0);
|
|
541
648
|
}
|
|
542
649
|
if (acc.stale) console.log(` ${acc.why} — accept again if this state is fine`);
|
|
543
|
-
console.log(` to accept this state: node scripts/lib/receipt.mjs --accept`);
|
|
650
|
+
console.log(` to accept this state: node scripts/lib/receipt.mjs --accept${narrowed ? ` --agent ${narrowed}` : ''}${requireMode ? ' --require' : ''}`);
|
|
544
651
|
process.exit(1);
|
|
545
652
|
}
|
|
546
653
|
|
|
@@ -553,14 +660,21 @@ if (process.argv[1] && fileURLToPath(import.meta.url) === resolve(process.argv[1
|
|
|
553
660
|
// behaviour — an agent asked to approve its own work will comply.
|
|
554
661
|
if (argv.includes('--accept')) {
|
|
555
662
|
const cwd = process.cwd();
|
|
556
|
-
const approval = latestApproval(cwd);
|
|
663
|
+
const approval = latestApproval(cwd, { agents: agentsArg() });
|
|
557
664
|
const current = currentForReceipt(cwd, approval?.receipt);
|
|
558
665
|
const cmp = compareReceipts(approval?.receipt ?? null, current);
|
|
559
666
|
if (cmp.state === 'unreadable') {
|
|
560
667
|
console.error(`receipt: cannot accept — ${cmp.why}`);
|
|
561
668
|
process.exit(2);
|
|
562
669
|
}
|
|
563
|
-
|
|
670
|
+
const requireMode = argv.includes('--require');
|
|
671
|
+
if (requireMode && (approval?.receipt?.truncated || current?.truncated)) {
|
|
672
|
+
console.error(`receipt: cannot accept — the receipt lists only the first ${MAX_FILES} files; what you would accept is not all on screen`);
|
|
673
|
+
process.exit(2);
|
|
674
|
+
}
|
|
675
|
+
// Under --require an added file is drift, so it must be acceptable too —
|
|
676
|
+
// otherwise verify says "accept it" and accept says "nothing to accept".
|
|
677
|
+
if (!(cmp.state === 'differs' || (cmp.state === 'extended' && requireMode))) {
|
|
564
678
|
console.log(`receipt: nothing to accept — ${cmp.why}`);
|
|
565
679
|
process.exit(0);
|
|
566
680
|
}
|
|
@@ -568,6 +682,9 @@ if (process.argv[1] && fileURLToPath(import.meta.url) === resolve(process.argv[1
|
|
|
568
682
|
const hash = receiptHash(current);
|
|
569
683
|
console.log(`\nstate: ${hash.slice(0, 16)}…`);
|
|
570
684
|
|
|
685
|
+
// The acceptance must be sealable before a human is asked to confirm it.
|
|
686
|
+
try { if (!loadSealKey({ create: true })) throw new Error('no usable seal key'); }
|
|
687
|
+
catch (e) { console.error(`\nreceipt: cannot record an acceptance — ${e.message}`); process.exit(2); }
|
|
571
688
|
if (!process.stdin.isTTY) {
|
|
572
689
|
console.error('\nreceipt: --accept needs a terminal. Run it yourself, in your own shell —');
|
|
573
690
|
console.error('an acceptance from inside an agent session is the agent approving its own work.');
|
|
@@ -578,7 +695,9 @@ if (process.argv[1] && fileURLToPath(import.meta.url) === resolve(process.argv[1
|
|
|
578
695
|
const answer = await new Promise((res) => rl.question('\nShip this state anyway? [y/N] ', (a) => { rl.close(); res(a); }));
|
|
579
696
|
if (!/^y(es)?$/i.test(answer.trim())) { console.log('not accepted — nothing recorded.'); process.exit(1); }
|
|
580
697
|
|
|
581
|
-
|
|
698
|
+
const stage = argv.includes('--agent') ? argv[argv.indexOf('--agent') + 1] : null;
|
|
699
|
+
writeAcceptance(cwd, { hash, agent: stage,
|
|
700
|
+
why: `drift accepted over ${cmp.changed.length} changed and ${cmp.added.length} added file(s)${stage ? ` for ${stage}` : ''}` });
|
|
582
701
|
console.log('accepted. This authorises ONE push of this exact state; any further edit voids it.');
|
|
583
702
|
process.exit(0);
|
|
584
703
|
}
|
|
@@ -33,6 +33,7 @@
|
|
|
33
33
|
*/
|
|
34
34
|
|
|
35
35
|
import { pendingQuestion, evaluateDag, parseAnswer } from './dag-metric.mjs';
|
|
36
|
+
import { isMainModule } from './is-main.mjs';
|
|
36
37
|
|
|
37
38
|
export const DIVERGENCE = Object.freeze({
|
|
38
39
|
AGREE: 'agree',
|
|
@@ -240,7 +241,7 @@ async function main(argv) {
|
|
|
240
241
|
return cmp.diverged.length ? 1 : 0;
|
|
241
242
|
}
|
|
242
243
|
|
|
243
|
-
if (import.meta.url
|
|
244
|
+
if (isMainModule(import.meta.url)) {
|
|
244
245
|
main(process.argv.slice(2)).then((c) => { process.exitCode = c; });
|
|
245
246
|
}
|
|
246
247
|
|
|
@@ -39,6 +39,7 @@ import os from 'node:os';
|
|
|
39
39
|
import path from 'node:path';
|
|
40
40
|
import { createHash } from 'node:crypto';
|
|
41
41
|
import { priceUsage, effectivePrices } from './cost-meter.mjs';
|
|
42
|
+
import { isMainModule } from './is-main.mjs';
|
|
42
43
|
|
|
43
44
|
const INDEX_VERSION = 3; // 2: hourly buckets, limit kinds, Codex limit series · 3: hook blocks, errors, timeouts
|
|
44
45
|
const RING = 256; // recent response ids remembered across reads
|
|
@@ -1067,7 +1068,7 @@ export function usageIndexSnapshot({ scan = () => scanUsage(), ttlMs = 60 * 1000
|
|
|
1067
1068
|
}
|
|
1068
1069
|
|
|
1069
1070
|
// CLI: node scripts/lib/session-usage.mjs [--days N] [--json]
|
|
1070
|
-
const isMain =
|
|
1071
|
+
const isMain = isMainModule(import.meta.url);
|
|
1071
1072
|
if (isMain) {
|
|
1072
1073
|
const i = process.argv.indexOf('--days');
|
|
1073
1074
|
const days = i > -1 ? Number(process.argv[i + 1]) : 30;
|
|
@@ -35,6 +35,8 @@
|
|
|
35
35
|
* "there is none" stops looking for something that exists.
|
|
36
36
|
*/
|
|
37
37
|
|
|
38
|
+
import { isMainModule } from './is-main.mjs';
|
|
39
|
+
|
|
38
40
|
/** The closed vocabulary. Adding one is a deliberate edit, not a free-text key. */
|
|
39
41
|
export const CAPABILITIES = Object.freeze([
|
|
40
42
|
'logs', // where log lines are searched
|
|
@@ -141,7 +143,7 @@ export function describeCapabilities({ map, declaredCount, unknownKeys }) {
|
|
|
141
143
|
// Exit 0 always. A project with nothing declared is a fact to report, not an
|
|
142
144
|
// error to fail on — failing here would make an unconfigured project unable to
|
|
143
145
|
// run an incident, which is exactly when you need the agent most.
|
|
144
|
-
if (import.meta.url
|
|
146
|
+
if (isMainModule(import.meta.url)) {
|
|
145
147
|
const { readFileSync } = await import('node:fs');
|
|
146
148
|
const { join } = await import('node:path');
|
|
147
149
|
const args = process.argv.slice(2);
|
|
@@ -26,6 +26,7 @@
|
|
|
26
26
|
|
|
27
27
|
import { openSync, writeSync, fsyncSync, closeSync, readFileSync, existsSync, mkdirSync } from 'node:fs';
|
|
28
28
|
import { join, dirname } from 'node:path';
|
|
29
|
+
import { isMainModule } from './is-main.mjs';
|
|
29
30
|
|
|
30
31
|
export const STAND_DOWN_PATH = join('.great_cto', 'stand-downs.jsonl');
|
|
31
32
|
|
|
@@ -123,7 +124,7 @@ export function readStandDowns(cwd, { limit = 0 } = {}) {
|
|
|
123
124
|
// board's inbox answers for gates that waited, and nothing answered for gates
|
|
124
125
|
// that did not.
|
|
125
126
|
|
|
126
|
-
if (import.meta.url
|
|
127
|
+
if (isMainModule(import.meta.url)) {
|
|
127
128
|
const argv = process.argv.slice(2);
|
|
128
129
|
const li = argv.indexOf('--limit');
|
|
129
130
|
const limit = li !== -1 ? Number(argv[li + 1]) : 0;
|
|
@@ -26,6 +26,7 @@
|
|
|
26
26
|
|
|
27
27
|
import { readdirSync, readFileSync, statSync, existsSync } from 'node:fs';
|
|
28
28
|
import { join, relative, sep } from 'node:path';
|
|
29
|
+
import { isMainModule } from './is-main.mjs';
|
|
29
30
|
|
|
30
31
|
/**
|
|
31
32
|
* The groups a great_cto project is built from, and what each one is.
|
|
@@ -191,7 +192,7 @@ export function pipelineMermaid(tomlText) {
|
|
|
191
192
|
|
|
192
193
|
// ── CLI ─────────────────────────────────────────────────────────────────────
|
|
193
194
|
|
|
194
|
-
if (import.meta.url
|
|
195
|
+
if (isMainModule(import.meta.url)) {
|
|
195
196
|
const map = systemMap(process.cwd());
|
|
196
197
|
if (process.argv.includes('--json')) {
|
|
197
198
|
console.log(JSON.stringify(map, null, 2));
|
|
@@ -28,6 +28,7 @@
|
|
|
28
28
|
* Writing is strict: everything new is v1 NDJSON.
|
|
29
29
|
*/
|
|
30
30
|
|
|
31
|
+
import { isMainModule } from './is-main.mjs';
|
|
31
32
|
export const VERDICT_FORMAT_VERSION = 1;
|
|
32
33
|
|
|
33
34
|
/** Verdict values with a defined meaning. Anything else is kept but flagged. */
|
|
@@ -93,6 +94,7 @@ export function validateVerdict(rec) {
|
|
|
93
94
|
if ('meta' in rec && (typeof rec.meta !== 'object' || rec.meta === null || Array.isArray(rec.meta))) {
|
|
94
95
|
errors.push('meta must be an object');
|
|
95
96
|
}
|
|
97
|
+
if ('seal' in rec && (typeof rec.seal !== 'string' || !/^v1:[0-9a-f]{64}$/.test(rec.seal))) errors.push('seal must be v1:<64 hex>');
|
|
96
98
|
return { valid: errors.length === 0, errors };
|
|
97
99
|
}
|
|
98
100
|
|
|
@@ -265,7 +267,7 @@ async function main(argv) {
|
|
|
265
267
|
if (i > 0) meta[kv.slice(0, i)] = kv.slice(i + 1);
|
|
266
268
|
}
|
|
267
269
|
try {
|
|
268
|
-
|
|
270
|
+
const rec = makeVerdict({
|
|
269
271
|
ts: process.env.TS,
|
|
270
272
|
agent: process.env.AGENT,
|
|
271
273
|
verdict: process.env.VERDICT,
|
|
@@ -273,7 +275,17 @@ async function main(argv) {
|
|
|
273
275
|
cost_usd: process.env.COST === '' || process.env.COST === undefined ? undefined : Number(process.env.COST),
|
|
274
276
|
meta,
|
|
275
277
|
receipt: parseReceiptEnv(process.env.RECEIPT),
|
|
276
|
-
})
|
|
278
|
+
});
|
|
279
|
+
// great_cto-dv3b: the writer seals what it writes; a line written any other
|
|
280
|
+
// way reads as unsealed or invalid to a deploy. A seal that cannot be made
|
|
281
|
+
// (no writable home) leaves the line unsealed, said on stderr — not unwritten.
|
|
282
|
+
try {
|
|
283
|
+
const { sealVerdict } = await import('./verdict-seal.mjs');
|
|
284
|
+
const seal = sealVerdict(rec);
|
|
285
|
+
if (seal) rec.seal = seal;
|
|
286
|
+
else console.error('verdict-record: verdict not sealed (no usable seal key) — a deploy will read it as not verified');
|
|
287
|
+
} catch (e) { console.error(`verdict-record: verdict not sealed (${e.message})`); }
|
|
288
|
+
process.stdout.write(formatVerdict(rec) + '\n');
|
|
277
289
|
return 0;
|
|
278
290
|
} catch (e) {
|
|
279
291
|
console.error(`verdict-record: ${e.message}`);
|
|
@@ -304,6 +316,6 @@ async function main(argv) {
|
|
|
304
316
|
return argv.includes('--strict') && bad ? 1 : 0;
|
|
305
317
|
}
|
|
306
318
|
|
|
307
|
-
if (import.meta.url
|
|
319
|
+
if (isMainModule(import.meta.url)) {
|
|
308
320
|
main(process.argv.slice(2)).then((c) => { process.exitCode = c; });
|
|
309
321
|
}
|