@tekyzinc/gsd-t 2.74.12 → 2.76.10

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.
Files changed (61) hide show
  1. package/CHANGELOG.md +130 -0
  2. package/README.md +71 -1
  3. package/bin/advisor-integration.js +93 -0
  4. package/bin/check-headless-sessions.js +140 -0
  5. package/bin/context-meter-config.cjs +101 -0
  6. package/bin/context-meter-config.test.cjs +101 -0
  7. package/bin/gsd-t.js +710 -16
  8. package/bin/headless-auto-spawn.js +290 -0
  9. package/bin/model-selector.js +224 -0
  10. package/bin/runway-estimator.js +242 -0
  11. package/bin/token-budget.js +96 -89
  12. package/bin/token-optimizer.js +471 -0
  13. package/bin/token-telemetry.js +246 -0
  14. package/commands/gsd-t-audit.md +3 -3
  15. package/commands/gsd-t-backlog-list.md +38 -0
  16. package/commands/gsd-t-brainstorm.md +3 -3
  17. package/commands/gsd-t-complete-milestone.md +24 -0
  18. package/commands/gsd-t-debug.md +124 -7
  19. package/commands/gsd-t-discuss.md +10 -3
  20. package/commands/gsd-t-doc-ripple.md +32 -4
  21. package/commands/gsd-t-execute.md +107 -52
  22. package/commands/gsd-t-help.md +19 -0
  23. package/commands/gsd-t-integrate.md +67 -4
  24. package/commands/gsd-t-optimization-apply.md +91 -0
  25. package/commands/gsd-t-optimization-reject.md +94 -0
  26. package/commands/gsd-t-partition.md +7 -0
  27. package/commands/gsd-t-pause.md +3 -0
  28. package/commands/gsd-t-plan.md +10 -3
  29. package/commands/gsd-t-prd.md +3 -3
  30. package/commands/gsd-t-quick.md +71 -9
  31. package/commands/gsd-t-reflect.md +3 -7
  32. package/commands/gsd-t-resume.md +36 -0
  33. package/commands/gsd-t-status.md +31 -0
  34. package/commands/gsd-t-test-sync.md +7 -0
  35. package/commands/gsd-t-verify.md +12 -5
  36. package/commands/gsd-t-visualize.md +3 -7
  37. package/commands/gsd-t-wave.md +82 -18
  38. package/docs/GSD-T-README.md +52 -0
  39. package/docs/architecture.md +95 -0
  40. package/docs/infrastructure.md +117 -0
  41. package/docs/methodology.md +36 -0
  42. package/docs/prd-harness-evolution.md +51 -37
  43. package/docs/requirements.md +66 -0
  44. package/package.json +1 -1
  45. package/scripts/context-meter/count-tokens-client.js +221 -0
  46. package/scripts/context-meter/count-tokens-client.test.js +308 -0
  47. package/scripts/context-meter/test-injector.js +55 -0
  48. package/scripts/context-meter/threshold.js +88 -0
  49. package/scripts/context-meter/threshold.test.js +255 -0
  50. package/scripts/context-meter/transcript-parser.js +252 -0
  51. package/scripts/context-meter/transcript-parser.test.js +320 -0
  52. package/scripts/gsd-t-context-meter.e2e.test.js +415 -0
  53. package/scripts/gsd-t-context-meter.js +350 -0
  54. package/scripts/gsd-t-context-meter.test.js +417 -0
  55. package/scripts/gsd-t-heartbeat.js +2 -2
  56. package/scripts/gsd-t-statusline.js +23 -8
  57. package/templates/CLAUDE-global.md +5 -1
  58. package/templates/CLAUDE-project.md +26 -6
  59. package/templates/context-meter-config.json +10 -0
  60. package/templates/prompts/README.md +1 -1
  61. package/bin/task-counter.cjs +0 -161
package/bin/gsd-t.js CHANGED
@@ -18,6 +18,7 @@
18
18
  const fs = require("fs");
19
19
  const path = require("path");
20
20
  const os = require("os");
21
+ const readline = require("readline");
21
22
  const { execFileSync, spawn: cpSpawn } = require("child_process");
22
23
  const debugLedger = require(path.join(__dirname, "debug-ledger.js"));
23
24
 
@@ -376,6 +377,311 @@ function addHeartbeatHook(hooks, event, cmd) {
376
377
  return true;
377
378
  }
378
379
 
