great-cto 2.91.0 → 2.92.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": "2.91.0",
5
+ "version": "2.92.0",
6
6
  "author": {
7
7
  "name": "Great CTO",
8
8
  "url": "https://github.com/avelikiy/great_cto"
@@ -132,7 +132,14 @@ function tasksMdStatus(rawStatus, id, title) {
132
132
  else if (s === 'in_progress' || s === 'in-progress' || s === 'wip' || s === 'doing') status = 'in_progress';
133
133
  else if (s === 'blocked') status = 'blocked';
134
134
  else status = isGate ? 'gate' : 'backlog';
135
- return { status, raw_status: status === 'done' ? 'closed' : 'open', isGate };
135
+ // raw_status is what the inbox filters on, because mapStatus() rewrites any
136
+ // gate-labelled task to 'gate' and filtering on the mapped value would leave
137
+ // closed gates in the inbox forever. That fix covered `done`; it did not cover
138
+ // `blocked`, so a gate marked blocked in tasks.md reported raw_status 'open'
139
+ // and never left the inbox — for exactly the bd-less projects this parser
140
+ // exists to serve.
141
+ const raw = status === 'done' ? 'closed' : status === 'blocked' ? 'blocked' : 'open';
142
+ return { status, raw_status: raw, isGate };
136
143
  }
137
144
 
138
145
  // Build the full task record both parsers emit — one shape so getTasks can
@@ -23,7 +23,12 @@ function deriveDomain(slug) {
23
23
  const s = slug.toLowerCase();
24
24
  if (/architect|adr|design|prompt/.test(s)) return 'arch';
25
25
  if (/security|sec-|threat|pci|gdpr|hipaa/.test(s)) return 'security';
26
- if (/qa|test|eval|review/.test(s)) return 'qa';
26
+ // `review` used to be in this alternation, and it matched every `*-reviewer`
27
+ // slug — so 37 of the 40 agents in `qa` were domain reviewers, the `domain`
28
+ // bucket below was permanently empty, and the panel grouped 58% of the fleet
29
+ // under one heading. The rule that was supposed to separate them sat two lines
30
+ // later and could never be reached.
31
+ if (/\bqa\b|test|eval|^code-reviewer$/.test(s)) return 'qa';
27
32
  if (/devops|deploy|infra|l3|support|oncall/.test(s)) return 'ops';
28
33
  if (/reviewer$/.test(s)) return 'domain';
29
34
  if (/pm|plan|product/.test(s)) return 'pm';
package/dist/main.js CHANGED
@@ -1196,6 +1196,23 @@ async function runSelfUpgrade() {
1196
1196
  return result.exitCode;
1197
1197
  }
1198
1198
  success(result.message);
1199
+ // The CLI is upgraded; the plugin Claude Code actually loads may not be.
1200
+ // `npm i -g` never touches the plugin cache, so without this the user is told
1201
+ // the upgrade succeeded and keeps running the previous release's hooks and
1202
+ // agent prompts with nothing anywhere saying so.
1203
+ try {
1204
+ const { pluginCacheLagWarning } = await import("./self-upgrade.js");
1205
+ const { readdirSync } = await import("node:fs");
1206
+ const { join } = await import("node:path");
1207
+ const cacheRoot = join(homedir(), ".claude", "plugins", "cache", "local", "great_cto");
1208
+ const dirs = readdirSync(cacheRoot);
1209
+ const warning = pluginCacheLagWarning(result.newVersion ?? "", dirs);
1210
+ if (warning) {
1211
+ log("");
1212
+ log(dim(warning));
1213
+ }
1214
+ }
1215
+ catch { /* no plugin cache is not a lag — the plugin is simply not installed */ }
1199
1216
  return 0;
1200
1217
  }
1201
1218
  async function runUpgrade(rawArgv, args) {
@@ -1242,6 +1259,10 @@ async function main() {
1242
1259
  // is the single exit funnel: it fires one fire-and-forget event (command + node + os
1243
1260
  // + exit_code + duration, no PII) only when the user has opted in, then exits.
1244
1261
  const __tStart = Date.now();
1262
+ // Promise<never>, not Promise<void>: this always exits, and typing it as such
1263
+ // is what lets `await finish(2)` narrow a nullable the way `process.exit(2)`
1264
+ // did. Without it, routing an exit through the update check would silently
1265
+ // widen types at every call site.
1245
1266
  const finish = async (code) => {
1246
1267
  try {
1247
1268
  await sendUsagePing({
@@ -1270,7 +1291,7 @@ async function main() {
1270
1291
  if (args.command === "telemetry") {
1271
1292
  const { exitCode, output } = telemetrySubcommand(args.positional[0]);
1272
1293
  process.stdout.write(output);
1273
- process.exit(exitCode);
1294
+ await finish(exitCode);
1274
1295
  }
1275
1296
  if (args.command === "help") {
1276
1297
  printHelp();
@@ -1281,7 +1302,7 @@ async function main() {
1281
1302
  error(`great-cto: unknown command or flag '${tok}'`);
1282
1303
  log("");
1283
1304
  log(`Run ${cyan("great-cto --help")} for usage.`);
1284
- process.exit(2);
1305
+ await finish(2);
1285
1306
  }
1286
1307
  if (args.command === "board") {
1287
1308
  try {
@@ -1304,7 +1325,7 @@ async function main() {
1304
1325
  }
1305
1326
  catch (e) {
1306
1327
  error(e.message);
1307
- process.exit(1);
1328
+ await finish(1);
1308
1329
  }
1309
1330
  }
1310
1331
  if (args.command === "console") {
@@ -1314,7 +1335,7 @@ async function main() {
1314
1335
  }
1315
1336
  catch (e) {
1316
1337
  error(e.message);
1317
- process.exit(1);
1338
+ await finish(1);
1318
1339
  }
1319
1340
  }
1320
1341
  if (args.command === "register") {
@@ -1324,7 +1345,7 @@ async function main() {
1324
1345
  }
1325
1346
  catch (e) {
1326
1347
  error(e.message);
1327
- process.exit(1);
1348
+ await finish(1);
1328
1349
  }
1329
1350
  }
1330
1351
  if (args.command === "ci") {
@@ -1335,7 +1356,7 @@ async function main() {
1335
1356
  }
1336
1357
  catch (e) {
1337
1358
  error(e.message);
1338
- process.exit(2);
1359
+ await finish(2);
1339
1360
  }
1340
1361
  }
1341
1362
  if (args.command === "mcp") {
@@ -1349,7 +1370,7 @@ async function main() {
1349
1370
  }
1350
1371
  catch (e) {
1351
1372
  error(e.message);
1352
- process.exit(2);
1373
+ await finish(2);
1353
1374
  }
1354
1375
  }
1355
1376
  if (args.command === "adapt") {
@@ -1364,16 +1385,16 @@ async function main() {
1364
1385
  }
1365
1386
  catch (e) {
1366
1387
  error(e.message);
1367
- process.exit(2);
1388
+ await finish(2);
1368
1389
  }
1369
1390
  }
1370
1391
  if (args.command === "task") {
1371
1392
  const { runTask } = await import("./worker.js");
1372
- process.exit(await runTask(args.taskArgs ?? []));
1393
+ await finish(await runTask(args.taskArgs ?? []));
1373
1394
  }
1374
1395
  if (args.command === "worker") {
1375
1396
  const { runWorker } = await import("./worker.js");
1376
- process.exit(await runWorker(args.taskArgs ?? []));
1397
+ await finish(await runWorker(args.taskArgs ?? []));
1377
1398
  }
1378
1399
  if (args.command === "serve") {
1379
1400
  try {
@@ -1390,7 +1411,7 @@ async function main() {
1390
1411
  }
1391
1412
  catch (e) {
1392
1413
  error(e.message);
1393
- process.exit(2);
1414
+ await finish(2);
1394
1415
  }
1395
1416
  }
1396
1417
  if (args.command === "webhook") {
@@ -1399,14 +1420,14 @@ async function main() {
1399
1420
  const parsed = parseWebhookArgs(rawArgv);
1400
1421
  if (!parsed) {
1401
1422
  error("usage: great-cto webhook list | add-incoming <name> --secret <s> | add-outgoing <name> --url <u> --format <f> --triggers <t1,t2> | remove <name> | test <name>");
1402
- process.exit(2);
1423
+ return finish(2);
1403
1424
  }
1404
1425
  const code = await runWebhookCli(parsed);
1405
1426
  await finish(code);
1406
1427
  }
1407
1428
  catch (e) {
1408
1429
  error(e.message);
1409
- process.exit(2);
1430
+ await finish(2);
1410
1431
  }
1411
1432
  }
1412
1433
  if (args.command === "upgrade") {
@@ -1416,7 +1437,7 @@ async function main() {
1416
1437
  }
1417
1438
  catch (e) {
1418
1439
  error(e.message);
1419
- process.exit(2);
1440
+ await finish(2);
1420
1441
  }
1421
1442
  }
1422
1443
  if (args.command === "chat-only-hint") {
@@ -1433,21 +1454,21 @@ async function main() {
1433
1454
  log(` ${cyan("serve")} · ${cyan("webhook")} · ${cyan("report")} · ${cyan("board")} · ${cyan("register")} · ${cyan("upgrade")}`);
1434
1455
  log("");
1435
1456
  log(`Run ${cyan("npx great-cto --help")} for the full CLI reference.`);
1436
- process.exit(2);
1457
+ await finish(2);
1437
1458
  }
1438
1459
  if (args.command === "report") {
1439
1460
  try {
1440
1461
  const { runReport, parseReportArgs } = await import("./report.js");
1441
1462
  const parsed = parseReportArgs(rawArgv, args.dir);
1442
1463
  if (!parsed) {
1443
- process.exit(2);
1464
+ return finish(2);
1444
1465
  }
1445
1466
  const code = await runReport(parsed);
1446
1467
  await finish(code);
1447
1468
  }
1448
1469
  catch (e) {
1449
1470
  error(e.message);
1450
- process.exit(2);
1471
+ await finish(2);
1451
1472
  }
1452
1473
  }
1453
1474
  if (args.command === "version") {
@@ -1464,7 +1485,7 @@ async function main() {
1464
1485
  const pkg = JSON.parse(readFileSync(p, "utf-8"));
1465
1486
  if (pkg.name === "great-cto" && pkg.version) {
1466
1487
  log(pkg.version);
1467
- process.exit(0);
1488
+ await finish(0);
1468
1489
  }
1469
1490
  }
1470
1491
  catch { /* keep searching */ }
@@ -1474,7 +1495,7 @@ async function main() {
1474
1495
  catch {
1475
1496
  log("0.0.0");
1476
1497
  }
1477
- process.exit(0);
1498
+ await finish(0);
1478
1499
  }
1479
1500
  try {
1480
1501
  const code = await runInit(args);
@@ -154,3 +154,57 @@ export function performSelfUpgrade(opts) {
154
154
  }
155
155
  // Re-export for callers that only need the prefix-derivation logic directly.
156
156
  export { derivePrefixFromBinPath as _derivePrefixFromBinPath };
157
+ /**
158
+ * Newest version present in the Claude Code plugin cache, or null.
159
+ *
160
+ * Takes the directory names so the rule is testable without a filesystem.
161
+ */
162
+ export function newestCachedPlugin(dirNames) {
163
+ const versions = dirNames.filter((d) => /^\d+\.\d+\.\d+$/.test(d));
164
+ if (!versions.length)
165
+ return null;
166
+ const key = (v) => v.split(".").map(Number);
167
+ return versions.sort((a, b) => {
168
+ const [A, B] = [key(a), key(b)];
169
+ for (let i = 0; i < 3; i++)
170
+ if (A[i] !== B[i])
171
+ return A[i] - B[i];
172
+ return 0;
173
+ })[versions.length - 1];
174
+ }
175
+ /**
176
+ * Warn when the npm package has moved ahead of the plugin Claude Code loads.
177
+ *
178
+ * `npm i -g` replaces the CLI. It does not touch
179
+ * ~/.claude/plugins/cache/local/great_cto/<version>/, which is where the hooks
180
+ * and agent prompts are actually read from — so a user can take the update
181
+ * prompt, be told the upgrade succeeded, and keep running the previous release's
182
+ * agents with nothing anywhere saying so. That is the same defect this codebase
183
+ * keeps finding: a step reported as done while the thing it was meant to change
184
+ * stayed as it was.
185
+ *
186
+ * Returns null when they match, when nothing is cached (the plugin is simply not
187
+ * installed), or when the cache is somehow ahead — none of those are the failure
188
+ * this exists to catch.
189
+ */
190
+ export function pluginCacheLagWarning(npmVersion, cachedDirs) {
191
+ const cached = newestCachedPlugin(cachedDirs);
192
+ if (!cached || !npmVersion)
193
+ return null;
194
+ if (cached === npmVersion)
195
+ return null;
196
+ const key = (v) => v.split(".").map(Number);
197
+ const [c, n] = [key(cached), key(npmVersion)];
198
+ for (let i = 0; i < 3; i++) {
199
+ if (c[i] > n[i])
200
+ return null; // cache ahead — not the case we guard
201
+ if (c[i] < n[i])
202
+ break;
203
+ }
204
+ return [
205
+ `note: the npm package is ${npmVersion}, but the plugin Claude Code loads is ${cached}.`,
206
+ " Hooks and agent prompts come from the plugin cache, not from npm — until it is",
207
+ " refreshed, this session keeps running the previous release's agents.",
208
+ " Refresh it with: bash scripts/install-local.sh --prune",
209
+ ].join("\n");
210
+ }
@@ -58,6 +58,29 @@ export function cachePath() {
58
58
  }
59
59
  /** Pure function: is a suppression condition active? Fail-open to "suppressed" on any ambiguity. */
60
60
  export function isSuppressed(opts = {}) {
61
+ if (isRefreshSuppressed(opts))
62
+ return true;
63
+ // Nothing may be printed when stderr is not a terminal — the output is being
64
+ // read by something. This is about PRINTING only; see isRefreshSuppressed.
65
+ if (opts.stderrIsTTY === false)
66
+ return true;
67
+ return false;
68
+ }
69
+ /**
70
+ * Should the background cache refresh be suppressed too?
71
+ *
72
+ * Deliberately narrower than isSuppressed. Not printing and not learning are
73
+ * different things, and conflating them had a consequence: the refresh sits
74
+ * behind the same non-TTY check as the hint, so a user whose runs are mostly
75
+ * non-interactive never warmed the cache — and their occasional interactive run
76
+ * therefore had no cache to read and showed nothing, every time. The cache file
77
+ * was simply absent on this machine, which is how this was found.
78
+ *
79
+ * A detached refresh with stdio ignored pollutes no output. What genuinely must
80
+ * not spawn: the protocol commands, whose children would outlive a parsed
81
+ * stream, and anyone who asked us not to check at all.
82
+ */
83
+ export function isRefreshSuppressed(opts = {}) {
61
84
  const env = opts.env ?? process.env;
62
85
  if (env.CI != null && env.CI !== "" && env.CI !== "0" && env.CI !== "false")
63
86
  return true;
@@ -65,8 +88,6 @@ export function isSuppressed(opts = {}) {
65
88
  return true;
66
89
  if (opts.command && PROTOCOL_SENSITIVE_COMMANDS.has(opts.command))
67
90
  return true;
68
- if (opts.stderrIsTTY === false)
69
- return true;
70
91
  return false;
71
92
  }
72
93
  /**
@@ -213,10 +234,15 @@ export async function checkForUpdate(opts) {
213
234
  const env = opts.env ?? process.env;
214
235
  const stderrIsTTY = opts.stderrIsTTY ?? Boolean(process.stderr.isTTY);
215
236
  const stdinIsTTY = opts.stdinIsTTY ?? Boolean(process.stdin.isTTY);
216
- if (isSuppressed({ env, command: opts.command, stderrIsTTY }))
237
+ // Warm the cache even when nothing may be printed. A run that cannot show a
238
+ // hint can still learn the version for the run that can.
239
+ if (isRefreshSuppressed({ env, command: opts.command }))
217
240
  return;
241
+ const quiet = isSuppressed({ env, command: opts.command, stderrIsTTY });
218
242
  const cache = readCache();
219
243
  if (isCacheFresh(cache, opts.now)) {
244
+ if (quiet)
245
+ return; // fresh cache, but this run may not speak
220
246
  if (!cache || !isNewerVersion(opts.currentVersion, cache.latest))
221
247
  return;
222
248
  if (shouldPrompt({ currentVersion: opts.currentVersion, cache, env, command: opts.command, stderrIsTTY, stdinIsTTY })) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "great-cto",
3
- "version": "2.91.0",
3
+ "version": "2.92.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",