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.
@@ -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
- const gitHooksDir = join(projectDir, ".git", "hooks");
1160
- if (!fsExistsSync(gitHooksDir))
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "great-cto",
3
- "version": "2.95.0",
3
+ "version": "2.97.0",
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",