great-cto 3.62.1 → 3.62.2

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.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "great-cto",
3
3
  "description": "You already have the agent. This is everything around it. great_cto runs Claude Code as a pipeline of 72 specialist agents \u2014 an independent model checks each stage before the next builds on it, spending caps refuse rather than warn, and three decisions stay yours: what gets built, how, and whether it ships.",
4
- "version": "3.62.1",
4
+ "version": "3.62.2",
5
5
  "author": {
6
6
  "name": "Alexander Velikiy",
7
7
  "url": "https://hashnode.com/@Greatcto"
@@ -16,6 +16,10 @@ if (process.argv.includes('--descendant')) {
16
16
  fs.writeFileSync(marker('worker'), String(process.ppid));
17
17
  if (process.env.FAKE_BD_CONTROL_MODE === 'fail') {
18
18
  process.exit(1);
19
+ } else if (['gate-receipt', 'gate-receipt-race'].includes(process.env.FAKE_BD_CONTROL_MODE)) {
20
+ const tasks = [{ id: 'FIXTURE-GATE', title: 'gate:ship', labels: ['gate'], status: 'open', priority: 0 }];
21
+ if (process.env.FAKE_BD_CONTROL_MODE === 'gate-receipt-race') tasks.push({ id: 'NEW-GATE', title: 'gate:plan', labels: ['gate'], status: 'open', priority: 0 });
22
+ process.stdout.write(JSON.stringify(tasks), () => process.exit(0));
19
23
  } else if (['hang', 'leader-exit'].includes(process.env.FAKE_BD_CONTROL_MODE)) {
20
24
  process.on('SIGTERM', () => {});
21
25
  spawn(process.execPath, [new URL(import.meta.url).pathname, '--descendant'], { stdio: 'inherit' });
@@ -0,0 +1,21 @@
1
+ #!/usr/bin/env node
2
+ // A fixed Git-read fixture. It never reads operator stores or invokes a shell.
3
+ import fs from 'node:fs';
4
+ import path from 'node:path';
5
+ import { spawnSync } from 'node:child_process';
6
+ const root = process.env.GCTO_RECEIPT_CONTROL_ROOT, binary = process.env.GCTO_RECEIPT_REAL_GIT;
7
+ if (!root || !path.isAbsolute(root) || !binary || !path.isAbsolute(binary)) process.exit(2);
8
+ const args = process.argv.slice(2);
9
+ const run = () => {
10
+ const result = spawnSync(binary, args, { stdio: ['ignore', 'pipe', 'pipe'], timeout: 5000 });
11
+ if (result.stdout) process.stdout.write(result.stdout);
12
+ if (result.stderr) process.stderr.write(result.stderr);
13
+ process.exit(result.status ?? 1);
14
+ };
15
+ if (args.includes('hash-object') || args.includes('--show-object-format') ||
16
+ (args.includes('rev-parse') && args.includes('HEAD') && fs.existsSync(path.join(root, 'hold-head')))) {
17
+ fs.appendFileSync(path.join(root, 'git-held'), `${process.pid}\n`);
18
+ const timer = setInterval(() => {
19
+ if (fs.existsSync(path.join(root, 'git-release'))) { clearInterval(timer); run(); }
20
+ }, 25);
21
+ } else run();
@@ -3,8 +3,10 @@
3
3
  import { fork, spawn } from 'node:child_process';
4
4
  import { fileURLToPath } from 'node:url';
5
5
  const entry = fileURLToPath(import.meta.url);
6
- export function spawnBdRead(binary, { readDeadlineMs = 20250, ...options }) {
7
- return fork(entry, [binary, String(readDeadlineMs)], { ...options, silent: true });
6
+ export function spawnBdRead(binary, { readDeadlineMs = 20250, commandArgs, ...options }) {
7
+ const args = [binary, String(readDeadlineMs)];
8
+ if (commandArgs !== undefined) args.push(JSON.stringify(commandArgs));
9
+ return fork(entry, args, { ...options, silent: true });
8
10
  }
9
11
  if (process.argv[1] === entry) {
10
12
  const grouped = process.platform !== 'win32';
@@ -48,7 +50,11 @@ if (process.argv[1] === entry) {
48
50
  catch { killOwnedGroup(); }
49
51
  }
50
52
  try {
51
- child = spawn(process.argv[2], ['list', '--json', '--all', '--include-gates'], {
53
+ const args = process.argv[4] === undefined
54
+ ? ['list', '--json', '--all', '--include-gates'] : JSON.parse(process.argv[4]);
55
+ if (!Array.isArray(args) || args.length > 8 || args.some(arg => typeof arg !== 'string' || arg.includes('\0'))
56
+ || JSON.stringify(args).length > 4096) throw Error('Invalid read command arguments');
57
+ child = spawn(process.argv[2], args, {
52
58
  stdio: ['ignore', 'pipe', 'pipe']
53
59
  });
54
60
  child.stdout.pipe(process.stdout, { end: false });
@@ -19,6 +19,7 @@ import fs from 'fs';
19
19
  import path from 'path';
20
20
  import { randomUUID } from 'crypto';
21
21
  import { treeReceipt, receiptHash, compareReceipts } from '../../../scripts/lib/receipt.mjs';
22
+ import { readBindingReceipt } from './receipt-read.mjs';
22
23
 
23
24
  export const TOKENS_FILE = 'gate-tokens.json';
24
25
  export const DEFAULT_TTL_MS = 24 * 60 * 60 * 1000;
@@ -36,6 +37,30 @@ export function tokenTtlMs(env = process.env) {
36
37
  }
37
38
 
38
39
  const storePath = (cwd) => path.join(cwd, '.great_cto', TOKENS_FILE);
40
+ // One board owns its HTTP listener. Fence awaited projections against that
41
+ // board's decision lifecycle; this is not a distributed multi-board lock.
42
+ const decisions = new Map();
43
+ function decisionState(cwd) {
44
+ let key;
45
+ try { key = fs.realpathSync(cwd); } catch { return null; }
46
+ if (!decisions.has(key)) decisions.set(key, { generation: randomUUID(), active: false });
47
+ return decisions.get(key);
48
+ }
49
+ export function approvalSnapshot(cwd) {
50
+ const state = decisionState(cwd);
51
+ return state ? { generation: state.generation, active: state.active } : { generation: null, active: true };
52
+ }
53
+ function snapshotFresh(cwd, snapshot) {
54
+ const state = decisionState(cwd);
55
+ return state && snapshot && !snapshot.active && !state.active && snapshot.generation === state.generation;
56
+ }
57
+ export function beginGateDecision(cwd) {
58
+ const state = decisionState(cwd);
59
+ if (!state || state.active) return null;
60
+ state.active = true; state.generation = randomUUID();
61
+ let ended = false;
62
+ return () => { if (!ended) { ended = true; state.active = false; state.generation = randomUUID(); } };
63
+ }
39
64
 
40
65
  /** @returns {{state:'none'|'ok'|'unreadable', tokens:object, why?:string}} */
41
66
  export function readTokens(cwd) {
@@ -77,7 +102,7 @@ export function bindingReceipt(cwd) {
77
102
  * @param {{id:string, gate?:string, guarded?:boolean}[]} gates
78
103
  * @returns {{state:'ok'|'unreadable', tokens:Record<string,string>, why?:string}}
79
104
  */
80
- export function issueTokens(cwd, gates, { now = Date.now(), env = process.env } = {}) {
105
+ export function issueTokens(cwd, gates, { now = Date.now(), env = process.env, readReceipt = bindingReceipt } = {}) {
81
106
  const store = readTokens(cwd);
82
107
  if (store.state === 'unreadable') return { state: 'unreadable', tokens: {}, why: store.why };
83
108
  const ttl = tokenTtlMs(env);
@@ -90,7 +115,7 @@ export function issueTokens(cwd, gates, { now = Date.now(), env = process.env }
90
115
  else toMint.push(g);
91
116
  }
92
117
  if (toMint.length) {
93
- const receipt = bindingReceipt(cwd);
118
+ const receipt = readReceipt(cwd);
94
119
  const hash = receiptHash(receipt);
95
120
  for (const g of toMint) {
96
121
  next[g.id] = {
@@ -104,6 +129,29 @@ export function issueTokens(cwd, gates, { now = Date.now(), env = process.env }
104
129
  return { state: 'ok', tokens: Object.fromEntries(Object.entries(next).map(([id, e]) => [id, e.token])) };
105
130
  }
106
131
 
132
+ export async function issueTokensAsync(cwd, gates, { env = process.env, readReceipt = readBindingReceipt, snapshot = approvalSnapshot(cwd) } = {}) {
133
+ const stale = () => ({ state: 'unreadable', tokens: {}, why: 'Gate decisions changed during this snapshot; reload the inbox' });
134
+ if (!snapshotFresh(cwd, snapshot)) return stale();
135
+ const store = readTokens(cwd), now = Date.now(), ttl = tokenTtlMs(env);
136
+ if (store.state === 'unreadable') return { state: 'unreadable', tokens: {}, why: store.why };
137
+ const needsReceipt = (gates || []).some(g => g?.id &&
138
+ (!store.tokens[g.id] || typeof store.tokens[g.id].token !== 'string' || !Number.isFinite(store.tokens[g.id].firstSeenAt) || now - store.tokens[g.id].firstSeenAt > ttl));
139
+ try {
140
+ const receipt = needsReceipt ? await readReceipt(cwd) : undefined;
141
+ if (!snapshotFresh(cwd, snapshot)) return stale();
142
+ const current = readTokens(cwd);
143
+ // Also refuse disappearance/replacement of an existing token by another
144
+ // store writer. Concurrent mint-only consumers remain safe to coalesce.
145
+ if (Object.entries(store.tokens).some(([id, entry]) => current.tokens[id]?.token !== entry?.token)) return stale();
146
+ // Reread the store after await: another consumer may already have minted
147
+ // these tokens, or corruption may have appeared. Never replace that state.
148
+ return issueTokens(cwd, gates, { now: Date.now(), env, readReceipt: () => {
149
+ if (!needsReceipt) throw Error('Receipt no longer available');
150
+ return receipt;
151
+ } });
152
+ } catch { return { state: 'unreadable', tokens: {}, why: 'Approval receipt is unavailable; retry' }; }
153
+ }
154
+
107
155
  /**
108
156
  * Is `token` the live token for gate `id`?
109
157
  * @returns {{ok:true, entry:object} | {ok:false, status:number, outcome:string, why:string}}
@@ -143,6 +191,9 @@ export function checkBinding(cwd, entry) {
143
191
 
144
192
  /** Single use: drop the gate's token once it has been decided. */
145
193
  export function consumeToken(cwd, id) {
194
+ const state = decisionState(cwd);
195
+ if (!state) return;
196
+ state.generation = randomUUID();
146
197
  const store = readTokens(cwd);
147
198
  if (store.state !== 'ok' || !(id in store.tokens)) return;
148
199
  const next = { ...store.tokens };
@@ -0,0 +1,37 @@
1
+ // Invoked only by the independent read supervisor, never by an HTTP argument.
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { treeReceipt, latestApproval, compareReceipts, mergeBase } from '../../../scripts/lib/receipt.mjs';
6
+ import { secondOpinionForTree } from '../../../scripts/lib/second-opinion-for-tree.mjs';
7
+ const entry = fileURLToPath(import.meta.url);
8
+ export const receiptReadEntry = entry;
9
+ if (process.argv[1] === entry) {
10
+ try {
11
+ const mode = process.argv[2] || 'binding';
12
+ if (!['binding', 'approval', 'second-opinion'].includes(mode) || process.argv.length > 3) throw Error('Invalid evidence operation');
13
+ let receipt;
14
+ if (mode === 'second-opinion') {
15
+ receipt = secondOpinionForTree(process.cwd());
16
+ } else if (mode === 'approval') {
17
+ const approval = latestApproval(process.cwd());
18
+ if (!approval?.receipt) {
19
+ receipt = { state: 'no-receipt', why: 'no approving verdict carries a receipt yet', agent: approval?.agent || null, ts: approval?.ts || null };
20
+ } else {
21
+ const current = treeReceipt(process.cwd(), { base: approval.receipt.base || mergeBase(process.cwd()), hashFilesInProcess: true });
22
+ receipt = { ...compareReceipts(approval.receipt, current, { cwd: process.cwd() }), agent: approval.agent, ts: approval.ts };
23
+ }
24
+ } else {
25
+ let dir = process.cwd(), gitMetadata = false;
26
+ for (;;) {
27
+ try { fs.lstatSync(path.join(dir, '.git')); gitMetadata = true; break; } catch (e) { if (e.code !== 'ENOENT') throw e; }
28
+ const parent = path.dirname(dir); if (parent === dir) break; dir = parent;
29
+ }
30
+ receipt = treeReceipt(process.cwd(), { exclude: ['.great_cto', '.beads'], hashFilesInProcess: true });
31
+ // Preserve explicit non-Git binding, but never mint an unbound token when
32
+ // a real repository's evidence read failed.
33
+ if (!receipt && gitMetadata) throw Error('Repository receipt unavailable');
34
+ }
35
+ process.stdout.write(JSON.stringify({ receipt }), error => process.exit(error ? 1 : 0));
36
+ } catch { process.exit(1); }
37
+ }
@@ -0,0 +1,70 @@
1
+ import path from 'node:path';
2
+ import { spawnBdRead } from './bd-read-worker.mjs';
3
+ import { receiptReadEntry } from './receipt-read-worker.mjs';
4
+ const pending = new Map();
5
+ const MAX_ACTIVE = 2, MAX_BYTES = 16 * 1024 * 1024;
6
+ export function readBindingReceipt(cwd, { timeoutMs = 20000 } = {}) {
7
+ return readEvidence(cwd, 'binding', timeoutMs);
8
+ }
9
+ export function readApprovalReceipt(cwd, { timeoutMs = 20000 } = {}) {
10
+ return readEvidence(cwd, 'approval', timeoutMs);
11
+ }
12
+ export function readSecondOpinion(cwd, { timeoutMs = 20000 } = {}) {
13
+ return readEvidence(cwd, 'second-opinion', timeoutMs);
14
+ }
15
+ function readEvidence(cwd, mode, timeoutMs) {
16
+ cwd = path.resolve(cwd);
17
+ const key = JSON.stringify([cwd, mode]);
18
+ if (pending.has(key)) return pending.get(key);
19
+ if (pending.size >= MAX_ACTIVE) return Promise.reject(Error('Receipt reader busy; retry'));
20
+ if (!Number.isFinite(timeoutMs) || timeoutMs < 1 || timeoutMs > 20000) return Promise.reject(Error('Invalid receipt deadline'));
21
+ const operation = new Promise((resolve, reject) => {
22
+ let child, deadline, escalation, settled = false, resultCode, out = '', bytes = 0;
23
+ const grouped = process.platform !== 'win32';
24
+ const signal = s => { try { if (grouped) process.kill(-child.pid, s); else child.kill(s); } catch {} };
25
+ const finish = (error, value) => {
26
+ if (settled) return; settled = true; clearTimeout(deadline); clearTimeout(escalation);
27
+ if (error) reject(error); else resolve(value);
28
+ };
29
+ const stop = message => {
30
+ if (settled || escalation) return;
31
+ signal('SIGTERM');
32
+ escalation = setTimeout(() => {
33
+ signal('SIGKILL'); child.stdout?.destroy(); child.stderr?.destroy(); finish(Error(message));
34
+ }, 250);
35
+ };
36
+ try {
37
+ child = spawnBdRead(process.execPath, { cwd, detached: grouped, commandArgs: [receiptReadEntry, mode], readDeadlineMs: timeoutMs + 250 });
38
+ deadline = setTimeout(() => stop('Receipt read timed out'), timeoutMs);
39
+ child.on('message', m => { if (m?.type === 'bd-read-result' && Number.isInteger(m.code)) resultCode = m.code; });
40
+ child.stdout.setEncoding('utf8');
41
+ child.stdout.on('data', chunk => {
42
+ if (settled || escalation) return;
43
+ bytes += Buffer.byteLength(chunk);
44
+ if (bytes > MAX_BYTES) stop('Receipt output exceeded limit'); else out += chunk;
45
+ });
46
+ child.stderr.resume();
47
+ child.on('error', () => finish(Error('Receipt worker unavailable')));
48
+ child.on('close', code => {
49
+ if (settled) return;
50
+ if (escalation) { signal('SIGKILL'); finish(Error('Receipt read unavailable')); return; }
51
+ if ((resultCode ?? code) !== 0) { finish(Error('Receipt worker failed')); return; }
52
+ try {
53
+ const value = JSON.parse(out);
54
+ if (!value || !Object.hasOwn(value, 'receipt')) throw Error();
55
+ const evidence = value.receipt;
56
+ if (mode === 'binding') {
57
+ if (evidence !== null && (!evidence?.head || !evidence.files)) throw Error();
58
+ } else {
59
+ const states = mode === 'approval' ? ['no-receipt', 'unreadable', 'differs', 'extended', 'matches'] : ['not-run', 'unmeasured', 'unreadable', 'ok'];
60
+ if (!states.includes(evidence?.state)) throw Error();
61
+ }
62
+ finish(null, evidence);
63
+ } catch { finish(Error('Receipt worker returned invalid evidence')); }
64
+ });
65
+ } catch { finish(Error('Receipt worker unavailable')); }
66
+ });
67
+ pending.set(key, operation);
68
+ operation.then(() => pending.delete(key), () => pending.delete(key));
69
+ return operation;
70
+ }
@@ -32,7 +32,7 @@ import { log } from './log.mjs';
32
32
  import { bdCacheInvalidate, checkBeadsAvailable, bdWriteSerialised, bd, bdErr, getTasks, getTasksAsync, setTaskStatusInTasksMd, getReadDegradation } from './beads.mjs';
33
33
  import { getMetrics } from './metrics.mjs';
34
34
  import { agentActivity, agentActivitySince } from './agent-activity.mjs';
35
- import { issueTokens, checkToken, checkBinding, consumeToken } from './gate-tokens.mjs';
35
+ import { issueTokensAsync, checkToken, checkBinding, consumeToken, approvalSnapshot, beginGateDecision } from './gate-tokens.mjs';
36
36
  import { readTurns, turnPatch } from '../../../scripts/lib/turn-snapshot.mjs';
37
37
  import { appendEvent } from '../../../scripts/lib/agent-events.mjs';
38
38
 
@@ -63,6 +63,7 @@ import { upsertCapability, capabilitiesFromProjectMd } from '../../../scripts/li
63
63
  // Moved to scripts/lib so the cross-review Stop hook can ask the same question
64
64
  // without importing a server. Re-exported here: this module's callers are unchanged.
65
65
  import { secondOpinionForTree } from '../../../scripts/lib/second-opinion-for-tree.mjs';
66
+ import { readApprovalReceipt, readSecondOpinion } from './receipt-read.mjs';
66
67
  import { getAgentsFleet, getAgentProfile, retireAgent, restoreAgent, appendDecisionLog, readDecisionsLog } from './fleet.mjs';
67
68
  import { getResume, getShareState, toggleShare } from './share.mjs';
68
69
  import { listSessions, readSession, editedFiles, searchSessions } from './transcripts.mjs';
@@ -743,6 +744,12 @@ async function dispatch(req, res, url, cwd) {
743
744
  // Named outside the serialised block so the wake below can say WHICH gate a
744
745
  // human approved; the lookup itself belongs inside the lock.
745
746
  let gateTitle = id;
747
+ const endDecision = beginGateDecision(gateCwd);
748
+ if (!endDecision) {
749
+ refuse({ status: 409, outcome: 'refused-busy', why: 'A gate decision is in progress for this project; retry' });
750
+ return;
751
+ }
752
+ try {
746
753
 
747
754
  // BH-16 fix: serialise gate writes through bd-write queue.
748
755
  // Without this, concurrent approve+reject on the same gate produced
@@ -871,12 +878,14 @@ async function dispatch(req, res, url, cwd) {
871
878
  .catch(e => log.warn(`report: republish after gate failed: ${e.message}`));
872
879
  }
873
880
  }
881
+ } finally { endDecision(); }
874
882
  });
875
883
  return true;
876
884
  }
877
885
 
878
886
  // Inbox — what needs your attention right now
879
887
  if (pathname === '/api/inbox') {
888
+ const snapshot = approvalSnapshot(cwd);
880
889
  let inbox;
881
890
  try { inbox = await getInboxAsync(cwd); }
882
891
  catch {
@@ -891,7 +900,9 @@ async function dispatch(req, res, url, cwd) {
891
900
  // verdict: `not-run` (capability none / undeclared / unavailable),
892
901
  // `unmeasured` (declared, no line for this sha), `unreadable` (only
893
902
  // pre-join-key lines exist), `ok` (a paired verdict).
894
- const second_opinion = secondOpinionForTree(cwd);
903
+ let second_opinion;
904
+ try { second_opinion = await readSecondOpinion(cwd); }
905
+ catch { second_opinion = { state: 'unreadable', why: 'Second opinion evidence is unavailable; retry', verdict: null }; }
895
906
  // What is waiting on the person in their OTHER projects. The headline and
896
907
  // the badge are about the person, and the person is not scoped to `cwd`.
897
908
  // If the registry itself cannot be walked, say so — `unreadable` is not
@@ -903,11 +914,11 @@ async function dispatch(req, res, url, cwd) {
903
914
  // bound to the project as it is now. The typed name stored with it is the one
904
915
  // the page asks for; a store that cannot be read issues nothing and says so.
905
916
  const gates = inbox.pending_gates || [];
906
- const issued = issueTokens(cwd, gates.map((g) => ({
917
+ const issued = await issueTokensAsync(cwd, gates.map((g) => ({
907
918
  id: g.id,
908
919
  gate: g.reversibility?.gate ? `gate:${g.reversibility.gate}` : g.id,
909
920
  guarded: ['expensive', 'unclassified'].includes(g.reversibility?.state),
910
- })));
921
+ })), { snapshot });
911
922
  const pending_gates = gates.map((g) => ({ ...g, token: issued.tokens[g.id] || null }));
912
923
  const approval_tokens = { state: issued.state, ...(issued.why ? { why: issued.why } : {}) };
913
924
  res.writeHead(200, verdictHeaders(cwd, { 'Content-Type': 'application/json' }));
@@ -926,15 +937,7 @@ async function dispatch(req, res, url, cwd) {
926
937
  const c = url.searchParams.get('project') ? resolveProjectCwd(url.searchParams.get('project')) : cwd;
927
938
  let out;
928
939
  try {
929
- const { latestApproval, treeReceipt, compareReceipts, mergeBase } = await import('../../../scripts/lib/receipt.mjs');
930
- const approval = latestApproval(c);
931
- if (!approval?.receipt) {
932
- out = { state: 'no-receipt', why: 'no approving verdict carries a receipt yet', agent: approval?.agent || null, ts: approval?.ts || null };
933
- } else {
934
- const current = treeReceipt(c, { base: approval.receipt.base || mergeBase(c) });
935
- const cmp = compareReceipts(approval.receipt, current, { cwd: c });
936
- out = { ...cmp, agent: approval.agent, ts: approval.ts };
937
- }
940
+ out = await readApprovalReceipt(c);
938
941
  } catch (e) {
939
942
  // Never "matches" on an error. Not knowing whether the approval still
940
943
  // holds is its own state, and calling it a match would put the strongest
@@ -88,9 +88,34 @@ const excludeLogs = ACTIVITY_LOGS.map((p) => `:(exclude)${p}`);
88
88
  * Returns `null` outside a git repository rather than a fabricated receipt: a
89
89
  * receipt that cannot be built must not look like one that matched.
90
90
  */
91
- export function treeReceipt(cwd = process.cwd(), { base = null, maxFiles = MAX_FILES, verdictsDir = null, exclude = [] } = {}) {
91
+ export function treeReceipt(cwd = process.cwd(), { base = null, maxFiles = MAX_FILES, verdictsDir = null, exclude = [], hashFilesInProcess = false } = {}) {
92
92
  const head = git(['rev-parse', 'HEAD'], cwd)?.trim();
93
93
  if (!head) return null;
94
+ // Off-loop board readers avoid one process per untracked file. Preserve Git's
95
+ // blob format (including SHA-256 repositories) and --no-filters semantics.
96
+ const algorithm = hashFilesInProcess ? git(['rev-parse', '--show-object-format'], cwd)?.trim() : null;
97
+ if (hashFilesInProcess && !['sha1', 'sha256'].includes(algorithm)) return null;
98
+ const digest = p => {
99
+ if (!hashFilesInProcess) return fileDigest(cwd, p);
100
+ try {
101
+ const fd = fsModule.openSync(join(cwd, p), 'r');
102
+ try {
103
+ const before = fsModule.fstatSync(fd, { bigint: true });
104
+ if (!before.isFile()) return null;
105
+ const hash = createHash(algorithm).update(`blob ${before.size}\0`);
106
+ const chunk = Buffer.allocUnsafe(64 * 1024);
107
+ let remaining = before.size;
108
+ while (remaining > 0n) {
109
+ const count = fsModule.readSync(fd, chunk, 0, Number(remaining > BigInt(chunk.length) ? BigInt(chunk.length) : remaining), null);
110
+ if (!count) return null;
111
+ hash.update(chunk.subarray(0, count)); remaining -= BigInt(count);
112
+ }
113
+ const after = fsModule.fstatSync(fd, { bigint: true });
114
+ if (after.size !== before.size || after.mtimeNs !== before.mtimeNs || after.ctimeNs !== before.ctimeNs) return null;
115
+ return hash.digest('hex');
116
+ } finally { fsModule.closeSync(fd); }
117
+ } catch { return null; }
118
+ };
94
119
 
95
120
  // Directories a caller does not want in this receipt, ON TOP OF the activity logs.
96
121
  // ADR-024: a gate token binds to the project outside .great_cto/, because an
@@ -121,7 +146,9 @@ export function treeReceipt(cwd = process.cwd(), { base = null, maxFiles = MAX_F
121
146
  if (diff === null || untrackedOutput === null) return null;
122
147
  const untracked = untrackedOutput
123
148
  .split('\n').map((s) => s.trim()).filter(Boolean);
124
- const untrackedDigest = untracked.map((p) => `${p}:${fileDigest(cwd, p) ?? '?'}`).join('\n');
149
+ const untrackedDigests = untracked.map(p => [p, digest(p)]);
150
+ if (hashFilesInProcess && untrackedDigests.some(([, value]) => !value)) return null;
151
+ const untrackedDigest = untrackedDigests.map(([p, value]) => `${p}:${value ?? '?'}`).join('\n');
125
152
  const dirty = (diff.trim() || untrackedDigest) ? sha(`${diff}\n--untracked--\n${untrackedDigest}`) : null;
126
153
 
127
154
  // Which files the change touches. `--diff-filter=d` drops deletions: a file
@@ -170,7 +197,7 @@ export function treeReceipt(cwd = process.cwd(), { base = null, maxFiles = MAX_F
170
197
  let truncated = false;
171
198
  for (const p of names) {
172
199
  if (Object.keys(files).length >= maxFiles) { truncated = true; break; }
173
- const blob = fileDigest(cwd, p);
200
+ const blob = digest(p);
174
201
  if (!blob) return null;
175
202
  files[p] = blob;
176
203
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "great-cto",
3
- "version": "3.62.1",
3
+ "version": "3.62.2",
4
4
  "description": "One command install for the great_cto Claude Code plugin. Auto-detects your stack, picks the right archetype, bootstraps PROJECT.md.",
5
5
  "keywords": [
6
6
  "claude-code",