@tekyzinc/gsd-t 2.74.13 → 2.76.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/CHANGELOG.md +116 -0
  2. package/README.md +71 -1
  3. package/bin/advisor-integration.js +93 -0
  4. package/bin/check-headless-sessions.js +140 -0
  5. package/bin/context-meter-config.cjs +101 -0
  6. package/bin/context-meter-config.test.cjs +101 -0
  7. package/bin/gsd-t.js +709 -16
  8. package/bin/headless-auto-spawn.js +290 -0
  9. package/bin/model-selector.js +224 -0
  10. package/bin/runway-estimator.js +242 -0
  11. package/bin/token-budget.js +96 -89
  12. package/bin/token-optimizer.js +471 -0
  13. package/bin/token-telemetry.js +246 -0
  14. package/commands/gsd-t-audit.md +3 -3
  15. package/commands/gsd-t-backlog-list.md +38 -0
  16. package/commands/gsd-t-brainstorm.md +3 -3
  17. package/commands/gsd-t-complete-milestone.md +24 -0
  18. package/commands/gsd-t-debug.md +124 -7
  19. package/commands/gsd-t-discuss.md +10 -3
  20. package/commands/gsd-t-doc-ripple.md +32 -4
  21. package/commands/gsd-t-execute.md +107 -52
  22. package/commands/gsd-t-help.md +19 -0
  23. package/commands/gsd-t-integrate.md +67 -4
  24. package/commands/gsd-t-optimization-apply.md +91 -0
  25. package/commands/gsd-t-optimization-reject.md +94 -0
  26. package/commands/gsd-t-partition.md +7 -0
  27. package/commands/gsd-t-pause.md +3 -0
  28. package/commands/gsd-t-plan.md +10 -3
  29. package/commands/gsd-t-prd.md +3 -3
  30. package/commands/gsd-t-quick.md +71 -9
  31. package/commands/gsd-t-reflect.md +3 -7
  32. package/commands/gsd-t-resume.md +36 -0
  33. package/commands/gsd-t-status.md +31 -0
  34. package/commands/gsd-t-test-sync.md +7 -0
  35. package/commands/gsd-t-verify.md +12 -5
  36. package/commands/gsd-t-visualize.md +3 -7
  37. package/commands/gsd-t-wave.md +82 -18
  38. package/docs/GSD-T-README.md +52 -0
  39. package/docs/architecture.md +95 -0
  40. package/docs/infrastructure.md +117 -0
  41. package/docs/methodology.md +36 -0
  42. package/docs/prd-harness-evolution.md +51 -37
  43. package/docs/requirements.md +66 -0
  44. package/package.json +1 -1
  45. package/scripts/context-meter/count-tokens-client.js +221 -0
  46. package/scripts/context-meter/count-tokens-client.test.js +308 -0
  47. package/scripts/context-meter/test-injector.js +55 -0
  48. package/scripts/context-meter/threshold.js +88 -0
  49. package/scripts/context-meter/threshold.test.js +255 -0
  50. package/scripts/context-meter/transcript-parser.js +252 -0
  51. package/scripts/context-meter/transcript-parser.test.js +320 -0
  52. package/scripts/gsd-t-context-meter.e2e.test.js +415 -0
  53. package/scripts/gsd-t-context-meter.js +350 -0
  54. package/scripts/gsd-t-context-meter.test.js +417 -0
  55. package/scripts/gsd-t-heartbeat.js +2 -2
  56. package/scripts/gsd-t-statusline.js +23 -8
  57. package/templates/CLAUDE-global.md +5 -1
  58. package/templates/CLAUDE-project.md +26 -6
  59. package/templates/context-meter-config.json +10 -0
  60. package/templates/prompts/README.md +1 -1
  61. package/bin/task-counter.cjs +0 -161
