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.
Files changed (37) hide show
  1. package/assets/statusline.mjs +7 -4
  2. package/board/.claude-plugin/plugin.json +1 -1
  3. package/board/agents/architect.md +19 -1
  4. package/board/agents/code-reviewer.md +50 -42
  5. package/board/agents/design-advisor.md +13 -2
  6. package/board/agents/devops.md +40 -1
  7. package/board/agents/qa-engineer.md +33 -0
  8. package/board/agents/security-officer.md +15 -7
  9. package/board/agents/senior-dev.md +16 -0
  10. package/board/packages/board/lib/bd-read-worker.mjs +2 -1
  11. package/board/packages/board/lib/receipt-read-worker.mjs +2 -1
  12. package/board/scripts/hooks/auto-attach-reviewers.mjs +2 -2
  13. package/board/scripts/lib/affected-tests.mjs +2 -2
  14. package/board/scripts/lib/agent-events.mjs +2 -1
  15. package/board/scripts/lib/agent-usage.mjs +2 -1
  16. package/board/scripts/lib/approval-level.mjs +3 -1
  17. package/board/scripts/lib/board-watch.mjs +2 -2
  18. package/board/scripts/lib/change-tier.mjs +2 -1
  19. package/board/scripts/lib/cost-meter.mjs +2 -2
  20. package/board/scripts/lib/cross-model-review.mjs +2 -2
  21. package/board/scripts/lib/dag-metric.mjs +2 -1
  22. package/board/scripts/lib/gate-plan.mjs +2 -2
  23. package/board/scripts/lib/gate-tier.mjs +2 -1
  24. package/board/scripts/lib/is-main.mjs +24 -0
  25. package/board/scripts/lib/judge-model.mjs +2 -1
  26. package/board/scripts/lib/pipeline-health.mjs +2 -1
  27. package/board/scripts/lib/receipt.mjs +138 -19
  28. package/board/scripts/lib/second-opinion.mjs +2 -1
  29. package/board/scripts/lib/session-usage.mjs +2 -1
  30. package/board/scripts/lib/stack-capabilities.mjs +3 -1
  31. package/board/scripts/lib/stand-down.mjs +2 -1
  32. package/board/scripts/lib/system-map.mjs +2 -1
  33. package/board/scripts/lib/verdict-record.mjs +15 -3
  34. package/board/scripts/lib/verdict-seal.mjs +95 -0
  35. package/board/scripts/skill-lint.mjs +2 -1
  36. package/package.json +1 -1
  37. 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
- import { fileURLToPath } from 'node:url';
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 = process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1];
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 === `file://${process.argv[1]}`) {
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 === `file://${process.argv[1]}`) {
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 === `file://${process.argv[1]}`) {
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 === `file://${process.argv[1]}`) {
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 === `file://${process.argv[1]}`) {
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 { fileURLToPath } from 'node:url';
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
- return { head, dirty, base: ref, base_from: baseFrom, files, ...(truncated ? { truncated: true } : {}) };
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
- writeFileSync(join(cwd, ACCEPT_PATH), JSON.stringify({ hash, why, at }) + '\n');
433
- return { hash, why, at };
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
- return treeReceipt(cwd, { base: base || null });
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 (process.argv[1] && fileURLToPath(import.meta.url) === resolve(process.argv[1])) {
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 approval = latestApproval(cwd);
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
- console.log('receipt: no approving verdict carries a receipt — nothing was verified');
522
- process.exit(0);
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
- if (cmp.state !== 'differs') process.exit(0);
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 acc = readAcceptance(cwd, hash);
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
- if (cmp.state !== 'differs') {
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
- writeAcceptance(cwd, { hash, why: `drift accepted over ${cmp.changed.length} changed file(s)` });
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 === `file://${process.argv[1]}`) {
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 = process.argv[1] && path.resolve(process.argv[1]) === path.resolve(new URL(import.meta.url).pathname);
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 === `file://${process.argv[1]}`) {
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 === `file://${process.argv[1]}`) {
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 === `file://${process.argv[1]}`) {
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
- process.stdout.write(formatVerdict(makeVerdict({
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
- })) + '\n');
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 === `file://${process.argv[1]}`) {
319
+ if (isMainModule(import.meta.url)) {
308
320
  main(process.argv.slice(2)).then((c) => { process.exitCode = c; });
309
321
  }