380
+ // ─── Context Meter ──────────────────────────────────────────────────────────
381
+
382
+ const CONTEXT_METER_SCRIPT = "gsd-t-context-meter.js";
383
+ const CONTEXT_METER_DEPS_DIR = "context-meter";
384
+ const CONTEXT_METER_CONFIG_TEMPLATE = "context-meter-config.json";
385
+ const CONTEXT_METER_CONFIG_DEST = path.join(".gsd-t", "context-meter-config.json");
386
+ const CONTEXT_METER_GITIGNORE_ENTRIES = [
387
+ ".gsd-t/.context-meter-state.json",
388
+ ".gsd-t/context-meter.log",
389
+ ];
390
+ const CONTEXT_METER_HOOK_MARKER = "gsd-t-context-meter";
391
+ const CONTEXT_METER_HOOK_COMMAND =
392
+ 'node "$CLAUDE_PROJECT_DIR/scripts/gsd-t-context-meter.js"';
393
+
394
+ // Append entries to {projectDir}/.gitignore. Each entry added only if absent.
395
+ // Idempotent. Returns true if any entries were added, false otherwise.
396
+ function ensureGitignoreEntries(projectDir, entries) {
397
+ const gitignorePath = path.join(projectDir, ".gitignore");
398
+ if (isSymlink(gitignorePath)) {
399
+ warn("Skipping .gitignore — target is a symlink");
400
+ return false;
401
+ }
402
+ let content = "";
403
+ try {
404
+ if (fs.existsSync(gitignorePath)) {
405
+ content = fs.readFileSync(gitignorePath, "utf8");
406
+ }
407
+ } catch (e) {
408
+ warn(`Failed to read .gitignore: ${e.message}`);
409
+ return false;
410
+ }
411
+ const existingLines = new Set(
412
+ content.split(/\r?\n/).map((l) => l.trim()).filter(Boolean)
413
+ );
414
+ const toAdd = entries.filter((e) => !existingLines.has(e));
415
+ if (toAdd.length === 0) return false;
416
+ try {
417
+ if (!fs.existsSync(gitignorePath)) {
418
+ fs.writeFileSync(gitignorePath, toAdd.join("\n") + "\n");
419
+ } else {
420
+ const prefix = content.length > 0 && !content.endsWith("\n") ? "\n" : "";
421
+ const block =
422
+ "\n# GSD-T context meter (session state — do not commit)\n" +
423
+ toAdd.join("\n") +
424
+ "\n";
425
+ fs.appendFileSync(gitignorePath, prefix + block);
426
+ }
427
+ return true;
428
+ } catch (e) {
429
+ warn(`Failed to append to .gitignore: ${e.message}`);
430
+ return false;
431
+ }
432
+ }
433
+
434
+ // Install the Context Meter into a project directory.
435
+ // Copies scripts/gsd-t-context-meter.js, scripts/context-meter/*.js (runtime
436
+ // only — skips .test.js and test-injector.js), and the config template (if
437
+ // missing). Also appends entries to .gitignore.
438
+ function installContextMeter(projectDir) {
439
+ try {
440
+ // 1. Copy gsd-t-context-meter.js → {projectDir}/scripts/
441
+ const projectScriptsDir = path.join(projectDir, "scripts");
442
+ if (!fs.existsSync(projectScriptsDir)) {
443
+ try {
444
+ fs.mkdirSync(projectScriptsDir, { recursive: true });
445
+ } catch (e) {
446
+ warn(`Failed to create scripts/: ${e.message}`);
447
+ return false;
448
+ }
449
+ }
450
+ const scriptSrc = path.join(PKG_SCRIPTS, CONTEXT_METER_SCRIPT);
451
+ const scriptDest = path.join(projectScriptsDir, CONTEXT_METER_SCRIPT);
452
+ if (!fs.existsSync(scriptSrc)) {
453
+ warn(`${CONTEXT_METER_SCRIPT} not found in package — skipping context meter`);
454
+ return false;
455
+ }
456
+ if (!isSymlink(scriptDest)) {
457
+ const srcContent = fs.readFileSync(scriptSrc, "utf8");
458
+ const destContent = fs.existsSync(scriptDest)
459
+ ? fs.readFileSync(scriptDest, "utf8")
460
+ : "";
461
+ if (normalizeEol(srcContent) !== normalizeEol(destContent)) {
462
+ fs.copyFileSync(scriptSrc, scriptDest);
463
+ try {
464
+ fs.chmodSync(scriptDest, 0o755);
465
+ } catch {}
466
+ }
467
+ }
468
+
469
+ // 2. Copy scripts/context-meter/*.js (runtime files only)
470
+ const depsSrcDir = path.join(PKG_SCRIPTS, CONTEXT_METER_DEPS_DIR);
471
+ const depsDestDir = path.join(projectScriptsDir, CONTEXT_METER_DEPS_DIR);
472
+ if (fs.existsSync(depsSrcDir)) {
473
+ if (!fs.existsSync(depsDestDir)) {
474
+ try {
475
+ fs.mkdirSync(depsDestDir, { recursive: true });
476
+ } catch (e) {
477
+ warn(`Failed to create scripts/${CONTEXT_METER_DEPS_DIR}/: ${e.message}`);
478
+ return false;
479
+ }
480
+ }
481
+ let depFiles = [];
482
+ try {
483
+ depFiles = fs.readdirSync(depsSrcDir);
484
+ } catch (e) {
485
+ warn(`Failed to read ${CONTEXT_METER_DEPS_DIR}/: ${e.message}`);
486
+ return false;
487
+ }
488
+ for (const fname of depFiles) {
489
+ // Skip test files and test-only infrastructure
490
+ if (fname.includes(".test.")) continue;
491
+ if (fname === "test-injector.js") continue;
492
+ const fsrc = path.join(depsSrcDir, fname);
493
+ const fdest = path.join(depsDestDir, fname);
494
+ try {
495
+ const stat = fs.statSync(fsrc);
496
+ if (!stat.isFile()) continue;
497
+ } catch {
498
+ continue;
499
+ }
500
+ if (isSymlink(fdest)) continue;
501
+ try {
502
+ const srcContent = fs.readFileSync(fsrc, "utf8");
503
+ const destContent = fs.existsSync(fdest)
504
+ ? fs.readFileSync(fdest, "utf8")
505
+ : "";
506
+ if (normalizeEol(srcContent) !== normalizeEol(destContent)) {
507
+ fs.copyFileSync(fsrc, fdest);
508
+ }
509
+ } catch (e) {
510
+ warn(`Failed to copy ${CONTEXT_METER_DEPS_DIR}/${fname}: ${e.message}`);
511
+ }
512
+ }
513
+ }
514
+
515
+ // 3. Copy config template → {projectDir}/.gsd-t/context-meter-config.json
516
+ // ONLY if the destination does not already exist (never overwrite user config).
517
+ const configSrc = path.join(PKG_TEMPLATES, CONTEXT_METER_CONFIG_TEMPLATE);
518
+ const configDest = path.join(projectDir, CONTEXT_METER_CONFIG_DEST);
519
+ const gsdtDir = path.dirname(configDest);
520
+ if (!fs.existsSync(gsdtDir)) {
521
+ try {
522
+ fs.mkdirSync(gsdtDir, { recursive: true });
523
+ } catch (e) {
524
+ warn(`Failed to create .gsd-t/: ${e.message}`);
525
+ }
526
+ }
527
+ if (fs.existsSync(configSrc) && !fs.existsSync(configDest)) {
528
+ if (!isSymlink(configDest)) {
529
+ try {
530
+ fs.copyFileSync(configSrc, configDest);
531
+ } catch (e) {
532
+ warn(`Failed to copy context-meter-config.json: ${e.message}`);
533
+ }
534
+ }
535
+ }
536
+
537
+ // 4. Append .gitignore entries
538
+ ensureGitignoreEntries(projectDir, CONTEXT_METER_GITIGNORE_ENTRIES);
539
+
540
+ return true;
541
+ } catch (e) {
542
+ warn(`installContextMeter failed: ${e.message}`);
543
+ return false;
544
+ }
545
+ }
546
+
547
+ // Register the Context Meter PostToolUse hook in ~/.claude/settings.json.
548
+ // Idempotent — if an existing hook references CONTEXT_METER_HOOK_MARKER the
549
+ // command string is refreshed in-place. All other settings/hooks preserved.
550
+ // Returns { installed: bool, action: "added"|"updated"|"noop" }.
551
+ function configureContextMeterHooks(settingsPath) {
552
+ const targetPath = settingsPath || SETTINGS_JSON;
553
+ let settings = {};
554
+ const fileExists = fs.existsSync(targetPath);
555
+ if (fileExists) {
556
+ try {
557
+ settings = JSON.parse(fs.readFileSync(targetPath, "utf8"));
558
+ if (!settings || typeof settings !== "object") settings = {};
559
+ } catch {
560
+ warn("settings.json has invalid JSON — cannot configure context meter hook");
561
+ return { installed: false, action: "noop" };
562
+ }
563
+ }
564
+
565
+ if (!settings.hooks) settings.hooks = {};
566
+ if (!Array.isArray(settings.hooks.PostToolUse)) settings.hooks.PostToolUse = [];
567
+
568
+ const cmd = CONTEXT_METER_HOOK_COMMAND;
569
+ let action = "noop";
570
+ let found = false;
571
+
572
+ for (const entry of settings.hooks.PostToolUse) {
573
+ if (!entry || !Array.isArray(entry.hooks)) continue;
574
+ for (const h of entry.hooks) {
575
+ if (h && typeof h.command === "string" && h.command.includes(CONTEXT_METER_HOOK_MARKER)) {
576
+ found = true;
577
+ if (h.command !== cmd) {
578
+ h.command = cmd;
579
+ action = "updated";
580
+ } else if (action === "noop") {
581
+ action = "noop";
582
+ }
583
+ }
584
+ }
585
+ }
586
+
587
+ if (!found) {
588
+ settings.hooks.PostToolUse.push({
589
+ matcher: "*",
590
+ hooks: [{ type: "command", command: cmd }],
591
+ });
592
+ action = "added";
593
+ }
594
+
595
+ if (action === "noop") {
596
+ return { installed: true, action: "noop" };
597
+ }
598
+
599
+ if (isSymlink(targetPath)) {
600
+ warn("Skipping settings.json write — target is a symlink");
601
+ return { installed: false, action: "noop" };
602
+ }
603
+ try {
604
+ fs.writeFileSync(targetPath, JSON.stringify(settings, null, 2));
605
+ } catch (e) {
606
+ warn(`Failed to write settings.json: ${e.message}`);
607
+ return { installed: false, action: "noop" };
608
+ }
609
+ return { installed: true, action };
610
+ }
611
+
612
+ // Interactive prompt for the Anthropic API key env var.
613
+ // Skips if not a TTY or if the env var is already set.
614
+ // Never writes the key anywhere — just prints the export command for the user
615
+ // to paste into their shell profile themselves. Always non-blocking.
616
+ async function promptForApiKeyIfMissing(envVarName) {
617
+ const varName = envVarName || "ANTHROPIC_API_KEY";
618
+ if (!process.stdout.isTTY || !process.stdin.isTTY) return "";
619
+ if (process.env[varName]) return process.env[varName];
620
+
621
+ heading("Context Meter — Anthropic API Key");
622
+ log("");
623
+ log(` ${YELLOW}⚠${RESET} Context Meter: ${varName} is not set.`);
624
+ log(` The hook uses Anthropic's count_tokens API (free, not billed) to measure`);
625
+ log(` real context window usage. Without it, the meter falls back to heuristics.`);
626
+ log(` Get a key: https://console.anthropic.com/settings/keys`);
627
+ log("");
628
+
629
+ let rl;
630
+ try {
631
+ rl = readline.createInterface({ input: process.stdin, output: process.stdout });
632
+ } catch (e) {
633
+ warn(`Could not open API key prompt: ${e.message}`);
634
+ return "";
635
+ }
636
+
637
+ return new Promise((resolve) => {
638
+ try {
639
+ rl.question(" Paste key now, or press Enter to skip: ", (answer) => {
640
+ try {
641
+ const key = (answer || "").trim();
642
+ if (key.length === 0) {
643
+ log("");
644
+ info(`Skipped. Set ${varName} later or run 'gsd-t doctor' to re-check.`);
645
+ log("");
646
+ } else {
647
+ log("");
648
+ success("Got it. Add this line to your shell profile (~/.zshrc or ~/.bashrc):");
649
+ log("");
650
+ log(` ${DIM}export ${varName}="${key}"${RESET}`);
651
+ log("");
652
+ log(" Re-open your shell or run the export command to activate.");
653
+ log("");
654
+ }
655
+ resolve(key);
656
+ } finally {
657
+ try { rl.close(); } catch {}
658
+ }
659
+ });
660
+ } catch (e) {
661
+ warn(`API key prompt failed: ${e.message}`);
662
+ try { rl.close(); } catch {}
663
+ resolve("");
664
+ }
665
+ });
666
+ }
667
+
668
+ // Resolve the apiKeyEnvVar from the project's context-meter-config.json
669
+ // Falls back to "ANTHROPIC_API_KEY" if loader unavailable or config absent.
670
+ function resolveApiKeyEnvVar(projectDir) {
671
+ try {
672
+ const loader = require(path.join(PKG_ROOT, "bin", "context-meter-config.cjs"));
673
+ if (loader && typeof loader.loadConfig === "function") {
674
+ const cfg = loader.loadConfig(projectDir || process.cwd());
675
+ if (cfg && typeof cfg.apiKeyEnvVar === "string" && cfg.apiKeyEnvVar.length > 0) {
676
+ return cfg.apiKeyEnvVar;
677
+ }
678
+ }
679
+ } catch {
680
+ // Fall through to default
681
+ }
682
+ return "ANTHROPIC_API_KEY";
683
+ }
684
+
379
685
  // ─── Update Check Hook ──────────────────────────────────────────────────────
