great-cto 3.62.1 → 3.62.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/board/.claude-plugin/plugin.json +1 -1
- package/board/packages/board/fixtures/fake-bd-controlled.mjs +4 -0
- package/board/packages/board/fixtures/held-receipt-git.mjs +21 -0
- package/board/packages/board/lib/bd-read-worker.mjs +9 -3
- package/board/packages/board/lib/gate-tokens.mjs +53 -2
- package/board/packages/board/lib/receipt-read-worker.mjs +37 -0
- package/board/packages/board/lib/receipt-read.mjs +70 -0
- package/board/packages/board/lib/routes.mjs +16 -13
- package/board/scripts/lib/receipt.mjs +30 -3
- package/package.json +1 -1
|
@@ -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.
|
|
4
|
+
"version": "3.62.3",
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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 {
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
|
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 =
|
|
200
|
+
const blob = digest(p);
|
|
174
201
|
if (!blob) return null;
|
|
175
202
|
files[p] = blob;
|
|
176
203
|
}
|
package/package.json
CHANGED