@tekyzinc/gsd-t 3.16.12 → 3.18.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 (53) hide show
  1. package/CHANGELOG.md +67 -0
  2. package/README.md +13 -3
  3. package/bin/gsd-t-depgraph-validate.cjs +140 -0
  4. package/bin/gsd-t-economics.cjs +287 -0
  5. package/bin/gsd-t-file-disjointness.cjs +227 -0
  6. package/bin/gsd-t-in-session-usage.cjs +213 -0
  7. package/bin/gsd-t-orchestrator-config.cjs +100 -3
  8. package/bin/gsd-t-orchestrator.js +2 -1
  9. package/bin/gsd-t-parallel.cjs +382 -0
  10. package/bin/gsd-t-report-tokens.cjs +549 -0
  11. package/bin/gsd-t-task-graph.cjs +366 -0
  12. package/bin/gsd-t-token-capture.cjs +29 -14
  13. package/bin/gsd-t-token-dashboard.cjs +35 -0
  14. package/bin/gsd-t-tool-attribution.cjs +377 -0
  15. package/bin/gsd-t-tool-cost.cjs +195 -0
  16. package/bin/gsd-t-unattended-platform.cjs +7 -1
  17. package/bin/gsd-t-unattended.cjs +2 -0
  18. package/bin/gsd-t.js +155 -5
  19. package/bin/headless-auto-spawn.cjs +69 -49
  20. package/bin/headless-auto-spawn.js +18 -24
  21. package/bin/runway-estimator.cjs +212 -0
  22. package/bin/spawn-plan-derive.cjs +163 -0
  23. package/bin/spawn-plan-status-updater.cjs +292 -0
  24. package/bin/spawn-plan-writer.cjs +204 -0
  25. package/commands/gsd-t-debug.md +26 -7
  26. package/commands/gsd-t-execute.md +36 -28
  27. package/commands/gsd-t-help.md +11 -0
  28. package/commands/gsd-t-integrate.md +27 -7
  29. package/commands/gsd-t-quick.md +30 -13
  30. package/commands/gsd-t-scan.md +5 -5
  31. package/commands/gsd-t-unattended-watch.md +4 -3
  32. package/commands/gsd-t-unattended.md +9 -3
  33. package/commands/gsd-t-verify.md +5 -5
  34. package/commands/gsd-t-wave.md +21 -8
  35. package/commands/gsd.md +45 -3
  36. package/docs/GSD-T-README.md +43 -5
  37. package/docs/architecture.md +423 -3
  38. package/docs/requirements.md +203 -0
  39. package/package.json +1 -1
  40. package/scripts/gsd-t-calibration-hook.js +256 -0
  41. package/scripts/gsd-t-compact-detector.js +223 -0
  42. package/scripts/gsd-t-compaction-scanner.js +305 -0
  43. package/scripts/gsd-t-dashboard-autostart.cjs +172 -0
  44. package/scripts/gsd-t-dashboard-server.js +179 -0
  45. package/scripts/gsd-t-dashboard.html +3 -3
  46. package/scripts/gsd-t-heartbeat.js +50 -2
  47. package/scripts/gsd-t-post-commit-spawn-plan.sh +86 -0
  48. package/scripts/gsd-t-transcript.html +546 -43
  49. package/scripts/hooks/gsd-t-in-session-usage-hook.js +84 -0
  50. package/scripts/spawn-plan-fmt-tokens.cjs +80 -0
  51. package/templates/CLAUDE-global.md +8 -3
  52. package/templates/CLAUDE-project.md +17 -14
  53. package/templates/hooks/post-commit-spawn-plan.sh +85 -0
