great-cto 2.95.0 → 2.97.0
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 +5 -1
- package/board/packages/board/lib/beads.mjs +53 -5
- package/board/packages/board/lib/data-readers.mjs +53 -8
- package/board/packages/board/lib/docs.mjs +176 -0
- package/board/packages/board/lib/fleet.mjs +13 -2
- package/board/packages/board/lib/portfolio.mjs +128 -0
- package/board/packages/board/lib/projects.mjs +31 -0
- package/board/packages/board/lib/routes.mjs +253 -11
- package/board/packages/board/lib/verdicts.mjs +63 -9
- package/board/packages/board/public/index.html +568 -34
- package/board/scripts/lib/freshness.mjs +175 -0
- package/board/scripts/lib/gate-tier.mjs +279 -0
- package/board/scripts/lib/pipeline-wake.mjs +120 -0
- package/board/scripts/lib/receipt.mjs +386 -0
- package/board/scripts/lib/stand-down.mjs +147 -0
- package/board/scripts/lib/system-map.mjs +206 -0
- package/board/scripts/lib/verdict-record.mjs +18 -1
- package/dist/detect.js +1 -0
- package/dist/main.js +40 -2
- package/package.json +1 -1
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* system-map — what this project is made of, derived from the source.
|
|
4
|
+
*
|
|
5
|
+
* Why this exists
|
|
6
|
+
* ---------------
|
|
7
|
+
* `docs/ARCHITECTURE.md` is a hand-drawn ASCII diagram. It was last touched
|
|
8
|
+
* three months ago and says "34 agents"; there are sixty-nine. Around it sit
|
|
9
|
+
* twenty architecture documents, ten ADRs and thirty-one plans, each about one
|
|
10
|
+
* feature — you can read all of them and still not know how the pieces fit.
|
|
11
|
+
*
|
|
12
|
+
* A drawn diagram is stale the week after it is drawn, and nothing tells you.
|
|
13
|
+
* This one is computed when you look at it, so the only way it can be wrong is
|
|
14
|
+
* if the code is.
|
|
15
|
+
*
|
|
16
|
+
* What it does NOT try to be
|
|
17
|
+
* --------------------------
|
|
18
|
+
* Not a file-level import graph. Several hundred modules drawn as a hairball is
|
|
19
|
+
* a picture nobody reads and nobody checks. The useful altitude is the group —
|
|
20
|
+
* agents, hooks, libraries, the board, the CLI, the contracts — and the edges
|
|
21
|
+
* between groups, which is roughly C4's container level.
|
|
22
|
+
*
|
|
23
|
+
* Not prose. It reports what is there and how it connects; what any of it is
|
|
24
|
+
* FOR belongs in a document a human wrote.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { readdirSync, readFileSync, statSync, existsSync } from 'node:fs';
|
|
28
|
+
import { join, relative, sep } from 'node:path';
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The groups a great_cto project is built from, and what each one is.
|
|
32
|
+
*
|
|
33
|
+
* Ordered so the map reads top-down the way the system runs: contracts describe
|
|
34
|
+
* it, hooks fire during it, agents do the work, libraries are what they share,
|
|
35
|
+
* and the board and CLI are how a human reaches any of it.
|
|
36
|
+
*/
|
|
37
|
+
export const GROUPS = Object.freeze([
|
|
38
|
+
{ key: 'contracts', label: 'Contracts', dirs: ['shared'], ext: ['.toml'], what: 'the pipeline map, orchestrator rules' },
|
|
39
|
+
{ key: 'agents', label: 'Agents', dirs: ['agents'], ext: ['.md'], what: 'the specialists the pipeline dispatches' },
|
|
40
|
+
{ key: 'commands', label: 'Commands', dirs: ['commands'], ext: ['.md'], what: 'what a human can invoke directly' },
|
|
41
|
+
{ key: 'skills', label: 'Skills', dirs: ['skills'], ext: ['.md'], what: 'knowledge agents load on demand' },
|
|
42
|
+
{ key: 'hooks', label: 'Hooks', dirs: ['scripts/hooks'], ext: ['.mjs', '.sh', '.py'], what: 'what fires on session, tool and stop events' },
|
|
43
|
+
{ key: 'libs', label: 'Libraries', dirs: ['scripts/lib'], ext: ['.mjs'], what: 'the logic hooks and commands share' },
|
|
44
|
+
{ key: 'board', label: 'Board', dirs: ['packages/board'], ext: ['.mjs', '.html'], what: 'the admin view, zero runtime dependencies' },
|
|
45
|
+
{ key: 'cli', label: 'CLI', dirs: ['packages/cli/src'], ext: ['.ts', '.mjs'], what: 'the published npm package' },
|
|
46
|
+
{ key: 'evals', label: 'Evals', dirs: ['tests/eval'], ext: ['.md'], what: 'what each agent is measured against' },
|
|
47
|
+
]);
|
|
48
|
+
|
|
49
|
+
// Generated or third-party trees, plus _shared: its contracts are counted by
|
|
50
|
+
// the groups that fetch them, and counting the directory twice inflated Agents.
|
|
51
|
+
const SKIP = new Set(['node_modules', 'dist', 'build', '.git', 'coverage', 'vendor', '_shared']);
|
|
52
|
+
|
|
53
|
+
function filesIn(root, dir, ext, depth = 0, out = []) {
|
|
54
|
+
if (depth > 3) return out;
|
|
55
|
+
let entries;
|
|
56
|
+
try { entries = readdirSync(join(root, dir), { withFileTypes: true }); } catch { return out; }
|
|
57
|
+
for (const e of entries) {
|
|
58
|
+
if (SKIP.has(e.name) || e.name.startsWith('.')) continue;
|
|
59
|
+
const rel = join(dir, e.name);
|
|
60
|
+
if (e.isDirectory()) { filesIn(root, rel, ext, depth + 1, out); continue; }
|
|
61
|
+
if (ext.some((x) => e.name.endsWith(x))) out.push(rel);
|
|
62
|
+
}
|
|
63
|
+
return out;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Which group a repo-relative path belongs to, or null. */
|
|
67
|
+
export function groupOf(rel) {
|
|
68
|
+
const p = String(rel).split(sep).join('/');
|
|
69
|
+
for (const g of GROUPS) {
|
|
70
|
+
if (g.dirs.some((d) => p === d || p.startsWith(`${d}/`))) return g.key;
|
|
71
|
+
}
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Edges between groups, counted from real `import ... from '...'` statements.
|
|
77
|
+
*
|
|
78
|
+
* Only relative imports: a dependency on `node:fs` says nothing about how this
|
|
79
|
+
* project is arranged, and a dependency on `scripts/lib/gate-state.mjs` says
|
|
80
|
+
* everything.
|
|
81
|
+
*/
|
|
82
|
+
export function importEdges(root, files) {
|
|
83
|
+
const counts = new Map();
|
|
84
|
+
for (const rel of files) {
|
|
85
|
+
const from = groupOf(rel);
|
|
86
|
+
if (!from) continue;
|
|
87
|
+
let src;
|
|
88
|
+
try { src = readFileSync(join(root, rel), 'utf8'); } catch { continue; }
|
|
89
|
+
for (const m of src.matchAll(/(?:^|\n)\s*import\s[^'"]*from\s+['"](\.[^'"]+)['"]/g)) {
|
|
90
|
+
const target = join(rel, '..', m[1]);
|
|
91
|
+
const to = groupOf(relative(root, join(root, target)));
|
|
92
|
+
if (!to || to === from) continue;
|
|
93
|
+
const key = `${from}→${to}`;
|
|
94
|
+
counts.set(key, (counts.get(key) || 0) + 1);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return [...counts.entries()]
|
|
98
|
+
.map(([k, count]) => ({ from: k.split('→')[0], to: k.split('→')[1], count }))
|
|
99
|
+
.sort((a, b) => b.count - a.count);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** The whole map: what exists, and how the parts reach each other. */
|
|
103
|
+
export function systemMap(root = process.cwd()) {
|
|
104
|
+
const nodes = [];
|
|
105
|
+
const allFiles = [];
|
|
106
|
+
for (const g of GROUPS) {
|
|
107
|
+
const files = g.dirs.flatMap((d) => filesIn(root, d, g.ext));
|
|
108
|
+
allFiles.push(...files);
|
|
109
|
+
if (files.length) nodes.push({ key: g.key, label: g.label, what: g.what, count: files.length });
|
|
110
|
+
}
|
|
111
|
+
return {
|
|
112
|
+
root,
|
|
113
|
+
generatedAt: new Date().toISOString(),
|
|
114
|
+
nodes,
|
|
115
|
+
edges: importEdges(root, allFiles.filter((f) => /\.(mjs|ts)$/.test(f))),
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* The map as Mermaid.
|
|
121
|
+
*
|
|
122
|
+
* Every count is in the label, so a stale screenshot of this diagram is
|
|
123
|
+
* self-evidently stale — the number is the part that dates it. `ARCHITECTURE.md`
|
|
124
|
+
* said 34 agents for three months because nothing in the picture disagreed with
|
|
125
|
+
* itself.
|
|
126
|
+
*/
|
|
127
|
+
export function toMermaid(map) {
|
|
128
|
+
const lines = ['flowchart TD'];
|
|
129
|
+
for (const n of map.nodes) {
|
|
130
|
+
lines.push(` ${n.key}["${n.label}<br/><small>${n.count} files</small>"]`);
|
|
131
|
+
}
|
|
132
|
+
for (const e of map.edges) {
|
|
133
|
+
lines.push(` ${e.from} -->|${e.count}| ${e.to}`);
|
|
134
|
+
}
|
|
135
|
+
return lines.join('\n');
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* The pipeline as a diagram — the picture the twenty architecture documents do
|
|
140
|
+
* not add up to.
|
|
141
|
+
*
|
|
142
|
+
* The import graph above says how the code is arranged; this says how the system
|
|
143
|
+
* RUNS, which is the question someone opening the board actually has. It is
|
|
144
|
+
* drawn from `shared/pipeline.toml`, the same file the dispatcher acts on, so a
|
|
145
|
+
* diagram that disagrees with the pipeline is impossible rather than merely
|
|
146
|
+
* unlikely.
|
|
147
|
+
*
|
|
148
|
+
* Gates are drawn because they are where a human stands in the flow, and a map
|
|
149
|
+
* of an automated pipeline that hides its stopping points describes something
|
|
150
|
+
* other than what runs.
|
|
151
|
+
*/
|
|
152
|
+
export function pipelineMermaid(tomlText) {
|
|
153
|
+
const transitions = {};
|
|
154
|
+
let cur = null;
|
|
155
|
+
for (const raw of String(tomlText || '').split('\n')) {
|
|
156
|
+
const line = raw.trim();
|
|
157
|
+
if (line.startsWith('#') || !line) continue;
|
|
158
|
+
const sec = line.match(/^\[transitions\.([\w-]+)\]$/);
|
|
159
|
+
if (sec) { cur = transitions[sec[1]] = {}; continue; }
|
|
160
|
+
if (line.startsWith('[')) { cur = null; continue; }
|
|
161
|
+
if (!cur) continue;
|
|
162
|
+
const kv = line.match(/^([\w-]+)\s*=\s*(.+)$/);
|
|
163
|
+
if (!kv) continue;
|
|
164
|
+
const [, k, v] = kv;
|
|
165
|
+
cur[k] = v.startsWith('[')
|
|
166
|
+
? v.replace(/^\[|\]$/g, '').split(',').map((x) => x.trim().replace(/^"|"$/g, '')).filter(Boolean)
|
|
167
|
+
: v.trim().replace(/^"|"$/g, '');
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const id = (a) => a.replace(/[^\w]/g, '_');
|
|
171
|
+
const lines = ['flowchart TD'];
|
|
172
|
+
const seen = new Set();
|
|
173
|
+
for (const [agent, rule] of Object.entries(transitions)) {
|
|
174
|
+
if (!seen.has(agent)) { lines.push(` ${id(agent)}["${agent}"]`); seen.add(agent); }
|
|
175
|
+
for (const next of rule.next || []) {
|
|
176
|
+
if (!seen.has(next)) { lines.push(` ${id(next)}["${next}"]`); seen.add(next); }
|
|
177
|
+
const gates = Array.isArray(rule.gate) ? rule.gate : rule.gate ? [rule.gate] : [];
|
|
178
|
+
lines.push(gates.length
|
|
179
|
+
? ` ${id(agent)} -->|${gates.join(' + ')}| ${id(next)}`
|
|
180
|
+
: ` ${id(agent)} --> ${id(next)}`);
|
|
181
|
+
}
|
|
182
|
+
for (const j of rule.join || []) {
|
|
183
|
+
if (!seen.has(j)) { lines.push(` ${id(j)}["${j}"]`); seen.add(j); }
|
|
184
|
+
lines.push(` ${id(j)} -.->|join| ${id(agent)}`);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
return lines.join('\n');
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// ── CLI ─────────────────────────────────────────────────────────────────────
|
|
191
|
+
|
|
192
|
+
if (import.meta.url === `file://${process.argv[1]}`) {
|
|
193
|
+
const map = systemMap(process.cwd());
|
|
194
|
+
if (process.argv.includes('--json')) {
|
|
195
|
+
console.log(JSON.stringify(map, null, 2));
|
|
196
|
+
} else if (process.argv.includes('--pipeline')) {
|
|
197
|
+
console.log(pipelineMermaid(readFileSync(join(process.cwd(), 'shared', 'pipeline.toml'), 'utf8')));
|
|
198
|
+
} else if (process.argv.includes('--mermaid')) {
|
|
199
|
+
console.log(toMermaid(map));
|
|
200
|
+
} else {
|
|
201
|
+
console.log(`${map.nodes.length} groups, ${map.edges.length} edges — generated ${map.generatedAt}\n`);
|
|
202
|
+
for (const n of map.nodes) console.log(` ${n.label.padEnd(12)} ${String(n.count).padStart(4)} ${n.what}`);
|
|
203
|
+
console.log('');
|
|
204
|
+
for (const e of map.edges.slice(0, 12)) console.log(` ${e.from} → ${e.to} (${e.count})`);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
@@ -74,7 +74,7 @@ export function validateVerdict(rec) {
|
|
|
74
74
|
}
|
|
75
75
|
|
|
76
76
|
/** Build a v1 record, normalising what the caller passed. Throws if unusable. */
|
|
77
|
-
export function makeVerdict({ ts, agent, verdict, project, cost_usd, meta } = {}) {
|
|
77
|
+
export function makeVerdict({ ts, agent, verdict, project, cost_usd, meta, receipt } = {}) {
|
|
78
78
|
const rec = {
|
|
79
79
|
v: VERDICT_FORMAT_VERSION,
|
|
80
80
|
ts: String(ts ?? '').trim(),
|
|
@@ -84,6 +84,12 @@ export function makeVerdict({ ts, agent, verdict, project, cost_usd, meta } = {}
|
|
|
84
84
|
if (project) rec.project = String(project).trim();
|
|
85
85
|
if (cost_usd !== undefined && cost_usd !== null && cost_usd !== '') rec.cost_usd = Number(cost_usd);
|
|
86
86
|
if (meta && Object.keys(meta).length) rec.meta = meta;
|
|
87
|
+
// The fingerprint of the tree this verdict was formed over — see receipt.mjs.
|
|
88
|
+
// Optional by design: every log written before this existed still validates,
|
|
89
|
+
// and a verdict whose receipt could not be built is still a verdict. Absent
|
|
90
|
+
// means "no receipt", which the checker reports as its own state rather than
|
|
91
|
+
// as a match.
|
|
92
|
+
if (receipt && typeof receipt === 'object' && receipt.head) rec.receipt = receipt;
|
|
87
93
|
|
|
88
94
|
const { valid, errors } = validateVerdict(rec);
|
|
89
95
|
// Refusing here is the point: a malformed record that reaches the log is read
|
|
@@ -92,6 +98,16 @@ export function makeVerdict({ ts, agent, verdict, project, cost_usd, meta } = {}
|
|
|
92
98
|
return rec;
|
|
93
99
|
}
|
|
94
100
|
|
|
101
|
+
/** A receipt from the environment, or nothing. Never throws — an unparseable
|
|
102
|
+
* receipt must not stop a verdict from being recorded. */
|
|
103
|
+
export function parseReceiptEnv(raw) {
|
|
104
|
+
if (!raw || !String(raw).trim()) return undefined;
|
|
105
|
+
try {
|
|
106
|
+
const r = JSON.parse(raw);
|
|
107
|
+
return r && r.head ? r : undefined;
|
|
108
|
+
} catch { return undefined; }
|
|
109
|
+
}
|
|
110
|
+
|
|
95
111
|
/** One record → one NDJSON line (no trailing newline). */
|
|
96
112
|
export function formatVerdict(rec) {
|
|
97
113
|
return JSON.stringify(rec);
|
|
@@ -202,6 +218,7 @@ async function main(argv) {
|
|
|
202
218
|
project: process.env.PROJECT_SLUG || undefined,
|
|
203
219
|
cost_usd: process.env.COST === '' || process.env.COST === undefined ? undefined : Number(process.env.COST),
|
|
204
220
|
meta,
|
|
221
|
+
receipt: parseReceiptEnv(process.env.RECEIPT),
|
|
205
222
|
})) + '\n');
|
|
206
223
|
return 0;
|
|
207
224
|
} catch (e) {
|
package/dist/detect.js
CHANGED
|
@@ -1341,6 +1341,7 @@ function mineInfraKeywords(dir, pkg) {
|
|
|
1341
1341
|
function collectTf(d, depth) {
|
|
1342
1342
|
if (depth > 4)
|
|
1343
1343
|
return;
|
|
1344
|
+
// Dependency and build output — scanning them counts other people's code.
|
|
1344
1345
|
const SKIP = new Set(["node_modules", ".git", "dist", ".terraform"]);
|
|
1345
1346
|
try {
|
|
1346
1347
|
for (const e of readdirSync(d)) {
|
package/dist/main.js
CHANGED
|
@@ -27,6 +27,7 @@ import { daemonSpec, decideEnsureAction, isBoardResponse } from "./board-daemon.
|
|
|
27
27
|
import { readFileSync, writeFileSync, copyFileSync, chmodSync, mkdirSync, unlinkSync, existsSync as fsExistsSync } from "node:fs";
|
|
28
28
|
import { dirname, join } from "node:path";
|
|
29
29
|
import { fileURLToPath } from "node:url";
|
|
30
|
+
import { execFileSync } from "node:child_process";
|
|
30
31
|
import { homedir } from "node:os";
|
|
31
32
|
function getCliVersion() {
|
|
32
33
|
try {
|
|
@@ -1154,11 +1155,48 @@ async function promoteTelemetryOptIn(opts) {
|
|
|
1154
1155
|
* .git/hooks/pre-push so that future pushes are scanned for private project
|
|
1155
1156
|
* name leaks. Best-effort — never throws.
|
|
1156
1157
|
*/
|
|
1158
|
+
/**
|
|
1159
|
+
* The directory git will actually read hooks from.
|
|
1160
|
+
*
|
|
1161
|
+
* `core.hooksPath` wins over `.git/hooks` when set, and a linked worktree reads
|
|
1162
|
+
* from the main checkout's git dir. Returns null when this is not a repository.
|
|
1163
|
+
*/
|
|
1164
|
+
function effectiveGitHooksDir(projectDir) {
|
|
1165
|
+
const git = (args) => {
|
|
1166
|
+
try {
|
|
1167
|
+
return execFileSync("git", args, {
|
|
1168
|
+
cwd: projectDir, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"],
|
|
1169
|
+
}).trim();
|
|
1170
|
+
}
|
|
1171
|
+
catch {
|
|
1172
|
+
return "";
|
|
1173
|
+
}
|
|
1174
|
+
};
|
|
1175
|
+
const top = git(["rev-parse", "--show-toplevel"]);
|
|
1176
|
+
if (!top)
|
|
1177
|
+
return null;
|
|
1178
|
+
const configured = git(["config", "--get", "core.hooksPath"]);
|
|
1179
|
+
if (configured)
|
|
1180
|
+
return resolve(top, configured);
|
|
1181
|
+
const common = git(["rev-parse", "--git-common-dir"]);
|
|
1182
|
+
return join(common ? resolve(top, common) : join(top, ".git"), "hooks");
|
|
1183
|
+
}
|
|
1157
1184
|
function installPrePushHook(projectDir) {
|
|
1158
1185
|
try {
|
|
1159
|
-
|
|
1160
|
-
|
|
1186
|
+
// Ask git where it reads hooks from, rather than assuming `.git/hooks`.
|
|
1187
|
+
//
|
|
1188
|
+
// This installer wrote the file it was asked to write and reported success
|
|
1189
|
+
// for months while `core.hooksPath` pointed at a directory the repository
|
|
1190
|
+
// had moved out of. Git honours that setting even when it does not exist, so
|
|
1191
|
+
// no hook ran, and the success message was the only evidence anyone had.
|
|
1192
|
+
const gitHooksDir = effectiveGitHooksDir(projectDir);
|
|
1193
|
+
if (!gitHooksDir)
|
|
1161
1194
|
return; // not a git repo — skip silently
|
|
1195
|
+
if (!fsExistsSync(gitHooksDir)) {
|
|
1196
|
+
warn(`git is configured to read hooks from ${gitHooksDir}, which does not exist — ` +
|
|
1197
|
+
`no hook can run until that is fixed (git config --unset core.hooksPath)`);
|
|
1198
|
+
return;
|
|
1199
|
+
}
|
|
1162
1200
|
const dest = join(gitHooksDir, "pre-push");
|
|
1163
1201
|
if (fsExistsSync(dest)) {
|
|
1164
1202
|
log(` ${dim("pre-push hook already present — skipped")}`);
|
package/package.json
CHANGED