great-cto 3.7.0 → 3.9.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.
@@ -2,7 +2,7 @@
2
2
  "name": "great_cto",
3
3
  "id": "great_cto",
4
4
  "description": "Engineering process for solo founders and teams up to 50 engineers. Agents do architecture, code review, QA, and security. You make two decisions per feature.",
5
- "version": "3.7.0",
5
+ "version": "3.9.0",
6
6
  "author": {
7
7
  "name": "Great CTO",
8
8
  "url": "https://github.com/avelikiy/great_cto"
@@ -416,6 +416,31 @@ function resolveProjectInfo(slugOrPath) {
416
416
  const matches = reg.projects.filter(p => p.slug === slugOrPath);
417
417
  const found = pickBestBySlug(matches);
418
418
  if (found) return { cwd: found.path, resolved: 'slug' };
419
+
420
+ // The identifier the board SHOWS and the identifier it RESOLVES were two
421
+ // different things.
422
+ //
423
+ // `listProjects()` derives a display slug at :184 —
424
+ // `project` / `name` / basename(dir) — while resolution matched only
425
+ // `p.slug` as stored in the registry. A project listed as `<private-project>`
426
+ // (basename of its directory) had a different slug on disk, so asking for the
427
+ // name the UI itself printed resolved to nothing and fell back to the server's
428
+ // own project: one project's session logs served under another's name. The
429
+ // fallback header said so, which is why this was a wrong answer rather than a
430
+ // silent one — but a caller that asks by the name it was given should not have
431
+ // to read a header to learn it was ignored.
432
+ //
433
+ // So the derived identifier is accepted too, and only after the stored one
434
+ // fails. Basename matching is last because it is the weakest claim: two
435
+ // directories can share a name, and pickBestBySlug already knows how to
436
+ // choose between candidates.
437
+ const byDerived = reg.projects.filter((p) => {
438
+ if (!p?.path) return false;
439
+ return path.basename(p.path) === slugOrPath;
440
+ });
441
+ const derived = pickBestBySlug(byDerived);
442
+ if (derived) return { cwd: derived.path, resolved: 'slug' };
443
+
419
444
  return { cwd: process.cwd(), resolved: 'fallback', requested: slugOrPath };
420
445
  }
421
446
 
@@ -11,6 +11,7 @@ import { eventSurface, readFileSafe, originAllowed } from './util.mjs';
11
11
  import { sseClients, notifHistory } from './state.mjs';
12
12
  import { autoRegisterProject, listProjects, resolveProjectCwd, resolveProjectInfo, getChangeTier, readProjectsRegistry, getRegistryDegradation } from './projects.mjs';
13
13
  import { readVerdictsWithHealth } from './verdicts.mjs';
14
+ import { readScores, summarizeScores } from '../../../scripts/lib/scores.mjs';
14
15
  import { broadcastTasks } from './sse.mjs';
15
16
  import { saveNotifHistory } from './notifications.mjs';
16
17
  import { getMemory, getPipeline, getCostHistory, getInbox } from './data-readers.mjs';
@@ -678,6 +679,39 @@ async function dispatch(req, res, url, cwd) {
678
679
  }
679
680
 
680
681
  // Decisions log — global ADR-style log across all projects
682
+ // Quality, kept apart from what happened.
683
+ //
684
+ // A verdict says what a run did; a score says how well, and is produced by a
685
+ // different actor at a different time. Exposed as its own endpoint for the
686
+ // same reason it is its own file: a re-score must not rewrite the run, and a
687
+ // run can carry several assessments from several scorers.
688
+ //
689
+ // `rate` is over ASSESSED runs and `unassessed` is returned beside it, so a
690
+ // caller cannot render "100%" without also being handed the count it is out
691
+ // of. An agent with nine unverifiable runs and one verified one is not a 100%
692
+ // agent, and the payload refuses to let the UI say it is.
693
+ if (pathname === '/api/scores') {
694
+ const agent = url.searchParams.get('agent') || null;
695
+ const name = url.searchParams.get('name') || 'independent-verify';
696
+ const rawLimit = url.searchParams.get('limit');
697
+ const parsed = rawLimit != null ? parseInt(rawLimit, 10) : 50;
698
+ const limit = Number.isFinite(parsed) && parsed > 0 ? Math.min(parsed, 500) : 50;
699
+
700
+ const { scores, rejected } = readScores(cwd, { agent, name });
701
+ const recent = scores.slice(-limit).reverse();
702
+ res.writeHead(200, verdictHeaders(cwd, { 'Content-Type': 'application/json' }));
703
+ res.end(JSON.stringify({
704
+ name,
705
+ agent,
706
+ scores: recent,
707
+ summary: summarizeScores(cwd, { name, agent }),
708
+ // A line that exists and could not be read is neither a score nor an
709
+ // absence of one, and saying so is cheaper than a support thread.
710
+ unreadable_lines: rejected,
711
+ }));
712
+ return true;
713
+ }
714
+
681
715
  if (pathname === '/api/decisions') {
682
716
  // Clamp `limit` to [1, 200]. Same defensive pattern as /api/cost?days
683
717
  // — handle ?limit=abc / ?limit=0 / ?limit=-5 / ?limit=999 deterministically.
@@ -0,0 +1,164 @@
1
+ /**
2
+ * scores — how well a run went, kept apart from what the run did.
3
+ *
4
+ * The verdict line answers one question: what happened. `senior-dev | APPROVED |
5
+ * feature=x | cost=$0.42`. Everything about QUALITY has had to squeeze into that
6
+ * same line as ad-hoc meta keys — `tests=46-pass`, `coverage=100`,
7
+ * `findings=3-medium` — which means an assessment cannot be added after the fact,
8
+ * cannot be revised, cannot say who made it, and cannot disagree with an earlier
9
+ * one. `independent-verify` produces exactly such an assessment and, until this
10
+ * module, wrote it nowhere at all: it printed a conclusion and returned an exit
11
+ * code, and the reasoning was gone when the terminal scrolled.
12
+ *
13
+ * A score is a separate record pointing AT a run. Borrowed from Langfuse, where
14
+ * scores are first-class objects attached to traces rather than fields inside
15
+ * them, and the separation buys four things this project needs:
16
+ *
17
+ * many per run a mechanical check and a model's judgement are different
18
+ * evidence and must not overwrite each other
19
+ * later than run a verification that takes 30s does not have to block the
20
+ * verdict that records the run
21
+ * revisable a re-score appends; nothing is rewritten
22
+ * attributable every score names its scorer, so "a script said so" and
23
+ * "a model said so" never read alike
24
+ *
25
+ * Append-only, on purpose. A judge changing its mind between runs is information
26
+ * — it was measured here that the same question got different answers — and an
27
+ * updating store would erase exactly that.
28
+ *
29
+ * NUMERIC VALUE AND THE THIRD STATE
30
+ * ---------------------------------
31
+ * `value` exists so scores can be averaged and trended. It is deliberately NULL
32
+ * for `unverifiable`, not 0. Zero would mean "scored, and scored badly"; the
33
+ * whole point of the third state is that nothing was assessed. An average that
34
+ * silently counts unassessed runs as failures is the same defect one level up
35
+ * from the one this project keeps closing.
36
+ */
37
+
38
+ import { appendFileSync, readFileSync, existsSync, mkdirSync } from 'node:fs';
39
+ import path from 'node:path';
40
+
41
+ export const SCORES_FILE = 'scores.jsonl';
42
+ export const SCORE_FORMAT_VERSION = 1;
43
+
44
+ /**
45
+ * Categorical states a score may carry, and the numeric value each maps to.
46
+ * `null` means "not assessed" and must never be coerced to a number.
47
+ */
48
+ export const SCORE_VALUES = Object.freeze({
49
+ verified: 1,
50
+ rework: 0,
51
+ unverifiable: null,
52
+ });
53
+
54
+ export const scoresPath = (cwd = process.cwd()) => path.join(cwd, '.great_cto', SCORES_FILE);
55
+
56
+ /**
57
+ * Build a score record. Throws on a shape that could not be read back, rather
58
+ * than writing a line that parses and means nothing.
59
+ *
60
+ * @param {object} o
61
+ * @param {string} o.agent the run being scored
62
+ * @param {string} [o.runTs] that run's verdict timestamp — the join key
63
+ * @param {string} o.name what was assessed, e.g. 'independent-verify'
64
+ * @param {string} o.state one of SCORE_VALUES
65
+ * @param {string} o.scorer who assessed it, e.g. 'mechanical' | 'kimi-k3'
66
+ */
67
+ export function makeScore({ ts, agent, runTs = null, name, state, scorer, findings = [], comment = '', meta = {} } = {}) {
68
+ const errors = [];
69
+ if (!agent) errors.push('agent is required — a score with no run to point at is not a score');
70
+ if (!name) errors.push('name is required');
71
+ if (!scorer) errors.push('scorer is required — an unattributed assessment cannot be weighed');
72
+ if (!Object.prototype.hasOwnProperty.call(SCORE_VALUES, state)) {
73
+ errors.push(`state must be one of ${Object.keys(SCORE_VALUES).join(' | ')}, got ${JSON.stringify(state)}`);
74
+ }
75
+ const stamp = ts || new Date().toISOString().replace(/\.\d+Z$/, 'Z');
76
+ if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/.test(stamp)) errors.push('ts must be ISO-8601');
77
+ if (errors.length) throw new Error(`invalid score: ${errors.join('; ')}`);
78
+
79
+ return {
80
+ v: SCORE_FORMAT_VERSION,
81
+ ts: stamp,
82
+ agent,
83
+ ...(runTs ? { run_ts: runTs } : {}),
84
+ name,
85
+ state,
86
+ value: SCORE_VALUES[state],
87
+ scorer,
88
+ ...(findings.length ? { findings } : {}),
89
+ ...(comment ? { comment } : {}),
90
+ ...(Object.keys(meta).length ? { meta } : {}),
91
+ };
92
+ }
93
+
94
+ /** Append one score. Creates `.great_cto/` if the project has none yet. */
95
+ export function writeScore(cwd, score) {
96
+ const rec = score.v === SCORE_FORMAT_VERSION ? score : makeScore(score);
97
+ const dir = path.join(cwd, '.great_cto');
98
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
99
+ appendFileSync(scoresPath(cwd), JSON.stringify(rec) + '\n');
100
+ return rec;
101
+ }
102
+
103
+ /**
104
+ * @returns {{scores: object[], rejected: number}} — a line that cannot be parsed
105
+ * is counted, not dropped silently. A store that quietly discards half its
106
+ * contents reads exactly like a store that is empty.
107
+ */
108
+ export function readScores(cwd = process.cwd(), { agent = null, name = null } = {}) {
109
+ const file = scoresPath(cwd);
110
+ if (!existsSync(file)) return { scores: [], rejected: 0 };
111
+ let text = '';
112
+ try { text = readFileSync(file, 'utf8'); } catch { return { scores: [], rejected: 0 }; }
113
+
114
+ const scores = [];
115
+ let rejected = 0;
116
+ for (const line of text.split('\n')) {
117
+ if (!line.trim()) continue;
118
+ let o;
119
+ try { o = JSON.parse(line); } catch { rejected += 1; continue; }
120
+ if (!o || !o.agent || !o.name || !Object.prototype.hasOwnProperty.call(SCORE_VALUES, o.state)) {
121
+ rejected += 1; continue;
122
+ }
123
+ if (agent && o.agent !== agent) continue;
124
+ if (name && o.name !== name) continue;
125
+ scores.push(o);
126
+ }
127
+ return { scores, rejected };
128
+ }
129
+
130
+ /**
131
+ * The current assessment for one run: the newest score of that name.
132
+ *
133
+ * Newest by `ts`, not by file order — a score can be written later than the run
134
+ * it points at, and two scorers can finish out of order.
135
+ */
136
+ export function latestScore(cwd, { agent, runTs = null, name }) {
137
+ const { scores } = readScores(cwd, { agent, name });
138
+ const candidates = runTs ? scores.filter((s) => s.run_ts === runTs) : scores;
139
+ if (!candidates.length) return null;
140
+ return candidates.reduce((a, b) => (String(b.ts) > String(a.ts) ? b : a));
141
+ }
142
+
143
+ /**
144
+ * Aggregate one score name across runs.
145
+ *
146
+ * `assessed` counts only scores with a numeric value, and `rate` divides by that
147
+ * — never by the total. An agent with nine unverifiable runs and one verified
148
+ * one scores 100% here, and says `assessed: 1` beside it. Reporting 10% instead
149
+ * would be a made-up number about work nobody looked at.
150
+ */
151
+ export function summarizeScores(cwd, { name, agent = null } = {}) {
152
+ const { scores, rejected } = readScores(cwd, { agent, name });
153
+ const numeric = scores.filter((s) => typeof s.value === 'number');
154
+ const unassessed = scores.length - numeric.length;
155
+ const sum = numeric.reduce((a, s) => a + s.value, 0);
156
+ return {
157
+ total: scores.length,
158
+ assessed: numeric.length,
159
+ unassessed,
160
+ rate: numeric.length ? Math.round((sum / numeric.length) * 100) : null,
161
+ byState: scores.reduce((acc, s) => { acc[s.state] = (acc[s.state] || 0) + 1; return acc; }, {}),
162
+ rejected,
163
+ };
164
+ }
package/dist/installer.js CHANGED
@@ -1,9 +1,11 @@
1
1
  // Install the great_cto plugin into ~/.claude/plugins/cache/local/great_cto/<version>/.
2
2
  // Uses git clone. Falls back to tarball fetch if git is unavailable.
3
3
  import { spawnSync, execFileSync } from "node:child_process";
4
+ import { cpSync } from "node:fs";
4
5
  import { existsSync, mkdirSync, rmSync, readFileSync, readdirSync } from "node:fs";
5
6
  import { homedir } from "node:os";
6
- import { join } from "node:path";
7
+ import { dirname, join } from "node:path";
8
+ import { fileURLToPath } from "node:url";
7
9
  import { log, success, warn, dim } from "./ui.js";
8
10
  const REPO_URL = "https://github.com/avelikiy/great_cto.git";
9
11
  export function hasGit() {
@@ -113,14 +115,81 @@ export function install(opts = {}) {
113
115
  throw new Error(`git clone failed: ${stderr}`);
114
116
  }
115
117
  }
116
- // Sanity check: did we get a plugin?
117
- const manifest = join(pluginDir, ".claude-plugin", "plugin.json");
118
- if (!existsSync(manifest)) {
119
- throw new Error(`Install appeared to succeed but ${manifest} is missing. Repo layout may have changed.`);
118
+ // A clone carries sources; the plugin runs on the build. Supply it from this
119
+ // CLI's own dist before checking whether the result can run.
120
+ const supplied = supplyBuiltDist(pluginDir);
121
+ if (supplied != null)
122
+ log(dim(` supplied ${supplied} built file(s) into packages/cli/dist/`));
123
+ // Sanity check: can it RUN, not merely "did files arrive".
124
+ const missing = missingRuntimeParts(pluginDir);
125
+ if (missing.length) {
126
+ throw new Error(`Install appeared to succeed but the plugin cannot run — missing:\n` +
127
+ missing.map((m) => ` - ${m}`).join("\n") +
128
+ `\nThis is an install bug, not a configuration problem. Please report it with this list.`);
120
129
  }
121
130
  success(`plugin installed at ${pluginDir}`);
122
131
  return { installed: true, pluginDir, version, alreadyInstalled: false };
123
132
  }
133
+ /**
134
+ * The plugin needs BUILT JavaScript, and a git clone does not contain any.
135
+ *
136
+ * `packages/cli/dist/` is a build artefact. Five of its thirty-two files are in
137
+ * git by accident; the rest, including `archetypes.js`, are not. So a cloned
138
+ * plugin gets 5 of 32, and `scripts/lib/gate-plan.mjs` — which the board's
139
+ * project reader imports — dies on
140
+ *
141
+ * ERR_MODULE_NOT_FOUND … packages/cli/dist/archetypes.js
142
+ *
143
+ * The board therefore did not start for anyone installing this the documented
144
+ * way. It started for the author, whose plugin cache is populated by
145
+ * `install-local.sh` from a working tree with a full local build, and it started
146
+ * from the npm tarball, which ships all 32. It failed on exactly one path: the
147
+ * one a new user takes.
148
+ *
149
+ * The build is not fetched or rebuilt — it is already here. This CLI IS the
150
+ * published package, so the version being installed and the version doing the
151
+ * installing are the same artefacts. Copying them across is both the cheapest
152
+ * source and the only one that cannot drift.
153
+ *
154
+ * @returns how many files were supplied, or null when this CLI has no dist of
155
+ * its own to give (running from source in the monorepo, where the clone is
156
+ * not what gets used anyway).
157
+ */
158
+ function supplyBuiltDist(pluginDir) {
159
+ const here = dirname(fileURLToPath(import.meta.url)); // …/dist
160
+ const target = join(pluginDir, "packages", "cli", "dist");
161
+ try {
162
+ if (!existsSync(join(here, "archetypes.js")))
163
+ return null;
164
+ mkdirSync(target, { recursive: true });
165
+ cpSync(here, target, { recursive: true });
166
+ return readdirSync(target).length;
167
+ }
168
+ catch {
169
+ return null;
170
+ }
171
+ }
172
+ /**
173
+ * Can the plugin actually run, or did we merely receive files?
174
+ *
175
+ * The check this replaces asked whether `.claude-plugin/plugin.json` exists —
176
+ * "did we get a plugin?" — which a clone always satisfies while the board is
177
+ * still unable to start. A sanity check that a broken install passes is not a
178
+ * sanity check.
179
+ *
180
+ * @returns a list of what is missing; empty means runnable.
181
+ */
182
+ export function missingRuntimeParts(pluginDir) {
183
+ const required = [
184
+ [".claude-plugin/plugin.json", "the plugin manifest"],
185
+ ["packages/board/server.mjs", "the board server"],
186
+ ["packages/cli/dist/archetypes.js", "the built archetype table the board imports"],
187
+ ["scripts/lib/gate-plan.mjs", "the gate planner"],
188
+ ];
189
+ return required
190
+ .filter(([rel]) => !existsSync(join(pluginDir, rel)))
191
+ .map(([rel, what]) => `${rel} (${what})`);
192
+ }
124
193
  function readPluginVersion(pluginDir) {
125
194
  try {
126
195
  const manifest = join(pluginDir, ".claude-plugin", "plugin.json");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "great-cto",
3
- "version": "3.7.0",
3
+ "version": "3.9.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",