great-cto 3.5.0 → 3.7.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.0",
5
+ "version": "3.7.0",
6
6
  "author": {
7
7
  "name": "Great CTO",
8
8
  "url": "https://github.com/avelikiy/great_cto"
@@ -5,6 +5,7 @@ import { GREAT_CTO_DIR } from './config.mjs';
5
5
  import { readFileSafe } from './util.mjs';
6
6
  import { getTasks } from './beads.mjs';
7
7
  import { readVerdicts, readSecStats } from './verdicts.mjs';
8
+ import { datePlans } from './plan-date.mjs';
8
9
 
9
10
  // ── Memory: 4-layer file contents ─────────────────────────────────────────────
10
11
  function getMemory(cwd = process.cwd()) {
@@ -166,11 +167,15 @@ function getCostHistory(cwd = process.cwd(), days = 30) {
166
167
  // but total_llm fell to zero because the LLM regex was too strict).
167
168
  const plansDir = path.join(cwd, 'docs/plans');
168
169
  if (fs.existsSync(plansDir)) {
169
- for (const f of fs.readdirSync(plansDir).filter(x => x.endsWith('.md'))) {
170
- const fp = path.join(plansDir, f);
171
- const stat = fs.statSync(fp);
172
- const dayKey = stat.mtime.toISOString().slice(0, 10);
173
- if (!buckets.has(dayKey)) continue;
170
+ const planFiles = fs.readdirSync(plansDir).filter(x => x.endsWith('.md')).map(x => path.join(plansDir, x));
171
+ // The day a plan lands on is read from the plan, not from its mtime. The
172
+ // twin of the same defect in readPlanCosts: on a fresh clone every file
173
+ // carries the clone time, so the whole history stacked onto one bar of this
174
+ // chart — today's — and every earlier day read as zero spend.
175
+ const dated = datePlans(planFiles, { root: cwd, dir: 'docs/plans' });
176
+ for (const fp of planFiles) {
177
+ const dayKey = dated.dates.get(fp)?.date;
178
+ if (!dayKey || !buckets.has(dayKey)) continue;
174
179
  const content = fs.readFileSync(fp, 'utf8');
175
180
  // Anchor LLM/Human at START of line (with optional markdown emphasis)
176
181
  // so we never mis-match cases like:
@@ -0,0 +1,136 @@
1
+ /**
2
+ * plan-date — when a plan was written, as opposed to when its file was touched.
3
+ *
4
+ * The defect
5
+ * ----------
6
+ * Both cost readers dated a plan by `statSync(fp).mtime`. mtime is not a fact
7
+ * about the plan; it is a fact about the filesystem, and everything ordinary
8
+ * changes it — a reformat, a checkout, a copy, a `sed -i` over the directory.
9
+ *
10
+ * `git clone` is the case that turns it from imprecise into wrong. Clone writes
11
+ * every file at clone time, so on a fresh checkout of this repository all 41
12
+ * plans carry today's date: THIRTEEN distinct dates collapse into ONE. The
13
+ * 30-day cost window then selects every plan ever written, and reports the
14
+ * project's entire history as the last month. Measured, both ways:
15
+ *
16
+ * fresh clone 30-day window → 41 plans, $150 human ← identical to
17
+ * all time → 41 plans, $150 human all-time
18
+ * this checkout 30-day window → 13 plans
19
+ * all time → 41 plans
20
+ *
21
+ * Nobody would read "$150 in the last 30 days" as suspicious. That is the shape
22
+ * of the whole defect class: a number that is wrong in a way that looks normal.
23
+ *
24
+ * Where the date actually lives
25
+ * -----------------------------
26
+ * Measured over this repository's 41 plans rather than assumed:
27
+ *
28
+ * 21 the filename — `PLAN-2026-08-17-gate-fail-closed.md`
29
+ * 20 nowhere in the file at all
30
+ * 0 front-matter (supported anyway; it is the one an author can correct)
31
+ *
32
+ * So git is not a nicety, it is the only honest source for half of them: the
33
+ * first commit that added the file is when the plan appeared. One batched
34
+ * `git log` covers the whole directory in 0.27s, against 1.47s for one call per
35
+ * file, so the cheap way is also the correct way.
36
+ *
37
+ * Three states, and the third is the point
38
+ * ----------------------------------------
39
+ * When no source but mtime exists the date is still returned — dropping the plan
40
+ * would silently shrink the window, which is a different wrong answer. It is
41
+ * returned with `reliable: false`, and callers report how many of those went
42
+ * into a figure. A total assembled partly from filesystem timestamps should say
43
+ * so rather than present itself as measured.
44
+ */
45
+
46
+ import { statSync, readFileSync } from 'node:fs';
47
+ import { spawnSync } from 'node:child_process';
48
+ import path from 'node:path';
49
+
50
+ const ISO_DAY = /(\d{4}-\d{2}-\d{2})/;
51
+
52
+ /** `date:` in YAML front-matter, if the file opens with a front-matter block. */
53
+ export function frontMatterDate(text) {
54
+ const m = String(text || '').match(/^---\r?\n([\s\S]*?)\r?\n---/);
55
+ if (!m) return null;
56
+ const d = m[1].match(/^date:\s*(\d{4}-\d{2}-\d{2})/m);
57
+ return d ? d[1] : null;
58
+ }
59
+
60
+ /** `YYYY-MM-DD` anywhere in the basename. */
61
+ export function fileNameDate(file) {
62
+ const m = path.basename(file).match(ISO_DAY);
63
+ return m ? m[1] : null;
64
+ }
65
+
66
+ /**
67
+ * One `git log` for a whole directory: relative path → date the file was ADDED.
68
+ *
69
+ * Returns an empty Map when git is absent, the directory is untracked, or the
70
+ * project is not a repository at all. That is not an error — it is a project
71
+ * without this source, and the caller falls through to the next one.
72
+ */
73
+ export function gitAddedIndex(root, dir) {
74
+ const out = new Map();
75
+ let res;
76
+ try {
77
+ res = spawnSync('git', ['log', '--diff-filter=A', '--name-only',
78
+ '--format=%x00%ad', '--date=short', '--', dir],
79
+ { cwd: root, encoding: 'utf8', timeout: 15_000 });
80
+ } catch { return out; }
81
+ if (!res || res.status !== 0 || !res.stdout) return out;
82
+
83
+ let current = null;
84
+ for (const line of res.stdout.split('\n')) {
85
+ if (line.startsWith('\0')) { current = line.slice(1).trim(); continue; }
86
+ const rel = line.trim();
87
+ // git log walks newest-first, so the LAST assignment for a path is its
88
+ // oldest add — a file added, deleted and re-added should date from the
89
+ // first time it appeared, not the most recent.
90
+ if (rel && current) out.set(rel, current);
91
+ }
92
+ return out;
93
+ }
94
+
95
+ /**
96
+ * @returns {{date: string|null, source: 'front-matter'|'filename'|'git'|'mtime'|'none', reliable: boolean}}
97
+ */
98
+ export function planDate(absPath, { root = process.cwd(), gitIndex = null, readText = null } = {}) {
99
+ let text = '';
100
+ try { text = readText ? readText(absPath) : readFileSync(absPath, 'utf8').slice(0, 2000); } catch { /* unreadable */ }
101
+
102
+ const fm = frontMatterDate(text);
103
+ if (fm) return { date: fm, source: 'front-matter', reliable: true };
104
+
105
+ const fn = fileNameDate(absPath);
106
+ if (fn) return { date: fn, source: 'filename', reliable: true };
107
+
108
+ if (gitIndex && gitIndex.size) {
109
+ const rel = path.relative(root, absPath);
110
+ const g = gitIndex.get(rel);
111
+ if (g) return { date: g, source: 'git', reliable: true };
112
+ }
113
+
114
+ try {
115
+ return { date: statSync(absPath).mtime.toISOString().slice(0, 10), source: 'mtime', reliable: false };
116
+ } catch {
117
+ return { date: null, source: 'none', reliable: false };
118
+ }
119
+ }
120
+
121
+ /**
122
+ * Date every `.md` in a directory in one pass, with one git call for all of them.
123
+ *
124
+ * @returns {{dates: Map<string,{date,source,reliable}>, unreliable: number, total: number}}
125
+ */
126
+ export function datePlans(files, { root = process.cwd(), dir = 'docs/plans' } = {}) {
127
+ const gitIndex = gitAddedIndex(root, dir);
128
+ const dates = new Map();
129
+ let unreliable = 0;
130
+ for (const f of files) {
131
+ const d = planDate(f, { root, gitIndex });
132
+ dates.set(f, d);
133
+ if (!d.reliable) unreliable += 1;
134
+ }
135
+ return { dates, unreliable, total: files.length };
136
+ }
@@ -1,6 +1,7 @@
1
1
  import fs from 'fs';
2
2
  import path from 'path';
3
3
  import { GREAT_CTO_DIR } from './config.mjs';
4
+ import { datePlans } from './plan-date.mjs';
4
5
  import { parseVerdictLine } from '../../../scripts/lib/verdict-record.mjs';
5
6
 
6
7
 
@@ -140,16 +141,42 @@ function readVerdicts(cwd = null, health = null) {
140
141
  if (!fs.existsSync(histPath)) continue;
141
142
  const lines = fs.readFileSync(histPath, 'utf8').split('\n').filter(Boolean);
142
143
  for (const line of lines) {
143
- const m = line.match(/^(\S+)\s+(\S+)\s+(\d+\.?\d*)/);
144
+ // Anchored on a real ISO timestamp rather than "the first run of
145
+ // non-space characters". The writer used to emit an entire compact-JSON
146
+ // verdict in that position, and `\S+` accepted it — producing keys like
147
+ // `{"v":1,"ts":"202|qa-engineer` that could never match a verdict.
148
+ // Lines from that era are skipped here instead of being half-parsed.
149
+ const m = line.match(/^(\d{4}-\d{2}-\d{2}T\S+)\s+(\S+)\s+(\d+\.?\d*)(?:\s|$)/);
144
150
  if (!m) continue;
151
+ const usd = parseFloat(m[3]);
152
+ // A recorded zero is `log-verdict.sh` writing through whatever the agent
153
+ // reported, which is nothing. Enriching a zero verdict with a zero from
154
+ // this file changes no number and would label it `measured` — a claim
155
+ // that something was measured when the file says only that a line exists.
156
+ if (!(usd > 0)) continue;
145
157
  const key = `${m[1].slice(0, 16)}|${m[2]}`; // minute + agent
146
- if (!costByKey.has(key)) costByKey.set(key, parseFloat(m[3])); // project wins
158
+ if (!costByKey.has(key)) costByKey.set(key, usd); // project wins
147
159
  }
148
160
  }
149
161
  for (const v of results) {
150
- if (v.cost_usd != null) continue;
162
+ // A self-reported ZERO is not a measurement — it is the absence of one,
163
+ // and it must not shadow a figure that was actually measured.
164
+ //
165
+ // This read `if (v.cost_usd != null) continue`, and every verdict carries
166
+ // `cost_usd: 0` because agents do not measure their own spend. Zero is not
167
+ // null, so enrichment was skipped for all 26 verdicts in the window while
168
+ // the measured costs sat in cost-history.log unused — and the budgets
169
+ // screen reported `unmeasured`, which was true of what it read and false
170
+ // of what existed.
171
+ //
172
+ // A measured value overrides a zero; nothing overrides a non-zero figure
173
+ // an agent actually reported.
174
+ if (v.cost_usd != null && v.cost_usd > 0) continue;
151
175
  const key = `${(v.ts || '').slice(0, 16)}|${v.agent}`;
152
- if (costByKey.has(key)) v.cost_usd = costByKey.get(key);
176
+ if (costByKey.has(key)) {
177
+ v.cost_usd = costByKey.get(key);
178
+ v.cost_source = 'measured'; // read from a transcript, not self-reported
179
+ }
153
180
  }
154
181
  }
155
182
 
@@ -161,12 +188,23 @@ function readPlanCosts(cwd = process.cwd(), sinceMsAgo = null) {
161
188
  let totalLlmMin = 0, totalLlmUsd = 0, totalHumanUsd = 0, count = 0;
162
189
  if (!fs.existsSync(plansDir)) return { llm_usd: 0, human_usd: 0, savings_x: 0, count: 0 };
163
190
  const cutoff = sinceMsAgo != null ? Date.now() - sinceMsAgo : null;
164
- for (const file of fs.readdirSync(plansDir).filter(f => f.endsWith('.md'))) {
165
- const fp = path.join(plansDir, file);
166
- // Skip plans outside the requested time window (use file mtime, same as
167
- // getCostHistory fixes BH-26 where readPlanCosts had no date filter and
168
- // included all-time plans while getCostHistory only looked at the window).
169
- if (cutoff != null && fs.statSync(fp).mtimeMs < cutoff) continue;
191
+ const planFiles = fs.readdirSync(plansDir).filter(f => f.endsWith('.md')).map(f => path.join(plansDir, f));
192
+ // Dated from the plan itself — front-matter, then the filename, then the
193
+ // commit that added it rather than from mtime. mtime is a fact about the
194
+ // filesystem: `git clone` stamps every plan with the clone time, which
195
+ // collapsed thirteen dates into one and made this 30-day window return the
196
+ // project's entire history. See plan-date.mjs for the measurement.
197
+ const dated = datePlans(planFiles, { root: cwd, dir: 'docs/plans' });
198
+ let fromMtime = 0;
199
+ for (const fp of planFiles) {
200
+ const d = dated.dates.get(fp);
201
+ if (cutoff != null) {
202
+ if (!d?.date) continue; // undatable: not in any window
203
+ if (Date.parse(`${d.date}T23:59:59Z`) < cutoff) continue; // outside it
204
+ }
205
+ // Counted after the window test, so the figure describes the plans that
206
+ // actually went into these totals rather than everything in the directory.
207
+ if (d && !d.reliable) fromMtime += 1;
170
208
  const content = fs.readFileSync(fp, 'utf8');
171
209
  // Parse cost lines from PLAN-*.md.
172
210
  // Use the SAME anchored regex as getCostHistory() so both endpoints agree
@@ -188,6 +226,10 @@ function readPlanCosts(cwd = process.cwd(), sinceMsAgo = null) {
188
226
  human_usd: Math.round(totalHumanUsd),
189
227
  savings_x: totalLlmUsd > 0 ? Math.round(totalHumanUsd / totalLlmUsd) : 0,
190
228
  count,
229
+ // How many of these plans could only be dated by their file timestamp. A
230
+ // figure assembled partly from filesystem metadata says so rather than
231
+ // presenting itself as measured.
232
+ dated_by_mtime: fromMtime,
191
233
  };
192
234
  }
193
235
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "great-cto",
3
- "version": "3.5.0",
3
+ "version": "3.7.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",