package/bin/gsd-t.js CHANGED
@@ -976,7 +976,7 @@ function configureFigmaMcp() {
976
976
 
977
977
  // ─── Utility Scripts ─────────────────────────────────────────────────────────
978
978
 
979
- const UTILITY_SCRIPTS = ["gsd-t-tools.js", "gsd-t-statusline.js", "gsd-t-event-writer.js", "gsd-t-dashboard-server.js", "gsd-t-dashboard.html"];
979
+ const UTILITY_SCRIPTS = ["gsd-t-tools.js", "gsd-t-statusline.js", "gsd-t-event-writer.js", "gsd-t-dashboard-server.js", "gsd-t-dashboard.html", "gsd-t-transcript.html"];
980
980
 
981
981
  function installUtilityScripts() {
982
982
  ensureDir(SCRIPTS_DIR);
@@ -2854,17 +2854,86 @@ function doGraphQuery(args) {
2854
2854
  }
2855
2855
 
2856
2856
  function doGraph(args) {
2857
+ // M44 D1-T4: `gsd-t graph --output json|table` prints the task-graph DAG
2858
+ // (parsed from .gsd-t/domains/*/tasks.md) for debugging. The pre-existing
2859
+ // `index|status|query` subcommands are the codebase entity graph (graph-
2860
+ // indexer) and remain unchanged.
2861
+ const outIdx = args.indexOf("--output");
2862
+ if (outIdx !== -1) {
2863
+ const fmt = args[outIdx + 1] || "json";
2864
+ return doGraphTaskOutput(fmt);
2865
+ }
2857
2866
  const sub = args[0] || "status";
2858
2867
  switch (sub) {
2859
2868
  case "index": doGraphIndex(); break;
2860
2869
  case "status": doGraphStatus(); break;
2861
2870
  case "query": doGraphQuery(args.slice(1)); break;
2871
+ case "tasks": doGraphTaskOutput(args[1] || "table"); break;
2862
2872
  default:
2863
2873
  error(`Unknown graph subcommand: ${sub}`);
2864
- info("Usage: gsd-t graph [index|status|query]");
2874
+ info("Usage: gsd-t graph [index|status|query|tasks]");
2875
+ info(" gsd-t graph --output json|table (task DAG)");
2865
2876
  }
2866
2877
  }
2867
2878
 
2879
+ // M44 D1-T4: print the task DAG built by bin/gsd-t-task-graph.cjs.
2880
+ function doGraphTaskOutput(format) {
2881
+ const tg = require("./gsd-t-task-graph.cjs");
2882
+ let graph;
2883
+ try {
2884
+ graph = tg.buildTaskGraph({ projectDir: process.cwd() });
2885
+ } catch (e) {
2886
+ if (e && e.name === "TaskGraphCycleError") {
2887
+ error(`Task graph cycle: ${(e.cycle || []).join(" → ")}`);
2888
+ process.exit(2);
2889
+ }
2890
+ error(e && e.message ? e.message : String(e));
2891
+ process.exit(2);
2892
+ }
2893
+ const fmt = String(format || "table").toLowerCase();
2894
+ if (fmt === "json") {
2895
+ process.stdout.write(JSON.stringify(graph, null, 2) + "\n");
2896
+ return;
2897
+ }
2898
+ if (fmt === "table") {
2899
+ if (!graph.nodes.length) {
2900
+ info("No tasks found in .gsd-t/domains/*/tasks.md");
2901
+ if (graph.warnings.length) graph.warnings.forEach((w) => warn(w));
2902
+ return;
2903
+ }
2904
+ // header
2905
+ const rows = graph.nodes.map((n) => ({
2906
+ id: n.id,
2907
+ domain: n.domain,
2908
+ wave: String(n.wave),
2909
+ status: n.status,
2910
+ ready: graph.ready.indexOf(n.id) !== -1 ? "yes" : "no",
2911
+ deps: n.deps.join(", ") || "-",
2912
+ }));
2913
+ const cols = ["id", "domain", "wave", "status", "ready", "deps"];
2914
+ const widths = {};
2915
+ for (const c of cols) {
2916
+ widths[c] = c.length;
2917
+ for (const r of rows) widths[c] = Math.max(widths[c], String(r[c]).length);
2918
+ }
2919
+ const fmtRow = (r) =>
2920
+ cols.map((c) => String(r[c]).padEnd(widths[c])).join(" ");
2921
+ log(fmtRow(Object.fromEntries(cols.map((c) => [c, c.toUpperCase()]))));
2922
+ log(cols.map((c) => "-".repeat(widths[c])).join(" "));
2923
+ for (const r of rows) log(fmtRow(r));
2924
+ log("");
2925
+ info(`${graph.nodes.length} tasks · ${graph.edges.length} edges · ${graph.ready.length} ready`);
2926
+ if (graph.warnings.length) {
2927
+ log("");
2928
+ warn(`${graph.warnings.length} warning(s):`);
2929
+ graph.warnings.forEach((w) => log(` ${w}`));
2930
+ }
2931
+ return;
2932
+ }
2933
+ error(`Unknown --output format: ${format} (expected: json|table)`);
2934
+ process.exit(2);
2935
+ }
2936
+
2868
2937
  // ─── Token-Log Writer (Fix 1, v3.12.12) ─────────────────────────────────────
2869
2938
 
2870
2939
  const _TL_HEADER =
@@ -3716,6 +3785,8 @@ function showHelp() {
3716
3785
  log(` ${CYAN}benchmark-orchestrator${RESET} M40 speed gate — compares orchestrator vs in-session wall-clock`);
3717
3786
  log(` ${CYAN}stream-feed${RESET} Localhost stream-json watcher (start|status|stop) — M40 D4`);
3718
3787
  log(` ${CYAN}design-build${RESET} Deterministic design→code pipeline (elements → widgets → pages)`);
3788
+ log(` ${CYAN}tool-cost${RESET} Per-tool token/cost attribution (M43 D2) — group-by tool|command|domain`);
3789
+ log(` ${CYAN}report tokens${RESET} Generate token-usage optimization report (Run → Iter → CW → Turn → Tool)`);
3719
3790
  log(` ${CYAN}help${RESET} Show this help\n`);
3720
3791
  log(`${BOLD}Examples:${RESET}`);
3721
3792
  log(` ${DIM}$${RESET} npx @tekyzinc/gsd-t install`);
@@ -3887,6 +3958,13 @@ if (require.main === module) {
3887
3958
  });
3888
3959
  process.exit(res.status == null ? 1 : res.status);
3889
3960
  }
3961
+ case "parallel": {
3962
+ // M44 D2 — `gsd-t parallel` wraps M40 orchestrator with task-level
3963
+ // parallelism + mode-aware gating math. Extends, does not replace.
3964
+ const { runCli: runParallelCli } = require(path.join(__dirname, "gsd-t-parallel.cjs"));
3965
+ const code = runParallelCli(args.slice(1), process.env);
3966
+ process.exit(code);
3967
+ }
3890
3968
  case "benchmark-orchestrator": {
3891
3969
  const { spawnSync } = require("child_process");
3892
3970
  const js = path.join(__dirname, "gsd-t-benchmark-orchestrator.js");
@@ -3967,7 +4045,7 @@ if (require.main === module) {
3967
4045
  break;
3968
4046
  }
3969
4047
  case "tokens": {
3970
- const tkOpts = { projectDir: process.cwd(), since: null, milestone: null, format: 'table', regenerateLog: false };
4048
+ const tkOpts = { projectDir: process.cwd(), since: null, milestone: null, format: 'table', regenerateLog: false, showToolCosts: false };
3971
4049
  for (let i = 1; i < args.length; i++) {
3972
4050
  const a = args[i];
3973
4051
  if (a === '--since' && args[i+1]) { tkOpts.since = args[++i]; }
@@ -3979,8 +4057,9 @@ if (require.main === module) {
3979
4057
  else if (a === '--project-dir' && args[i+1]) { tkOpts.projectDir = args[++i]; }
3980
4058
  else if (a.startsWith('--project-dir=')) { tkOpts.projectDir = a.slice(14); }
3981
4059
  else if (a === '--regenerate-log') { tkOpts.regenerateLog = true; }
4060
+ else if (a === '--show-tool-costs') { tkOpts.showToolCosts = true; }
3982
4061
  else if (a === '--help' || a === '-h') {
3983
- log('Usage: gsd-t tokens [--since YYYY-MM-DD] [--milestone Mxx] [--format table|json]');
4062
+ log('Usage: gsd-t tokens [--since YYYY-MM-DD] [--milestone Mxx] [--format table|json] [--show-tool-costs]');
3984
4063
  log(' gsd-t tokens --regenerate-log (rewrite .gsd-t/token-log.md from token-usage.jsonl)');
3985
4064
  process.exit(0);
3986
4065
  }
@@ -4008,7 +4087,22 @@ if (require.main === module) {
4008
4087
  const dashboard = require(path.join(__dirname, 'gsd-t-token-dashboard.cjs'));
4009
4088
  dashboard.aggregate(tkOpts)
4010
4089
  .then((agg) => {
4011
- log(tkOpts.format === 'json' ? dashboard.renderJson(agg) : dashboard.renderTable(agg));
4090
+ const baseOut = tkOpts.format === 'json' ? dashboard.renderJson(agg) : dashboard.renderTable(agg);
4091
+ let out = baseOut;
4092
+ if (tkOpts.showToolCosts) {
4093
+ try {
4094
+ out = out + '\n' + dashboard.renderToolCostsSection({
4095
+ projectDir: tkOpts.projectDir,
4096
+ since: tkOpts.since,
4097
+ milestone: tkOpts.milestone,
4098
+ format: tkOpts.format,
4099
+ });
4100
+ } catch (e) {
4101
+ // Non-fatal: tool-cost section failure doesn't break primary output.
4102
+ out = out + '\n── Top 10 tools by cost ──\n (tool-cost section unavailable: ' + (e.message || String(e)) + ')';
4103
+ }
4104
+ }
4105
+ log(out);
4012
4106
  process.exit(0);
4013
4107
  })
4014
4108
  .catch((e) => { error(e.message || String(e)); process.exit(3); });
@@ -4019,6 +4113,62 @@ if (require.main === module) {
4019
4113
  orchestrator.run(args.slice(1)).catch(e => { console.error(e); process.exit(1); });
4020
4114
  break;
4021
4115
  }
4116
+ case "tool-cost": {
4117
+ const toolCost = require(path.join(__dirname, 'gsd-t-tool-cost.cjs'));
4118
+ process.exit(toolCost.run(args.slice(1)));
4119
+ break;
4120
+ }
4121
+ case "report": {
4122
+ // Nested dispatch: `gsd-t report tokens [--date YYYY-MM-DD] [--out PATH]`
4123
+ const sub = args[1];
4124
+ if (!sub || sub === '--help' || sub === '-h') {
4125
+ log('Usage: gsd-t report tokens [--date YYYY-MM-DD] [--out PATH]');
4126
+ log('');
4127
+ log('Subcommands:');
4128
+ log(' tokens Generate token-usage optimization report (Run → Iter → CW → Turn → Tool).');
4129
+ process.exit(sub ? 0 : 2);
4130
+ break;
4131
+ }
4132
+ if (sub !== 'tokens') {
4133
+ error(`report: unknown subcommand: ${sub}`);
4134
+ log('Usage: gsd-t report tokens [--date YYYY-MM-DD] [--out PATH]');
4135
+ process.exit(2);
4136
+ break;
4137
+ }
4138
+ const rOpts = { projectDir: process.cwd(), date: null, outPath: null };
4139
+ for (let i = 2; i < args.length; i++) {
4140
+ const a = args[i];
4141
+ if (a === '--date' && args[i+1]) { rOpts.date = args[++i]; }
4142
+ else if (a.startsWith('--date=')) { rOpts.date = a.slice(7); }
4143
+ else if (a === '--out' && args[i+1]) { rOpts.outPath = args[++i]; }
4144
+ else if (a.startsWith('--out=')) { rOpts.outPath = a.slice(6); }
4145
+ else if (a === '--project-dir' && args[i+1]) { rOpts.projectDir = args[++i]; }
4146
+ else if (a.startsWith('--project-dir=')) { rOpts.projectDir = a.slice(14); }
4147
+ else if (a === '--help' || a === '-h') {
4148
+ log('Usage: gsd-t report tokens [--date YYYY-MM-DD] [--out PATH]');
4149
+ log(' --date YYYY-MM-DD Date stamp in output filename (default: today UTC)');
4150
+ log(' --out PATH Output path (default: .gsd-t/reports/token-usage-{DATE}.md)');
4151
+ process.exit(0);
4152
+ }
4153
+ else {
4154
+ error(`report tokens: unknown arg: ${a}`);
4155
+ process.exit(2);
4156
+ }
4157
+ }
4158
+ try {
4159
+ const rep = require(path.join(__dirname, 'gsd-t-report-tokens.cjs'));
4160
+ const res = rep.generateReport(rOpts);
4161
+ log(`Wrote ${res.path}`);
4162
+ const s = res.summary;
4163
+ log(` ${s.cws} CW(s) · ${s.turns} turn rows · ${s.compactions} compactions · ${s.sessions} session(s)`);
4164
+ log(` ${s.compactionEndedCWs}/${s.cws} CW${s.cws === 1 ? '' : 's'} ended by compaction · top tool: ${s.topTool || '—'}`);
4165
+ process.exit(0);
4166
+ } catch (e) {
4167
+ error(e.message || String(e));
4168
+ process.exit(3);
4169
+ }
4170
+ break;
4171
+ }
4022
4172
  case "scan": {
4023
4173
  const exportFlag = args.find(a => a.startsWith('--export='));
4024
4174
  const exportFormat = exportFlag ? exportFlag.split('=')[1] : null;
@@ -3,19 +3,26 @@
3
3
  /**
4
4
  * GSD-T Headless Auto-Spawn — Detached headless continuation
5
5
  *
6
- * When the runway estimator refuses a run (projected context ≥ stop band),
7
- * the caller invokes `autoSpawnHeadless()` to hand off to a detached child
8
- * process running `gsd-t headless {command} --log`. The interactive session
9
- * never blocks on the child (`child.unref()`), so the user retains their
10
- * terminal and can work on unrelated tasks. On child completion, a macOS
11
- * notification fires (T2). The interactive session surfaces the result via
12
- * a read-back banner on the next `gsd-t-resume` or `gsd-t-status` call (T4).
6
+ * Every GSD-T command spawn routes through `autoSpawnHeadless()` to a
7
+ * detached child process running `gsd-t headless {command} --log`. The
8
+ * interactive session never blocks on the child (`child.unref()`), so the
9
+ * user retains their terminal and can work on unrelated tasks. On child
10
+ * completion, a macOS notification fires (T2). The interactive session
11
+ * surfaces the result via a read-back banner on the next `gsd-t-resume`
12
+ * or `gsd-t-status` call (T4).
13
13
  *
14
14
  * Zero external dependencies (Node.js built-ins only).
15
15
  *
16
- * Contract: .gsd-t/contracts/headless-auto-spawn-contract.md v1.0.0
17
- * Consumers: commands/gsd-t-execute|wave|integrate|quick|debug.md (via runway
18
- * estimator handoff), bin/runway-estimator.js (conceptual target).
16
+ * Contract: .gsd-t/contracts/headless-default-contract.md v2.0.0
17
+ * - v2.0.0 (M43 D4): channel-separation invariant. Every command spawns.
18
+ * No opt-out flag, no context-meter threshold gating, no `--in-session`
19
+ * escape hatch. `shouldSpawnHeadless` is a constant `() => true`. The
20
+ * `watch` parameter is accepted for caller backward-compat for one
21
+ * version but ignored (deprecation warning emitted once per process).
22
+ * Consumers: every command file that spawns subagents (execute, wave, quick,
23
+ * integrate, debug, scan, verify, complete-milestone, test-sync,
24
+ * scan, gap-analysis, populate, feature, project, partition);
25
+ * the `/gsd` router (Step 2 action-turn handoff).
19
26
  */
20
27
 
21
28
  const fs = require("fs");
@@ -43,8 +50,17 @@ module.exports = {
43
50
  writeSessionFile,
44
51
  writeContinueHereFile,
45
52
  markSessionCompleted,
53
+ // M43 D4 — channel-separation invariant. The helper is retained for
54
+ // backward-compat with any caller that imported it from a v1.x consumer;
55
+ // it now unconditionally returns true. See headless-default-contract
56
+ // v2.0.0 §Invariants.
57
+ shouldSpawnHeadless: () => true,
46
58
  };
47
59
 
60
+ // M43 D4 — one-shot deprecation banner when a caller still passes `watch`
61
+ // (or the never-shipped `inSession`). Module-level flag avoids log spam.
62
+ let _deprecatedWatchWarned = false;
63
+
48
64
  // ── autoSpawnHeadless ────────────────────────────────────────────────────────
49
65
 
50
66
  /**
@@ -67,7 +83,23 @@ function autoSpawnHeadless(opts) {
67
83
  const continue_from = opts.continue_from || ".";
68
84
  const projectDir = opts.projectDir || process.cwd();
69
85
  const context = opts.context || opts.sessionContext || null;
70
- const watch = opts.watch === true;
86
+ // M43 D4 — `watch` is accepted for caller backward-compat but IGNORED.
87
+ // `inSession` was never shipped; accept+ignore for the same reason.
88
+ // Under headless-default-contract v2.0.0 every spawn goes headless; the
89
+ // only in-session surface is the `/gsd` router dialog channel, which is
90
+ // upstream of this function. One-shot deprecation warning on stderr.
91
+ const legacyWatch = opts.watch === true;
92
+ const legacyInSession = opts.inSession === true;
93
+ if ((legacyWatch || legacyInSession) && !_deprecatedWatchWarned) {
94
+ _deprecatedWatchWarned = true;
95
+ try {
96
+ process.stderr.write(
97
+ "[headless-default] `watch`/`inSession` flag is deprecated under headless-default-contract v2.0.0 — every spawn is headless; caller hint ignored.\n",
98
+ );
99
+ } catch (_) {
100
+ /* best-effort */
101
+ }
102
+ }
71
103
  const spawnType = opts.spawnType || "primary";
72
104
 
73
105
  if (!command || typeof command !== "string") {
@@ -79,44 +111,6 @@ function autoSpawnHeadless(opts) {
79
111
  );
80
112
  }
81
113
 
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
- // M39 D2 — append watch-progress tree below banner (best-effort).
88
- // Banner here is the in-context-fallback signal printed by the caller;
89
- // we don't own it, so we only render the tree to stdout. Never throws.
90
- try {
91
- const wp = require("./watch-progress.js");
92
- const stateDir = path.join(projectDir, ".gsd-t", ".watch-state");
93
- const tree = wp.buildTree(stateDir);
94
- const rendered = wp.renderTree(tree, { currentAgent: null });
95
- if (rendered) {
96
- // eslint-disable-next-line no-console
97
- console.log(rendered);
98
- }
99
- } catch (_) {
100
- /* watch-progress is best-effort; never crash the watch */
101
- }
102
- return {
103
- id: null,
104
- pid: null,
105
- logPath: null,
106
- timestamp: new Date().toISOString(),
107
- mode: "in-context",
108
- };
109
- }
110
- if (watch && spawnType === "validation") {
111
- try {
112
- process.stderr.write(
113
- `[headless-default] --watch ignored for validation spawn type: ${spawnType}\n`,
114
- );
115
- } catch (_) {
116
- /* best effort */
117
- }
118
- }
119
-
120
114
  const timestamp = new Date().toISOString();
121
115
  const id = makeSessionId(command, new Date());
122
116
  const logPath = path.join(projectDir, LOG_DIR_REL, `headless-${id}.log`);
@@ -124,6 +118,32 @@ function autoSpawnHeadless(opts) {
124
118
  ensureDir(path.join(projectDir, LOG_DIR_REL));
125
119
  ensureDir(path.join(projectDir, SESSIONS_DIR_REL));
126
120
 
121
+ // M43 D6-T4 — Ensure dashboard is running (idempotent; no-op if already up).
122
+ // Must happen BEFORE the URL banner print (D6-T3) so the link is live.
123
+ // Never throws — autostart is best-effort.
124
+ let autostartInfo = null;
125
+ try {
126
+ const { ensureDashboardRunning } = require("../scripts/gsd-t-dashboard-autostart.cjs");
127
+ autostartInfo = ensureDashboardRunning({ projectDir });
128
+ } catch (_) {
129
+ /* best-effort; fall through without banner port info */
130
+ }
131
+
132
+ // M43 D6-T3 — Live transcript URL banner. Printed for every spawn so the
133
+ // viewer at :PORT is "the" primary watching surface. Never throws.
134
+ // Text is coordinated with D4 — exact line shape is part of
135
+ // dashboard-server-contract.md §Banner Format.
136
+ try {
137
+ let port = autostartInfo && autostartInfo.port;
138
+ if (!port) {
139
+ const { projectScopedDefaultPort } = require("../scripts/gsd-t-dashboard-server.js");
140
+ port = projectScopedDefaultPort(projectDir);
141
+ }
142
+ process.stdout.write(`▶ Live transcript: http://127.0.0.1:${port}/transcript/${id}\n`);
143
+ } catch (_) {
144
+ /* best-effort — never crash the spawn on banner failure */
145
+ }
146
+
127
147
  // Handoff-lock gate (m36 gap-fix T2). Only engaged when the caller
128
148
  // supplies a `sessionId` — existing callers that do not pass one keep
129
149
  // the pre-m36 behavior unchanged. When engaged, the lock is held
@@ -43,8 +43,13 @@ module.exports = {
43
43
  writeSessionFile,
44
44
  writeContinueHereFile,
45
45
  markSessionCompleted,
46
+ // M43 D4 — channel-separation invariant; see .cjs twin.
47
+ shouldSpawnHeadless: () => true,
46
48
  };
47
49
 
50
+ // M43 D4 — one-shot deprecation banner (legacy `.js` copy mirrors `.cjs`).
51
+ let _deprecatedWatchWarned = false;
52
+
48
53
  // ── autoSpawnHeadless ────────────────────────────────────────────────────────
49
54
 
50
55
  /**
@@ -67,7 +72,19 @@ function autoSpawnHeadless(opts) {
67
72
  const continue_from = opts.continue_from || ".";
68
73
  const projectDir = opts.projectDir || process.cwd();
69
74
  const context = opts.context || opts.sessionContext || null;
70
- const watch = opts.watch === true;
75
+ // M43 D4 — `watch`/`inSession` ignored under v2.0.0 channel-separation.
76
+ const legacyWatch = opts.watch === true;
77
+ const legacyInSession = opts.inSession === true;
78
+ if ((legacyWatch || legacyInSession) && !_deprecatedWatchWarned) {
79
+ _deprecatedWatchWarned = true;
80
+ try {
81
+ process.stderr.write(
82
+ "[headless-default] `watch`/`inSession` flag is deprecated under headless-default-contract v2.0.0 — every spawn is headless; caller hint ignored.\n",
83
+ );
84
+ } catch (_) {
85
+ /* best-effort */
86
+ }
87
+ }
71
88
  const spawnType = opts.spawnType || "primary";
72
89
 
73
90
  if (!command || typeof command !== "string") {
@@ -79,29 +96,6 @@ function autoSpawnHeadless(opts) {
79
96
  );
80
97
  }
81
98
 
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
- }
104
-
105
99
  const timestamp = new Date().toISOString();
106
100
  const id = makeSessionId(command, new Date());
107
101
  const logPath = path.join(projectDir, LOG_DIR_REL, `headless-${id}.log`);
@@ -0,0 +1,212 @@
1
+ 'use strict';
2
+ /**
3
+ * GSD-T Runway Estimator (M43 D5 — dialog-channel growth meter)
4
+ *
5
+ * Under M43's channel-separation model, the only thing that runs in the
6
+ * in-session channel is the `/gsd` router dialog. Everything else spawns.
7
+ * This module reads the per-turn usage rows the M43 D1 capture writes to
8
+ * `.gsd-t/metrics/token-usage.jsonl` (schema v2) and surfaces a one-line
9
+ * "~N turns to `/compact`" warning to the router.
10
+ *
11
+ * Scope (revised 2026-04-21 per M43 partition.md §D5):
12
+ * - Read-only. Never refuses, never reroutes. Under always-headless there
13
+ * is nothing to reroute *to*.
14
+ * - Median-of-deltas growth slope (outlier-resistant; a single spike turn
15
+ * does not flip `shouldWarn`).
16
+ * - Zero external deps. `.cjs` so it loads in both ESM-default projects
17
+ * and CJS projects without transpilation.
18
+ *
19
+ * Contracts:
20
+ * - .gsd-t/contracts/context-meter-contract.md §Dialog Growth Meter
21
+ * - .gsd-t/contracts/metrics-schema-contract.md (schema v2)
22
+ *
23
+ * Consumers:
24
+ * - commands/gsd.md (router warning footer)
25
+ */
26
+
27
+ const fs = require('fs');
28
+ const path = require('path');
29
+
30
+ const DEFAULT_K = 5;
31
+ const DEFAULT_MODEL_CONTEXT_CAP = 200000;
32
+ // Claude Code starts auto-compacting ~8% before the model window fills, so the
33
+ // effective dialog ceiling is 0.92 × modelContextCap.
34
+ const PRE_COMPACT_HEADROOM = 0.92;
35
+ const DEFAULT_WARN_THRESHOLD_TURNS = 5;
36
+ const MIN_HISTORY = 3;
37
+
38
+ // ── Row loading ──────────────────────────────────────────────────────
39
+
40
+ function _safeParse(line) {
41
+ const s = String(line || '').trim();
42
+ if (!s || s[0] !== '{') return null;
43
+ try { return JSON.parse(s); } catch (_) { return null; }
44
+ }
45
+
46
+ /**
47
+ * Load in-session rows for the given session from the canonical sink.
48
+ *
49
+ * @param {string} projectDir
50
+ * @param {string} sessionId
51
+ * @returns {object[]} schema-v2 rows, unsorted
52
+ */
53
+ function _loadInSessionRows(projectDir, sessionId) {
54
+ const p = path.join(projectDir, '.gsd-t', 'metrics', 'token-usage.jsonl');
55
+ if (!fs.existsSync(p)) return [];
56
+ let text;
57
+ try { text = fs.readFileSync(p, 'utf8'); } catch (_) { return []; }
58
+ const rows = [];
59
+ for (const line of text.split(/\r?\n/)) {
60
+ const j = _safeParse(line);
61
+ if (!j) continue;
62
+ if (j.sessionType !== 'in-session') continue;
63
+ if (sessionId != null && j.session_id !== sessionId) continue;
64
+ rows.push(j);
65
+ }
66
+ return rows;
67
+ }
68
+
69
+ // ── Math helpers ─────────────────────────────────────────────────────
70
+
71
+ function _median(arr) {
72
+ if (!arr.length) return 0;
73
+ const sorted = arr.slice().sort((a, b) => a - b);
74
+ const mid = Math.floor(sorted.length / 2);
75
+ return sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2;
76
+ }
77
+
78
+ /**
79
+ * Deterministic ordering for per-turn rows. Prefers timestamp (`ts`) when
80
+ * present (monotonic per session), falls back to `turn_id` string compare.
81
+ */
82
+ function _sortTurns(rows) {
83
+ return rows.slice().sort((a, b) => {
84
+ const ta = a.ts || '';
85
+ const tb = b.ts || '';
86
+ if (ta !== tb) return ta < tb ? -1 : 1;
87
+ const ia = String(a.turn_id || '');
88
+ const ib = String(b.turn_id || '');
89
+ if (ia !== ib) return ia < ib ? -1 : 1;
90
+ return 0;
91
+ });
92
+ }
93
+
94
+ /**
95
+ * Compute the dialog-growth signal for one session.
96
+ *
97
+ * Reads the last K in-session turns for `sessionId` from
98
+ * `.gsd-t/metrics/token-usage.jsonl`, computes the median of turn-over-turn
99
+ * `input_tokens` deltas (robust to single-turn spikes), then predicts how
100
+ * many turns remain before dialog input crosses the pre-auto-compact ceiling
101
+ * (`modelContextCap × 0.92`).
102
+ *
103
+ * Returns `{ shouldWarn: false, reason: 'insufficient_history' }` when fewer
104
+ * than `MIN_HISTORY` in-session turns exist for the session.
105
+ *
106
+ * @param {object} opts
107
+ * @param {string} opts.projectDir
108
+ * @param {string} opts.sessionId required
109
+ * @param {number} [opts.k] default 5 (last K turns)
110
+ * @param {number} [opts.modelContextCap] default 200000
111
+ * @param {number} [opts.warnThresholdTurns] default 5
112
+ * @returns {{
113
+ * shouldWarn: boolean,
114
+ * slope: number,
115
+ * median_delta: number,
116
+ * latest_input_tokens: number,
117
+ * predicted_turns_to_compact: number,
118
+ * k: number,
119
+ * history_len: number,
120
+ * reason?: string
121
+ * }}
122
+ */
123
+ function estimateDialogGrowth(opts) {
124
+ const projectDir = (opts && opts.projectDir) || '.';
125
+ const sessionId = opts && opts.sessionId;
126
+ const k = (opts && Number.isFinite(opts.k) && opts.k > 0) ? Math.floor(opts.k) : DEFAULT_K;
127
+ const cap = (opts && Number.isFinite(opts.modelContextCap) && opts.modelContextCap > 0)
128
+ ? opts.modelContextCap
129
+ : DEFAULT_MODEL_CONTEXT_CAP;
130
+ const warnThreshold = (opts && Number.isFinite(opts.warnThresholdTurns) && opts.warnThresholdTurns > 0)
131
+ ? opts.warnThresholdTurns
132
+ : DEFAULT_WARN_THRESHOLD_TURNS;
133
+
134
+ const empty = {
135
+ shouldWarn: false,
136
+ slope: 0,
137
+ median_delta: 0,
138
+ latest_input_tokens: 0,
139
+ predicted_turns_to_compact: Infinity,
140
+ k,
141
+ history_len: 0,
142
+ };
143
+
144
+ if (!sessionId) {
145
+ return { ...empty, reason: 'missing_session_id' };
146
+ }
147
+
148
+ const allRows = _loadInSessionRows(projectDir, sessionId);
149
+ if (allRows.length === 0) {
150
+ return { ...empty, reason: 'no_rows' };
151
+ }
152
+
153
+ const sorted = _sortTurns(allRows);
154
+ const window = sorted.slice(-k);
155
+
156
+ if (window.length < MIN_HISTORY) {
157
+ return { ...empty, history_len: window.length, reason: 'insufficient_history' };
158
+ }
159
+
160
+ // Per-turn input token footprint. Schema v2 writes `inputTokens` (camel)
161
+ // via the token-capture wrapper; older rows may carry the raw envelope.
162
+ const inputs = window.map(r => {
163
+ if (Number.isFinite(r.inputTokens)) return r.inputTokens;
164
+ if (r.usage && Number.isFinite(r.usage.input_tokens)) return r.usage.input_tokens;
165
+ return 0;
166
+ });
167
+
168
+ const deltas = [];
169
+ for (let i = 1; i < inputs.length; i++) {
170
+ deltas.push(inputs[i] - inputs[i - 1]);
171
+ }
172
+
173
+ const median_delta = _median(deltas);
174
+ const slope = median_delta;
175
+ const latest_input_tokens = inputs[inputs.length - 1];
176
+
177
+ const ceiling = cap * PRE_COMPACT_HEADROOM;
178
+ let predicted_turns_to_compact;
179
+ if (slope > 0) {
180
+ const headroom = ceiling - latest_input_tokens;
181
+ predicted_turns_to_compact = headroom <= 0 ? 0 : Math.ceil(headroom / slope);
182
+ } else {
183
+ predicted_turns_to_compact = Infinity;
184
+ }
185
+
186
+ const shouldWarn = Number.isFinite(predicted_turns_to_compact) && predicted_turns_to_compact <= warnThreshold;
187
+
188
+ return {
189
+ shouldWarn,
190
+ slope,
191
+ median_delta,
192
+ latest_input_tokens,
193
+ predicted_turns_to_compact,
194
+ k,
195
+ history_len: window.length,
196
+ };
197
+ }
198
+
199
+ module.exports = {
200
+ estimateDialogGrowth,
201
+ // Exposed for unit tests; not part of the stable contract.
202
+ _internal: {
203
+ _median,
204
+ _sortTurns,
205
+ _loadInSessionRows,
206
+ DEFAULT_K,
207
+ DEFAULT_MODEL_CONTEXT_CAP,
208
+ PRE_COMPACT_HEADROOM,
209
+ DEFAULT_WARN_THRESHOLD_TURNS,
210
+ MIN_HISTORY,
211
+ },
212
+ };