@tekyzinc/gsd-t 3.11.11 → 3.12.12

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 (60) hide show
  1. package/CHANGELOG.md +111 -0
  2. package/README.md +4 -10
  3. package/bin/event-stream.cjs +205 -0
  4. package/bin/gsd-t-unattended.cjs +142 -2
  5. package/bin/gsd-t-unattended.js +87 -1
  6. package/bin/gsd-t.js +131 -124
  7. package/bin/headless-auto-spawn.cjs +102 -6
  8. package/bin/headless-auto-spawn.js +44 -4
  9. package/bin/scan-data-collector.js +39 -11
  10. package/bin/token-budget.cjs +43 -126
  11. package/bin/unattended-watch-format.cjs +154 -0
  12. package/commands/gsd-t-backlog-list.md +0 -38
  13. package/commands/gsd-t-complete-milestone.md +2 -30
  14. package/commands/gsd-t-debug.md +18 -122
  15. package/commands/gsd-t-doc-ripple.md +0 -21
  16. package/commands/gsd-t-execute.md +58 -117
  17. package/commands/gsd-t-help.md +3 -59
  18. package/commands/gsd-t-integrate.md +18 -78
  19. package/commands/gsd-t-quick.md +22 -80
  20. package/commands/gsd-t-resume.md +2 -2
  21. package/commands/gsd-t-scan.md +15 -1
  22. package/commands/gsd-t-status.md +2 -32
  23. package/commands/gsd-t-unattended-watch.md +37 -1
  24. package/commands/gsd-t-verify.md +14 -2
  25. package/commands/gsd-t-wave.md +22 -91
  26. package/commands/gsd.md +43 -4
  27. package/docs/GSD-T-README.md +2 -6
  28. package/docs/architecture.md +10 -8
  29. package/docs/infrastructure.md +8 -14
  30. package/docs/methodology.md +10 -4
  31. package/docs/prd-harness-evolution.md +1 -1
  32. package/docs/requirements.md +28 -12
  33. package/package.json +2 -2
  34. package/scripts/context-meter/threshold.js +25 -46
  35. package/scripts/context-meter/threshold.test.js +52 -80
  36. package/scripts/gsd-t-agent-dashboard-server.js +4 -4
  37. package/scripts/gsd-t-agent-dashboard.html +699 -380
  38. package/scripts/gsd-t-context-meter.e2e.test.js +4 -3
  39. package/scripts/gsd-t-context-meter.js +1 -1
  40. package/scripts/gsd-t-context-meter.test.js +58 -50
  41. package/scripts/gsd-t-event-writer.js +8 -2
  42. package/templates/CLAUDE-global.md +7 -25
  43. package/templates/CLAUDE-project.md +22 -23
  44. package/bin/qa-calibrator.js +0 -194
  45. package/bin/runway-estimator.cjs +0 -242
  46. package/bin/runway-estimator.js +0 -242
  47. package/bin/token-optimizer.cjs +0 -471
  48. package/bin/token-optimizer.js +0 -471
  49. package/bin/token-telemetry.cjs +0 -246
  50. package/bin/token-telemetry.js +0 -246
  51. package/commands/gsd-t-audit.md +0 -196
  52. package/commands/gsd-t-brainstorm.md +0 -201
  53. package/commands/gsd-t-discuss.md +0 -178
  54. package/commands/gsd-t-optimization-apply.md +0 -91
  55. package/commands/gsd-t-optimization-reject.md +0 -94
  56. package/commands/gsd-t-prompt.md +0 -137
  57. package/commands/gsd-t-reflect.md +0 -130
  58. package/scripts/context-meter/count-tokens-client.js +0 -221
  59. package/scripts/context-meter/count-tokens-client.test.js +0 -308
  60. package/scripts/context-meter/test-injector.js +0 -55
@@ -56,6 +56,13 @@ const {
56
56
  notify,
57
57
  } = require("./gsd-t-unattended-platform.js");
58
58
 
59
+ // Event stream (M38 ES) — additive, non-blocking. `_emit` swallows its own
60
+ // errors per unattended-event-stream-contract.md §6.
61
+ const { appendEvent: _esAppendEvent } = require("./event-stream.cjs");
62
+ function _emit(projectDir, ev) {
63
+ try { _esAppendEvent(projectDir, ev); } catch (_) { /* never halt the loop */ }
64
+ }
65
+
59
66
  // ── Constants ───────────────────────────────────────────────────────────────
60
67
 
61
68
  const CONTRACT_VERSION = "1.0.0";
