great-cto 3.59.0 → 3.60.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.
@@ -86,6 +86,68 @@ export function projectName(cwd, homeDir = home()) {
86
86
  return path.basename(cwd) || null;
87
87
  }
88
88
 
89
+ const canonical = (p) => { try { return fs.realpathSync(p); } catch { return path.resolve(p); } };
90
+
91
+ /** Stable repository identity, including linked worktrees. Never infer it from a title/basename. */
92
+ function repositoryIdentity(cwd) {
93
+ for (let dir = canonical(cwd); ; dir = path.dirname(dir)) {
94
+ const marker = path.join(dir, '.git');
95
+ try {
96
+ const stat = fs.statSync(marker);
97
+ let gitDir;
98
+ if (stat.isDirectory()) gitDir = marker;
99
+ else {
100
+ const match = fs.readFileSync(marker, 'utf8').match(/^gitdir:\s*(.+)\s*$/m);
101
+ if (!match) return null;
102
+ gitDir = path.resolve(dir, match[1].trim());
103
+ }
104
+ let common = gitDir;
105
+ try { common = path.resolve(gitDir, fs.readFileSync(path.join(gitDir, 'commondir'), 'utf8').trim()); } catch { /* main checkout */ }
106
+ return { common: canonical(common), root: dir };
107
+ } catch { /* continue to parent */ }
108
+ if (path.dirname(dir) === dir) return null;
109
+ }
110
+ }
111
+
112
+ /** Filter before aggregation: children without cwd inherit only their own host's parent. */
113
+ export function projectUsageIndex(index, projectPath) {
114
+ if (!projectPath) return index;
115
+ const root = canonical(projectPath);
116
+ const git = repositoryIdentity(root);
117
+ const projectRelative = git ? path.relative(git.root, root) : null;
118
+ const identities = new Map();
119
+ const entries = Object.entries(index.files || {});
120
+ const sessions = new Map();
121
+ for (const [, e] of entries) if (e.meta?.session) sessions.set(`${e.host}|${e.meta.session}`, e);
122
+ const inheritedCwd = e => {
123
+ const visited = new Set();
124
+ while (e) {
125
+ if (e.meta?.cwd) return e.meta.cwd;
126
+ const key = `${e.host}|${e.meta?.parent}`;
127
+ if (!e.meta?.parent || visited.has(key)) return null;
128
+ visited.add(key); e = sessions.get(key);
129
+ }
130
+ return null;
131
+ };
132
+ const matches = (cwd) => {
133
+ if (!cwd || typeof cwd !== 'string' || !path.isAbsolute(cwd)) return false;
134
+ if (identities.has(cwd)) return identities.get(cwd);
135
+ const actual = canonical(cwd);
136
+ const repo = repositoryIdentity(actual);
137
+ // Transfer the selected project's relative directory to this worktree.
138
+ // A shared Git database alone does not make sibling monorepo apps one project.
139
+ const scopeRoot = git && repo && git.common === repo.common
140
+ ? path.resolve(repo.root, projectRelative)
141
+ : root;
142
+ const rel = path.relative(scopeRoot, actual);
143
+ const inside = rel === '' || (!rel.startsWith(`..${path.sep}`) && rel !== '..' && !path.isAbsolute(rel));
144
+ const match = git && repo ? git.common === repo.common && inside : inside;
145
+ identities.set(cwd, match);
146
+ return match;
147
+ };
148
+ return { ...index, files: Object.fromEntries(entries.filter(([, e]) => matches(inheritedCwd(e)))) };
149
+ }
150
+
89
151
  // ── file discovery ────────────────────────────────────────────────────────────
90
152
 
