great-cto 3.7.0 → 3.8.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.8.0",
|
|
6
6
|
"author": {
|
|
7
7
|
"name": "Great CTO",
|
|
8
8
|
"url": "https://github.com/avelikiy/great_cto"
|
|
@@ -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/package.json
CHANGED