@tekyzinc/gsd-t 3.11.11 → 3.12.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 (59) hide show
  1. package/CHANGELOG.md +74 -0
  2. package/README.md +4 -10
  3. package/bin/event-stream.cjs +205 -0
  4. package/bin/gsd-t-unattended.cjs +87 -1
  5. package/bin/gsd-t-unattended.js +87 -1
  6. package/bin/gsd-t.js +15 -117
  7. package/bin/headless-auto-spawn.cjs +44 -4
  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/templates/CLAUDE-global.md +7 -25
  42. package/templates/CLAUDE-project.md +22 -23
  43. package/bin/qa-calibrator.js +0 -194
  44. package/bin/runway-estimator.cjs +0 -242
  45. package/bin/runway-estimator.js +0 -242
  46. package/bin/token-optimizer.cjs +0 -471
  47. package/bin/token-optimizer.js +0 -471
  48. package/bin/token-telemetry.cjs +0 -246
  49. package/bin/token-telemetry.js +0 -246
  50. package/commands/gsd-t-audit.md +0 -196
  51. package/commands/gsd-t-brainstorm.md +0 -201
  52. package/commands/gsd-t-discuss.md +0 -178
  53. package/commands/gsd-t-optimization-apply.md +0 -91
  54. package/commands/gsd-t-optimization-reject.md +0 -94
  55. package/commands/gsd-t-prompt.md +0 -137
  56. package/commands/gsd-t-reflect.md +0 -130
  57. package/scripts/context-meter/count-tokens-client.js +0 -221
  58. package/scripts/context-meter/count-tokens-client.test.js +0 -308
  59. package/scripts/context-meter/test-injector.js +0 -55
