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.7.0",
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "great-cto",
3
- "version": "3.7.0",
3
+ "version": "3.8.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",