@@ -0,0 +1,415 @@
1
+ /**
2
+ * gsd-t-context-meter.e2e.test.js — black-box E2E integration test for M34.
3
+ *
4
+ * TEST-ONLY FILE. Not shipped to users. Does not participate in production
5
+ * require graphs. Spawned as part of `node --test` only.
6
+ *
7
+ * Tasks 1–4 of the context-meter-hook domain unit-tested `runMeter()` via
8
+ * dependency injection. This test exercises the real child-process hook as
9
+ * Claude Code would invoke it:
10
+ *
11
+ * 1. A temporary project root is constructed under os.tmpdir() containing:
12
+ * - .gsd-t/context-meter-config.json (real config loader target)
13
+ * - transcript.jsonl (minimal Claude-Code-shaped transcript)
14
+ * 2. A local stub HTTP server mimics POST /v1/messages/count_tokens and
15
+ * returns a configurable `input_tokens` value.
16
+ * 3. `node scripts/gsd-t-context-meter.js` is spawned as a child process
17
+ * with cwd = tempdir, NODE_OPTIONS = --require <test-injector>, and
18
+ * GSD_T_CONTEXT_METER_TEST_BASE_URL pointing at the stub.
19
+ * 4. We write the PostToolUse JSON payload to the child's stdin, close
20
+ * stdin, collect stdout, and assert both the stdout shape and the
21
+ * on-disk state file.
22
+ *
23
+ * The test-injector.js file is the single unavoidable bit of test-only
24
+ * infrastructure: the production hook's CLI shim takes no base-URL override
25
+ * (by design — production must not be routable to a non-Anthropic host),
26
+ * so redirecting HTTP in a black-box test requires a --require-level
27
+ * monkey-patch inside the child process. See that file's comment block.
28
+ *
29
+ * Timing budget: each test < 2s, whole suite < 10s. Hard timeouts on every
30
+ * async wait prevent suite hangs on unclosed sockets or child processes.
31
+ *
32
+ * @module scripts/gsd-t-context-meter.e2e.test
33
+ */
34
+
35
+ "use strict";
36
+
37
+ const { test, beforeEach, afterEach } = require("node:test");
38
+ const assert = require("node:assert/strict");
39
+ const { spawn } = require("node:child_process");
40
+ const http = require("node:http");
41
+ const fs = require("node:fs");
42
+ const path = require("node:path");
43
+ const os = require("node:os");
44
+
45
+ const HOOK_SCRIPT = path.resolve(__dirname, "gsd-t-context-meter.js");
46
+ const INJECTOR = path.resolve(__dirname, "context-meter", "test-injector.js");
47
+ const HARD_TIMEOUT_MS = 6000;
48
+
49
+ /* ──────────────────────────── test fixtures ──────────────────────────── */
50
+
51
+ /**
52
+ * Sandbox state for a single test. Holds the tempdir, stub server, and a
53
+ * dispose() that guarantees everything is torn down — even on failure.
54
+ */
55
+ class Sandbox {
56
+ constructor() {
57
+ this.tempdir = null;
58
+ this.server = null;
59
+ this.serverUrl = null;
60
+ this.hitCount = 0;
61
+ this.childProcs = [];
62
+ }
63
+
64
+ async setup() {
65
+ this.tempdir = fs.mkdtempSync(path.join(os.tmpdir(), "gsd-t-cm-e2e-"));
66
+ fs.mkdirSync(path.join(this.tempdir, ".gsd-t"), { recursive: true });
67
+ }
68
+
69
+ writeConfig(config) {
70
+ const full = Object.assign(
71
+ {
72
+ version: 1,
73
+ thresholdPct: 75,
74
+ modelWindowSize: 200000,
75
+ checkFrequency: 1,
76
+ apiKeyEnvVar: "ANTHROPIC_API_KEY",
77
+ statePath: ".gsd-t/.context-meter-state.json",
78
+ logPath: ".gsd-t/context-meter.log",
79
+ timeoutMs: 2000,
80
+ },
81
+ config || {}
82
+ );
83
+ fs.writeFileSync(
84
+ path.join(this.tempdir, ".gsd-t", "context-meter-config.json"),
85
+ JSON.stringify(full, null, 2),
86
+ "utf8"
87
+ );
88
+ return full;
89
+ }
90
+
91
+ /**
92
+ * Write a minimal Claude-Code transcript JSONL containing one user turn and
93
+ * one assistant turn — enough for parseTranscript() to return a non-empty
94
+ * messages array.
95
+ */
96
+ writeTranscript(filename = "transcript.jsonl") {
97
+ const lines = [
98
+ JSON.stringify({
99
+ type: "user",
100
+ message: { role: "user", content: "hello world" },
101
+ uuid: "u1",
102
+ sessionId: "sess-1",
103
+ }),
104
+ JSON.stringify({
105
+ type: "assistant",
106
+ message: {
107
+ role: "assistant",
108
+ content: [{ type: "text", text: "hi there" }],
109
+ model: "claude-opus-4-6",
110
+ },
111
+ uuid: "a1",
112
+ sessionId: "sess-1",
113
+ }),
114
+ ];
115
+ const p = path.join(this.tempdir, filename);
116
+ fs.writeFileSync(p, lines.join("\n") + "\n", "utf8");
117
+ return p;
118
+ }
119
+
120
+ /**
121
+ * Optional: pre-seed the state file so we can test the checkFrequency skip
122
+ * path (where runMeter increments but does not call the API).
123
+ */
124
+ writeState(state) {
125
+ const full = Object.assign(
126
+ {
127
+ version: 1,
128
+ timestamp: null,
129
+ inputTokens: 0,
130
+ modelWindowSize: 0,
131
+ pct: 0,
132
+ threshold: "normal",
133
+ checkCount: 0,
134
+ lastError: null,
135
+ },
136
+ state || {}
137
+ );
138
+ fs.writeFileSync(
139
+ path.join(this.tempdir, ".gsd-t", ".context-meter-state.json"),
140
+ JSON.stringify(full, null, 2),
141
+ "utf8"
142
+ );
143
+ }
144
+
145
+ /**
146
+ * Start a local stub HTTP server that responds to every request with the
147
+ * given inputTokens value. Tracks hit count so tests can assert the API
148
+ * was (or was not) called.
149
+ */
150
+ async startStub({ inputTokens }) {
151
+ this.server = http.createServer((req, res) => {
152
+ this.hitCount++;
153
+ // Drain the request body (even though we don't inspect it) so the
154
+ // client sees a clean close.
155
+ req.on("data", () => {});
156
+ req.on("end", () => {
157
+ res.writeHead(200, { "content-type": "application/json" });
158
+ res.end(JSON.stringify({ input_tokens: inputTokens }));
159
+ });
160
+ });
161
+ await new Promise((resolve, reject) => {
162
+ const t = setTimeout(
163
+ () => reject(new Error("stub server listen timeout")),
164
+ HARD_TIMEOUT_MS
165
+ );
166
+ this.server.on("error", (err) => {
167
+ clearTimeout(t);
168
+ reject(err);
169
+ });
170
+ this.server.listen(0, "127.0.0.1", () => {
171
+ clearTimeout(t);
172
+ const { port } = this.server.address();
173
+ this.serverUrl = `http://127.0.0.1:${port}`;
174
+ resolve();
175
+ });
176
+ });
177
+ }
178
+
179
+ /**
180
+ * Spawn the real hook as a child process, write a payload to stdin, and
181
+ * resolve with { stdout, stderr, code }. Enforces a hard timeout so the
182
+ * test can never hang the suite.
183
+ */
184
+ async runHook({ payload, env }) {
185
+ const fullEnv = Object.assign({}, process.env, {
186
+ ANTHROPIC_API_KEY: "test-key-ignored",
187
+ GSD_T_CONTEXT_METER_TEST_BASE_URL: this.serverUrl || "",
188
+ NODE_OPTIONS: `--require ${INJECTOR}`,
189
+ });
190
+ // Allow caller to override any env (including unsetting ANTHROPIC_API_KEY).
191
+ if (env) {
192
+ for (const [k, v] of Object.entries(env)) {
193
+ if (v === null || v === undefined) {
194
+ delete fullEnv[k];
195
+ } else {
196
+ fullEnv[k] = v;
197
+ }
198
+ }
199
+ }
200
+
201
+ const child = spawn(process.execPath, [HOOK_SCRIPT], {
202
+ cwd: this.tempdir,
203
+ env: fullEnv,
204
+ stdio: ["pipe", "pipe", "pipe"],
205
+ });
206
+ this.childProcs.push(child);
207
+
208
+ const stdoutChunks = [];
209
+ const stderrChunks = [];
210
+ child.stdout.on("data", (c) => stdoutChunks.push(c));
211
+ child.stderr.on("data", (c) => stderrChunks.push(c));
212
+
213
+ child.stdin.write(JSON.stringify(payload || {}));
214
+ child.stdin.end();
215
+
216
+ const result = await new Promise((resolve, reject) => {
217
+ const killTimer = setTimeout(() => {
218
+ try {
219
+ child.kill("SIGKILL");
220
+ } catch (_) {
221
+ /* ignore */
222
+ }
223
+ reject(new Error(`hook child process timeout after ${HARD_TIMEOUT_MS}ms`));
224
+ }, HARD_TIMEOUT_MS);
225
+
226
+ child.on("close", (code) => {
227
+ clearTimeout(killTimer);
228
+ resolve({
229
+ stdout: Buffer.concat(stdoutChunks).toString("utf8"),
230
+ stderr: Buffer.concat(stderrChunks).toString("utf8"),
231
+ code,
232
+ });
233
+ });
234
+ child.on("error", (err) => {
235
+ clearTimeout(killTimer);
236
+ reject(err);
237
+ });
238
+ });
239
+
240
+ return result;
241
+ }
242
+
243
+ readState() {
244
+ const p = path.join(this.tempdir, ".gsd-t", ".context-meter-state.json");
245
+ if (!fs.existsSync(p)) return null;
246
+ return JSON.parse(fs.readFileSync(p, "utf8"));
247
+ }
248
+
249
+ stateFileExists() {
250
+ const p = path.join(this.tempdir, ".gsd-t", ".context-meter-state.json");
251
+ return fs.existsSync(p);
252
+ }
253
+
254
+ tmpFileExists() {
255
+ const p = path.join(this.tempdir, ".gsd-t", ".context-meter-state.json.tmp");
256
+ return fs.existsSync(p);
257
+ }
258
+
259
+ async dispose() {
260
+ // Kill any lingering children first.
261
+ for (const c of this.childProcs) {
262
+ try {
263
+ if (!c.killed) c.kill("SIGKILL");
264
+ } catch (_) {
265
+ /* ignore */
266
+ }
267
+ }
268
+ this.childProcs = [];
269
+
270
+ if (this.server) {
271
+ await new Promise((resolve) => {
272
+ try {
273
+ this.server.close(() => resolve());
274
+ } catch (_) {
275
+ resolve();
276
+ }
277
+ });
278
+ this.server = null;
279
+ }
280
+
281
+ if (this.tempdir) {
282
+ try {
283
+ fs.rmSync(this.tempdir, { recursive: true, force: true });
284
+ } catch (_) {
285
+ /* ignore */
286
+ }
287
+ this.tempdir = null;
288
+ }
289
+ }
290
+ }
291
+
292
+ /* ──────────────────────────── shared state ──────────────────────────── */
293
+
294
+ let sandbox;
295
+
296
+ beforeEach(async () => {
297
+ sandbox = new Sandbox();
298
+ await sandbox.setup();
299
+ });
300
+
301
+ afterEach(async () => {
302
+ if (sandbox) {
303
+ await sandbox.dispose();
304
+ sandbox = null;
305
+ }
306
+ });
307
+
308
+ /* ──────────────────────────── tests ──────────────────────────── */
309
+
310
+ test("E2E 1. below threshold — stdout {} and state reflects 25%", async () => {
311
+ sandbox.writeConfig({ thresholdPct: 75, modelWindowSize: 200000, checkFrequency: 1 });
312
+ const transcriptPath = sandbox.writeTranscript();
313
+ await sandbox.startStub({ inputTokens: 50000 });
314
+
315
+ const { stdout, code } = await sandbox.runHook({
316
+ payload: { session_id: "test-below", transcript_path: transcriptPath },
317
+ });
318
+
319
+ assert.equal(code, 0, "hook should always exit 0");
320
+ const parsed = JSON.parse(stdout || "{}");
321
+ assert.deepEqual(parsed, {}, "below-threshold stdout must be exactly {}");
322
+
323
+ const state = sandbox.readState();
324
+ assert.ok(state, "state file should exist");
325
+ assert.equal(state.version, 1);
326
+ assert.equal(state.inputTokens, 50000);
327
+ assert.equal(state.modelWindowSize, 200000);
328
+ assert.ok(Math.abs(state.pct - 25) < 0.0001, `pct ${state.pct} should ≈ 25`);
329
+ assert.equal(state.threshold, "normal");
330
+ assert.equal(state.checkCount, 1);
331
+ assert.equal(state.lastError, null);
332
+ assert.ok(typeof state.timestamp === "string" && state.timestamp.length > 0);
333
+
334
+ assert.equal(sandbox.tmpFileExists(), false, "no leftover .tmp file");
335
+ assert.equal(sandbox.hitCount, 1, "stub server should have been called exactly once");
336
+ });
337
+
338
+ test("E2E 2. above threshold — stdout additionalContext and state reflects 80%", async () => {
339
+ sandbox.writeConfig({ thresholdPct: 75, modelWindowSize: 200000, checkFrequency: 1 });
340
+ const transcriptPath = sandbox.writeTranscript();
341
+ await sandbox.startStub({ inputTokens: 160000 });
342
+
343
+ const { stdout, code } = await sandbox.runHook({
344
+ payload: { session_id: "test-above", transcript_path: transcriptPath },
345
+ });
346
+
347
+ assert.equal(code, 0);
348
+ const parsed = JSON.parse(stdout || "{}");
349
+ assert.deepEqual(parsed, {
350
+ additionalContext:
351
+ "⚠️ Context window at 80.0% of 200000. Run /user:gsd-t-pause to checkpoint and clear before continuing.",
352
+ });
353
+
354
+ const state = sandbox.readState();
355
+ assert.ok(state);
356
+ assert.equal(state.inputTokens, 160000);
357
+ assert.equal(state.modelWindowSize, 200000);
358
+ assert.ok(Math.abs(state.pct - 80) < 0.0001, `pct ${state.pct} should ≈ 80`);
359
+ // v3.0.0 three-band (M35): 80% ∈ [70, 85) → warn
360
+ assert.equal(state.threshold, "warn");
361
+ assert.equal(state.checkCount, 1);
362
+ assert.equal(state.lastError, null);
363
+
364
+ assert.equal(sandbox.tmpFileExists(), false);
365
+ assert.equal(sandbox.hitCount, 1);
366
+ });
367
+
368
+ test("E2E 3. API key missing — stdout {}, state has lastError.code='missing_key'", async () => {
369
+ sandbox.writeConfig({ thresholdPct: 75, checkFrequency: 1 });
370
+ const transcriptPath = sandbox.writeTranscript();
371
+ await sandbox.startStub({ inputTokens: 50000 });
372
+
373
+ const { stdout, code } = await sandbox.runHook({
374
+ payload: { session_id: "test-nokey", transcript_path: transcriptPath },
375
+ env: { ANTHROPIC_API_KEY: null }, // explicitly unset
376
+ });
377
+
378
+ assert.equal(code, 0);
379
+ const parsed = JSON.parse(stdout || "{}");
380
+ assert.deepEqual(parsed, {});
381
+
382
+ const state = sandbox.readState();
383
+ assert.ok(state);
384
+ assert.equal(state.checkCount, 1);
385
+ assert.ok(state.lastError && typeof state.lastError === "object");
386
+ assert.equal(state.lastError.code, "missing_key");
387
+
388
+ // API must NOT have been called.
389
+ assert.equal(sandbox.hitCount, 0, "stub server must not be hit when key is missing");
390
+ });
391
+
392
+ test("E2E 4. checkFrequency skip — API not called, checkCount increments", async () => {
393
+ sandbox.writeConfig({ thresholdPct: 75, checkFrequency: 5 });
394
+ const transcriptPath = sandbox.writeTranscript();
395
+ // Pre-seed state so that checkCount goes 3 → 4, which is NOT a multiple of 5.
396
+ sandbox.writeState({ checkCount: 3 });
397
+ await sandbox.startStub({ inputTokens: 50000 });
398
+
399
+ const { stdout, code } = await sandbox.runHook({
400
+ payload: { session_id: "test-skip", transcript_path: transcriptPath },
401
+ });
402
+
403
+ assert.equal(code, 0);
404
+ const parsed = JSON.parse(stdout || "{}");
405
+ assert.deepEqual(parsed, {});
406
+
407
+ const state = sandbox.readState();
408
+ assert.ok(state);
409
+ assert.equal(state.checkCount, 4, "counter increments even on skipped turn");
410
+ // lastError/inputTokens unchanged from seed on skipped turn.
411
+ assert.equal(state.inputTokens, 0);
412
+
413
+ assert.equal(sandbox.hitCount, 0, "stub server must not be hit on skipped turn");
414
+ assert.equal(sandbox.tmpFileExists(), false);
415
+ });