380
686
 
381
687
  const UPDATE_CHECK_SCRIPT = "gsd-t-update-check.js";
@@ -884,7 +1190,7 @@ function appendGsdtToClaudeMd(template) {
884
1190
  info("Your existing content was preserved.");
885
1191
  }
886
1192
 
887
- function doInstall(opts = {}) {
1193
+ async function doInstall(opts = {}) {
888
1194
  const isUpdate = opts.update || false;
889
1195
  heading(`${isUpdate ? "Updating" : "Installing"} GSD-T ${versionLink()}`);
890
1196
  log("");
@@ -912,12 +1218,23 @@ function doInstall(opts = {}) {
912
1218
  heading("Utility Scripts");
913
1219
  installUtilityScripts();
914
1220
 
1221
+ heading("Context Meter (PostToolUse)");
1222
+ const cmHook = configureContextMeterHooks(SETTINGS_JSON);
1223
+ if (cmHook.installed) {
1224
+ if (cmHook.action === "added") success("Context meter PostToolUse hook added");
1225
+ else if (cmHook.action === "updated") success("Context meter hook command refreshed");
1226
+ else info("Context meter hook already configured");
1227
+ }
1228
+
915
1229
  heading("Graph Engine (CGC)");
916
1230
  installCgc();
917
1231
 
918
1232
  saveInstalledVersion();
919
1233
 
920
1234
  showInstallSummary(gsdtCommands.length, utilityCommands.length);
1235
+
1236
+ // Interactive prompt (skipped silently in non-TTY shells)
1237
+ await promptForApiKeyIfMissing(resolveApiKeyEnvVar(process.cwd()));
921
1238
  }
922
1239
 
923
1240
  function showInstallSummary(gsdtCount, utilCount) {
@@ -942,7 +1259,7 @@ function showInstallSummary(gsdtCount, utilCount) {
942
1259
  log("");
943
1260
  }
944
1261
 
945
- function doUpdate() {
1262
+ async function doUpdate() {
946
1263
  const installedVersion = getInstalledVersion();
947
1264
 
948
1265
  if (installedVersion === PKG_VERSION) {
@@ -959,7 +1276,7 @@ function doUpdate() {
959
1276
  heading(`Updating GSD-T: ${versionLink(installedVersion)} → ${versionLink()}`);
960
1277
  }
961
1278
 
962
- doInstall({ update: true });
1279
+ await doInstall({ update: true });
963
1280
  }
964
1281
 
965
1282
  function initClaudeMd(projectDir, projectName, today) {
@@ -1092,7 +1409,7 @@ function writeTemplateFile(templateName, destPath, label, projectName, today) {
1092
1409
  }
1093
1410
  }
1094
1411
 
1095
- function doInit(projectName) {
1412
+ async function doInit(projectName) {
1096
1413
  if (!projectName) projectName = path.basename(process.cwd());
1097
1414
 
1098
1415
  if (!validateProjectName(projectName)) {
@@ -1110,10 +1427,26 @@ function doInit(projectName) {
1110
1427
  initClaudeMd(projectDir, projectName, today);
1111
1428
  initDocs(projectDir, projectName, today);
1112
1429
  initGsdtDir(projectDir, projectName, today);
1430
+ copyBinToolsToProject(projectDir, projectName);
1431
+
1432
+ // Context Meter: copy script + deps + config template, append .gitignore,
1433
+ // register the global PostToolUse hook.
1434
+ if (installContextMeter(projectDir)) {
1435
+ success("Context meter installed (scripts/, config template, .gitignore)");
1436
+ }
1437
+ const cmHook = configureContextMeterHooks(SETTINGS_JSON);
1438
+ if (cmHook.installed) {
1439
+ if (cmHook.action === "added") success("Context meter PostToolUse hook added");
1440
+ else if (cmHook.action === "updated") success("Context meter hook command refreshed");
1441
+ else info("Context meter hook already configured");
1442
+ }
1113
1443
 
1114
1444
  if (registerProject(projectDir)) success("Registered in ~/.claude/.gsd-t-projects");
1115
1445
 
1116
1446
  showInitTree(projectDir);
1447
+
1448
+ // Interactive prompt (skipped silently in non-TTY shells)
1449
+ await promptForApiKeyIfMissing(resolveApiKeyEnvVar(projectDir));
1117
1450
  }
1118
1451
 
1119
1452
  function showInitTree(projectDir) {
@@ -1148,10 +1481,83 @@ function doStatus() {
1148
1481
  showStatusCommands();
1149
1482
  showStatusConfig();
1150
1483
  showStatusTeams();
1484
+ showStatusContextMeter();
1151
1485
  showStatusProject();
1152
1486
  log("");
1153
1487
  }
1154
1488
 
1489
+ function formatRelativeTime(timestampIso) {
1490
+ const then = Date.parse(timestampIso);
1491
+ if (!Number.isFinite(then)) return "unknown";
1492
+ const deltaSec = Math.max(0, Math.floor((Date.now() - then) / 1000));
1493
+ if (deltaSec < 60) return `${deltaSec}s ago`;
1494
+ if (deltaSec < 3600) return `${Math.floor(deltaSec / 60)}m ago`;
1495
+ if (deltaSec < 86400) return `${Math.floor(deltaSec / 3600)}h ago`;
1496
+ return `${Math.floor(deltaSec / 86400)} days ago`;
1497
+ }
1498
+
1499
+ function showStatusContextMeter() {
1500
+ heading("Context Meter");
1501
+ const cwd = process.cwd();
1502
+ const statePath = path.join(cwd, ".gsd-t", ".context-meter-state.json");
1503
+
1504
+ let state = null;
1505
+ try {
1506
+ if (fs.existsSync(statePath)) {
1507
+ state = JSON.parse(fs.readFileSync(statePath, "utf8"));
1508
+ }
1509
+ } catch {
1510
+ state = null;
1511
+ }
1512
+
1513
+ if (!state || typeof state !== "object") {
1514
+ log(` ${DIM}Context: N/A (meter hook not run this session)${RESET}`);
1515
+ return;
1516
+ }
1517
+
1518
+ // Error case: inputTokens === 0 AND lastError set
1519
+ if (state.lastError && state.inputTokens === 0) {
1520
+ const code = (state.lastError && state.lastError.code) || "unknown";
1521
+ const rel = state.timestamp ? formatRelativeTime(state.timestamp) : "unknown";
1522
+ log(` ${DIM}Context: N/A (meter error: ${code}) — last check ${rel}${RESET}`);
1523
+ return;
1524
+ }
1525
+
1526
+ // Require minimum viable fields for a fresh reading
1527
+ if (
1528
+ typeof state.pct !== "number" ||
1529
+ typeof state.modelWindowSize !== "number" ||
1530
+ typeof state.threshold !== "string" ||
1531
+ typeof state.timestamp !== "string"
1532
+ ) {
1533
+ log(` ${DIM}Context: N/A (meter hook not run this session)${RESET}`);
1534
+ return;
1535
+ }
1536
+
1537
+ const pctStr = state.pct.toFixed(1);
1538
+ const rel = formatRelativeTime(state.timestamp);
1539
+ const ageMs = Date.now() - Date.parse(state.timestamp);
1540
+ const stale = !Number.isFinite(ageMs) || ageMs > 5 * 60 * 1000;
1541
+ const staleSuffix = stale ? " (stale)" : "";
1542
+
1543
+ // v3.0.0 three-band: normal (green) / warn (yellow) / stop (bold red).
1544
+ let color = DIM;
1545
+ switch (state.threshold) {
1546
+ case "normal":
1547
+ color = GREEN;
1548
+ break;
1549
+ case "warn":
1550
+ color = YELLOW;
1551
+ break;
1552
+ case "stop":
1553
+ color = BOLD + RED;
1554
+ break;
1555
+ }
1556
+
1557
+ const line = `Context: ${pctStr}% of ${state.modelWindowSize} tokens (${state.threshold} band) — last check ${rel}${staleSuffix}`;
1558
+ log(` ${color}${line}${RESET}`);
1559
+ }
1560
+
1155
1561
  function showStatusVersion() {
1156
1562
  const installedVersion = getInstalledVersion();
1157
1563
  if (installedVersion) {
@@ -1485,8 +1891,8 @@ function exportUniversalRulesForNpm() {
1485
1891
  }
1486
1892
  }
1487
1893
 
1488
- function doUpdateAll() {
1489
- updateGlobalCommands();
1894
+ async function doUpdateAll() {
1895
+ await updateGlobalCommands();
1490
1896
  heading("Updating registered projects...");
1491
1897
  log("");
1492
1898
 
@@ -1510,9 +1916,9 @@ function doUpdateAll() {
1510
1916
  showUpdateAllSummary(projects.length, counts, playwrightMissing, swaggerMissing, syncCount);
1511
1917
  }
1512
1918
 
1513
- function updateGlobalCommands() {
1919
+ async function updateGlobalCommands() {
1514
1920
  if (getInstalledVersion() !== PKG_VERSION) {
1515
- doInstall({ update: true });
1921
+ await doInstall({ update: true });
1516
1922
  } else {
1517
1923
  heading(`GSD-T ${versionLink()}`);
1518
1924
  success("Global commands already up to date");
@@ -1548,7 +1954,8 @@ function updateSingleProject(projectDir, counts) {
1548
1954
  const changelogCreated = createProjectChangelog(projectDir, projectName);
1549
1955
  const binToolsCopied = copyBinToolsToProject(projectDir, projectName);
1550
1956
  const archiveRan = runProgressArchiveMigration(projectDir, projectName);
1551
- if (guardAdded || changelogCreated || binToolsCopied || archiveRan) {
1957
+ const taskCounterRetired = runTaskCounterRetirementMigration(projectDir, projectName);
1958
+ if (guardAdded || changelogCreated || binToolsCopied || archiveRan || taskCounterRetired) {
1552
1959
  counts.updated++;
1553
1960
  } else {
1554
1961
  info(`${projectName} — already up to date`);
@@ -1559,7 +1966,7 @@ function updateSingleProject(projectDir, counts) {
1559
1966
  // Bin tools that should ship with every registered project. Listed here so adding
1560
1967
  // a new tool only requires appending to this array. Use .cjs extension so they
1561
1968
  // always run as CommonJS regardless of the project's package.json "type" field.
1562
- const PROJECT_BIN_TOOLS = ["archive-progress.cjs", "log-tail.cjs", "context-budget-audit.cjs"];
1969
+ const PROJECT_BIN_TOOLS = ["archive-progress.cjs", "log-tail.cjs", "context-budget-audit.cjs", "context-meter-config.cjs"];
1563
1970
 
1564
1971
  function copyBinToolsToProject(projectDir, projectName) {
1565
1972
  const projectBinDir = path.join(projectDir, "bin");
@@ -1633,6 +2040,55 @@ function runProgressArchiveMigration(projectDir, projectName) {
1633
2040
  }
1634
2041
  }
1635
2042
 
2043
+ // One-shot migration: retire the v2.74.12 task-counter proxy. Removes the stale
2044
+ // bin/task-counter.cjs and any leftover .task-counter* state/config files from
2045
+ // downstream projects, then writes a marker so subsequent runs are no-ops. The
2046
+ // context meter hook (M34) is the authoritative context-burn signal now.
2047
+ function runTaskCounterRetirementMigration(projectDir, projectName) {
2048
+ const gsdtDir = path.join(projectDir, ".gsd-t");
2049
+ if (!fs.existsSync(gsdtDir)) return false;
2050
+
2051
+ const markerPath = path.join(gsdtDir, ".task-counter-retired-v1");
2052
+ if (fs.existsSync(markerPath)) return false;
2053
+
2054
+ const targets = [
2055
+ path.join(projectDir, "bin", "task-counter.cjs"),
2056
+ path.join(gsdtDir, "task-counter-config.json"),
2057
+ path.join(gsdtDir, ".task-counter-state.json"),
2058
+ path.join(gsdtDir, ".task-counter"),
2059
+ ];
2060
+
2061
+ let removed = 0;
2062
+ for (const target of targets) {
2063
+ try {
2064
+ if (fs.existsSync(target)) {
2065
+ try { if (fs.lstatSync(target).isSymbolicLink()) continue; } catch {}
2066
+ fs.unlinkSync(target);
2067
+ removed++;
2068
+ }
2069
+ } catch (e) {
2070
+ warn(`${projectName} — failed to remove ${path.basename(target)}: ${e.message}`);
2071
+ }
2072
+ }
2073
+
2074
+ try {
2075
+ fs.writeFileSync(
2076
+ markerPath,
2077
+ `# task-counter-retired-v1\nApplied: ${new Date().toISOString()}\nReplaced-by: scripts/gsd-t-context-meter.js (M34)\n`
2078
+ );
2079
+ } catch (e) {
2080
+ warn(`${projectName} — failed to write retirement marker: ${e.message}`);
2081
+ return false;
2082
+ }
2083
+
2084
+ if (removed > 0) {
2085
+ info(`${projectName} — retired task-counter (removed ${removed} file(s))`);
2086
+ } else {
2087
+ info(`${projectName} — retired task-counter (no legacy files found)`);
2088
+ }
2089
+ return true;
2090
+ }
2091
+
1636
2092
  function showUpdateAllSummary(total, counts, playwrightMissing, swaggerMissing, syncCount) {
1637
2093
  log("");
1638
2094
  heading("Update All Complete");
@@ -1786,7 +2242,120 @@ function checkDoctorCgc() {
1786
2242
  return issues;
1787
2243
  }
1788
2244
 
1789
- function doDoctor() {
2245
+ // Verify context meter wiring: API key env var, hook registration,
2246
+ // hook script presence, config validity, and a live count_tokens dry-run.
2247
+ // Returns number of issues (RED results). Mirrors checkDoctorCgc shape.
2248
+ async function checkDoctorContextMeter(projectDir) {
2249
+ let issues = 0;
2250
+ heading("Context Meter");
2251
+
2252
+ const cwd = projectDir || process.cwd();
2253
+
2254
+ // Load config (used by checks 1, 4, and 5). Missing file → defaults; invalid
2255
+ // JSON or schema-mismatch → throws (handled in Check 4).
2256
+ let cfg = null;
2257
+ let cfgLoadErr = null;
2258
+ try {
2259
+ const { loadConfig } = require("./context-meter-config.cjs");
2260
+ cfg = loadConfig(cwd);
2261
+ } catch (e) {
2262
+ cfgLoadErr = e;
2263
+ }
2264
+ const apiKeyEnvVar = (cfg && cfg.apiKeyEnvVar) || "ANTHROPIC_API_KEY";
2265
+
2266
+ // Check 1: API key env var present
2267
+ const apiKeyValue = process.env[apiKeyEnvVar];
2268
+ const apiKeyPresent = typeof apiKeyValue === "string" && apiKeyValue.length > 0;
2269
+ if (apiKeyPresent) {
2270
+ success(`API key present ($${apiKeyEnvVar})`);
2271
+ } else {
2272
+ error(`Missing API key: set $${apiKeyEnvVar} — https://console.anthropic.com/settings/keys`);
2273
+ issues++;
2274
+ }
2275
+
2276
+ // Check 2: Hook registered in ~/.claude/settings.json
2277
+ let hookRegistered = false;
2278
+ try {
2279
+ if (fs.existsSync(SETTINGS_JSON)) {
2280
+ const raw = fs.readFileSync(SETTINGS_JSON, "utf8");
2281
+ const settings = JSON.parse(raw);
2282
+ const postHooks = (settings && settings.hooks && settings.hooks.PostToolUse) || [];
2283
+ for (const entry of postHooks) {
2284
+ if (!entry || !Array.isArray(entry.hooks)) continue;
2285
+ for (const h of entry.hooks) {
2286
+ if (h && typeof h.command === "string" && h.command.includes(CONTEXT_METER_HOOK_MARKER)) {
2287
+ hookRegistered = true;
2288
+ break;
2289
+ }
2290
+ }
2291
+ if (hookRegistered) break;
2292
+ }
2293
+ }
2294
+ } catch {
2295
+ // Fall through — treat as not registered
2296
+ }
2297
+ if (hookRegistered) {
2298
+ success("Context meter hook registered in settings.json");
2299
+ } else {
2300
+ error("Context meter hook not installed — run gsd-t install");
2301
+ issues++;
2302
+ }
2303
+
2304
+ // Check 3: Hook script file exists in project
2305
+ const scriptPath = path.join(cwd, "scripts", CONTEXT_METER_SCRIPT);
2306
+ if (fs.existsSync(scriptPath)) {
2307
+ success("Hook script present");
2308
+ } else {
2309
+ error(`Hook script missing at scripts/${CONTEXT_METER_SCRIPT} — run gsd-t update`);
2310
+ issues++;
2311
+ }
2312
+
2313
+ // Check 4: Config file parses via loader
2314
+ const configPath = path.join(cwd, CONTEXT_METER_CONFIG_DEST);
2315
+ if (cfgLoadErr) {
2316
+ error(`Config file invalid: ${cfgLoadErr.message} — fix ${CONTEXT_METER_CONFIG_DEST}`);
2317
+ issues++;
2318
+ } else if (fs.existsSync(configPath)) {
2319
+ success(`Config valid (threshold ${cfg.thresholdPct}%, check every ${cfg.checkFrequency} calls)`);
2320
+ } else {
2321
+ warn("Using default config — run gsd-t install to copy template");
2322
+ }
2323
+
2324
+ // Check 5: Dry-run count_tokens API call (skip if no API key)
2325
+ if (!apiKeyPresent) {
2326
+ log(` ${DIM}Skipped count_tokens dry-run (no API key)${RESET}`);
2327
+ } else {
2328
+ const clientPath = path.join(cwd, "scripts", "context-meter", "count-tokens-client.js");
2329
+ if (!fs.existsSync(clientPath)) {
2330
+ error("count_tokens client missing at scripts/context-meter/count-tokens-client.js — run gsd-t update");
2331
+ issues++;
2332
+ } else {
2333
+ try {
2334
+ const { countTokens } = require(clientPath);
2335
+ const result = await countTokens({
2336
+ apiKey: apiKeyValue,
2337
+ model: "claude-opus-4-6",
2338
+ system: "",
2339
+ messages: [{ role: "user", content: [{ type: "text", text: "ping" }] }],
2340
+ timeoutMs: 5000,
2341
+ });
2342
+ if (result && typeof result.inputTokens === "number") {
2343
+ success(`count_tokens dry-run OK (${result.inputTokens} tokens)`);
2344
+ } else {
2345
+ error("count_tokens API call failed — check API key and network");
2346
+ issues++;
2347
+ }
2348
+ } catch (e) {
2349
+ error(`count_tokens dry-run threw: ${e.message}`);
2350
+ issues++;
2351
+ }
2352
+ }
2353
+ }
2354
+
2355
+ return issues;
2356
+ }
2357
+
2358
+ async function doDoctor() {
1790
2359
  heading("GSD-T Doctor");
1791
2360
  log("");
1792
2361
  let issues = 0;
@@ -1794,6 +2363,7 @@ function doDoctor() {
1794
2363
  issues += checkDoctorInstallation();
1795
2364
  issues += checkDoctorProject();
1796
2365
  issues += checkDoctorCgc();
2366
+ issues += await checkDoctorContextMeter(process.cwd());
1797
2367
  log("");
1798
2368
  if (issues === 0) {
1799
2369
  log(`${GREEN}${BOLD} All checks passed!${RESET}`);
@@ -1801,6 +2371,7 @@ function doDoctor() {
1801
2371
  log(`${YELLOW}${BOLD} ${issues} issue${issues > 1 ? "s" : ""} found${RESET}`);
1802
2372
  }
1803
2373
  log("");
2374
+ if (issues > 0) process.exit(1);
1804
2375
  }
1805
2376
 
1806
2377
  function doRegister() {
@@ -2626,6 +3197,119 @@ function showHeadlessHelp() {
2626
3197
  log(` ${DIM}$${RESET} gsd-t headless query domains\n`);
2627
3198
  }
2628
3199
 
3200
+ // ─── Metrics (M35 token telemetry CLI) ────────────────────────────────────────
3201
+
3202
+ function parseMetricsByFlag(args) {
3203
+ const byArg = args.find(a => a.startsWith("--by="));
3204
+ if (!byArg) return [];
3205
+ const raw = byArg.slice(5).trim();
3206
+ if (!raw) return [];
3207
+ return raw.split(",").map(s => s.trim()).filter(Boolean);
3208
+ }
3209
+
3210
+ function formatPct(v) {
3211
+ if (v === null || v === undefined || Number.isNaN(v)) return "—";
3212
+ return `${Number(v).toFixed(1)}%`;
3213
+ }
3214
+
3215
+ function formatInt(v) {
3216
+ if (v === null || v === undefined || Number.isNaN(v)) return "—";
3217
+ return String(Math.round(Number(v)));
3218
+ }
3219
+
3220
+ function doMetrics(args) {
3221
+ const projectDir = process.cwd();
3222
+ let tt;
3223
+ try {
3224
+ tt = require(path.join(projectDir, "bin", "token-telemetry.js"));
3225
+ } catch (e) {
3226
+ error(`bin/token-telemetry.js not found in ${projectDir} — run gsd-t install first.`);
3227
+ process.exit(1);
3228
+ }
3229
+
3230
+ const records = tt.readAll(projectDir);
3231
+ if (records.length === 0) {
3232
+ log(`${DIM}No telemetry records yet — .gsd-t/token-metrics.jsonl is empty or missing.${RESET}`);
3233
+ return;
3234
+ }
3235
+
3236
+ const isTokens = args.includes("--tokens");
3237
+ const isHalts = args.includes("--halts");
3238
+ const isContextWindow = args.includes("--context-window");
3239
+
3240
+ if (!isTokens && !isHalts && !isContextWindow) {
3241
+ log(`${YELLOW}Specify at least one of: --tokens, --halts, --context-window${RESET}`);
3242
+ return;
3243
+ }
3244
+
3245
+ if (isHalts) {
3246
+ const halts = records.filter(r => r.halt_type);
3247
+ log(`\n${BOLD}Halts — ${halts.length} record(s)${RESET}`);
3248
+ if (halts.length === 0) {
3249
+ log(`${DIM} (no halts recorded — all spawns completed normally)${RESET}\n`);
3250
+ } else {
3251
+ const byType = {};
3252
+ for (const r of halts) {
3253
+ const key = String(r.halt_type);
3254
+ byType[key] = (byType[key] || 0) + 1;
3255
+ }
3256
+ for (const [type, count] of Object.entries(byType).sort()) {
3257
+ log(` ${CYAN}${type.padEnd(24)}${RESET} ${count}`);
3258
+ }
3259
+ log("");
3260
+ }
3261
+ }
3262
+
3263
+ if (isTokens) {
3264
+ const by = parseMetricsByFlag(args);
3265
+ if (by.length === 0) {
3266
+ const totalConsumed = records.reduce((a, r) => a + (Number(r.tokens_consumed) || 0), 0);
3267
+ const totalDuration = records.reduce((a, r) => a + (Number(r.duration_s) || 0), 0);
3268
+ log(`\n${BOLD}Tokens — ${records.length} spawn(s)${RESET}`);
3269
+ log(` total tokens consumed: ${formatInt(totalConsumed)}`);
3270
+ log(` total duration (s): ${formatInt(totalDuration)}`);
3271
+ if (records.length > 0) {
3272
+ log(` mean tokens/spawn: ${formatInt(totalConsumed / records.length)}`);
3273
+ }
3274
+ log("");
3275
+ } else {
3276
+ const groups = tt.aggregate(records, { by });
3277
+ log(`\n${BOLD}Tokens by ${by.join(",")} — ${groups.length} group(s)${RESET}`);
3278
+ const keyHeader = by.join("/");
3279
+ log(` ${keyHeader.padEnd(36)} ${"count".padStart(7)} ${"total".padStart(12)} ${"mean".padStart(10)} ${"median".padStart(10)} ${"p95".padStart(10)}`);
3280
+ log(` ${"-".repeat(36)} ${"-".repeat(7)} ${"-".repeat(12)} ${"-".repeat(10)} ${"-".repeat(10)} ${"-".repeat(10)}`);
3281
+ for (const g of groups) {
3282
+ const label = by.map(k => g.key[k] == null ? "—" : String(g.key[k])).join("/");
3283
+ log(` ${label.padEnd(36)} ${formatInt(g.count).padStart(7)} ${formatInt(g.total_tokens).padStart(12)} ${formatInt(g.mean).padStart(10)} ${formatInt(g.median).padStart(10)} ${formatInt(g.p95).padStart(10)}`);
3284
+ }
3285
+ log("");
3286
+ }
3287
+ }
3288
+
3289
+ if (isContextWindow) {
3290
+ log(`\n${BOLD}Context window trend — ${records.length} record(s)${RESET}`);
3291
+ const withPct = records.filter(r => r.context_window_pct_after != null && !Number.isNaN(Number(r.context_window_pct_after)));
3292
+ if (withPct.length === 0) {
3293
+ log(`${DIM} (no context-window measurements in records)${RESET}\n`);
3294
+ } else {
3295
+ const sorted = [...withPct].map(r => Number(r.context_window_pct_after)).sort((a, b) => a - b);
3296
+ const min = sorted[0];
3297
+ const max = sorted[sorted.length - 1];
3298
+ const mean = sorted.reduce((a, b) => a + b, 0) / sorted.length;
3299
+ const p95 = sorted[Math.min(sorted.length - 1, Math.floor(sorted.length * 0.95))];
3300
+ log(` min: ${formatPct(min)}`);
3301
+ log(` mean: ${formatPct(mean)}`);
3302
+ log(` p95: ${formatPct(p95)}`);
3303
+ log(` max: ${formatPct(max)}`);
3304
+ const atWarn = withPct.filter(r => Number(r.context_window_pct_after) >= 70).length;
3305
+ const atStop = withPct.filter(r => Number(r.context_window_pct_after) >= 85).length;
3306
+ log(` spawns at warn band (≥70%): ${atWarn}`);
3307
+ log(` spawns at stop band (≥85%): ${atStop}`);
3308
+ log("");
3309
+ }
3310
+ }
3311
+ }
3312
+
2629
3313
  function showHelp() {
2630
3314
  log(`\n${BOLD}GSD-T${RESET} — Contract-Driven Development for Claude Code\n`);
2631
3315
  log(`${BOLD}Usage:${RESET} npx @tekyzinc/gsd-t ${CYAN}<command>${RESET} [options]\n`);
@@ -2734,6 +3418,13 @@ module.exports = {
2734
3418
  syncGlobalRulesToProject,
2735
3419
  syncGlobalRules,
2736
3420
  exportUniversalRulesForNpm,
3421
+ // M34: Context Meter installer integration
3422
+ ensureGitignoreEntries,
3423
+ installContextMeter,
3424
+ configureContextMeterHooks,
3425
+ promptForApiKeyIfMissing,
3426
+ resolveApiKeyEnvVar,
3427
+ runTaskCounterRetirementMigration,
2737
3428
  };
2738
3429
 
2739
3430
  // ─── Main ────────────────────────────────────────────────────────────────────
@@ -2744,16 +3435,16 @@ if (require.main === module) {
2744
3435
 
2745
3436
  switch (command) {
2746
3437
  case "install":
2747
- doInstall();
3438
+ doInstall().catch((e) => { error(e.message || String(e)); process.exit(1); });
2748
3439
  break;
2749
3440
  case "update":
2750
- doUpdate();
3441
+ doUpdate().catch((e) => { error(e.message || String(e)); process.exit(1); });
2751
3442
  break;
2752
3443
  case "update-all":
2753
- doUpdateAll();
3444
+ doUpdateAll().catch((e) => { error(e.message || String(e)); process.exit(1); });
2754
3445
  break;
2755
3446
  case "init":
2756
- doInit(args[1]);
3447
+ doInit(args[1]).catch((e) => { error(e.message || String(e)); process.exit(1); });
2757
3448
  break;
2758
3449
  case "register":
2759
3450
  doRegister();
@@ -2765,7 +3456,7 @@ if (require.main === module) {
2765
3456
  doUninstall();
2766
3457
  break;
2767
3458
  case "doctor":
2768
- doDoctor();
3459
+ doDoctor().catch((e) => { error(e.message || String(e)); process.exit(1); });
2769
3460
  break;
2770
3461
  case "changelog":
2771
3462
  doChangelog();
@@ -2776,6 +3467,9 @@ if (require.main === module) {
2776
3467
  case "headless":
2777
3468
  doHeadless(args.slice(1));
2778
3469
  break;
3470
+ case "metrics":
3471
+ doMetrics(args.slice(1));
3472
+ break;
2779
3473
  case "design-build": {
2780
3474
  const orchestrator = require("./design-orchestrator.js");
2781
3475
  orchestrator.run(args.slice(1)).catch(e => { console.error(e); process.exit(1); });