@@ -477,7 +484,32 @@ function finalizeState(state, dir, terminalStatus) {
477
484
  */
478
485
  function doUnattended(argv, deps) {
479
486
  deps = deps || {};
480
- const opts = parseArgs(argv || []);
487
+ const rawArgv = argv || [];
488
+
489
+ // --watch rejection (headless-default-contract §2) — unattended is detached
490
+ // by definition; passing --watch is a category error. Refuse fast so the
491
+ // user sees a clear message before any state.json / PID work happens.
492
+ if (
493
+ Array.isArray(rawArgv) &&
494
+ rawArgv.some(
495
+ (a) => typeof a === "string" && (a === "--watch" || a.startsWith("--watch=")),
496
+ )
497
+ ) {
498
+ // eslint-disable-next-line no-console
499
+ console.error(
500
+ "[gsd-t-unattended] --watch is incompatible with unattended.\n" +
501
+ "Unattended supervisor is detached by definition.\n" +
502
+ "Run /user:gsd-t-unattended-watch from your interactive session to see live activity.",
503
+ );
504
+ return {
505
+ ok: false,
506
+ dryRun: false,
507
+ exitCode: 2,
508
+ reason: "--watch is incompatible with unattended",
509
+ };
510
+ }
511
+
512
+ const opts = parseArgs(rawArgv);
481
513
  const projectDir = path.resolve(opts.project || ".");
482
514
 
483
515
  // ── Resolve injection points (real impls by default) ─────────────────────
@@ -884,6 +916,16 @@ function runMainLoop(state, dir, opts, deps, ctx) {
884
916
  state.lastWorkerStartedAt = workerStart.toISOString();
885
917
  writeState(state, dir);
886
918
 
919
+ _emit(projectDir, {
920
+ ts: workerStart.toISOString(),
921
+ iter: state.iter,
922
+ type: "task_start",
923
+ source: "supervisor",
924
+ milestone: state.milestone || "",
925
+ wave: state.wave || "",
926
+ task: state.nextTask || "",
927
+ });
928
+
887
929
  let res;
888
930
  try {
889
931
  res = spawnWorker(state, {
@@ -921,6 +963,29 @@ function runMainLoop(state, dir, opts, deps, ctx) {
921
963
  state.lastElapsedMs = elapsedMs;
922
964
  writeState(state, dir);
923
965
 
966
+ // Event-stream: task_complete on success, error on non-zero.
967
+ const durationS = Math.round(elapsedMs / 1000);
968
+ if (exitCode === 0) {
969
+ _emit(projectDir, {
970
+ ts: workerEnd.toISOString(),
971
+ iter: state.iter,
972
+ type: "task_complete",
973
+ source: "supervisor",
974
+ task: state.nextTask || "",
975
+ verdict: "pass",
976
+ duration_s: durationS,
977
+ });
978
+ } else {
979
+ _emit(projectDir, {
980
+ ts: workerEnd.toISOString(),
981
+ iter: state.iter,
982
+ type: "error",
983
+ source: "supervisor",
984
+ error: `worker exit ${exitCode}`,
985
+ recoverable: exitCode !== 4 && exitCode !== 5,
986
+ });
987
+ }
988
+
924
989
  // ── POST-WORKER HOOK (contract §12) ────────────────────────────────────
925
990
  // Read the tail of run.log for pattern detection. ~200 lines is enough
926
991
  // to span the last several iteration blocks for the gutter detector.
@@ -959,6 +1024,13 @@ function runMainLoop(state, dir, opts, deps, ctx) {
959
1024
  break;
960
1025
  }
961
1026
  // Not yet done — continue relay.
1027
+ _emit(projectDir, {
1028
+ iter: state.iter,
1029
+ type: "retry",
1030
+ source: "supervisor",
1031
+ attempt: state.iter,
1032
+ reason: "milestone_incomplete",
1033
+ });
962
1034
  continue;
963
1035
  }
964
1036
  if (exitCode === 4) {
@@ -975,9 +1047,23 @@ function runMainLoop(state, dir, opts, deps, ctx) {
975
1047
  }
976
1048
  if (exitCode === 124) {
977
1049
  // Timeout — continue unless the iter cap is hit on the next check.
1050
+ _emit(projectDir, {
1051
+ iter: state.iter,
1052
+ type: "retry",
1053
+ source: "supervisor",
1054
+ attempt: state.iter,
1055
+ reason: "timeout",
1056
+ });
978
1057
  continue;
979
1058
  }
980
1059
  // Non-terminal (1/2/3) — continue the relay.
1060
+ _emit(projectDir, {
1061
+ iter: state.iter,
1062
+ type: "retry",
1063
+ source: "supervisor",
1064
+ attempt: state.iter,
1065
+ reason: `exit_${exitCode}`,
1066
+ });
981
1067
  }
982
1068
 
983
1069
  // If we exited because the user dropped a stop sentinel and no terminal
package/bin/gsd-t.js CHANGED
@@ -388,8 +388,14 @@ const CONTEXT_METER_GITIGNORE_ENTRIES = [
388
388
  ".gsd-t/context-meter.log",
389
389
  ];
390
390
  const CONTEXT_METER_HOOK_MARKER = "gsd-t-context-meter";
391
+ // Canonical global hook — runs the script from the globally-installed npm package.
392
+ // Guarded so it silently exits 0 if the package is not present (non-GSD-T projects).
391
393
  const CONTEXT_METER_HOOK_COMMAND =
392
- 'node "$CLAUDE_PROJECT_DIR/scripts/gsd-t-context-meter.js"';
394
+ 'bash -c \'[ -f "$(npm root -g)/@tekyzinc/gsd-t/scripts/gsd-t-context-meter.js" ] && node "$(npm root -g)/@tekyzinc/gsd-t/scripts/gsd-t-context-meter.js" || true\'';
395
+ // Legacy command patterns that must be migrated on install/update/init.
396
+ const CONTEXT_METER_STALE_PATTERNS = [
397
+ /node\s+"?\$CLAUDE_PROJECT_DIR\/scripts\/gsd-t-context-meter\.js"?/,
398
+ ];
393
399
 
394
400
  // Append entries to {projectDir}/.gitignore. Each entry added only if absent.
395
401
  // Idempotent. Returns true if any entries were added, false otherwise.
@@ -433,8 +439,8 @@ function ensureGitignoreEntries(projectDir, entries) {
433
439
 
434
440
  // Install the Context Meter into a project directory.
435
441
  // 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.
442
+ // only — skips .test.js), and the config template (if missing). Also appends
443
+ // entries to .gitignore.
438
444
  function installContextMeter(projectDir) {
439
445
  try {
440
446
  // 1. Copy gsd-t-context-meter.js → {projectDir}/scripts/
@@ -486,9 +492,7 @@ function installContextMeter(projectDir) {
486
492
  return false;
487
493
  }
488
494
  for (const fname of depFiles) {
489
- // Skip test files and test-only infrastructure
490
495
  if (fname.includes(".test.")) continue;
491
- if (fname === "test-injector.js") continue;
492
496
  const fsrc = path.join(depsSrcDir, fname);
493
497
  const fdest = path.join(depsDestDir, fname);
494
498
  try {
@@ -546,7 +550,9 @@ function installContextMeter(projectDir) {
546
550
 
547
551
  // Register the Context Meter PostToolUse hook in ~/.claude/settings.json.
548
552
  // Idempotent — if an existing hook references CONTEXT_METER_HOOK_MARKER the
549
- // command string is refreshed in-place. All other settings/hooks preserved.
553
+ // command string is refreshed/migrated in-place to the canonical form.
554
+ // Stale entries matching CONTEXT_METER_STALE_PATTERNS are migrated on the spot.
555
+ // All other settings/hooks are preserved.
550
556
  // Returns { installed: bool, action: "added"|"updated"|"noop" }.
551
557
  function configureContextMeterHooks(settingsPath) {
552
558
  const targetPath = settingsPath || SETTINGS_JSON;
@@ -572,13 +578,17 @@ function configureContextMeterHooks(settingsPath) {
572
578
  for (const entry of settings.hooks.PostToolUse) {
573
579
  if (!entry || !Array.isArray(entry.hooks)) continue;
574
580
  for (const h of entry.hooks) {
575
- if (h && typeof h.command === "string" && h.command.includes(CONTEXT_METER_HOOK_MARKER)) {
581
+ if (!h || typeof h.command !== "string") continue;
582
+ const isCurrentCanonical = h.command === cmd;
583
+ const isMarkerMatch = h.command.includes(CONTEXT_METER_HOOK_MARKER);
584
+ const isStaleMatch = !isCurrentCanonical &&
585
+ CONTEXT_METER_STALE_PATTERNS.some((re) => re.test(h.command));
586
+
587
+ if (isCurrentCanonical || isMarkerMatch || isStaleMatch) {
576
588
  found = true;
577
- if (h.command !== cmd) {
589
+ if (!isCurrentCanonical) {
578
590
  h.command = cmd;
579
591
  action = "updated";
580
- } else if (action === "noop") {
581
- action = "noop";
582
592
  }
583
593
  }
584
594
  }
@@ -609,6 +619,46 @@ function configureContextMeterHooks(settingsPath) {
609
619
  return { installed: true, action };
610
620
  }
611
621
 
622
+ // Remove any context meter PostToolUse hooks from settings.json.
623
+ // Used during uninstall. Leaves all other hooks intact.
624
+ function removeContextMeterHook(settingsPath) {
625
+ const targetPath = settingsPath || SETTINGS_JSON;
626
+ if (!fs.existsSync(targetPath)) return false;
627
+ let settings;
628
+ try {
629
+ settings = JSON.parse(fs.readFileSync(targetPath, "utf8"));
630
+ if (!settings || typeof settings !== "object") return false;
631
+ } catch {
632
+ warn("settings.json has invalid JSON — cannot remove context meter hook");
633
+ return false;
634
+ }
635
+
636
+ if (!settings.hooks || !Array.isArray(settings.hooks.PostToolUse)) return false;
637
+
638
+ const before = settings.hooks.PostToolUse.length;
639
+ settings.hooks.PostToolUse = settings.hooks.PostToolUse.filter((entry) => {
640
+ if (!entry || !Array.isArray(entry.hooks)) return true;
641
+ // Keep the entry only if NONE of its hooks reference the context meter
642
+ return !entry.hooks.some(
643
+ (h) => h && typeof h.command === "string" && h.command.includes(CONTEXT_METER_HOOK_MARKER)
644
+ );
645
+ });
646
+ const removed = before - settings.hooks.PostToolUse.length;
647
+ if (removed === 0) return false;
648
+
649
+ if (isSymlink(targetPath)) {
650
+ warn("Skipping settings.json write — target is a symlink");
651
+ return false;
652
+ }
653
+ try {
654
+ fs.writeFileSync(targetPath, JSON.stringify(settings, null, 2));
655
+ return true;
656
+ } catch (e) {
657
+ warn(`Failed to write settings.json: ${e.message}`);
658
+ return false;
659
+ }
660
+ }
661
+
612
662
  // Interactive prompt for the Anthropic API key env var.
613
663
  // Skips if not a TTY or if the env var is already set.
614
664
  // Never writes the key anywhere — just prints the export command for the user
@@ -1651,6 +1701,11 @@ function doUninstall() {
1651
1701
  removeInstalledCommands();
1652
1702
  removeVersionFile();
1653
1703
 
1704
+ // Remove context meter PostToolUse hook from settings.json
1705
+ if (removeContextMeterHook(SETTINGS_JSON)) {
1706
+ success("Context meter PostToolUse hook removed from settings.json");
1707
+ }
1708
+
1654
1709
  warn("~/.claude/CLAUDE.md was NOT removed (may contain your customizations)");
1655
1710
  info("Remove manually if desired: delete the GSD-T section from ~/.claude/CLAUDE.md");
1656
1711
  info("Project files (.gsd-t/, docs/, CLAUDE.md) were NOT removed");
@@ -1970,8 +2025,7 @@ const PROJECT_BIN_TOOLS = [
1970
2025
  "archive-progress.cjs", "log-tail.cjs", "context-budget-audit.cjs",
1971
2026
  "context-meter-config.cjs", "token-budget.cjs",
1972
2027
  "gsd-t-unattended.cjs", "gsd-t-unattended-platform.cjs", "gsd-t-unattended-safety.cjs",
1973
- "handoff-lock.cjs", "headless-auto-spawn.cjs", "runway-estimator.cjs",
1974
- "token-telemetry.cjs", "token-optimizer.cjs",
2028
+ "handoff-lock.cjs", "headless-auto-spawn.cjs",
1975
2029
  ];
1976
2030
 
1977
2031
  function copyBinToolsToProject(projectDir, projectName) {
@@ -2603,6 +2657,39 @@ function doGraph(args) {
2603
2657
  }
2604
2658
  }
2605
2659
 
2660
+ // ─── Token-Log Writer (Fix 1, v3.12.12) ─────────────────────────────────────
2661
+
2662
+ const _TL_HEADER =
2663
+ "| Datetime-start | Datetime-end | Command | Step | Model | Duration(s) | Notes | Domain | Task | Ctx% |\n" +
2664
+ "|---|---|---|---|---|---|---|---|---|---|\n";
2665
+
2666
+ /**
2667
+ * Append one row to {projectDir}/.gsd-t/token-log.md for a headless exec
2668
+ * invocation. Best-effort — never throws.
2669
+ */
2670
+ function appendHeadlessTokenLog(projectDir, entry) {
2671
+ try {
2672
+ const logPath = path.join(projectDir, ".gsd-t", "token-log.md");
2673
+ const note = entry.exitCode === 0 ? "headless exec: ok" : `headless exec: exit ${entry.exitCode}`;
2674
+ const row =
2675
+ `| ${entry.dtStart} | ${entry.dtEnd} | ${entry.command} | headless | unknown | ${entry.durationS}s | ${note} | - | - | unknown |\n`;
2676
+ const gsdtDir = path.join(projectDir, ".gsd-t");
2677
+ if (!fs.existsSync(gsdtDir)) fs.mkdirSync(gsdtDir, { recursive: true });
2678
+ if (!fs.existsSync(logPath)) {
2679
+ fs.writeFileSync(logPath, `# GSD-T Token Log\n\n${_TL_HEADER}${row}`);
2680
+ } else {
2681
+ const existing = fs.readFileSync(logPath, "utf8");
2682
+ if (!existing.includes("| Datetime-start |")) {
2683
+ fs.writeFileSync(logPath, `# GSD-T Token Log\n\n${_TL_HEADER}${existing}${row}`);
2684
+ } else {
2685
+ fs.appendFileSync(logPath, row);
2686
+ }
2687
+ }
2688
+ } catch (_) {
2689
+ /* best-effort */
2690
+ }
2691
+ }
2692
+
2606
2693
  // ─── Headless Mode ────────────────────────────────────────────────────────────
2607
2694
 
2608
2695
  /**
@@ -2720,12 +2807,20 @@ function doHeadlessExec(command, cmdArgs, flags) {
2720
2807
  let output = "";
2721
2808
  let processExitCode = 0;
2722
2809
 
2810
+ // Inject command/phase env vars so worker event-stream entries are tagged
2811
+ // (Fix 2, v3.12.12).
2812
+ const workerEnv = Object.assign({}, process.env, {
2813
+ GSD_T_COMMAND: `gsd-t-${command}`,
2814
+ GSD_T_PHASE: process.env.GSD_T_PHASE || "execute",
2815
+ });
2816
+
2723
2817
  try {
2724
- const result = execFileSync("claude", ["-p", prompt], {
2818
+ const result = execFileSync("claude", ["-p", "--dangerously-skip-permissions", prompt], {
2725
2819
  encoding: "utf8",
2726
2820
  timeout: timeoutMs,
2727
2821
  stdio: ["pipe", "pipe", "pipe"],
2728
- cwd: process.cwd()
2822
+ cwd: process.cwd(),
2823
+ env: workerEnv,
2729
2824
  });
2730
2825
  output = result;
2731
2826
  } catch (e) {
@@ -2741,6 +2836,16 @@ function doHeadlessExec(command, cmdArgs, flags) {
2741
2836
  const gsdtExitCode = mapHeadlessExitCode(processExitCode, output);
2742
2837
  const duration = Date.now() - startTime;
2743
2838
 
2839
+ // Append to token-log.md (Fix 1, v3.12.12) — headless exec writes a row so
2840
+ // `gsd-t headless <command>` spawns are visible in the log.
2841
+ appendHeadlessTokenLog(process.cwd(), {
2842
+ dtStart: new Date(startTime).toISOString().slice(0, 16).replace("T", " "),
2843
+ dtEnd: new Date(startTime + duration).toISOString().slice(0, 16).replace("T", " "),
2844
+ command: `gsd-t-${command}`,
2845
+ durationS: Math.round(duration / 1000),
2846
+ exitCode: gsdtExitCode,
2847
+ });
2848
+
2744
2849
  // Write log file if requested
2745
2850
  if (logMode && logFile) {
2746
2851
  try {
@@ -3245,117 +3350,10 @@ function showHeadlessHelp() {
3245
3350
  log(` ${DIM}$${RESET} gsd-t headless query domains\n`);
3246
3351
  }
3247
3352
 
3248
- // ─── Metrics (M35 token telemetry CLI) ────────────────────────────────────────
3249
-
3250
- function parseMetricsByFlag(args) {
3251
- const byArg = args.find(a => a.startsWith("--by="));
3252
- if (!byArg) return [];
3253
- const raw = byArg.slice(5).trim();
3254
- if (!raw) return [];
3255
- return raw.split(",").map(s => s.trim()).filter(Boolean);
3256
- }
3257
-
3258
- function formatPct(v) {
3259
- if (v === null || v === undefined || Number.isNaN(v)) return "—";
3260
- return `${Number(v).toFixed(1)}%`;
3261
- }
3353
+ // ─── Metrics (removed in v3.12 — M38 meter reduction) ─────────────────────────
3262
3354
 
3263
- function formatInt(v) {
3264
- if (v === null || v === undefined || Number.isNaN(v)) return "—";
3265
- return String(Math.round(Number(v)));
3266
- }
3267
-
3268
- function doMetrics(args) {
3269
- const projectDir = process.cwd();
3270
- let tt;
3271
- try {
3272
- tt = require(path.join(projectDir, "bin", "token-telemetry.js"));
3273
- } catch (e) {
3274
- error(`bin/token-telemetry.js not found in ${projectDir} — run gsd-t install first.`);
3275
- process.exit(1);
3276
- }
3277
-
3278
- const records = tt.readAll(projectDir);
3279
- if (records.length === 0) {
3280
- log(`${DIM}No telemetry records yet — .gsd-t/token-metrics.jsonl is empty or missing.${RESET}`);
3281
- return;
3282
- }
3283
-
3284
- const isTokens = args.includes("--tokens");
3285
- const isHalts = args.includes("--halts");
3286
- const isContextWindow = args.includes("--context-window");
3287
-
3288
- if (!isTokens && !isHalts && !isContextWindow) {
3289
- log(`${YELLOW}Specify at least one of: --tokens, --halts, --context-window${RESET}`);
3290
- return;
3291
- }
3292
-
3293
- if (isHalts) {
3294
- const halts = records.filter(r => r.halt_type);
3295
- log(`\n${BOLD}Halts — ${halts.length} record(s)${RESET}`);
3296
- if (halts.length === 0) {
3297
- log(`${DIM} (no halts recorded — all spawns completed normally)${RESET}\n`);
3298
- } else {
3299
- const byType = {};
3300
- for (const r of halts) {
3301
- const key = String(r.halt_type);
3302
- byType[key] = (byType[key] || 0) + 1;
3303
- }
3304
- for (const [type, count] of Object.entries(byType).sort()) {
3305
- log(` ${CYAN}${type.padEnd(24)}${RESET} ${count}`);
3306
- }
3307
- log("");
3308
- }
3309
- }
3310
-
3311
- if (isTokens) {
3312
- const by = parseMetricsByFlag(args);
3313
- if (by.length === 0) {
3314
- const totalConsumed = records.reduce((a, r) => a + (Number(r.tokens_consumed) || 0), 0);
3315
- const totalDuration = records.reduce((a, r) => a + (Number(r.duration_s) || 0), 0);
3316
- log(`\n${BOLD}Tokens — ${records.length} spawn(s)${RESET}`);
3317
- log(` total tokens consumed: ${formatInt(totalConsumed)}`);
3318
- log(` total duration (s): ${formatInt(totalDuration)}`);
3319
- if (records.length > 0) {
3320
- log(` mean tokens/spawn: ${formatInt(totalConsumed / records.length)}`);
3321
- }
3322
- log("");
3323
- } else {
3324
- const groups = tt.aggregate(records, { by });
3325
- log(`\n${BOLD}Tokens by ${by.join(",")} — ${groups.length} group(s)${RESET}`);
3326
- const keyHeader = by.join("/");
3327
- log(` ${keyHeader.padEnd(36)} ${"count".padStart(7)} ${"total".padStart(12)} ${"mean".padStart(10)} ${"median".padStart(10)} ${"p95".padStart(10)}`);
3328
- log(` ${"-".repeat(36)} ${"-".repeat(7)} ${"-".repeat(12)} ${"-".repeat(10)} ${"-".repeat(10)} ${"-".repeat(10)}`);
3329
- for (const g of groups) {
3330
- const label = by.map(k => g.key[k] == null ? "—" : String(g.key[k])).join("/");
3331
- 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)}`);
3332
- }
3333
- log("");
3334
- }
3335
- }
3336
-
3337
- if (isContextWindow) {
3338
- log(`\n${BOLD}Context window trend — ${records.length} record(s)${RESET}`);
3339
- const withPct = records.filter(r => r.context_window_pct_after != null && !Number.isNaN(Number(r.context_window_pct_after)));
3340
- if (withPct.length === 0) {
3341
- log(`${DIM} (no context-window measurements in records)${RESET}\n`);
3342
- } else {
3343
- const sorted = [...withPct].map(r => Number(r.context_window_pct_after)).sort((a, b) => a - b);
3344
- const min = sorted[0];
3345
- const max = sorted[sorted.length - 1];
3346
- const mean = sorted.reduce((a, b) => a + b, 0) / sorted.length;
3347
- const p95 = sorted[Math.min(sorted.length - 1, Math.floor(sorted.length * 0.95))];
3348
- log(` min: ${formatPct(min)}`);
3349
- log(` mean: ${formatPct(mean)}`);
3350
- log(` p95: ${formatPct(p95)}`);
3351
- log(` max: ${formatPct(max)}`);
3352
- const atWarn = withPct.filter(r => Number(r.context_window_pct_after) >= 70).length;
3353
- const atStop = withPct.filter(r => Number(r.context_window_pct_after) >= 85).length;
3354
- log(` spawns at warn band (≥70%): ${atWarn}`);
3355
- log(` spawns at stop band (≥85%): ${atStop}`);
3356
- log("");
3357
- }
3358
- }
3355
+ function doMetrics(_args) {
3356
+ log(`${DIM}metrics removed in v3.12 context meter is no longer telemetry-instrumented${RESET}`);
3359
3357
  }
3360
3358
 
3361
3359
  function showHelp() {
@@ -3470,6 +3468,7 @@ module.exports = {
3470
3468
  ensureGitignoreEntries,
3471
3469
  installContextMeter,
3472
3470
  configureContextMeterHooks,
3471
+ removeContextMeterHook,
3473
3472
  promptForApiKeyIfMissing,
3474
3473
  resolveApiKeyEnvVar,
3475
3474
  runTaskCounterRetirementMigration,
@@ -3515,6 +3514,14 @@ if (require.main === module) {
3515
3514
  case "headless":
3516
3515
  doHeadless(args.slice(1));
3517
3516
  break;
3517
+ case "unattended": {
3518
+ const { spawnSync } = require("child_process");
3519
+ const cjs = path.join(__dirname, "gsd-t-unattended.cjs");
3520
+ const res = spawnSync(process.execPath, [cjs, ...args.slice(1)], {
3521
+ stdio: "inherit",
3522
+ });
3523
+ process.exit(res.status == null ? 1 : res.status);
3524
+ }
3518
3525
  case "metrics":
3519
3526
  doMetrics(args.slice(1));
3520
3527
  break;
@@ -53,20 +53,54 @@ module.exports = {
53
53
  * args?: string[],
54
54
  * continue_from?: string,
55
55
  * projectDir?: string,
56
- * context?: object
56
+ * context?: object,
57
+ * sessionContext?: object,
58
+ * sessionId?: string,
59
+ * watch?: boolean,
60
+ * spawnType?: 'primary' | 'validation'
57
61
  * }} opts
58
- * @returns {{ id: string, pid: number, logPath: string, timestamp: string }}
62
+ * @returns {{ id: string | null, pid: number | null, logPath: string | null, timestamp: string, mode: 'headless' | 'in-context' }}
59
63
  */
60
64
  function autoSpawnHeadless(opts) {
61
65
  const command = opts.command;
62
66
  const args = opts.args || [];
63
67
  const continue_from = opts.continue_from || ".";
64
68
  const projectDir = opts.projectDir || process.cwd();
65
- const context = opts.context || null;
69
+ const context = opts.context || opts.sessionContext || null;
70
+ const watch = opts.watch === true;
71
+ const spawnType = opts.spawnType || "primary";
66
72
 
67
73
  if (!command || typeof command !== "string") {
68
74
  throw new Error("autoSpawnHeadless: `command` is required");
69
75
  }
76
+ if (spawnType !== "primary" && spawnType !== "validation") {
77
+ throw new Error(
78
+ `autoSpawnHeadless: \`spawnType\` must be 'primary' or 'validation' (got ${JSON.stringify(spawnType)})`,
79
+ );
80
+ }
81
+
82
+ // Propagation rules (headless-default-contract §2):
83
+ // watch=true + primary → signal in-context fallback (caller uses Task)
84
+ // watch=true + validation → warn on stderr; proceed headless
85
+ // watch=false → headless (default behavior)
86
+ if (watch && spawnType === "primary") {
87
+ return {
88
+ id: null,
89
+ pid: null,
90
+ logPath: null,
91
+ timestamp: new Date().toISOString(),
92
+ mode: "in-context",
93
+ };
94
+ }
95
+ if (watch && spawnType === "validation") {
96
+ try {
97
+ process.stderr.write(
98
+ `[headless-default] --watch ignored for validation spawn type: ${spawnType}\n`,
99
+ );
100
+ } catch (_) {
101
+ /* best effort */
102
+ }
103
+ }
70
104
 
71
105
  const timestamp = new Date().toISOString();
72
106
  const id = makeSessionId(command, new Date());
@@ -101,11 +135,18 @@ function autoSpawnHeadless(opts) {
101
135
  const gsdtCli = path.join(projectDir, "bin", "gsd-t.js");
102
136
  const childArgs = [gsdtCli, "headless", stripGsdtPrefix(command), ...args, "--log"];
103
137
 
138
+ // Inject command/phase into worker env so event-stream entries are tagged
139
+ // (Fix 2, v3.12.12). GSD_T_PHASE defaults to "execute" for primary spawns.
140
+ const workerEnv = Object.assign({}, process.env, {
141
+ GSD_T_COMMAND: command,
142
+ GSD_T_PHASE: process.env.GSD_T_PHASE || "execute",
143
+ });
144
+
104
145
  const child = spawn("node", childArgs, {
105
146
  cwd: projectDir,
106
147
  detached: true,
107
148
  stdio: ["ignore", logFd, logFd],
108
- env: process.env,
149
+ env: workerEnv,
109
150
  });
110
151
 
111
152
  child.unref();
@@ -145,7 +186,13 @@ function autoSpawnHeadless(opts) {
145
186
  // a detached approach that survives even after the parent's `unref()`.
146
187
  installCompletionWatcher({ projectDir, id, logPath, pid, startTimestamp: timestamp });
147
188
 
148
- return { id, pid, logPath: path.relative(projectDir, logPath), timestamp };
189
+ return {
190
+ id,
191
+ pid,
192
+ logPath: path.relative(projectDir, logPath),
193
+ timestamp,
194
+ mode: "headless",
195
+ };
149
196
  }
150
197
 
151
198
  // ── makeSessionId ────────────────────────────────────────────────────────────
@@ -251,6 +298,7 @@ function installCompletionWatcher(opts) {
251
298
  const POLL_MS = 2000;
252
299
  const MAX_WAIT_MS = 60 * 60 * 1000; // 1 hour safety cap
253
300
  const startMs = Date.now();
301
+ const dtStart = new Date(startTimestamp).toLocaleString("sv-SE", { hour12: false }).slice(0, 16);
254
302
 
255
303
  const timer = setInterval(() => {
256
304
  let alive = false;
@@ -265,9 +313,20 @@ function installCompletionWatcher(opts) {
265
313
  // Exit code is unknown from a signal-based probe. Best-effort: read
266
314
  // the log's last lines to guess, otherwise default to 0.
267
315
  const exitCode = guessExitCodeFromLog(projectDir, id);
316
+ const endTimestamp = new Date().toISOString();
268
317
  markSessionCompleted(projectDir, id, {
269
318
  exitCode,
270
- endTimestamp: new Date().toISOString(),
319
+ endTimestamp,
320
+ });
321
+ // Append token-log row (Fix 1, v3.12.12)
322
+ const dtEnd = new Date(endTimestamp).toLocaleString("sv-SE", { hour12: false }).slice(0, 16);
323
+ const durationS = Math.round((Date.now() - startMs) / 1000);
324
+ appendTokenLog(projectDir, {
325
+ dtStart,
326
+ dtEnd,
327
+ command: extractCommand(id),
328
+ durationS,
329
+ exitCode,
271
330
  });
272
331
  fireMacNotification({ id, command: extractCommand(id), startTimestamp });
273
332
  } else if (Date.now() - startMs > MAX_WAIT_MS) {
@@ -316,6 +375,43 @@ function fireMacNotification({ id, command }) {
316
375
  }
317
376
  }
318
377
 
378
+ // ── Token-Log Writer (Fix 1, v3.12.12) ───────────────────────────────────────
379
+
380
+ const TOKEN_LOG_HEADER =
381
+ "| Datetime-start | Datetime-end | Command | Step | Model | Duration(s) | Notes | Domain | Task | Ctx% |\n" +
382
+ "|---|---|---|---|---|---|---|---|---|---|\n";
383
+
384
+ /**
385
+ * Append one row to {projectDir}/.gsd-t/token-log.md matching the schema used
386
+ * by interactive command-file observability blocks.
387
+ *
388
+ * @param {string} projectDir
389
+ * @param {{ dtStart: string, dtEnd: string, command: string, durationS: number, exitCode: number }} entry
390
+ */
391
+ function appendTokenLog(projectDir, entry) {
392
+ try {
393
+ const logPath = path.join(projectDir, ".gsd-t", "token-log.md");
394
+ const note = entry.exitCode === 0 ? "headless spawn: ok" : `headless spawn: exit ${entry.exitCode}`;
395
+ const row =
396
+ `| ${entry.dtStart} | ${entry.dtEnd} | ${entry.command} | headless | unknown | ${entry.durationS}s | ${note} | - | - | unknown |\n`;
397
+ if (!fs.existsSync(logPath)) {
398
+ // Create with header
399
+ ensureDir(path.dirname(logPath));
400
+ fs.writeFileSync(logPath, `# GSD-T Token Log\n\n${TOKEN_LOG_HEADER}${row}`);
401
+ } else {
402
+ // Check if header row exists; if not prepend it (migration for files created before this fix)
403
+ const existing = fs.readFileSync(logPath, "utf8");
404
+ if (!existing.includes("| Datetime-start |")) {
405
+ fs.writeFileSync(logPath, `# GSD-T Token Log\n\n${TOKEN_LOG_HEADER}${existing}${row}`);
406
+ } else {
407
+ fs.appendFileSync(logPath, row);
408
+ }
409
+ }
410
+ } catch (_) {
411
+ /* best-effort — never halt the completion watcher */
412
+ }
413
+ }
414
+
319
415
  // ── Helpers ──────────────────────────────────────────────────────────────────
320
416
 
321
417
  function ensureDir(d) {