91
153
  function walk(dir, maxDepth, out) {
@@ -823,7 +885,8 @@ function laneDetail(samples, { fromMs, now }) {
823
885
  * @param {object} index from scanUsage
824
886
  * @param {{days?:number, now?:number, codexTitles?:Record<string,string>, prices?:object, top?:number}} opts
825
887
  */
826
- export function summarizeUsage(index, { days = 30, now = Date.now(), codexTitles = {}, prices = effectivePrices(), top = 10, claudeReadings = [], claudeRecorderState = 'off' } = {}) {
888
+ export function summarizeUsage(index, { days = 30, now = Date.now(), codexTitles = {}, prices = effectivePrices(), top = 10, claudeReadings = [], claudeRecorderState = 'off', projectPath = null } = {}) {
889
+ index = projectUsageIndex(index, projectPath);
827
890
  const span = Math.max(1, Math.min(365, Math.floor(days) || 30));
828
891
  const dates = [];
829
892
  for (let i = span - 1; i >= 0; i--) dates.push(dayOf(now - i * 86400000));
@@ -918,7 +981,7 @@ export function summarizeUsage(index, { days = 30, now = Date.now(), codexTitles
918
981
  // The conversation's own file names it; a subagent's file only fills a gap.
919
982
  const own = !e.meta?.parent;
920
983
  if (own || !s.title) s.title = (host === 'codex' ? codexTitles[e.meta?.session] : null) || e.meta?.title || e.meta?.prompt || s.title;
921
- if (own || !s.project) s.project = projectName(e.meta?.cwd) || s.project;
984
+ if (own || !s.project) s.project = projectName(projectPath || e.meta?.cwd) || s.project;
922
985
  if (own || !s.fn) s.fn = fn;
923
986
  }
924
987
 
@@ -940,6 +1003,11 @@ export function summarizeUsage(index, { days = 30, now = Date.now(), codexTitles
940
1003
 
941
1004
  return {
942
1005
  state: 'counted',
1006
+ ...(projectPath ? {
1007
+ scope: { kind: 'project', path: canonical(projectPath) },
1008
+ files: Object.keys(index.files || {}).length,
1009
+ seen: Object.fromEntries(['claude', 'codex'].map(host => [host, Object.values(index.files || {}).some(e => e.host === host)])),
1010
+ } : {}),
943
1011
  window: { from, to: dates[dates.length - 1], days: span },
944
1012
  hosts: Object.fromEntries(Object.entries(hosts).map(([k, h]) => [k, {
945
1013
  tokens: tokensOf(h), input: h.in, output: h.out, cacheRead: h.cr, cacheWrite: h.cw + h.cwh, reasoning: h.rs,
@@ -957,8 +1025,8 @@ export function summarizeUsage(index, { days = 30, now = Date.now(), codexTitles
957
1025
  tools: rank(l.tools, 15), skills: rank(l.skills, 15), agents: rank(l.agents, 15), mcp: rank(l.mcp, 15, mcpLabel),
958
1026
  }])),
959
1027
  limits: {
960
- codex: codexLimits ? { ...codexLimits, plan: codexLimits.plan || codexPlan?.plan || null, detail: codexLimitDetail(codexPoints, { fromMs, now }) } : null,
961
- claude: { ...claudeLimits(hourly.claude, { fromMs, now, lastHit }), plan: claudePlanDetail(claudeReadings, { fromMs, now }), recorder: claudeRecorderState },
1028
+ codex: !projectPath && codexLimits ? { ...codexLimits, plan: codexLimits.plan || codexPlan?.plan || null, detail: codexLimitDetail(codexPoints, { fromMs, now }) } : null,
1029
+ claude: { ...claudeLimits(hourly.claude, { fromMs, now, lastHit }), plan: projectPath ? null : claudePlanDetail(claudeReadings, { fromMs, now }), recorder: claudeRecorderState },
962
1030
  },
963
1031
  unpricedModels: [...unpriced].sort(),
964
1032
  // Claude Code only: Codex does not write hook outcomes to its session logs.
@@ -0,0 +1,182 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * scripts/skill-lint.mjs — structural validator for skills/*\/SKILL.md, the skill
4
+ * counterpart of agent-prompt-lint.mjs. Agents had a lint, a review and a retire
5
+ * loop; skills shipped with none, so a skill whose frontmatter did not load, whose
6
+ * name drifted from its directory, or which pointed at a deleted script was found
7
+ * only by a model failing to use it.
8
+ *
9
+ * Rules:
10
+ * SK-001 error YAML frontmatter present and parses
11
+ * SK-002 error `name` equals the skill's directory
12
+ * SK-003 error `description` present, 40–1024 chars (the model routes on it)
13
+ * SK-004 warn SKILL.md over 20 KB (loaded whole into context) — HEAVY_ALLOWED exempts
14
+ * SK-005 error a relative link or backticked repo path points at a file that does not exist
15
+ * SK-006 error a private absolute path (/Users/<name>/…, /home/<name>/…)
16
+ * SK-007 error an unquoted frontmatter value strict YAML reads differently (`: `, ` #`, a reserved first character)
17
+ *
18
+ * Usage:
19
+ * node scripts/skill-lint.mjs [--root <repo>] [--json]
20
+ * Exit: 0 clean or warnings only · 1 errors · 2 invalid invocation
21
+ */
22
+ import fs from 'node:fs';
23
+ import path from 'node:path';
24
+ import { fileURLToPath } from 'node:url';
25
+
26
+ export const SIZE_BUDGET = 20 * 1024;
27
+ export const DESC_MIN = 40;
28
+ export const DESC_MAX = 1024;
29
+
30
+ /** Skills allowed over the size budget, each with the reason it is allowed. */
31
+ export const HEAVY_ALLOWED = {
32
+ 'ui-ux-pro-max': 'vendored design reference (MIT, upstream layout); its tables are the payload and are loaded only for UI work',
33
+ };
34
+
35
+ // Backticked paths into great_cto's own tree. docs/ and tests/ are left out on
36
+ // purpose: skills name those as paths in the *target* project far more often.
37
+ const REPO_PATH = /^(scripts|skills|agents|commands|shared|hooks|packages)\/[A-Za-z0-9._/-]+\.[A-Za-z0-9]+$/;
38
+ const MD_LINK = /\[[^\]\n]*\]\(([^)\s]+)(?:\s+"[^"]*")?\)/g;
39
+ const TICKED = /`([^`\n]+)`/g;
40
+ const PRIVATE_PATH = /(?<![\w.~$}])\/(?:Users|home)\/[A-Za-z0-9][A-Za-z0-9._-]*\//g;
41
+
42
+ /**
43
+ * Minimal YAML frontmatter reader: top-level `key: value`, block scalars
44
+ * (`|` / `>`), and indented continuation (lists, nested maps) kept as text.
45
+ * Throws on a top-level line that is not a key.
46
+ */
47
+ /**
48
+ * Unquoted top-level values that a strict YAML parser reads differently from how
49
+ * this file's lenient parser reads them — so the frontmatter "works" here and the
50
+ * skill (or agent, or command) silently fails to load elsewhere. ECC shipped a
51
+ * preflight for exactly this after a release did (techwolf-ai/ai-first-toolkit
52
+ * preflight.py, v1.12.2). Quote the value to fix it.
53
+ */
54
+ export function strictYamlIssues(text) {
55
+ const t = String(text).replace(/\r\n/g, '\n');
56
+ if (!t.startsWith('---\n')) return [];
57
+ const end = t.indexOf('\n---', 3);
58
+ if (end === -1) return [];
59
+ const out = [];
60
+ for (const line of t.slice(4, end).split('\n')) {
61
+ const m = line.match(/^([A-Za-z_][A-Za-z0-9_-]*)\s*:\s+(.+)$/);
62
+ if (!m) continue;
63
+ const v = m[2].trim();
64
+ if (/^["'|>\[{]/.test(v)) continue; // quoted, block scalar or flow collection
65
+ if (/:\s/.test(v) || /:$/.test(v)) out.push(`\`${m[1]}\`: an unquoted value containing ": " is read as a nested mapping`);
66
+ else if (/\s#/.test(v)) out.push(`\`${m[1]}\`: an unquoted " #" starts a comment — the value is cut there`);
67
+ else if (/^[@`%&*!]/.test(v)) out.push(`\`${m[1]}\`: an unquoted value may not start with ${v[0]}`);
68
+ }
69
+ return out;
70
+ }
71
+
72
+ export function parseFrontmatter(text) {
73
+ const t = String(text).replace(/\r\n/g, '\n');
74
+ if (!t.startsWith('---\n')) return null;
75
+ const end = t.indexOf('\n---', 3);
76
+ if (end === -1) return null;
77
+ const yaml = t.slice(4, end);
78
+ const body = t.slice(end + 4).replace(/^\n/, '');
79
+ const data = {};
80
+ let key = null;
81
+ let block = false;
82
+ for (const line of yaml.split('\n')) {
83
+ if (!line.trim() || /^\s*#/.test(line)) continue;
84
+ if (/^\s/.test(line)) {
85
+ if (key === null) throw new Error(`indented line before any key: ${line.slice(0, 60)}`);
86
+ const v = line.trim();
87
+ if (block || typeof data[key] === 'string') data[key] = data[key] ? `${data[key]} ${v}` : v;
88
+ continue;
89
+ }
90
+ const m = line.match(/^([A-Za-z_][A-Za-z0-9_-]*)\s*:(?:\s+(.*)|\s*)$/);
91
+ if (!m) throw new Error(`unparseable line: ${line.slice(0, 60)}`);
92
+ key = m[1];
93
+ const v = (m[2] ?? '').trim();
94
+ block = /^[|>][+-]?$/.test(v);
95
+ data[key] = block ? '' : v === '' ? null : v.replace(/^(["'])([\s\S]*)\1$/, '$2');
96
+ }
97
+ return { data, body };
98
+ }
99
+
100
+ const placeholder = (s) => /[{}<>*…$]|\.\.\./.test(s);
101
+
102
+ function danglingRefs(text, skillDir, repoRoot) {
103
+ const out = [];
104
+ const exists = (rel) => [skillDir, repoRoot].some((base) => fs.existsSync(path.resolve(base, rel)));
105
+ for (const m of text.matchAll(MD_LINK)) {
106
+ const target = m[1].replace(/#.*$/, '');
107
+ if (!target || /^[a-z][a-z0-9+.-]*:/i.test(m[1]) || m[1].startsWith('#') || target.startsWith('/') || placeholder(target)) continue;
108
+ if (!exists(target)) out.push(`link to missing file \`${target}\``);
109
+ }
110
+ for (const m of text.matchAll(TICKED)) {
111
+ const p = m[1].trim().replace(/:\d+(-\d+)?$/, '');
112
+ if (!REPO_PATH.test(p) || placeholder(p)) continue;
113
+ if (!exists(p)) out.push(`reference to missing file \`${p}\``);
114
+ }
115
+ return out;
116
+ }
117
+
118
+ function lintOne(name, file, repoRoot) {
119
+ const errors = [];
120
+ const warnings = [];
121
+ const push = (list, rule, msg) => list.push({ skill: name, rule, msg });
122
+ const text = fs.readFileSync(file, 'utf8');
123
+
124
+ let fm = null;
125
+ try {
126
+ fm = parseFrontmatter(text);
127
+ if (!fm) push(errors, 'SK-001', 'no `---` frontmatter at the start of the file');
128
+ } catch (e) {
129
+ push(errors, 'SK-001', `frontmatter does not parse: ${e.message}`);
130
+ }
131
+ if (fm) {
132
+ const { name: n, description: d } = fm.data;
133
+ if (n !== name) push(errors, 'SK-002', `name \`${n ?? '(missing)'}\` does not match directory \`${name}\``);
134
+ if (typeof d !== 'string' || !d.trim()) push(errors, 'SK-003', 'frontmatter missing `description`');
135
+ else if (d.length < DESC_MIN) push(errors, 'SK-003', `description too short (${d.length} chars; min ${DESC_MIN})`);
136
+ else if (d.length > DESC_MAX) push(errors, 'SK-003', `description too long (${d.length} chars; max ${DESC_MAX})`);
137
+ }
138
+
139
+ const bytes = Buffer.byteLength(text);
140
+ if (bytes > SIZE_BUDGET && !HEAVY_ALLOWED[name]) {
141
+ push(warnings, 'SK-004', `${(bytes / 1024).toFixed(1)} KB over the ${SIZE_BUDGET / 1024} KB budget — loaded whole into context`);
142
+ }
143
+ for (const msg of danglingRefs(text, path.dirname(file), repoRoot)) push(errors, 'SK-005', msg);
144
+ for (const m of text.matchAll(PRIVATE_PATH)) push(errors, 'SK-006', `private absolute path \`${m[0]}…\``);
145
+ for (const msg of strictYamlIssues(text)) push(errors, 'SK-007', msg);
146
+ return { errors, warnings };
147
+ }
148
+
149
+ /** Lint every skills/<name>/SKILL.md under repoRoot. */
150
+ export function lintSkills({ repoRoot }) {
151
+ const dir = path.join(repoRoot, 'skills');
152
+ const names = fs.readdirSync(dir, { withFileTypes: true })
153
+ .filter((e) => e.isDirectory() && fs.existsSync(path.join(dir, e.name, 'SKILL.md')))
154
+ .map((e) => e.name).sort();
155
+ const errors = [];
156
+ const warnings = [];
157
+ for (const n of names) {
158
+ const r = lintOne(n, path.join(dir, n, 'SKILL.md'), repoRoot);
159
+ errors.push(...r.errors);
160
+ warnings.push(...r.warnings);
161
+ }
162
+ return { skills: names.length, errors, warnings };
163
+ }
164
+
165
+ const isMain = process.argv[1] && fileURLToPath(import.meta.url) === path.resolve(process.argv[1]);
166
+ if (isMain) {
167
+ const a = process.argv.slice(2);
168
+ const i = a.indexOf('--root');
169
+ if (i >= 0 && !a[i + 1]) { process.stderr.write('usage: skill-lint.mjs [--root <repo>] [--json]\n'); process.exit(2); }
170
+ const repoRoot = i >= 0 ? path.resolve(a[i + 1]) : path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
171
+ let r;
172
+ try { r = lintSkills({ repoRoot }); } catch (e) { process.stderr.write(`skill-lint: ${e.message}\n`); process.exit(2); }
173
+ if (a.includes('--json')) {
174
+ process.stdout.write(`${JSON.stringify(r, null, 2)}\n`);
175
+ } else {
176
+ for (const e of r.errors) process.stdout.write(`ERROR ${e.rule} skills/${e.skill}: ${e.msg}\n`);
177
+ for (const w of r.warnings) process.stdout.write(`warn ${w.rule} skills/${w.skill}: ${w.msg}\n`);
178
+ const heavy = Object.keys(HEAVY_ALLOWED).join(', ');
179
+ process.stdout.write(`skill-lint: ${r.skills} skills · ${r.errors.length} errors · ${r.warnings.length} warnings (heavy by allow-list: ${heavy})\n`);
180
+ }
181
+ process.exit(r.errors.length ? 1 : 0);
182
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "great-cto",
3
- "version": "3.59.0",
3
+ "version": "3.60.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",
package/postinstall.mjs CHANGED
@@ -138,32 +138,38 @@ function main() {
138
138
  * - never block. The board takes the better part of a minute to become
139
139
  * responsive; the installer does not wait for it. Detached, unref'd, output
140
140
  * discarded.
141
- * - never in CI, and never when asked not to. Both already guard the notice
142
- * above; `GREAT_CTO_NO_BOARD=1` opts out of this specifically.
141
+ * - never in CI or a Node test runner, and never when asked not to. A test
142
+ * HOME isolates files, NOT ports: a detached board would still claim the
143
+ * user's :3141 and serve fixture projects after the test exits.
144
+ * `GREAT_CTO_NO_BOARD=1` opts out of this specifically.
143
145
  * - never twice. `ensure` probes the port first and adopts a board that is
144
146
  * already answering, whoever started it.
145
147
  */
146
- function ensureBoard() {
147
- if (process.env.GREAT_CTO_NO_BOARD) return;
148
+ export function ensureBoard({ env = process.env, spawnFn = spawn,
149
+ write = (text) => process.stdout.write(text) } = {}) {
150
+ if (env.CI || env.GREAT_CTO_QUIET_POSTINSTALL || env.GREAT_CTO_NO_BOARD || env.NODE_TEST_CONTEXT) return false;
148
151
  try {
149
152
  const here = dirname(fileURLToPath(import.meta.url));
150
153
  const entry = join(here, 'index.mjs');
151
- if (!existsSync(entry)) return;
154
+ if (!existsSync(entry)) return false;
152
155
 
153
- const child = spawn(process.execPath, [entry, 'board', 'ensure'], {
156
+ const child = spawnFn(process.execPath, [entry, 'board', 'ensure'], {
154
157
  detached: true,
155
158
  stdio: 'ignore',
159
+ env,
156
160
  });
157
161
  child.unref();
158
- process.stdout.write(' great-cto: starting the board — http://localhost:3141\n');
162
+ write(' great-cto: starting the board — http://localhost:3141\n');
163
+ return true;
159
164
  } catch (e) {
160
165
  // Never fail the install — but never fail SILENTLY either. The first version
161
166
  // of this function referenced an import that was not there; the catch ate the
162
167
  // ReferenceError and the hook printed nothing, so the board simply did not
163
168
  // start and nothing said why. A swallowed error is the defect this project
164
169
  // spends most of its checks on.
165
- process.stdout.write(` great-cto: could not start the board — ${e?.message || e}\n`);
166
- process.stdout.write(' Start it yourself with: great-cto board\n');
170
+ write(` great-cto: could not start the board — ${e?.message || e}\n`);
171
+ write(' Start it yourself with: great-cto board\n');
172
+ return false;
167
173
  }
168
174
  }
169
175