@tekyzinc/gsd-t 2.74.13 → 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 +116 -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 +709 -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)) {
@@ -1112,9 +1429,24 @@ function doInit(projectName) {
1112
1429
  initGsdtDir(projectDir, projectName, today);
1113
1430
  copyBinToolsToProject(projectDir, projectName);
1114
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
+ }
1443
+
1115
1444
  if (registerProject(projectDir)) success("Registered in ~/.claude/.gsd-t-projects");
1116
1445
 
1117
1446
  showInitTree(projectDir);
1447
+
1448
+ // Interactive prompt (skipped silently in non-TTY shells)
1449
+ await promptForApiKeyIfMissing(resolveApiKeyEnvVar(projectDir));
1118
1450
  }
1119
1451
 
1120
1452
  function showInitTree(projectDir) {
@@ -1149,10 +1481,83 @@ function doStatus() {
1149
1481
  showStatusCommands();
1150
1482
  showStatusConfig();
1151
1483
  showStatusTeams();
1484
+ showStatusContextMeter();
1152
1485
  showStatusProject();
1153
1486
  log("");
1154
1487
  }
1155
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
+
1156
1561
  function showStatusVersion() {
1157
1562
  const installedVersion = getInstalledVersion();
1158
1563
  if (installedVersion) {
@@ -1486,8 +1891,8 @@ function exportUniversalRulesForNpm() {
1486
1891
  }
1487
1892
  }
1488
1893
 
1489
- function doUpdateAll() {
1490
- updateGlobalCommands();
1894
+ async function doUpdateAll() {
1895
+ await updateGlobalCommands();
1491
1896
  heading("Updating registered projects...");
1492
1897
  log("");
1493
1898
 
@@ -1511,9 +1916,9 @@ function doUpdateAll() {
1511
1916
  showUpdateAllSummary(projects.length, counts, playwrightMissing, swaggerMissing, syncCount);
1512
1917
  }
1513
1918
 
1514
- function updateGlobalCommands() {
1919
+ async function updateGlobalCommands() {
1515
1920
  if (getInstalledVersion() !== PKG_VERSION) {
1516
- doInstall({ update: true });
1921
+ await doInstall({ update: true });
1517
1922
  } else {
1518
1923
  heading(`GSD-T ${versionLink()}`);
1519
1924
  success("Global commands already up to date");
@@ -1549,7 +1954,8 @@ function updateSingleProject(projectDir, counts) {
1549
1954
  const changelogCreated = createProjectChangelog(projectDir, projectName);
1550
1955
  const binToolsCopied = copyBinToolsToProject(projectDir, projectName);
1551
1956
  const archiveRan = runProgressArchiveMigration(projectDir, projectName);
1552
- if (guardAdded || changelogCreated || binToolsCopied || archiveRan) {
1957
+ const taskCounterRetired = runTaskCounterRetirementMigration(projectDir, projectName);
1958
+ if (guardAdded || changelogCreated || binToolsCopied || archiveRan || taskCounterRetired) {
1553
1959
  counts.updated++;
1554
1960
  } else {
1555
1961
  info(`${projectName} — already up to date`);
@@ -1560,7 +1966,7 @@ function updateSingleProject(projectDir, counts) {
1560
1966
  // Bin tools that should ship with every registered project. Listed here so adding
1561
1967
  // a new tool only requires appending to this array. Use .cjs extension so they
1562
1968
  // always run as CommonJS regardless of the project's package.json "type" field.
1563
- const PROJECT_BIN_TOOLS = ["archive-progress.cjs", "log-tail.cjs", "context-budget-audit.cjs", "task-counter.cjs"];
1969
+ const PROJECT_BIN_TOOLS = ["archive-progress.cjs", "log-tail.cjs", "context-budget-audit.cjs", "context-meter-config.cjs"];
1564
1970
 
1565
1971
  function copyBinToolsToProject(projectDir, projectName) {
1566
1972
  const projectBinDir = path.join(projectDir, "bin");
@@ -1634,6 +2040,55 @@ function runProgressArchiveMigration(projectDir, projectName) {
1634
2040
  }
1635
2041
  }
1636
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
+
1637
2092
  function showUpdateAllSummary(total, counts, playwrightMissing, swaggerMissing, syncCount) {
1638
2093
  log("");
1639
2094
  heading("Update All Complete");
@@ -1787,7 +2242,120 @@ function checkDoctorCgc() {
1787
2242
  return issues;
1788
2243
  }
1789
2244
 
1790
- 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() {
1791
2359
  heading("GSD-T Doctor");
1792
2360
  log("");
1793
2361
  let issues = 0;
@@ -1795,6 +2363,7 @@ function doDoctor() {
1795
2363
  issues += checkDoctorInstallation();
1796
2364
  issues += checkDoctorProject();
1797
2365
  issues += checkDoctorCgc();
2366
+ issues += await checkDoctorContextMeter(process.cwd());
1798
2367
  log("");
1799
2368
  if (issues === 0) {
1800
2369
  log(`${GREEN}${BOLD} All checks passed!${RESET}`);
@@ -1802,6 +2371,7 @@ function doDoctor() {
1802
2371
  log(`${YELLOW}${BOLD} ${issues} issue${issues > 1 ? "s" : ""} found${RESET}`);
1803
2372
  }
1804
2373
  log("");
2374
+ if (issues > 0) process.exit(1);
1805
2375
  }
1806
2376
 
1807
2377
  function doRegister() {
@@ -2627,6 +3197,119 @@ function showHeadlessHelp() {
2627
3197
  log(` ${DIM}$${RESET} gsd-t headless query domains\n`);
2628
3198
  }
2629
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
+
2630
3313
  function showHelp() {
2631
3314
  log(`\n${BOLD}GSD-T${RESET} — Contract-Driven Development for Claude Code\n`);
2632
3315
  log(`${BOLD}Usage:${RESET} npx @tekyzinc/gsd-t ${CYAN}<command>${RESET} [options]\n`);
@@ -2735,6 +3418,13 @@ module.exports = {
2735
3418
  syncGlobalRulesToProject,
2736
3419
  syncGlobalRules,
2737
3420
  exportUniversalRulesForNpm,
3421
+ // M34: Context Meter installer integration
3422
+ ensureGitignoreEntries,
3423
+ installContextMeter,
3424
+ configureContextMeterHooks,
3425
+ promptForApiKeyIfMissing,
3426
+ resolveApiKeyEnvVar,
3427
+ runTaskCounterRetirementMigration,
2738
3428
  };
2739
3429
 
2740
3430
  // ─── Main ────────────────────────────────────────────────────────────────────
@@ -2745,16 +3435,16 @@ if (require.main === module) {
2745
3435
 
2746
3436
  switch (command) {
2747
3437
  case "install":
2748
- doInstall();
3438
+ doInstall().catch((e) => { error(e.message || String(e)); process.exit(1); });
2749
3439
  break;
2750
3440
  case "update":
2751
- doUpdate();
3441
+ doUpdate().catch((e) => { error(e.message || String(e)); process.exit(1); });
2752
3442
  break;
2753
3443
  case "update-all":
2754
- doUpdateAll();
3444
+ doUpdateAll().catch((e) => { error(e.message || String(e)); process.exit(1); });
2755
3445
  break;
2756
3446
  case "init":
2757
- doInit(args[1]);
3447
+ doInit(args[1]).catch((e) => { error(e.message || String(e)); process.exit(1); });
2758
3448
  break;
2759
3449
  case "register":
2760
3450
  doRegister();
@@ -2766,7 +3456,7 @@ if (require.main === module) {
2766
3456
  doUninstall();
2767
3457
  break;
2768
3458
  case "doctor":
2769
- doDoctor();
3459
+ doDoctor().catch((e) => { error(e.message || String(e)); process.exit(1); });
2770
3460
  break;
2771
3461
  case "changelog":
2772
3462
  doChangelog();
@@ -2777,6 +3467,9 @@ if (require.main === module) {
2777
3467
  case "headless":
2778
3468
  doHeadless(args.slice(1));
2779
3469
  break;
3470
+ case "metrics":
3471
+ doMetrics(args.slice(1));
3472
+ break;
2780
3473
  case "design-build": {
2781
3474
  const orchestrator = require("./design-orchestrator.js");
2782
3475
  orchestrator.run(args.slice(1)).catch(e => { console.error(e); process.exit(1); });