package/bin/gsd-t.js CHANGED
@@ -433,8 +433,8 @@ function ensureGitignoreEntries(projectDir, entries) {
433
433
 
434
434
  // Install the Context Meter into a project directory.
435
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.
436
+ // only — skips .test.js), and the config template (if missing). Also appends
437
+ // entries to .gitignore.
438
438
  function installContextMeter(projectDir) {
439
439
  try {
440
440
  // 1. Copy gsd-t-context-meter.js → {projectDir}/scripts/
@@ -486,9 +486,7 @@ function installContextMeter(projectDir) {
486
486
  return false;
487
487
  }
488
488
  for (const fname of depFiles) {
489
- // Skip test files and test-only infrastructure
490
489
  if (fname.includes(".test.")) continue;
491
- if (fname === "test-injector.js") continue;
492
490
  const fsrc = path.join(depsSrcDir, fname);
493
491
  const fdest = path.join(depsDestDir, fname);
494
492
  try {
@@ -1970,8 +1968,7 @@ const PROJECT_BIN_TOOLS = [
1970
1968
  "archive-progress.cjs", "log-tail.cjs", "context-budget-audit.cjs",
1971
1969
  "context-meter-config.cjs", "token-budget.cjs",
1972
1970
  "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",
1971
+ "handoff-lock.cjs", "headless-auto-spawn.cjs",
1975
1972
  ];
1976
1973
 
1977
1974
  function copyBinToolsToProject(projectDir, projectName) {
@@ -2721,7 +2718,7 @@ function doHeadlessExec(command, cmdArgs, flags) {
2721
2718
  let processExitCode = 0;
2722
2719
 
2723
2720
  try {
2724
- const result = execFileSync("claude", ["-p", prompt], {
2721
+ const result = execFileSync("claude", ["-p", "--dangerously-skip-permissions", prompt], {
2725
2722
  encoding: "utf8",
2726
2723
  timeout: timeoutMs,
2727
2724
  stdio: ["pipe", "pipe", "pipe"],
@@ -3245,117 +3242,10 @@ function showHeadlessHelp() {
3245
3242
  log(` ${DIM}$${RESET} gsd-t headless query domains\n`);
3246
3243
  }
3247
3244
 
3248
- // ─── Metrics (M35 token telemetry CLI) ────────────────────────────────────────
3245
+ // ─── Metrics (removed in v3.12 — M38 meter reduction) ─────────────────────────
3249
3246
 
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
- }
3262
-
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
- }
3247
+ function doMetrics(_args) {
3248
+ log(`${DIM}metrics removed in v3.12 context meter is no longer telemetry-instrumented${RESET}`);
3359
3249
  }
3360
3250
 
3361
3251
  function showHelp() {
@@ -3515,6 +3405,14 @@ if (require.main === module) {
3515
3405
  case "headless":
3516
3406
  doHeadless(args.slice(1));
3517
3407
  break;
3408
+ case "unattended": {
3409
+ const { spawnSync } = require("child_process");
3410
+ const cjs = path.join(__dirname, "gsd-t-unattended.cjs");
3411
+ const res = spawnSync(process.execPath, [cjs, ...args.slice(1)], {
3412
+ stdio: "inherit",
3413
+ });
3414
+ process.exit(res.status == null ? 1 : res.status);
3415
+ }
3518
3416
  case "metrics":
3519
3417
  doMetrics(args.slice(1));
3520
3418
  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());
@@ -145,7 +179,13 @@ function autoSpawnHeadless(opts) {
145
179
  // a detached approach that survives even after the parent's `unref()`.
146
180
  installCompletionWatcher({ projectDir, id, logPath, pid, startTimestamp: timestamp });
147
181
 
148
- return { id, pid, logPath: path.relative(projectDir, logPath), timestamp };
182
+ return {
183
+ id,
184
+ pid,
185
+ logPath: path.relative(projectDir, logPath),
186
+ timestamp,
187
+ mode: "headless",
188
+ };
149
189
  }
150
190
 
151
191
  // ── makeSessionId ────────────────────────────────────────────────────────────
@@ -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());
@@ -145,7 +179,13 @@ function autoSpawnHeadless(opts) {
145
179
  // a detached approach that survives even after the parent's `unref()`.
146
180
  installCompletionWatcher({ projectDir, id, logPath, pid, startTimestamp: timestamp });
147
181
 
148
- return { id, pid, logPath: path.relative(projectDir, logPath), timestamp };
182
+ return {
183
+ id,
184
+ pid,
185
+ logPath: path.relative(projectDir, logPath),
186
+ timestamp,
187
+ mode: "headless",
188
+ };
149
189
  }
150
190
 
151
191
  // ── makeSessionId ────────────────────────────────────────────────────────────
@@ -25,22 +25,50 @@ function parseTestCoverage(text) {
25
25
  function parseFilesAndLoc(text) {
26
26
  const m = text.match(/\|\s*\*?\*?(?:Grand\s+)?Total[^|]*\*?\*?\s*\|\s*\*?\*?(\d+)\s+files?\*?\*?\s*\|\s*\*?\*?([\d,]+)[^|]*\*?\*?\s*\|/i);
27
27
  if (m) return { filesScanned: parseInt(m[1], 10), totalLoc: parseInt(m[2].replace(/,/g, ''), 10) };
28
+ let files = 0;
29
+ let loc = 0;
30
+ const lineRe = /(\d+)\s+files?\s*\(\s*~?\s*([\d,]+)\s+LOC\s*\)/gi;
31
+ let match;
32
+ while ((match = lineRe.exec(text)) !== null) {
33
+ files += parseInt(match[1], 10);
34
+ loc += parseInt(match[2].replace(/,/g, ''), 10);
35
+ }
36
+ if (files > 0) return { filesScanned: files, totalLoc: loc };
28
37
  return { filesScanned: 0, totalLoc: 0 };
29
38
  }
30
39
 
31
40
  function parseComponents(text) {
32
41
  const sec = text.match(/## Component Inventory([\s\S]*?)(?=\n## |\n---|\n#[^#]|$)/);
33
- if (!sec) return [];
34
- return sec[1].split('\n')
35
- .filter(l => /^\|/.test(l) && !/---/.test(l) && !/Component.*File/i.test(l))
36
- .map(row => {
37
- const cols = row.split('|').map(c => c.trim().replace(/\*\*/g, '').replace(/`/g, '')).filter(Boolean);
38
- if (cols.length < 3) return null;
39
- const name = cols[0];
40
- if (!name || /^total/i.test(name)) return null;
41
- return { name, filePath: cols[1] || '', size: cols[2] || '', purpose: cols[3] || '', files: 1, healthScore: 80 };
42
- })
43
- .filter(Boolean);
42
+ if (sec) {
43
+ const tableRows = sec[1].split('\n')
44
+ .filter(l => /^\|/.test(l) && !/---/.test(l) && !/Component.*File/i.test(l))
45
+ .map(row => {
46
+ const cols = row.split('|').map(c => c.trim().replace(/\*\*/g, '').replace(/`/g, '')).filter(Boolean);
47
+ if (cols.length < 3) return null;
48
+ const name = cols[0];
49
+ if (!name || /^total/i.test(name)) return null;
50
+ return { name, filePath: cols[1] || '', size: cols[2] || '', purpose: cols[3] || '', files: 1, healthScore: 80 };
51
+ })
52
+ .filter(Boolean);
53
+ if (tableRows.length > 0) return tableRows;
54
+ }
55
+ const structSec = text.match(/## Structure([\s\S]*?)(?=\n## |\n---|\n#[^#]|$)/);
56
+ if (!structSec) return [];
57
+ const entryRe = /^([a-zA-Z0-9_.\-]+\/)\s+(?:~?\s*)?(?:(\d+)\s+files?\s*)?\(?\s*~?\s*([\d,]+)\s+LOC\s*\)?\s*(.*)$/gm;
58
+ const out = [];
59
+ let m;
60
+ while ((m = entryRe.exec(structSec[1])) !== null) {
61
+ const name = m[1].replace(/\/$/, '');
62
+ out.push({
63
+ name,
64
+ filePath: m[1],
65
+ size: (m[2] ? m[2] + ' files, ' : '') + m[3] + ' LOC',
66
+ purpose: (m[4] || '').trim(),
67
+ files: m[2] ? parseInt(m[2], 10) : 1,
68
+ healthScore: 80,
69
+ });
70
+ }
71
+ return out;
44
72
  }
45
73
 
46
74
  function parseSeverityMap(text) {
@@ -1,22 +1,25 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  /**
4
- * GSD-T Token Budget — Session-level token tracking (three-band model)
4
+ * GSD-T Token Budget — Session-level token tracking (single-band, v3.12)
5
5
  *
6
- * Reads .gsd-t/.context-meter-state.json (M34) for real context-window
7
- * readings, tracks session usage, and returns a three-band status signal
8
- * (normal / warn / stop) that callers use to decide whether to proceed,
9
- * log a warning, or halt cleanly.
6
+ * Reads .gsd-t/.context-meter-state.json (M34) for context-window readings
7
+ * and returns a single-band status signal (normal / threshold) that the
8
+ * orchestrator uses to decide whether the next subagent spawn must go
9
+ * through autoSpawnHeadless().
10
10
  *
11
- * v3.0.0 (M35clean break from v2.0.0):
12
- * - The `downgrade` and `conserve` bands were REMOVED. Silent model
13
- * degradation and silent phase-skipping are anti-features — they
14
- * violate GSD-T's "quality is non-negotiable" principle.
15
- * - `getDegradationActions()` now returns `{band, pct, message}` instead
16
- * of `{threshold, actions, modelOverrides}`. No `modelOverride`, no
17
- * `skipPhases`, no `checkpoint` side-channel.
18
- * - `warn` threshold tightened from 60% 70%. `stop` tightened from
19
- * 95% → 85% — keeps us clear of the runtime's native ~95% compact.
11
+ * v3.12.0 (M38meter reduction):
12
+ * - Collapsed three-band model (normal/warn/stop) to single-band
13
+ * (normal/threshold). The orchestrator makes the routing decision;
14
+ * the meter reports a band, not a degradation policy.
15
+ * - `getDegradationActions` export removed.
16
+ * - Dead-meter detection, `stale` band, and `deadReason` removed.
17
+ * Stale state transparently falls through to the heuristic — the
18
+ * fail-open hook never raised a user-visible alarm anyway, and the
19
+ * orchestrator's headless-by-default posture handles overflow
20
+ * structurally rather than by trying to instruct Claude mid-session.
21
+ * - Threshold default is `thresholdPct` from context-meter-config.json
22
+ * (default 75%). There is no intermediate warn band.
20
23
  *
21
24
  * Zero external dependencies (Node.js built-ins only).
22
25
  */
@@ -40,17 +43,8 @@ const BASE_ESTIMATES = {
40
43
  default: 6000,
41
44
  };
42
45
 
43
- // v3.0.0 three-band thresholds. Lower-bound inclusive.
44
- // pct < 70 normal
45
- // 70 ≤ pct < 85 → warn (informational — log, proceed)
46
- // pct ≥ 85 → stop (halt cleanly, hand off to runway estimator)
47
- const WARN_THRESHOLD_PCT = 70;
48
- const STOP_THRESHOLD_PCT = 85;
49
-
50
- const THRESHOLDS = {
51
- warn: WARN_THRESHOLD_PCT,
52
- stop: STOP_THRESHOLD_PCT,
53
- };
46
+ // v3.12 single-band default. Overridable via context-meter-config.json.
47
+ const DEFAULT_THRESHOLD_PCT = 75;
54
48
 
55
49
  // ── Exports ──────────────────────────────────────────────────────────────────
56
50
 
@@ -58,7 +52,6 @@ module.exports = {
58
52
  estimateCost,
59
53
  getSessionStatus,
60
54
  recordUsage,
61
- getDegradationActions,
62
55
  estimateMilestoneCost,
63
56
  getModelCostRatios,
64
57
  };
@@ -92,45 +85,34 @@ function estimateCost(model, taskType, options) {
92
85
  // ── getSessionStatus ─────────────────────────────────────────────────────────
93
86
 
94
87
  const STATE_FILE_REL = path.join(".gsd-t", ".context-meter-state.json");
88
+ const CONFIG_FILE_REL = path.join(".gsd-t", "context-meter-config.json");
95
89
  const STATE_STALE_MS = 5 * 60 * 1000;
96
90
 
97
91
  /**
98
92
  * @param {string} [projectDir]
99
- * @returns {{ consumed: number, estimated_remaining: number, pct: number, threshold: string }}
93
+ * @returns {{ consumed: number, estimated_remaining: number, pct: number, threshold: 'normal'|'threshold' }}
100
94
  *
101
- * v2.0.0 (M34): reads `.gsd-t/.context-meter-state.json` produced by the
102
- * Context Meter PostToolUse hook. When that file is fresh (timestamp within
103
- * the last 5 minutes), real `input_tokens` drive the response. Otherwise we
104
- * fall back to a historical heuristic from `.gsd-t/token-log.md`, preserving
105
- * graceful degradation for projects without the hook installed.
95
+ * v3.12 (M38): reads `.gsd-t/.context-meter-state.json` produced by the
96
+ * Context Meter PostToolUse hook. When the state file is fresh (timestamp
97
+ * within 5 minutes), real `input_tokens` drive the response. Otherwise we
98
+ * fall back to a historical heuristic from `.gsd-t/token-log.md`. Stale or
99
+ * missing state is not a distinct band — the fail-open hook never raised
100
+ * a user-visible alarm, and the orchestrator's headless-by-default spawn
101
+ * path handles overflow structurally.
106
102
  */
107
103
  function getSessionStatus(projectDir) {
108
104
  const dir = projectDir || process.cwd();
105
+ const thresholdPct = resolveThresholdPct(dir);
109
106
  const real = readContextMeterState(dir);
110
107
  if (real) {
111
108
  const consumed = real.inputTokens;
112
109
  const window = real.modelWindowSize > 0 ? real.modelWindowSize : 200000;
113
110
  const estimated_remaining = Math.max(0, window - consumed);
114
111
  const pct = Math.round(real.pct * 10) / 10;
115
- const threshold = resolveThreshold(pct);
112
+ const threshold = bandFor(pct, thresholdPct);
116
113
  return { consumed, estimated_remaining, pct, threshold };
117
114
  }
118
- // Meter exists but is dead (file present, API key missing, parse/API error,
119
- // or timestamp stale). Return a `stale` band so callers halt instead of
120
- // silently running with a blind gate. This is the fix for the M36 /compact
121
- // regression where checkCount=2102 but every hook call failed fail-open.
122
- const dead = readContextMeterDead(dir);
123
- if (dead) {
124
- const window = dead.modelWindowSize > 0 ? dead.modelWindowSize : 200000;
125
- return {
126
- consumed: 0,
127
- estimated_remaining: window,
128
- pct: 0,
129
- threshold: "stale",
130
- deadReason: dead.reason,
131
- };
132
- }
133
- return getSessionStatusHeuristic(dir);
115
+ return getSessionStatusHeuristic(dir, thresholdPct);
134
116
  }
135
117
 
136
118
  function readContextMeterState(dir) {
@@ -149,41 +131,25 @@ function readContextMeterState(dir) {
149
131
  }
150
132
  }
151
133
 
152
- // Return { reason, modelWindowSize } when the state file exists but the meter
153
- // is not producing a fresh, clean reading. Returns null when the file is
154
- // missing entirely (no meter installed — fall through to heuristic).
155
- function readContextMeterDead(dir) {
134
+ function resolveThresholdPct(dir) {
156
135
  try {
157
- const fp = path.join(dir, STATE_FILE_REL);
158
- if (!fs.existsSync(fp)) return null;
136
+ const fp = path.join(dir, CONFIG_FILE_REL);
159
137
  const raw = fs.readFileSync(fp, "utf8");
160
- const s = JSON.parse(raw);
161
- if (!s || typeof s !== "object") {
162
- return { reason: "state_file_corrupt", modelWindowSize: 0 };
163
- }
164
- const window = Number.isInteger(s.modelWindowSize) ? s.modelWindowSize : 0;
165
- if (s.lastError && typeof s.lastError === "object" && typeof s.lastError.code === "string") {
166
- return { reason: `meter_error:${s.lastError.code}`, modelWindowSize: window };
167
- }
168
- if (!s.timestamp) {
169
- return { reason: "meter_never_measured", modelWindowSize: window };
170
- }
171
- const age = Date.now() - Date.parse(s.timestamp);
172
- if (isNaN(age) || age < 0 || age > STATE_STALE_MS) {
173
- return { reason: "meter_state_stale", modelWindowSize: window };
174
- }
175
- return null;
138
+ const c = JSON.parse(raw);
139
+ const pct = Number(c.thresholdPct);
140
+ if (Number.isFinite(pct) && pct > 0 && pct < 100) return pct;
176
141
  } catch (_) {
177
- return { reason: "state_file_unreadable", modelWindowSize: 0 };
142
+ /* fall through */
178
143
  }
144
+ return DEFAULT_THRESHOLD_PCT;
179
145
  }
180
146
 
181
- function getSessionStatusHeuristic(dir) {
147
+ function getSessionStatusHeuristic(dir, thresholdPct) {
182
148
  const window = 200000;
183
149
  const consumed = readSessionConsumed(dir);
184
150
  const estimated_remaining = Math.max(0, window - consumed);
185
151
  const pct = window > 0 ? Math.round((consumed / window) * 100 * 10) / 10 : 0;
186
- const threshold = resolveThreshold(pct);
152
+ const threshold = bandFor(pct, thresholdPct);
187
153
  return { consumed, estimated_remaining, pct, threshold };
188
154
  }
189
155
 
@@ -205,22 +171,6 @@ function recordUsage(usage) {
205
171
  fs.appendFileSync(fp, line);
206
172
  }
207
173
 
208
- // ── getDegradationActions (v3.0.0 — three-band) ─────────────────────────────
209
-
210
- /**
211
- * v3.0.0 three-band response. The name is preserved for caller-identification
212
- * convenience; the return shape is a CLEAN BREAK from v2.0.0 — no
213
- * `modelOverrides`, no `actions` list, no `skipPhases`, no `checkpoint`
214
- * side-channel. Callers that relied on those fields MUST be updated.
215
- *
216
- * @param {string} [projectDir]
217
- * @returns {{ band: 'normal'|'warn'|'stop', pct: number, message: string }}
218
- */
219
- function getDegradationActions(projectDir) {
220
- const { threshold, pct } = getSessionStatus(projectDir);
221
- return buildBandResponse(threshold, pct);
222
- }
223
-
224
174
  // ── estimateMilestoneCost ─────────────────────────────────────────────────────
225
175
 
226
176
  /**
@@ -239,44 +189,11 @@ function estimateMilestoneCost(remainingTasks, projectDir) {
239
189
  return { estimatedTokens, estimatedPct, feasible };
240
190
  }
241
191
 
242
- // ── Internal: threshold resolution (v3.0.0 — three-band) ─────────────────────
192
+ // ── Internal: single-band resolution ─────────────────────────────────────────
243
193
 
244
- function resolveThreshold(pct) {
194
+ function bandFor(pct, thresholdPct) {
245
195
  if (!Number.isFinite(pct)) return "normal";
246
- if (pct >= THRESHOLDS.stop) return "stop";
247
- if (pct >= THRESHOLDS.warn) return "warn";
248
- return "normal";
249
- }
250
-
251
- function buildBandResponse(band, pct) {
252
- const safePct = Number.isFinite(pct) ? pct : 0;
253
- switch (band) {
254
- case "warn":
255
- return {
256
- band: "warn",
257
- pct: safePct,
258
- message: `Context ${safePct.toFixed(1)}% — warn band (≥${WARN_THRESHOLD_PCT}%). Informational only; proceed.`,
259
- };
260
- case "stop":
261
- return {
262
- band: "stop",
263
- pct: safePct,
264
- message: `Context ${safePct.toFixed(1)}% — stop band (≥${STOP_THRESHOLD_PCT}%). Halt cleanly; hand off to runway estimator / headless auto-spawn.`,
265
- };
266
- case "stale":
267
- return {
268
- band: "stale",
269
- pct: safePct,
270
- message: `Context meter is DEAD — no fresh measurements. Gate treats this as STOP. Run: gsd-t doctor. Fix the cause (usually missing ANTHROPIC_API_KEY) before running gated commands.`,
271
- };
272
- case "normal":
273
- default:
274
- return {
275
- band: "normal",
276
- pct: safePct,
277
- message: `Context ${safePct.toFixed(1)}% — normal band. Proceed.`,
278
- };
279
- }
196
+ return pct >= thresholdPct ? "threshold" : "normal";
280
197
  }
281
198
 
282
199
  // ── Internal: token-log parsing ───────────────────────────────────────────────