great-cto 2.99.0 → 3.0.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,224 @@
1
+ // Per-agent spending limits, and the one rule that keeps them honest.
2
+ //
3
+ // A limit that fires on a number nobody measured is worse than no limit: work
4
+ // stops, the operator is told an agent has spent $52 of its $50, and the $52 was
5
+ // derived from how long the agent ran multiplied by a hardcoded rate. This
6
+ // repository already carries that distinction in its cost model —
7
+ // `agents_cost[].cost_source` is `'estimate'` unless verdicts carry real token
8
+ // spend, and `real_llm_usd` is deleted rather than zeroed when there is none.
9
+ //
10
+ // So the states are four, not two:
11
+ //
12
+ // no-limit nothing was declared for this agent — say so, do not invent one
13
+ // within MEASURED spend is under the cap
14
+ // exceeded MEASURED spend is over the cap — the only state that may refuse
15
+ // unmeasured a cap exists and there is no verdict cost data to judge it by
16
+ //
17
+ // `unmeasured` never refuses. It reports the estimate, labelled as an estimate,
18
+ // and the caller decides. That is the same shape as `proof-status.mjs`
19
+ // (passed / failed / not_run / inconclusive) and for the same reason: a check
20
+ // that could not run must not return the answer of one that ran.
21
+ //
22
+ // Declared in the project's own PROJECT.md, next to `monthly-budget`:
23
+ //
24
+ // agent-budgets:
25
+ // senior-dev: $50
26
+ // architect: $20
27
+ // product-owner: $5
28
+
29
+ /** Dollars from `$50`, `50`, `$1,250.50`. Returns null for anything else. */
30
+ function parseUsd(raw) {
31
+ if (raw == null) return null;
32
+ const cleaned = String(raw).replace(/[$\s,]/g, '');
33
+ if (!/^\d+(\.\d+)?$/.test(cleaned)) return null;
34
+ const n = Number(cleaned);
35
+ return Number.isFinite(n) && n > 0 ? n : null;
36
+ }
37
+
38
+ /**
39
+ * Read the `agent-budgets:` block out of a PROJECT.md.
40
+ *
41
+ * The block is a key with indented `agent: $amount` lines beneath it — the rest
42
+ * of the file is flat `key: value`, and a flat key per agent would collide with
43
+ * every other setting the moment an agent is called `phase` or `stack`.
44
+ *
45
+ * @returns {{budgets: Map<string, number>, malformed: Array<{line: string, why: string}>}}
46
+ * Malformed lines are RETURNED, not dropped. A budget the operator wrote and
47
+ * this parser silently ignored is a limit they believe they have.
48
+ */
49
+ export function parseAgentBudgets(text) {
50
+ const budgets = new Map();
51
+ const malformed = [];
52
+ if (!text) return { budgets, malformed, deprecatedKey: null };
53
+
54
+ // Two spellings. `agent-budget:` (singular) predates this module: the board
55
+ // parsed it with its own inline regex in routes.mjs and only displayed it,
56
+ // labelled "$X/run". I then added `agent-budgets:` (plural) with a second
57
+ // parser and a different meaning, which is how a repository ends up with two
58
+ // definitions of one concept differing by a letter — the exact drift the
59
+ // dispatcher's own comment warns about.
60
+ //
61
+ // No project, template or document used the singular form, so consolidating
62
+ // costs nothing. It is still accepted, and reported as deprecated, because a
63
+ // config someone wrote must not stop working silently.
64
+ const lines = String(text).split('\n');
65
+ let start = lines.findIndex((l) => /^agent-budgets:\s*$/.test(l));
66
+ let deprecatedKey = null;
67
+ if (start < 0) {
68
+ start = lines.findIndex((l) => /^agent-budget:\s*$/.test(l));
69
+ if (start >= 0) deprecatedKey = 'agent-budget';
70
+ }
71
+ if (start < 0) return { budgets, malformed, deprecatedKey: null };
72
+
73
+ for (let i = start + 1; i < lines.length; i++) {
74
+ const line = lines[i];
75
+ if (/^\S/.test(line)) break; // dedent ends the block
76
+ if (!line.trim()) continue;
77
+ const m = line.match(/^\s+([a-z0-9][a-z0-9-]*)\s*:\s*(.+?)\s*$/i);
78
+ if (!m) { malformed.push({ line: line.trim(), why: 'not `agent: amount`' }); continue; }
79
+ const usd = parseUsd(m[2]);
80
+ if (usd == null) { malformed.push({ line: line.trim(), why: `\`${m[2]}\` is not a dollar amount` }); continue; }
81
+ budgets.set(m[1].toLowerCase(), usd);
82
+ }
83
+ return { budgets, malformed, deprecatedKey };
84
+ }
85
+
86
+ /**
87
+ * Judge one agent against its limit.
88
+ *
89
+ * @param {object} a
90
+ * @param {string} a.agent
91
+ * @param {Map<string, number>} a.budgets
92
+ * @param {object} [a.spend] One entry from metrics' `agents_cost`:
93
+ * `{ llm_usd, real_llm_usd?, cost_source }`. `real_llm_usd` is ABSENT rather
94
+ * than 0 when nothing was measured — that absence is the whole signal here.
95
+ * @returns {{state: 'no-limit'|'within'|'exceeded'|'unmeasured', limitUsd: number|null,
96
+ * measuredUsd: number|null, estimateUsd: number|null, pct: number|null, why: string}}
97
+ */
98
+ export function judgeAgentBudget({ agent, budgets, spend }) {
99
+ const limitUsd = budgets?.get?.(String(agent || '').toLowerCase()) ?? null;
100
+ const estimateUsd = Number.isFinite(spend?.llm_usd) ? spend.llm_usd : null;
101
+
102
+ if (limitUsd == null) {
103
+ return { state: 'no-limit', limitUsd: null, measuredUsd: null, estimateUsd, pct: null,
104
+ why: `no budget declared for ${agent}` };
105
+ }
106
+
107
+ // Measured means it came from verdicts. `real_llm_usd` is deleted when zero,
108
+ // so `!= null` is the test — `> 0` would read a genuine measured zero as
109
+ // "never measured", which is the confusion this whole module exists to avoid.
110
+ const measuredUsd = spend?.real_llm_usd != null ? spend.real_llm_usd : null;
111
+ if (measuredUsd == null) {
112
+ return { state: 'unmeasured', limitUsd, measuredUsd: null, estimateUsd, pct: null,
113
+ why: `${agent} has a $${limitUsd} budget and no verdict cost data to judge it by`
114
+ + (estimateUsd != null ? ` — the $${estimateUsd.toFixed(2)} shown is a time-based estimate, not spend` : '') };
115
+ }
116
+
117
+ const pct = Math.round((measuredUsd / limitUsd) * 100);
118
+ return measuredUsd > limitUsd
119
+ ? { state: 'exceeded', limitUsd, measuredUsd, estimateUsd, pct,
120
+ why: `${agent} has spent $${measuredUsd.toFixed(2)} of its $${limitUsd} budget (${pct}%), measured from verdicts` }
121
+ : { state: 'within', limitUsd, measuredUsd, estimateUsd, pct,
122
+ why: `${agent} has spent $${measuredUsd.toFixed(2)} of $${limitUsd} (${pct}%)` };
123
+ }
124
+
125
+ /**
126
+ * May this agent be dispatched?
127
+ *
128
+ * The ONLY state that refuses is `exceeded`, and `exceeded` is reachable only
129
+ * from measured spend. Everything else proceeds, because the alternative is
130
+ * halting a pipeline on arithmetic over a rate constant.
131
+ */
132
+ export function budgetAllowsDispatch(verdict) {
133
+ return verdict?.state !== 'exceeded';
134
+ }
135
+
136
+ // ── Editing the declaration ─────────────────────────────────────────────────
137
+ //
138
+ // The board can set a cap, which means writing to a file the operator owns and
139
+ // git tracks. Two rules govern that, and both are about not surprising them:
140
+ //
141
+ // 1. Everything else in PROJECT.md survives byte for byte. These functions
142
+ // touch the budget block and nothing around it.
143
+ // 2. The key already in the file wins. A project written with the deprecated
144
+ // `agent-budget:` keeps it — silently rewriting somebody's config to a
145
+ // different spelling while they asked for an unrelated change is the kind
146
+ // of helpfulness that loses trust.
147
+
148
+ /** The block header this file uses, or null when it has none. */
149
+ function budgetKeyIn(text) {
150
+ if (/^agent-budgets:\s*$/m.test(text)) return 'agent-budgets';
151
+ if (/^agent-budget:\s*$/m.test(text)) return 'agent-budget';
152
+ return null;
153
+ }
154
+
155
+ /**
156
+ * Set or replace one agent's cap.
157
+ *
158
+ * @returns {{text: string, created: boolean, previousUsd: number|null}}
159
+ * `created` is true when the block did not exist and was appended.
160
+ * @throws when `limitUsd` is not a positive number — a cap of zero or NaN would
161
+ * hold every dispatch of that agent forever, and an accident should not be
162
+ * able to do that.
163
+ */
164
+ export function upsertAgentBudget(text, agent, limitUsd) {
165
+ const slug = String(agent || '').trim().toLowerCase();
166
+ if (!/^[a-z0-9][a-z0-9-]*$/.test(slug)) throw new Error(`not an agent slug: ${agent}`);
167
+ const usd = Number(limitUsd);
168
+ if (!Number.isFinite(usd) || usd <= 0) throw new Error(`cap must be a positive number, got: ${limitUsd}`);
169
+
170
+ const before = parseAgentBudgets(text).budgets.get(slug) ?? null;
171
+ const key = budgetKeyIn(text);
172
+ const line = ` ${slug}: $${usd}`;
173
+
174
+ if (!key) {
175
+ const sep = text.endsWith('\n') ? '' : '\n';
176
+ return { text: `${text}${sep}agent-budgets:\n${line}\n`, created: true, previousUsd: null };
177
+ }
178
+
179
+ const lines = text.split('\n');
180
+ const start = lines.findIndex((l) => new RegExp(`^${key}:\\s*$`).test(l));
181
+ // The block ends at a dedent OR a blank line. A blank is not a dedent — it
182
+ // starts with no non-space character — so scanning only for `^\S` walked past
183
+ // the gap at the end of the file and inserted the new cap below it, leaving a
184
+ // block split by an empty line. The parser tolerates that; a person reading
185
+ // their own PROJECT.md should not have to.
186
+ let end = start + 1;
187
+ while (end < lines.length && lines[end].trim() && !/^\S/.test(lines[end])) end++;
188
+
189
+ const existing = lines.slice(start + 1, end)
190
+ .findIndex((l) => new RegExp(`^\\s+${slug}\\s*:`, 'i').test(l));
191
+ if (existing >= 0) lines[start + 1 + existing] = line;
192
+ else lines.splice(end, 0, line);
193
+
194
+ return { text: lines.join('\n'), created: false, previousUsd: before };
195
+ }
196
+
197
+ /**
198
+ * Remove one agent's cap.
199
+ *
200
+ * @returns {{text: string, removed: boolean, previousUsd: number|null}}
201
+ * `removed: false` when the agent had no cap — the caller must be able to tell
202
+ * "there is now no limit" from "there never was one".
203
+ */
204
+ export function removeAgentBudget(text, agent) {
205
+ const slug = String(agent || '').trim().toLowerCase();
206
+ const before = parseAgentBudgets(text).budgets.get(slug) ?? null;
207
+ const key = budgetKeyIn(text);
208
+ if (!key || before == null) return { text, removed: false, previousUsd: null };
209
+
210
+ const lines = text.split('\n');
211
+ const start = lines.findIndex((l) => new RegExp(`^${key}:\\s*$`).test(l));
212
+ let end = start + 1;
213
+ while (end < lines.length && lines[end].trim() && !/^\S/.test(lines[end])) end++;
214
+
215
+ const kept = lines.slice(start + 1, end)
216
+ .filter((l) => !new RegExp(`^\\s+${slug}\\s*:`, 'i').test(l));
217
+
218
+ // An empty block is removed with its header. A bare `agent-budgets:` reads as
219
+ // a declaration that produced no limits, which is a different and confusing
220
+ // thing from having none.
221
+ const replacement = kept.some((l) => l.trim()) ? [lines[start], ...kept] : [];
222
+ lines.splice(start, end - start, ...replacement);
223
+ return { text: lines.join('\n'), removed: true, previousUsd: before };
224
+ }
@@ -0,0 +1,127 @@
1
+ // Could the dispatcher dispatch here?
2
+ //
3
+ // Every way the pipeline broke this week failed by SILENCE. The map was resolved
4
+ // against the project instead of the plugin, so thirteen of seventeen registered
5
+ // projects hit `return process.exit(0)` and said nothing — no dispatch, no
6
+ // verdict, no task, and nowhere to look. The budget check was wired into
7
+ // `decideNext` and never passed its arguments, so it never ran. In both cases
8
+ // the machinery reported success while being incapable of acting.
9
+ //
10
+ // `guard-parity` asks whether a guard EXECUTES. `declared-consumed` asks whether
11
+ // a declaration is CONSUMED. This asks the question underneath both:
12
+ //
13
+ // **Given a project, would the dispatcher be ABLE to act at all?**
14
+ //
15
+ // It checks the same preconditions the hook checks, in the same order, without
16
+ // running an agent. A project that cannot chain is reported with the reason and
17
+ // what would fix it — because the failure mode being closed here is not a broken
18
+ // pipeline, it is a pipeline that is broken and silent.
19
+
20
+ import { existsSync, readFileSync, readdirSync } from 'node:fs';
21
+ import { join, dirname } from 'node:path';
22
+ import { fileURLToPath } from 'node:url';
23
+ import os from 'node:os';
24
+
25
+ const REPO = join(dirname(fileURLToPath(import.meta.url)), '..', '..');
26
+
27
+ /** The map the dispatcher would use for `cwd`, and where it came from. */
28
+ export function pipelineMapFor(cwd, { pluginMap = join(REPO, 'shared', 'pipeline.toml') } = {}) {
29
+ const local = join(cwd, 'shared', 'pipeline.toml');
30
+ if (existsSync(local)) return { path: local, source: 'project' };
31
+ if (existsSync(pluginMap)) return { path: pluginMap, source: 'plugin' };
32
+ return { path: null, source: 'none' };
33
+ }
34
+
35
+ /**
36
+ * One project's ability to run a pipeline.
37
+ *
38
+ * @returns {{slug, path, state: 'ready'|'not-a-project'|'blocked', why: string, mapSource: string}}
39
+ * `not-a-project` is a fine and common answer — a directory with no
40
+ * `.great_cto/` is not ours to dispatch in, and must not be reported as a
41
+ * fault. `blocked` means the project IS ours and the dispatcher could not act.
42
+ */
43
+ export function projectPipelineHealth(entry, opts = {}) {
44
+ const base = { slug: entry.slug || '?', path: entry.path || '', mapSource: 'none' };
45
+
46
+ if (!entry.path || !existsSync(entry.path)) {
47
+ return { ...base, state: 'not-a-project', why: 'the directory does not exist' };
48
+ }
49
+ if (!existsSync(join(entry.path, '.great_cto'))) {
50
+ return { ...base, state: 'not-a-project', why: 'no .great_cto/ — not a great_cto project' };
51
+ }
52
+
53
+ const map = pipelineMapFor(entry.path, opts);
54
+ if (!map.path) {
55
+ return {
56
+ ...base, state: 'blocked',
57
+ why: 'no pipeline map — not in the project and not in the plugin, so the dispatcher exits before reading a verdict',
58
+ };
59
+ }
60
+
61
+ let transitions = 0;
62
+ try {
63
+ const text = readFileSync(map.path, 'utf8');
64
+ transitions = (text.match(/^\[transitions\./gm) || []).length;
65
+ } catch (e) {
66
+ return { ...base, state: 'blocked', mapSource: map.source, why: `the map at ${map.path} could not be read: ${e.message}` };
67
+ }
68
+ if (transitions === 0) {
69
+ // A map that parses to nothing is the same silence with a file behind it.
70
+ return { ...base, state: 'blocked', mapSource: map.source, why: `the map at ${map.path} declares no transitions` };
71
+ }
72
+
73
+ return {
74
+ ...base, state: 'ready', mapSource: map.source,
75
+ why: `${transitions} transitions, map from the ${map.source}`,
76
+ };
77
+ }
78
+
79
+ /** Every registered project, judged. */
80
+ export function auditPipelineHealth({ registry = join(os.homedir(), '.great_cto', 'projects.json'), ...opts } = {}) {
81
+ let entries = [];
82
+ try {
83
+ const raw = JSON.parse(readFileSync(registry, 'utf8'));
84
+ const list = raw.projects ?? raw;
85
+ entries = Array.isArray(list) ? list : Object.values(list);
86
+ } catch (e) {
87
+ // An unreadable registry is not an empty fleet. Reporting "0 projects, all
88
+ // healthy" from a file we could not open is the defect this module exists
89
+ // to prevent, one level up.
90
+ return { state: 'unreadable', why: `could not read ${registry}: ${e.message}`, rows: [] };
91
+ }
92
+
93
+ const rows = entries.map((e) => projectPipelineHealth(e, opts));
94
+ const blocked = rows.filter((r) => r.state === 'blocked');
95
+ return {
96
+ state: blocked.length ? 'blocked' : 'ready',
97
+ why: blocked.length
98
+ ? `${blocked.length} of ${rows.filter((r) => r.state !== 'not-a-project').length} project(s) cannot dispatch`
99
+ : `every registered project can dispatch`,
100
+ rows,
101
+ };
102
+ }
103
+
104
+ // ── CLI ─────────────────────────────────────────────────────────────────────
105
+ //
106
+ // node scripts/lib/pipeline-health.mjs [--strict] [--json]
107
+
108
+ if (import.meta.url === `file://${process.argv[1]}`) {
109
+ const r = auditPipelineHealth();
110
+ if (process.argv.includes('--json')) {
111
+ console.log(JSON.stringify(r, null, 2));
112
+ process.exit(r.state === 'ready' ? 0 : 1);
113
+ }
114
+ const projects = r.rows.filter((x) => x.state !== 'not-a-project');
115
+ console.log(`pipeline-health: ${r.why}`);
116
+ for (const x of r.rows.filter((y) => y.state === 'blocked')) {
117
+ console.log(`\n ${x.slug}\n ${x.why}`);
118
+ }
119
+ if (r.state === 'ready') {
120
+ const bySource = projects.reduce((a, x) => ({ ...a, [x.mapSource]: (a[x.mapSource] || 0) + 1 }), {});
121
+ console.log(` ${projects.length} project(s): ` +
122
+ Object.entries(bySource).map(([k, v]) => `${v} using the ${k} map`).join(', '));
123
+ }
124
+ // `unreadable` fails under --strict too: a check that could not look must not
125
+ // exit like one that looked and found nothing.
126
+ process.exit(process.argv.includes('--strict') && r.state !== 'ready' ? 1 : 0);
127
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "great-cto",
3
- "version": "2.99.0",
3
+ "version": "3.0.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",