great-cto 3.11.0 → 3.13.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.11.0",
5
+ "version": "3.13.0",
6
6
  "author": {
7
7
  "name": "Great CTO",
8
8
  "url": "https://github.com/avelikiy/great_cto"
@@ -155,7 +155,18 @@ function getCostHistory(cwd = process.cwd(), days = 30) {
155
155
  for (let i = 0; i <= days; i++) {
156
156
  const d = new Date(now - i * 86400000);
157
157
  const key = d.toISOString().slice(0, 10);
158
- buckets.set(key, { date: key, llm: 0, human: 0, plans: 0, runs: 0 });
158
+ // `runs` was one field incremented in two places for two different things:
159
+ // once per verdict that carried a cost, and once per closed task on a day that
160
+ // happened to have no cost data. Its meaning therefore changed from day to day
161
+ // depending on whether anything had been measured, and the two sums were
162
+ // indistinguishable once added.
163
+ //
164
+ // Measured against the verdict logs on disk, it was wrong in BOTH directions —
165
+ // +18 on one project over 90 days (closed tasks counted as agent runs) and −3
166
+ // on two others (verdicts with no cost contributing nothing). Split into the
167
+ // two facts, with `runs` kept as their sum so every existing reader keeps
168
+ // working and no chart silently changes shape.
169
+ buckets.set(key, { date: key, llm: 0, human: 0, plans: 0, runs: 0, agent_runs: 0, task_estimates: 0 });
159
170
  }
160
171
 
161
172
  // Plans: file mtime as date
@@ -165,6 +176,20 @@ function getCostHistory(cwd = process.cwd(), days = 30) {
165
176
  // "LLM" and another near "Human", and FIRE the sanity check below to
166
177
  // reject pathological pairs (the 7,638× regression — total_human present
167
178
  // but total_llm fell to zero because the LLM regex was too strict).
179
+ // Whether a plan carried a cost at all, kept apart from what that cost was.
180
+ //
181
+ // The pair of regexes below reads a format nothing writes: measured across
182
+ // this repository's 41 plans, ZERO carry an LLM figure at the start of a line
183
+ // and one carries a Human figure — which the guard then subtracts back out. So
184
+ // `total_human` was $0 on every window, beside a plan count in the dozens, and
185
+ // read as "these plans cost nothing" rather than "no plan states a cost".
186
+ //
187
+ // There is no plan template to blame the plans for: the only template shipped
188
+ // is GAP-WAVE-PLAN-template.yaml, so no cost line was ever specified. Counted
189
+ // rather than parsed harder — the dollar figures that DO appear in plans are
190
+ // prose ("10 × ~$0.15", "assert total < $5") and reading them as this plan's
191
+ // cost would be inventing a number.
192
+ let planCostParsed = 0, planCostAbsent = 0, planHumanSuppressed = 0;
168
193
  const plansDir = path.join(cwd, 'docs/plans');
169
194
  if (fs.existsSync(plansDir)) {
170
195
  const planFiles = fs.readdirSync(plansDir).filter(x => x.endsWith('.md')).map(x => path.join(plansDir, x));
@@ -199,8 +224,10 @@ function getCostHistory(cwd = process.cwd(), days = 30) {
199
224
  if (humanMatch && !llmMatch && b.human > 0) {
200
225
  // Reverse the suppression — drop the bogus single-sided Human entry.
201
226
  b.human -= parseFloat(humanMatch[1].replace(/,/g, ''));
227
+ planHumanSuppressed += 1;
202
228
  }
203
229
  b.plans++;
230
+ if (llmMatch || humanMatch) planCostParsed += 1; else planCostAbsent += 1;
204
231
  }
205
232
  }
206
233
 
@@ -217,18 +244,28 @@ function getCostHistory(cwd = process.cwd(), days = 30) {
217
244
  // feature=X aggregation — answers "how much did stripe-webhook cost?"
218
245
  const featureMap = new Map(); // feature → { llm, runs }
219
246
  for (const v of verdicts) {
220
- if (v.cost_usd == null) continue;
221
247
  const dayKey = (v.ts || '').slice(0, 10);
222
248
  if (!buckets.has(dayKey)) continue;
223
249
  const b = buckets.get(dayKey);
224
- b.llm += v.cost_usd;
250
+ // A run that was never priced is still a run.
251
+ //
252
+ // This began `if (v.cost_usd == null) continue`, so a verdict carrying no
253
+ // cost contributed nothing at all — not even its own existence. Measured
254
+ // against the logs: one project had four verdicts and reported ZERO agent
255
+ // runs, because none of the four had been priced. "We do not know what it
256
+ // cost" was being rendered as "it did not happen", which is the confusion
257
+ // this codebase keeps having to unpick.
258
+ //
259
+ // Cost is added only when there is one; the run is counted either way.
260
+ if (v.cost_usd != null) b.llm += v.cost_usd;
261
+ b.agent_runs++;
225
262
  b.runs++;
226
263
  // Extract feature= tag from raw verdict line
227
264
  const featMatch = v.raw && v.raw.match(/\bfeature=([^\s|]+)/);
228
265
  if (featMatch) {
229
266
  const feat = featMatch[1];
230
267
  const f = featureMap.get(feat) || { llm: 0, runs: 0 };
231
- f.llm += v.cost_usd;
268
+ if (v.cost_usd != null) f.llm += v.cost_usd;
232
269
  f.runs++;
233
270
  featureMap.set(feat, f);
234
271
  }
@@ -258,6 +295,10 @@ function getCostHistory(cwd = process.cwd(), days = 30) {
258
295
  if (b.llm === 0) {
259
296
  const mins = t.estimated_minutes || DEFAULT_TASK_MIN;
260
297
  b.llm += mins / 60 * LLM_RATE_PER_HR;
298
+ // A closed task is not an agent run. It is counted, and counted
299
+ // separately, so a caller asking "how many times did agents run" is not
300
+ // handed a number that is partly something else.
301
+ b.task_estimates++;
261
302
  b.runs++;
262
303
  }
263
304
  }
@@ -267,6 +308,11 @@ function getCostHistory(cwd = process.cwd(), days = 30) {
267
308
  let totalLlm = series.reduce((a, b) => a + b.llm, 0);
268
309
  let totalHuman = series.reduce((a, b) => a + b.human, 0);
269
310
  const totalPlans = series.reduce((a, b) => a + b.plans, 0);
311
+ // Reported alongside the sum rather than instead of it: `total_runs` keeps its
312
+ // meaning for every existing caller, and the two facts it was made of are now
313
+ // separately answerable.
314
+ const totalAgentRuns = series.reduce((a, b) => a + b.agent_runs, 0);
315
+ const totalTaskEstimates = series.reduce((a, b) => a + b.task_estimates, 0);
270
316
 
271
317
  // SANITY GUARD — anti-7,638× regression. If ratio > 1000×, one of the
272
318
  // numbers is wrong. Almost always: total_llm collapsed to ~0 because plan
@@ -291,6 +337,14 @@ function getCostHistory(cwd = process.cwd(), days = 30) {
291
337
  total_llm: Math.round(totalLlm * 100) / 100,
292
338
  total_human: Math.round(totalHuman),
293
339
  total_plans: totalPlans,
340
+ total_agent_runs: totalAgentRuns,
341
+ total_task_estimates: totalTaskEstimates,
342
+ // Three states, not a number and a silence: a plan that states a cost, a
343
+ // plan that states none, and a plan whose Human figure was suppressed
344
+ // because its LLM counterpart could not be read.
345
+ plans_with_cost: planCostParsed,
346
+ plans_without_cost: planCostAbsent,
347
+ plans_human_suppressed: planHumanSuppressed,
294
348
  daily_avg: Math.round(dayRate * 100) / 100,
295
349
  projected_monthly: projectedMonthly,
296
350
  monthly_budget: budget,
@@ -92,6 +92,28 @@ function readVerdicts(cwd = null, health = null) {
92
92
  continue;
93
93
  }
94
94
  for (const file of files) {
95
+ // Only per-agent verdict logs. `<agent>-YYYY-MM-DD-HHMMSS.log` is a different
96
+ // artefact that lives in the same directory: a free-text report of one run,
97
+ // written as prose. Reading it as a verdict log made its first words into
98
+ // records — `ts: "DONE:"`, `ts: "artifact:"`, `ts: "next:"`, with the agent
99
+ // name taken from the filename including its timestamp. Six of the ten
100
+ // "verdicts" one project appeared to have were paragraph openings.
101
+ //
102
+ // Skipped rather than parsed leniently: a run report has no verdict token, no
103
+ // cost and no meta, so anything recovered from it would be invented. Counted,
104
+ // though — a directory whose contents this reader ignores is worth saying out
105
+ // loud, because from outside it looks identical to a project that never ran.
106
+ if (!file.endsWith('.log')) continue;
107
+ // Rejected by the shape that IDENTIFIES a run report, not by trying to
108
+ // describe what a valid agent name looks like. The first attempt did the
109
+ // latter — `/^[A-Za-z0-9_:.-]+\.log$/` — and matched
110
+ // `architect-2026-08-26-134109.log` perfectly well, because digits and
111
+ // hyphens are exactly what an agent name may contain. An allowlist that
112
+ // cannot exclude the thing it was written to exclude is not a filter.
113
+ if (RUN_REPORT_FILE.test(file)) {
114
+ health?.notes?.push(`${file} is a run report, not a verdict log — not read`);
115
+ continue;
116
+ }
95
117
  const agent = file.replace('.log', '');
96
118
  let lines;
97
119
  try {
@@ -192,6 +214,9 @@ function readVerdicts(cwd = null, health = null) {
192
214
  return results.sort((a, b) => a.ts.localeCompare(b.ts));
193
215
  }
194
216
 
217
+ /** `<agent>-YYYY-MM-DD-HHMMSS.log` — a prose report of one run, not a verdict. */
218
+ const RUN_REPORT_FILE = /-\d{4}-\d{2}-\d{2}-\d{6}\.log$/;
219
+
195
220
  function readPlanCosts(cwd = process.cwd(), sinceMsAgo = null) {
196
221
  const plansDir = path.join(cwd, 'docs/plans');
197
222
  let totalLlmMin = 0, totalLlmUsd = 0, totalHumanUsd = 0, count = 0;
@@ -164,10 +164,29 @@ export function parseVerdictLine(line) {
164
164
  agent = fields.length >= 2 ? fields[0] : '';
165
165
  verdict = fields.length >= 2 ? fields[1] : fields[0];
166
166
  } else {
167
+ // The space form comes in two shapes, and this used to assume only one:
168
+ //
169
+ // <ts> <verdict> <rest> what the comment here claimed
170
+ // <ts> <agent> <verdict> <rest> also written, e.g. by log-verdict.sh
171
+ //
172
+ // Assuming the first turned `2026-08-26T16:59:22Z great_cto:code-reviewer
173
+ // APPROVED …` into a record whose VERDICT was `GREAT_CTO:CODE-REVIEWER`. It
174
+ // parsed, it validated, and the board showed an agent name in the verdict
175
+ // column — a wrong value that looks like a value.
176
+ //
177
+ // Discriminated by the one fact the file already carries: whether the token
178
+ // is a verdict this system knows. Neither position matching leaves the
179
+ // original reading in place rather than guessing a different wrong one.
167
180
  const parts = raw.split(/\s+/);
168
181
  ts = parts[0] || '';
169
- verdict = parts[1] || '';
170
- agent = ''; // the space form never carried one; the filename did
182
+ const known = (t) => KNOWN_VERDICTS.includes(String(t || '').toUpperCase());
183
+ if (!known(parts[1]) && known(parts[2])) {
184
+ agent = parts[1] || '';
185
+ verdict = parts[2] || '';
186
+ } else {
187
+ verdict = parts[1] || '';
188
+ agent = ''; // this shape does not carry one; the filename does
189
+ }
171
190
  }
172
191
 
173
192
  const rec = { v: VERDICT_FORMAT_VERSION, ts, agent, verdict: String(verdict || '').toUpperCase() };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "great-cto",
3
- "version": "3.11.0",
3
+ "version": "3.13.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",