great-cto 3.4.0 → 3.6.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.4.0",
5
+ "version": "3.6.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
 
@@ -161,12 +162,23 @@ function readPlanCosts(cwd = process.cwd(), sinceMsAgo = null) {
161
162
  let totalLlmMin = 0, totalLlmUsd = 0, totalHumanUsd = 0, count = 0;
162
163
  if (!fs.existsSync(plansDir)) return { llm_usd: 0, human_usd: 0, savings_x: 0, count: 0 };
163
164
  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;
165
+ const planFiles = fs.readdirSync(plansDir).filter(f => f.endsWith('.md')).map(f => path.join(plansDir, f));
166
+ // Dated from the plan itself — front-matter, then the filename, then the
167
+ // commit that added it rather than from mtime. mtime is a fact about the
168
+ // filesystem: `git clone` stamps every plan with the clone time, which
169
+ // collapsed thirteen dates into one and made this 30-day window return the
170
+ // project's entire history. See plan-date.mjs for the measurement.
171
+ const dated = datePlans(planFiles, { root: cwd, dir: 'docs/plans' });
172
+ let fromMtime = 0;
173
+ for (const fp of planFiles) {
174
+ const d = dated.dates.get(fp);
175
+ if (cutoff != null) {
176
+ if (!d?.date) continue; // undatable: not in any window
177
+ if (Date.parse(`${d.date}T23:59:59Z`) < cutoff) continue; // outside it
178
+ }
179
+ // Counted after the window test, so the figure describes the plans that
180
+ // actually went into these totals rather than everything in the directory.
181
+ if (d && !d.reliable) fromMtime += 1;
170
182
  const content = fs.readFileSync(fp, 'utf8');
171
183
  // Parse cost lines from PLAN-*.md.
172
184
  // Use the SAME anchored regex as getCostHistory() so both endpoints agree
@@ -188,6 +200,10 @@ function readPlanCosts(cwd = process.cwd(), sinceMsAgo = null) {
188
200
  human_usd: Math.round(totalHumanUsd),
189
201
  savings_x: totalLlmUsd > 0 ? Math.round(totalHumanUsd / totalLlmUsd) : 0,
190
202
  count,
203
+ // How many of these plans could only be dated by their file timestamp. A
204
+ // figure assembled partly from filesystem metadata says so rather than
205
+ // presenting itself as measured.
206
+ dated_by_mtime: fromMtime,
191
207
  };
192
208
  }
193
209
 
@@ -33,6 +33,11 @@ export const VERDICT_FORMAT_VERSION = 1;
33
33
  /** Verdict values with a defined meaning. Anything else is kept but flagged. */
34
34
  export const KNOWN_VERDICTS = Object.freeze([
35
35
  'APPROVED', 'BLOCKED', 'DONE', 'FAIL', 'PASS', 'REJECTED', 'SKIPPED', 'ESCALATED',
36
+ // REWORK — independent verification found the stage incomplete and the agent
37
+ // that ran it can fix that itself. Distinct from BLOCKED, which means a human
38
+ // must decide. Without it here the verifier could reach its conclusion and not
39
+ // be able to write it down, which is the same as not reaching it.
40
+ 'REWORK',
36
41
  ]);
37
42
 
38
43
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "great-cto",
3
- "version": "3.4.0",
3
+ "version": "3.6.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",