@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.
- package/CHANGELOG.md +74 -0
- package/README.md +4 -10
- package/bin/event-stream.cjs +205 -0
- package/bin/gsd-t-unattended.cjs +87 -1
- package/bin/gsd-t-unattended.js +87 -1
- package/bin/gsd-t.js +15 -117
- package/bin/headless-auto-spawn.cjs +44 -4
- package/bin/headless-auto-spawn.js +44 -4
- package/bin/scan-data-collector.js +39 -11
- package/bin/token-budget.cjs +43 -126
- package/bin/unattended-watch-format.cjs +154 -0
- package/commands/gsd-t-backlog-list.md +0 -38
- package/commands/gsd-t-complete-milestone.md +2 -30
- package/commands/gsd-t-debug.md +18 -122
- package/commands/gsd-t-doc-ripple.md +0 -21
- package/commands/gsd-t-execute.md +58 -117
- package/commands/gsd-t-help.md +3 -59
- package/commands/gsd-t-integrate.md +18 -78
- package/commands/gsd-t-quick.md +22 -80
- package/commands/gsd-t-resume.md +2 -2
- package/commands/gsd-t-scan.md +15 -1
- package/commands/gsd-t-status.md +2 -32
- package/commands/gsd-t-unattended-watch.md +37 -1
- package/commands/gsd-t-verify.md +14 -2
- package/commands/gsd-t-wave.md +22 -91
- package/commands/gsd.md +43 -4
- package/docs/GSD-T-README.md +2 -6
- package/docs/architecture.md +10 -8
- package/docs/infrastructure.md +8 -14
- package/docs/methodology.md +10 -4
- package/docs/prd-harness-evolution.md +1 -1
- package/docs/requirements.md +28 -12
- package/package.json +2 -2
- package/scripts/context-meter/threshold.js +25 -46
- package/scripts/context-meter/threshold.test.js +52 -80
- package/scripts/gsd-t-agent-dashboard-server.js +4 -4
- package/scripts/gsd-t-agent-dashboard.html +699 -380
- package/scripts/gsd-t-context-meter.e2e.test.js +4 -3
- package/scripts/gsd-t-context-meter.js +1 -1
- package/scripts/gsd-t-context-meter.test.js +58 -50
- package/templates/CLAUDE-global.md +7 -25
- package/templates/CLAUDE-project.md +22 -23
- package/bin/qa-calibrator.js +0 -194
- package/bin/runway-estimator.cjs +0 -242
- package/bin/runway-estimator.js +0 -242
- package/bin/token-optimizer.cjs +0 -471
- package/bin/token-optimizer.js +0 -471
- package/bin/token-telemetry.cjs +0 -246
- package/bin/token-telemetry.js +0 -246
- package/commands/gsd-t-audit.md +0 -196
- package/commands/gsd-t-brainstorm.md +0 -201
- package/commands/gsd-t-discuss.md +0 -178
- package/commands/gsd-t-optimization-apply.md +0 -91
- package/commands/gsd-t-optimization-reject.md +0 -94
- package/commands/gsd-t-prompt.md +0 -137
- package/commands/gsd-t-reflect.md +0 -130
- package/scripts/context-meter/count-tokens-client.js +0 -221
- package/scripts/context-meter/count-tokens-client.test.js +0 -308
- 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
|
|
437
|
-
//
|
|
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",
|
|
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 (
|
|
3245
|
+
// ─── Metrics (removed in v3.12 — M38 meter reduction) ─────────────────────────
|
|
3249
3246
|
|
|
3250
|
-
function
|
|
3251
|
-
|
|
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 {
|
|
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 {
|
|
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 (
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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) {
|
package/bin/token-budget.cjs
CHANGED
|
@@ -1,22 +1,25 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* GSD-T Token Budget — Session-level token tracking (
|
|
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
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
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.
|
|
12
|
-
* -
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
* - `getDegradationActions
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
11
|
+
* v3.12.0 (M38 — meter 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.
|
|
44
|
-
|
|
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:
|
|
93
|
+
* @returns {{ consumed: number, estimated_remaining: number, pct: number, threshold: 'normal'|'threshold' }}
|
|
100
94
|
*
|
|
101
|
-
*
|
|
102
|
-
* Context Meter PostToolUse hook. When
|
|
103
|
-
*
|
|
104
|
-
* fall back to a historical heuristic from `.gsd-t/token-log.md
|
|
105
|
-
*
|
|
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 =
|
|
112
|
+
const threshold = bandFor(pct, thresholdPct);
|
|
116
113
|
return { consumed, estimated_remaining, pct, threshold };
|
|
117
114
|
}
|
|
118
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
|
161
|
-
|
|
162
|
-
|
|
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
|
-
|
|
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 =
|
|
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:
|
|
192
|
+
// ── Internal: single-band resolution ─────────────────────────────────────────
|
|
243
193
|
|
|
244
|
-
function
|
|
194
|
+
function bandFor(pct, thresholdPct) {
|
|
245
195
|
if (!Number.isFinite(pct)) return "normal";
|
|
246
|
-
|
|
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 ───────────────────────────────────────────────
|