great-cto 3.28.9 → 3.29.1
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 +23 -2
- package/board/packages/board/lib/agent-activity.mjs +53 -0
- package/board/packages/board/lib/gate-tokens.mjs +151 -0
- package/board/packages/board/lib/routes.mjs +131 -3
- package/board/packages/board/lib/util.mjs +34 -6
- package/board/packages/board/lib/watchers.mjs +34 -1
- package/board/packages/board/public/index.html +229 -29
- package/board/packages/board/server.mjs +19 -2
- package/board/scripts/codex-pipeline.mjs +82 -0
- package/board/scripts/lib/agent-events.mjs +191 -0
- package/board/scripts/lib/codex-artifacts.mjs +41 -0
- package/board/scripts/lib/codex-checks.mjs +84 -0
- package/board/scripts/lib/codex-exec.mjs +65 -6
- package/board/scripts/lib/codex-github-release.mjs +146 -0
- package/board/scripts/lib/codex-host-state.mjs +93 -0
- package/board/scripts/lib/codex-pipeline.mjs +544 -0
- package/board/scripts/lib/codex-release.mjs +167 -0
- package/board/scripts/lib/codex-role-profiles.mjs +49 -0
- package/board/scripts/lib/cross-model-review.mjs +8 -3
- package/board/scripts/lib/pipeline-toml.mjs +21 -0
- package/board/scripts/lib/receipt.mjs +36 -4
- package/board/scripts/lib/router-key.mjs +53 -6
- package/board/scripts/lib/secret-patterns.mjs +56 -0
- package/board/scripts/lib/turn-snapshot.mjs +212 -0
- package/board/shared/pipeline.toml +323 -0
- package/dist/codex-host.js +29 -0
- package/dist/codex.js +3 -2
- package/dist/main.js +28 -2
- 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 70 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.
|
|
4
|
+
"version": "3.29.1",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Alexander Velikiy",
|
|
7
7
|
"url": "https://hashnode.com/@Greatcto"
|
|
@@ -76,6 +76,12 @@
|
|
|
76
76
|
"command": "PLUGIN_DIR=${CLAUDE_PLUGIN_ROOT:-$(ls -d ~/.claude/plugins/cache/*/great_cto/*/ 2>/dev/null | sort -V | tail -1 | sed 's|/$||')}; [ -n \"$PLUGIN_DIR\" ] && node \"${PLUGIN_DIR}/scripts/hooks/session-pipeline-resume.mjs\" 2>/dev/null || true",
|
|
77
77
|
"timeout": 10,
|
|
78
78
|
"statusMessage": "Checking for pipeline work in flight..."
|
|
79
|
+
},
|
|
80
|
+
{
|
|
81
|
+
"type": "command",
|
|
82
|
+
"command": "PLUGIN_DIR=${CLAUDE_PLUGIN_ROOT:-$(ls -d ~/.claude/plugins/cache/*/great_cto/*/ 2>/dev/null | sort -V | tail -1 | sed \"s|/$||\")}; [ -n \"$PLUGIN_DIR\" ] && node \"${PLUGIN_DIR}/scripts/hooks/register-project.mjs\" </dev/null 2>/dev/null || true",
|
|
83
|
+
"timeout": 10,
|
|
84
|
+
"statusMessage": "Registering project on the board..."
|
|
79
85
|
}
|
|
80
86
|
]
|
|
81
87
|
}
|
|
@@ -121,6 +127,11 @@
|
|
|
121
127
|
"command": "PLUGIN_DIR=${CLAUDE_PLUGIN_ROOT:-$(ls -d ~/.claude/plugins/cache/*/great_cto/*/ 2>/dev/null | sort -V | tail -1 | sed 's|/$||')}; node \"${PLUGIN_DIR}/scripts/hooks/subagent-stop-completion.mjs\" 2>&1; EXIT=$?; if [ $EXIT -eq 2 ]; then exit 2; fi; exit 0",
|
|
122
128
|
"timeout": 5,
|
|
123
129
|
"statusMessage": "Three-state completion check..."
|
|
130
|
+
},
|
|
131
|
+
{
|
|
132
|
+
"type": "command",
|
|
133
|
+
"async": true,
|
|
134
|
+
"command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/hooks/turn-snapshot.mjs\" 2>/dev/null || true"
|
|
124
135
|
}
|
|
125
136
|
]
|
|
126
137
|
}
|
|
@@ -272,7 +283,7 @@
|
|
|
272
283
|
"hooks": [
|
|
273
284
|
{
|
|
274
285
|
"type": "command",
|
|
275
|
-
"command": "INPUT=$(cat 2>/dev/null); TOOL=$(echo \"$INPUT\" | python3 -c \"import sys,json; d=json.load(sys.stdin); print(d.get('tool_name','unknown'))\" 2>/dev/null); mkdir -p .great_cto 2>/dev/null; printf '%s PERMISSION_DENIED tool=%s\\n' \"$(date -u +%Y-%m-%dT%H:%M:%SZ)\" \"$TOOL\" >> .great_cto/permission-denied.log 2>/dev/null; if [ \"$TOOL\" = \"Bash\" ] || [ \"$TOOL\" = \"Write\" ]; then printf '%s PERMISSION_DENIED %s \u2014 agent may be stuck. Run /inbox.\\n' \"$(date -u +%Y-%m-%dT%H:%M:%SZ)\" \"$TOOL\" >> .great_cto/permission-denied.log 2>/dev/null; fi; true",
|
|
286
|
+
"command": "INPUT=$(cat 2>/dev/null); TOOL=$(echo \"$INPUT\" | python3 -c \"import sys,json; d=json.load(sys.stdin); print(d.get('tool_name','unknown'))\" 2>/dev/null); mkdir -p .great_cto 2>/dev/null; printf '%s PERMISSION_DENIED tool=%s\\n' \"$(date -u +%Y-%m-%dT%H:%M:%SZ)\" \"$TOOL\" >> .great_cto/permission-denied.log 2>/dev/null; if [ \"$TOOL\" = \"Bash\" ] || [ \"$TOOL\" = \"Write\" ]; then printf '%s PERMISSION_DENIED %s \u2014 agent may be stuck. Run /inbox.\\n' \"$(date -u +%Y-%m-%dT%H:%M:%SZ)\" \"$TOOL\" >> .great_cto/permission-denied.log 2>/dev/null; fi; true; node \"${CLAUDE_PLUGIN_ROOT}/scripts/lib/agent-events.mjs\" --emit denied --tool \"$TOOL\" 2>/dev/null || true",
|
|
276
287
|
"timeout": 3
|
|
277
288
|
}
|
|
278
289
|
]
|
|
@@ -288,6 +299,16 @@
|
|
|
288
299
|
{
|
|
289
300
|
"type": "command",
|
|
290
301
|
"command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/hooks/cross-review-gate.mjs\" 2>/dev/null || true"
|
|
302
|
+
},
|
|
303
|
+
{
|
|
304
|
+
"type": "command",
|
|
305
|
+
"async": true,
|
|
306
|
+
"command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/hooks/turn-snapshot.mjs\" 2>/dev/null || true"
|
|
307
|
+
},
|
|
308
|
+
{
|
|
309
|
+
"type": "command",
|
|
310
|
+
"async": true,
|
|
311
|
+
"command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/hooks/register-project.mjs\" 2>/dev/null || true"
|
|
291
312
|
}
|
|
292
313
|
]
|
|
293
314
|
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// Agent activity for the board (ADR-021, phase 1).
|
|
2
|
+
//
|
|
3
|
+
// The hooks append agent events to <project>/.great_cto/events.jsonl through
|
|
4
|
+
// scripts/lib/agent-events.mjs; this reads them for one project, and gives the
|
|
5
|
+
// watcher a cheap stamp to notice that something new arrived.
|
|
6
|
+
//
|
|
7
|
+
// Three states come straight from the reader and are passed through unchanged:
|
|
8
|
+
// `none` (no file — no events recorded, which is not an idle agent), `live`, and
|
|
9
|
+
// `unreadable` (a file the board could not read — not zero events). `attention`
|
|
10
|
+
// is the pipeline events whose outcome means the chain did not move: on
|
|
11
|
+
// 2026-09-14 a run stopped on `no-rule` and nothing on the board said so.
|
|
12
|
+
|
|
13
|
+
import fs from 'fs';
|
|
14
|
+
import path from 'path';
|
|
15
|
+
import { readEvents, readEventsSince, EVENTS_FILE } from '../../../scripts/lib/agent-events.mjs';
|
|
16
|
+
|
|
17
|
+
/** Journal outcomes after which the pipeline did not move on. */
|
|
18
|
+
export const STALLED_OUTCOMES = Object.freeze(['no-rule', 'no-verdict', 'unknown-verdict', 'no-map', 'breaker', 'blocked-budget']);
|
|
19
|
+
|
|
20
|
+
export function agentActivity(cwd, { limit = 20 } = {}) {
|
|
21
|
+
const r = readEvents(path.join(cwd, '.great_cto'), { limit });
|
|
22
|
+
const attention = r.events.filter((e) => e.kind === 'pipeline' && STALLED_OUTCOMES.includes(e.outcome)).slice(-3);
|
|
23
|
+
return { state: r.state, why: r.why ?? null, events: r.events, attention, bad: r.bad ?? 0 };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The same, resumable (ADR-021 phase 2): events after `cursor` as a `delta`, or a
|
|
28
|
+
* `snapshot` when the cursor no longer points into this project's file. `cursor` is
|
|
29
|
+
* what the next call should pass; `gap` says a snapshot replaced more events than a
|
|
30
|
+
* replay sends. `attention` covers only the events returned — the page merges.
|
|
31
|
+
*/
|
|
32
|
+
export function agentActivitySince(cwd, cursor, { limit = 20 } = {}) {
|
|
33
|
+
const r = readEventsSince(path.join(cwd, '.great_cto'), cursor, { limit });
|
|
34
|
+
const attention = r.events.filter((e) => e.kind === 'pipeline' && STALLED_OUTCOMES.includes(e.outcome)).slice(-3);
|
|
35
|
+
return {
|
|
36
|
+
state: r.state, mode: r.mode ?? 'snapshot', why: r.why ?? null, events: r.events, attention,
|
|
37
|
+
bad: r.bad ?? 0, cursor: r.cursor, gap: r.gap === true,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* What the watcher compares between polls. `none` when there is no file; the
|
|
43
|
+
* error code when it cannot be stat'ed, so a file that turns unreadable is a
|
|
44
|
+
* change too.
|
|
45
|
+
*/
|
|
46
|
+
export function activityStamp(cwd) {
|
|
47
|
+
try {
|
|
48
|
+
const s = fs.statSync(path.join(cwd, '.great_cto', EVENTS_FILE));
|
|
49
|
+
return `${s.size}:${s.mtimeMs}`;
|
|
50
|
+
} catch (err) {
|
|
51
|
+
return err?.code === 'ENOENT' ? 'none' : `error:${err?.code || 'unknown'}`;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
// Gate approval tokens (ADR-024 §1).
|
|
2
|
+
//
|
|
3
|
+
// POST /api/gates/:id closed a gate, logged it, woke the pipeline and, with sharing
|
|
4
|
+
// on, republished the public report — guarded by the Host allowlist and the Origin
|
|
5
|
+
// check alone. The controlled Codex host already refused an approval without the
|
|
6
|
+
// token issued for that pending gate, or after the tree changed; the board did not.
|
|
7
|
+
//
|
|
8
|
+
// A token here is minted when the board lists a pending gate, is single-use,
|
|
9
|
+
// expires, and is bound to the project tree OUTSIDE .great_cto/ as it was when the
|
|
10
|
+
// board first showed the gate. .great_cto/ is excluded because an approval writes
|
|
11
|
+
// the pipeline's own files there (gate row, decision log, wake record, this store),
|
|
12
|
+
// and `init` does not gitignore it: bound to the whole tree, the first approval
|
|
13
|
+
// would invalidate every other open gate though no reviewed code moved.
|
|
14
|
+
//
|
|
15
|
+
// Every check fails closed and says why. A store that cannot be read issues no
|
|
16
|
+
// tokens and accepts none — it is never silently overwritten.
|
|
17
|
+
|
|
18
|
+
import fs from 'fs';
|
|
19
|
+
import path from 'path';
|
|
20
|
+
import { randomUUID } from 'crypto';
|
|
21
|
+
import { treeReceipt, receiptHash, compareReceipts } from '../../../scripts/lib/receipt.mjs';
|
|
22
|
+
|
|
23
|
+
export const TOKENS_FILE = 'gate-tokens.json';
|
|
24
|
+
export const DEFAULT_TTL_MS = 24 * 60 * 60 * 1000;
|
|
25
|
+
// Why these are outside the binding: both are the pipeline's own state, and a
|
|
26
|
+
// decision itself writes to them — .great_cto/ gets the gate row, decision log, wake
|
|
27
|
+
// and this store; .beads/ gets `bd update`'s interactions.jsonl. Counting either would
|
|
28
|
+
// let one approval refuse every other open gate as "changed". .beads/ was found by
|
|
29
|
+
// tests/pipeline-e2e.test.mjs: approving gate:plan made gate:ship read stale.
|
|
30
|
+
export const BINDING_EXCLUDE = Object.freeze(['.great_cto', '.beads']);
|
|
31
|
+
|
|
32
|
+
/** Test seam: GREAT_CTO_GATE_TOKEN_TTL_MS; production is 24 h. */
|
|
33
|
+
export function tokenTtlMs(env = process.env) {
|
|
34
|
+
const n = Number(env.GREAT_CTO_GATE_TOKEN_TTL_MS);
|
|
35
|
+
return Number.isFinite(n) && n > 0 ? n : DEFAULT_TTL_MS;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const storePath = (cwd) => path.join(cwd, '.great_cto', TOKENS_FILE);
|
|
39
|
+
|
|
40
|
+
/** @returns {{state:'none'|'ok'|'unreadable', tokens:object, why?:string}} */
|
|
41
|
+
export function readTokens(cwd) {
|
|
42
|
+
let raw;
|
|
43
|
+
try { raw = fs.readFileSync(storePath(cwd), 'utf8'); }
|
|
44
|
+
catch (err) {
|
|
45
|
+
if (err?.code === 'ENOENT') return { state: 'none', tokens: {} };
|
|
46
|
+
return { state: 'unreadable', tokens: {}, why: String(err?.code || err?.message || err) };
|
|
47
|
+
}
|
|
48
|
+
try {
|
|
49
|
+
const parsed = JSON.parse(raw);
|
|
50
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) throw Error('not an object');
|
|
51
|
+
return { state: 'ok', tokens: parsed };
|
|
52
|
+
} catch (err) {
|
|
53
|
+
return { state: 'unreadable', tokens: {}, why: `the token store could not be parsed: ${err.message}` };
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function writeTokens(cwd, tokens) {
|
|
58
|
+
fs.mkdirSync(path.join(cwd, '.great_cto'), { recursive: true });
|
|
59
|
+
const file = storePath(cwd);
|
|
60
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
61
|
+
fs.writeFileSync(tmp, JSON.stringify(tokens, null, 2) + '\n', { mode: 0o600 });
|
|
62
|
+
fs.renameSync(tmp, file);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** The tree a token is bound to: the project outside .great_cto/. Null outside git. */
|
|
66
|
+
export function bindingReceipt(cwd) {
|
|
67
|
+
return treeReceipt(cwd, { exclude: BINDING_EXCLUDE });
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Tokens for the pending gates, minting what is missing or expired.
|
|
72
|
+
*
|
|
73
|
+
* `gates` is the FULL pending list — entries for gates no longer pending are
|
|
74
|
+
* dropped. At most one receipt is computed per call (about 300 ms on a real tree),
|
|
75
|
+
* and only when something has to be minted.
|
|
76
|
+
*
|
|
77
|
+
* @param {{id:string, gate?:string, guarded?:boolean}[]} gates
|
|
78
|
+
* @returns {{state:'ok'|'unreadable', tokens:Record<string,string>, why?:string}}
|
|
79
|
+
*/
|
|
80
|
+
export function issueTokens(cwd, gates, { now = Date.now(), env = process.env } = {}) {
|
|
81
|
+
const store = readTokens(cwd);
|
|
82
|
+
if (store.state === 'unreadable') return { state: 'unreadable', tokens: {}, why: store.why };
|
|
83
|
+
const ttl = tokenTtlMs(env);
|
|
84
|
+
const pending = new Map((gates || []).filter((g) => g && typeof g.id === 'string' && g.id).map((g) => [g.id, g]));
|
|
85
|
+
const next = {};
|
|
86
|
+
const toMint = [];
|
|
87
|
+
for (const [id, g] of pending) {
|
|
88
|
+
const e = store.tokens[id];
|
|
89
|
+
if (e && typeof e.token === 'string' && Number.isFinite(e.firstSeenAt) && now - e.firstSeenAt <= ttl) next[id] = e;
|
|
90
|
+
else toMint.push(g);
|
|
91
|
+
}
|
|
92
|
+
if (toMint.length) {
|
|
93
|
+
const receipt = bindingReceipt(cwd);
|
|
94
|
+
const hash = receiptHash(receipt);
|
|
95
|
+
for (const g of toMint) {
|
|
96
|
+
next[g.id] = {
|
|
97
|
+
token: randomUUID(), firstSeenAt: now, receiptHash: hash, receipt,
|
|
98
|
+
gate: typeof g.gate === 'string' ? g.gate : '', guarded: g.guarded === true,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
const changed = toMint.length || Object.keys(store.tokens).some((id) => !pending.has(id));
|
|
103
|
+
if (changed) writeTokens(cwd, next);
|
|
104
|
+
return { state: 'ok', tokens: Object.fromEntries(Object.entries(next).map(([id, e]) => [id, e.token])) };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Is `token` the live token for gate `id`?
|
|
109
|
+
* @returns {{ok:true, entry:object} | {ok:false, status:number, outcome:string, why:string}}
|
|
110
|
+
*/
|
|
111
|
+
export function checkToken(cwd, id, token, { now = Date.now(), env = process.env } = {}) {
|
|
112
|
+
const store = readTokens(cwd);
|
|
113
|
+
if (store.state === 'unreadable') {
|
|
114
|
+
return { ok: false, status: 403, outcome: 'refused-unreadable', why: `approval tokens could not be read: ${store.why}` };
|
|
115
|
+
}
|
|
116
|
+
const entry = store.tokens[id];
|
|
117
|
+
if (!entry || typeof token !== 'string' || !token || token !== entry.token) {
|
|
118
|
+
return { ok: false, status: 403, outcome: 'refused-token', why: 'no valid approval token for this gate — reload the inbox and try again' };
|
|
119
|
+
}
|
|
120
|
+
if (!Number.isFinite(entry.firstSeenAt) || now - entry.firstSeenAt > tokenTtlMs(env)) {
|
|
121
|
+
return { ok: false, status: 403, outcome: 'refused-expired', why: 'this approval token has expired — reload the inbox to see the gate as it is now' };
|
|
122
|
+
}
|
|
123
|
+
return { ok: true, entry };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Is the tree outside .great_cto/ still the one the gate was shown with?
|
|
128
|
+
* @returns {{ok:true, bound:boolean} | {ok:false, status:409, outcome:'refused-stale', why:string, changed:string[]}}
|
|
129
|
+
*/
|
|
130
|
+
export function checkBinding(cwd, entry) {
|
|
131
|
+
const current = bindingReceipt(cwd);
|
|
132
|
+
if (!entry.receipt && !current) return { ok: true, bound: false }; // not a git repository: nothing to bind to, and said so
|
|
133
|
+
if (receiptHash(current) === entry.receiptHash) return { ok: true, bound: true };
|
|
134
|
+
const cmp = compareReceipts(entry.receipt, current, { cwd });
|
|
135
|
+
const changed = [...(cmp.changed || []), ...(cmp.removed || []), ...(cmp.added || [])];
|
|
136
|
+
return {
|
|
137
|
+
ok: false, status: 409, outcome: 'refused-stale', changed,
|
|
138
|
+
why: changed.length
|
|
139
|
+
? `the project changed since this gate was shown: ${changed.slice(0, 10).join(', ')}${changed.length > 10 ? ` and ${changed.length - 10} more` : ''}`
|
|
140
|
+
: 'the project changed since this gate was shown (a commit or an unreadable tree)',
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Single use: drop the gate's token once it has been decided. */
|
|
145
|
+
export function consumeToken(cwd, id) {
|
|
146
|
+
const store = readTokens(cwd);
|
|
147
|
+
if (store.state !== 'ok' || !(id in store.tokens)) return;
|
|
148
|
+
const next = { ...store.tokens };
|
|
149
|
+
delete next[id];
|
|
150
|
+
writeTokens(cwd, next);
|
|
151
|
+
}
|
|
@@ -13,17 +13,31 @@ import { sseClients, notifHistory } from './state.mjs';
|
|
|
13
13
|
import { autoRegisterProject, listProjects, resolveProjectCwd, resolveProjectInfo, getChangeTier, readProjectsRegistry, getRegistryDegradation } from './projects.mjs';
|
|
14
14
|
import { readVerdictsWithHealth } from './verdicts.mjs';
|
|
15
15
|
import { readScores, summarizeScores } from '../../../scripts/lib/scores.mjs';
|
|
16
|
-
import { status as routerKeyStatus, writeKey as writeRouterKey } from '../../../scripts/lib/router-key.mjs';
|
|
16
|
+
import { status as routerKeyStatus, writeKey as writeRouterKey, verifyKey as verifyRouterKey } from '../../../scripts/lib/router-key.mjs';
|
|
17
|
+
|
|
18
|
+
// The last live check of the judge key, by fingerprint. In memory: a restart
|
|
19
|
+
// re-checks, which costs one free request.
|
|
20
|
+
let routerKeyCheck = null;
|
|
17
21
|
import { broadcastTasks } from './sse.mjs';
|
|
18
22
|
import { saveNotifHistory } from './notifications.mjs';
|
|
19
23
|
import { getMemory, getPipeline, getCostHistory, getInbox, inboxElsewhere } from './data-readers.mjs';
|
|
20
24
|
import { log } from './log.mjs';
|
|
21
25
|
import { bdCacheInvalidate, checkBeadsAvailable, bdWriteSerialised, bd, bdErr, getTasks, setTaskStatusInTasksMd, getReadDegradation } from './beads.mjs';
|
|
22
26
|
import { getMetrics } from './metrics.mjs';
|
|
27
|
+
import { agentActivity, agentActivitySince } from './agent-activity.mjs';
|
|
28
|
+
import { issueTokens, checkToken, checkBinding, consumeToken } from './gate-tokens.mjs';
|
|
29
|
+
import { readTurns, turnPatch } from '../../../scripts/lib/turn-snapshot.mjs';
|
|
30
|
+
import { appendEvent } from '../../../scripts/lib/agent-events.mjs';
|
|
31
|
+
|
|
32
|
+
/** A gate decision — made or refused — as an agent event (ADR-024 §1). */
|
|
33
|
+
function recordGateEvent(cwd, id, outcome) {
|
|
34
|
+
return appendEvent(path.join(cwd, '.great_cto'), { kind: 'pipeline', agent: id, outcome });
|
|
35
|
+
}
|
|
23
36
|
import { readVerdicts } from './verdicts.mjs';
|
|
24
37
|
import { parseAgentBudgets, upsertAgentBudget, removeAgentBudget } from '../../../scripts/lib/agent-budget.mjs';
|
|
25
38
|
import { resolveSecondOpinion, SECOND_OPINION_PROVIDERS } from '../../../scripts/lib/second-opinion.mjs';
|
|
26
39
|
import { detectCodex } from '../../../scripts/lib/codex-exec.mjs';
|
|
40
|
+
import { listCodexRuns } from '../../../scripts/lib/codex-host-state.mjs';
|
|
27
41
|
import { upsertCapability, capabilitiesFromProjectMd } from '../../../scripts/lib/stack-capabilities.mjs';
|
|
28
42
|
// Moved to scripts/lib so the cross-review Stop hook can ask the same question
|
|
29
43
|
// without importing a server. Re-exported here: this module's callers are unchanged.
|
|
@@ -92,6 +106,19 @@ async function dispatch(req, res, url, cwd) {
|
|
|
92
106
|
res._gctoCwd = cwd; // remember which project this client wants
|
|
93
107
|
sseClients.add(res);
|
|
94
108
|
res.write(`event: tasks\ndata: ${JSON.stringify(getTasks(cwd))}\n\n`);
|
|
109
|
+
// ADR-021: the agent activity strip starts from a snapshot, then follows pushes.
|
|
110
|
+
// Phase 2: a client that hands back the cursor of the last frame it saw — the
|
|
111
|
+
// Last-Event-ID header, or ?since= from a page that had to recreate its
|
|
112
|
+
// EventSource — gets only what came after it. Each frame names its cursor.
|
|
113
|
+
let a;
|
|
114
|
+
try {
|
|
115
|
+
a = agentActivitySince(cwd, req.headers['last-event-id'] || url.searchParams.get('since') || null);
|
|
116
|
+
} catch (err) {
|
|
117
|
+
// Not "no events": the strip must say it could not measure them.
|
|
118
|
+
a = { state: 'unreadable', mode: 'snapshot', why: String(err?.message || err), events: [], attention: [], bad: 0, cursor: null, gap: false };
|
|
119
|
+
}
|
|
120
|
+
res._gctoAgentCursor = a.cursor;
|
|
121
|
+
res.write(`${a.cursor ? `id: ${a.cursor}\n` : ''}event: agent\ndata: ${JSON.stringify(a)}\n\n`);
|
|
95
122
|
req.on('close', () => sseClients.delete(res));
|
|
96
123
|
return true;
|
|
97
124
|
}
|
|
@@ -520,6 +547,26 @@ async function dispatch(req, res, url, cwd) {
|
|
|
520
547
|
res.end(JSON.stringify({ error: 'invalid action' }));
|
|
521
548
|
return;
|
|
522
549
|
}
|
|
550
|
+
// ADR-024 §1 — an approval is bound to one request and one state. Checked before
|
|
551
|
+
// anything is written: a refused request changes no gate, logs no decision,
|
|
552
|
+
// wakes nothing and publishes nothing. A rejection needs the token too (a forged
|
|
553
|
+
// reject is still a forged request) but is never refused for a changed tree.
|
|
554
|
+
const refuse = (r) => {
|
|
555
|
+
recordGateEvent(gateCwd, id, r.outcome);
|
|
556
|
+
res.writeHead(r.status, { 'Content-Type': 'application/json' });
|
|
557
|
+
res.end(JSON.stringify({ error: r.why, outcome: r.outcome, ...(r.changed ? { changed: r.changed } : {}) }));
|
|
558
|
+
};
|
|
559
|
+
const tok = checkToken(gateCwd, id, parsed.token);
|
|
560
|
+
if (!tok.ok) { refuse(tok); return; }
|
|
561
|
+
if (action === 'approve') {
|
|
562
|
+
// The typed-name ritual used to live only in the page; the server now agrees.
|
|
563
|
+
if (tok.entry.guarded && String(parsed.confirm ?? '').trim() !== tok.entry.gate) {
|
|
564
|
+
refuse({ status: 403, outcome: 'refused-confirm', why: `this gate is expensive to undo — type ${tok.entry.gate} to approve it` });
|
|
565
|
+
return;
|
|
566
|
+
}
|
|
567
|
+
const bound = checkBinding(gateCwd, tok.entry);
|
|
568
|
+
if (!bound.ok) { refuse(bound); return; }
|
|
569
|
+
}
|
|
523
570
|
// A project can be tasks.md-backed (no working beads — e.g. its path
|
|
524
571
|
// contains a space, which embedded-dolt can't open). Only 409 when there
|
|
525
572
|
// is neither a beads store NOR a tasks.md to record the decision in.
|
|
@@ -610,6 +657,9 @@ async function dispatch(req, res, url, cwd) {
|
|
|
610
657
|
res.end(JSON.stringify({ error: (result && result.error) || 'bd update failed' }));
|
|
611
658
|
return;
|
|
612
659
|
}
|
|
660
|
+
// Single use: the decision landed, so its token is spent.
|
|
661
|
+
consumeToken(gateCwd, id);
|
|
662
|
+
recordGateEvent(gateCwd, id, action === 'approve' ? 'approved' : 'rejected');
|
|
613
663
|
// An approval is evidence that the pipeline is waiting — record it.
|
|
614
664
|
//
|
|
615
665
|
// `session-pipeline-resume` opens with a freshness shortcut: a pipeline
|
|
@@ -677,8 +727,19 @@ async function dispatch(req, res, url, cwd) {
|
|
|
677
727
|
let elsewhere;
|
|
678
728
|
try { elsewhere = inboxElsewhere(listProjects(), cwd, { readInbox: getInbox }); }
|
|
679
729
|
catch (e) { elsewhere = { state: 'unreadable', why: String(e?.message || e) }; }
|
|
730
|
+
// ADR-024 §1: each pending gate carries the token an approval must present,
|
|
731
|
+
// bound to the project as it is now. The typed name stored with it is the one
|
|
732
|
+
// the page asks for; a store that cannot be read issues nothing and says so.
|
|
733
|
+
const gates = inbox.pending_gates || [];
|
|
734
|
+
const issued = issueTokens(cwd, gates.map((g) => ({
|
|
735
|
+
id: g.id,
|
|
736
|
+
gate: g.reversibility?.gate ? `gate:${g.reversibility.gate}` : g.id,
|
|
737
|
+
guarded: ['expensive', 'unclassified'].includes(g.reversibility?.state),
|
|
738
|
+
})));
|
|
739
|
+
const pending_gates = gates.map((g) => ({ ...g, token: issued.tokens[g.id] || null }));
|
|
740
|
+
const approval_tokens = { state: issued.state, ...(issued.why ? { why: issued.why } : {}) };
|
|
680
741
|
res.writeHead(200, verdictHeaders(cwd, { 'Content-Type': 'application/json' }));
|
|
681
|
-
res.end(JSON.stringify({ ...inbox, elsewhere, second_opinion }));
|
|
742
|
+
res.end(JSON.stringify({ ...inbox, pending_gates, approval_tokens, elsewhere, second_opinion }));
|
|
682
743
|
return true;
|
|
683
744
|
}
|
|
684
745
|
|
|
@@ -822,8 +883,31 @@ async function dispatch(req, res, url, cwd) {
|
|
|
822
883
|
// single-page app a secret disclosure, and buys nothing: nobody needs to read
|
|
823
884
|
// back a key they already hold.
|
|
824
885
|
if (pathname === '/api/router-key' && req.method === 'GET') {
|
|
886
|
+
// The last live check rides along, but only for the key it checked: a replaced
|
|
887
|
+
// key must not inherit its predecessor's "verified". GET makes no network call.
|
|
888
|
+
const st = routerKeyStatus({ cwd });
|
|
889
|
+
const verification = routerKeyCheck && routerKeyCheck.fingerprint === st.fingerprint ? routerKeyCheck : null;
|
|
825
890
|
res.writeHead(200, verdictHeaders(cwd, { 'Content-Type': 'application/json' }));
|
|
826
|
-
res.end(JSON.stringify(
|
|
891
|
+
res.end(JSON.stringify({ ...st, verification }));
|
|
892
|
+
return true;
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
// A live check against OpenRouter. POST and origin-checked like the write: it
|
|
896
|
+
// spends the key on an outbound request, so another page must not trigger it.
|
|
897
|
+
if (pathname === '/api/router-key/verify' && req.method === 'POST') {
|
|
898
|
+
if (!originAllowed(req)) {
|
|
899
|
+
res.writeHead(403, { 'Content-Type': 'application/json' });
|
|
900
|
+
res.end(JSON.stringify({ error: 'origin not allowed' }));
|
|
901
|
+
return true;
|
|
902
|
+
}
|
|
903
|
+
verifyRouterKey({ cwd }).then((r) => {
|
|
904
|
+
routerKeyCheck = r;
|
|
905
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
906
|
+
res.end(JSON.stringify(r));
|
|
907
|
+
}, (e) => {
|
|
908
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
909
|
+
res.end(JSON.stringify({ state: 'unreachable', reason: String(e.message || e) }));
|
|
910
|
+
});
|
|
827
911
|
return true;
|
|
828
912
|
}
|
|
829
913
|
|
|
@@ -902,12 +986,56 @@ async function dispatch(req, res, url, cwd) {
|
|
|
902
986
|
}
|
|
903
987
|
|
|
904
988
|
// Pipeline — current stage states (idle / active / done / failed)
|
|
989
|
+
// ADR-021: agent events for this project — three states, never an empty list
|
|
990
|
+
// that could mean either "nothing happened" or "could not read".
|
|
991
|
+
if (pathname === '/api/agent-events') {
|
|
992
|
+
let limit = parseInt(url.searchParams.get('limit') || '20', 10);
|
|
993
|
+
if (!Number.isFinite(limit) || limit < 1) limit = 20;
|
|
994
|
+
if (limit > 200) limit = 200;
|
|
995
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
996
|
+
res.end(JSON.stringify(agentActivity(cwd, { limit })));
|
|
997
|
+
return true;
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
// ADR-023: what each agent turn changed. Turns are git refs in the project's own
|
|
1001
|
+
// repository; these read them and nothing else. A diff is file content, served only
|
|
1002
|
+
// on a Host the board knows (xq9h) and escaped by the page before it is drawn.
|
|
1003
|
+
if (pathname === '/api/turns') {
|
|
1004
|
+
let limit = parseInt(url.searchParams.get('limit') || '10', 10);
|
|
1005
|
+
if (!Number.isFinite(limit) || limit < 1) limit = 10;
|
|
1006
|
+
if (limit > 50) limit = 50;
|
|
1007
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1008
|
+
res.end(JSON.stringify(readTurns(cwd, { limit })));
|
|
1009
|
+
return true;
|
|
1010
|
+
}
|
|
1011
|
+
if (pathname === '/api/turns/diff') {
|
|
1012
|
+
const turnParam = url.searchParams.get('turn');
|
|
1013
|
+
const turn = /^\d{1,9}$/.test(turnParam || '') ? Number(turnParam) : NaN;
|
|
1014
|
+
const p = turnPatch(cwd, { session: url.searchParams.get('session') || '', turn });
|
|
1015
|
+
const status = p.state === 'ok' ? 200 : p.state === 'invalid' ? 400 : 404;
|
|
1016
|
+
res.writeHead(status, { 'Content-Type': 'application/json' });
|
|
1017
|
+
res.end(JSON.stringify(p));
|
|
1018
|
+
return true;
|
|
1019
|
+
}
|
|
1020
|
+
|
|
905
1021
|
if (pathname === '/api/pipeline') {
|
|
906
1022
|
res.writeHead(200, verdictHeaders(cwd, { 'Content-Type': 'application/json' }));
|
|
907
1023
|
res.end(JSON.stringify(getPipeline(cwd)));
|
|
908
1024
|
return true;
|
|
909
1025
|
}
|
|
910
1026
|
|
|
1027
|
+
// Controlled Codex host runs are stored outside worker projects. Return the
|
|
1028
|
+
// shared sanitized projection only: prompts, approval tokens and artifact
|
|
1029
|
+
// bytes never cross into the browser payload.
|
|
1030
|
+
if (pathname === '/api/codex-runs') {
|
|
1031
|
+
const result = listCodexRuns({ root: cwd });
|
|
1032
|
+
const headers = { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' };
|
|
1033
|
+
if (result.state === 'degraded') headers['X-Board-Degraded'] = encodeURIComponent(`${result.unreadable} unreadable Codex run state file(s)`);
|
|
1034
|
+
res.writeHead(200, headers);
|
|
1035
|
+
res.end(JSON.stringify(result));
|
|
1036
|
+
return true;
|
|
1037
|
+
}
|
|
1038
|
+
|
|
911
1039
|
// change_tier for the current working-tree diff — the gate + judge plan (ADR-003/004).
|
|
912
1040
|
if (pathname === '/api/change-tier') {
|
|
913
1041
|
res.writeHead(200, verdictHeaders(cwd, { 'Content-Type': 'application/json' }));
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import fs from 'fs';
|
|
2
2
|
import path from 'path';
|
|
3
|
-
import { PORT } from './config.mjs';
|
|
3
|
+
import { PORT, HOST } from './config.mjs';
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
6
|
* Escape a single value for inclusion in a CSV cell.
|
|
@@ -14,13 +14,41 @@ function csvCell(v) {
|
|
|
14
14
|
}
|
|
15
15
|
|
|
16
16
|
// Only same-origin (the board's own page) may make a state-changing request — a malicious page must not.
|
|
17
|
+
//
|
|
18
|
+
// "Same origin" used to mean: the Origin matches the Host this request arrived with.
|
|
19
|
+
// A DNS-rebinding page points its own domain at 127.0.0.1, so the browser sends
|
|
20
|
+
// Host: evil.test:PORT and Origin: http://evil.test:PORT — a perfect match. It could
|
|
21
|
+
// read every GET and pass this guard for POST, gate approvals included (great_cto-xq9h).
|
|
22
|
+
// So the Host itself must be a name the board was told about: loopback on its port,
|
|
23
|
+
// the concrete bind host, or an entry in GREAT_CTO_ALLOWED_HOSTS (a tunnel or hosted
|
|
24
|
+
// console). server.mjs checks it for every request; the Origin must name one too.
|
|
25
|
+
const WILDCARD_BINDS = new Set(['0.0.0.0', '::', '[::]', '']);
|
|
26
|
+
|
|
27
|
+
function parseAllowedHosts(raw = process.env.GREAT_CTO_ALLOWED_HOSTS) {
|
|
28
|
+
return String(raw || '').split(',').map((h) => h.trim().toLowerCase()).filter(Boolean);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function allowedHostList({ port = PORT, bindHost = HOST, extra = parseAllowedHosts() } = {}) {
|
|
32
|
+
const list = [`localhost:${port}`, `127.0.0.1:${port}`, `[::1]:${port}`];
|
|
33
|
+
const b = String(bindHost || '').toLowerCase();
|
|
34
|
+
if (!WILDCARD_BINDS.has(b)) list.push(`${b.includes(':') && !b.startsWith('[') ? `[${b}]` : b}:${port}`);
|
|
35
|
+
return [...list, ...extra];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function hostAllowed(hostHeader, opts = {}) {
|
|
39
|
+
if (typeof hostHeader !== 'string' || !hostHeader.trim()) return false;
|
|
40
|
+
return allowedHostList(opts).includes(hostHeader.trim().toLowerCase());
|
|
41
|
+
}
|
|
42
|
+
|
|
17
43
|
function originAllowed(req) {
|
|
18
44
|
const o = req.headers.origin || req.headers.referer || '';
|
|
19
45
|
if (!o) return true; // same-origin fetch / curl with no Origin
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
46
|
+
let u;
|
|
47
|
+
try { u = new URL(o); } catch { return false; }
|
|
48
|
+
if (u.protocol !== 'http:' && u.protocol !== 'https:') return false;
|
|
49
|
+
// u.host keeps an explicit port and drops a default one (https://x → "x"), which is
|
|
50
|
+
// how a tunnel host is listed. Never "whatever Host this request came with".
|
|
51
|
+
return allowedHostList().includes(u.host.toLowerCase());
|
|
24
52
|
}
|
|
25
53
|
|
|
26
54
|
// Which surface an alert belongs to. Operate-side events (autopilot runtime: dead-letters,
|
|
@@ -93,4 +121,4 @@ function isInsideDir(base, target) {
|
|
|
93
121
|
return resolvedTarget === resolvedBase || resolvedTarget.startsWith(resolvedBase + path.sep);
|
|
94
122
|
}
|
|
95
123
|
|
|
96
|
-
export { csvCell, originAllowed, eventSurface, readFileSafe, readSafe, parseSafe, isInsideDir };
|
|
124
|
+
export { csvCell, originAllowed, hostAllowed, allowedHostList, parseAllowedHosts, eventSurface, readFileSafe, readSafe, parseSafe, isInsideDir };
|
|
@@ -5,6 +5,7 @@ import { sseClients } from './state.mjs';
|
|
|
5
5
|
import { listProjects } from './projects.mjs';
|
|
6
6
|
import { bdCacheStale, getTasks, isSelfInflictedTouch } from './beads.mjs';
|
|
7
7
|
import { getPipeline, getInbox } from './data-readers.mjs';
|
|
8
|
+
import { agentActivitySince, activityStamp } from './agent-activity.mjs';
|
|
8
9
|
|
|
9
10
|
// ── File watcher ───────────────────────────────────────────────────────────────
|
|
10
11
|
function watchBeads() {
|
|
@@ -147,4 +148,36 @@ function watchVerdicts() {
|
|
|
147
148
|
} catch {}
|
|
148
149
|
}
|
|
149
150
|
|
|
150
|
-
|
|
151
|
+
// ADR-021: push agent events to the clients watching the project that wrote them.
|
|
152
|
+
//
|
|
153
|
+
// Polled, not fs.watch'd: events.jsonl is created on the first event and rotated
|
|
154
|
+
// at its cap, and a watch on a file that does not exist yet — or was just renamed
|
|
155
|
+
// away — silently watches nothing. One stat per watched project per second is
|
|
156
|
+
// cheap, and it meets the ADR's "on the board within two seconds".
|
|
157
|
+
function watchAgentEvents({ intervalMs = 1000 } = {}) {
|
|
158
|
+
const stamps = new Map();
|
|
159
|
+
const timer = setInterval(() => {
|
|
160
|
+
const cwds = new Set();
|
|
161
|
+
for (const res of sseClients) cwds.add(res._gctoCwd || process.cwd());
|
|
162
|
+
for (const cwd of cwds) {
|
|
163
|
+
const stamp = activityStamp(cwd);
|
|
164
|
+
if (stamps.get(cwd) === stamp) continue;
|
|
165
|
+
stamps.set(cwd, stamp);
|
|
166
|
+
// Phase 2: each client gets what came after ITS cursor — a delta, or a snapshot
|
|
167
|
+
// when its cursor stopped pointing into this file — and the frame names the new
|
|
168
|
+
// cursor. Nothing new for a client is nothing sent.
|
|
169
|
+
for (const res of sseClients) {
|
|
170
|
+
if ((res._gctoCwd || process.cwd()) !== cwd) continue;
|
|
171
|
+
let a;
|
|
172
|
+
try { a = agentActivitySince(cwd, res._gctoAgentCursor ?? null); } catch { continue; }
|
|
173
|
+
res._gctoAgentCursor = a.cursor;
|
|
174
|
+
if (a.mode === 'delta' && a.events.length === 0) continue;
|
|
175
|
+
try { res.write(`${a.cursor ? `id: ${a.cursor}\n` : ''}event: agent\ndata: ${JSON.stringify(a)}\n\n`); } catch { sseClients.delete(res); }
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}, intervalMs);
|
|
179
|
+
if (typeof timer.unref === 'function') timer.unref();
|
|
180
|
+
return timer;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export { watchBeads, watchVerdicts, watchAgentEvents };
|