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.
|
|
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
|
-
//
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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