@tekyzinc/gsd-t 2.74.12 → 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 +130 -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 +710 -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,221 @@
1
+ /**
2
+ * count-tokens-client.js
3
+ *
4
+ * Zero-dependency Node.js client for Anthropic's `POST /v1/messages/count_tokens`
5
+ * endpoint. Built on the built-in `https` / `http` modules so the Context Meter
6
+ * hook ships with no runtime dependencies.
7
+ *
8
+ * Contract: `.gsd-t/contracts/context-meter-contract.md` — "count_tokens API usage"
9
+ *
10
+ * Design notes:
11
+ *
12
+ * - Every failure mode returns `null`. The caller (the hook in Task 4) treats
13
+ * `null` as "fail open" — it simply emits `{}` on stdout and Claude is never
14
+ * blocked. This function NEVER throws.
15
+ *
16
+ * - The `system` field on the Messages API rejects an empty string
17
+ * (`system: ""` → 400). The `{ system, messages }` shape produced by
18
+ * `transcript-parser.js` starts with an empty system string when the
19
+ * transcript has no system blocks, so this client DROPS the `system` key
20
+ * from the request body when the input is an empty string. Any non-empty
21
+ * system is forwarded as-is.
22
+ *
23
+ * - The hard timeout uses `req.setTimeout(ms)`; on fire we `req.destroy()` to
24
+ * release the socket and return `null`. Without the explicit destroy the
25
+ * socket can linger for the OS-level keep-alive window, which matters when
26
+ * the hook is already at its ~200ms latency budget.
27
+ *
28
+ * - We NEVER log the request body. The only diagnostic signal this module
29
+ * produces is the returned value itself (`null` on failure). Any logging
30
+ * is the caller's responsibility — the hook writes to `logPath` per config.
31
+ *
32
+ * - A hidden `_baseUrl` option lets the tests point the client at a local
33
+ * stub HTTP server bound to `127.0.0.1:0`. Production callers never pass
34
+ * `_baseUrl`. Parsing uses `URL` so either http or https works transparently.
35
+ *
36
+ * @module scripts/context-meter/count-tokens-client
37
+ */
38
+
39
+ "use strict";
40
+
41
+ const https = require("https");
42
+ const http = require("http");
43
+ const { URL } = require("url");
44
+
45
+ const DEFAULT_BASE_URL = "https://api.anthropic.com";
46
+ const COUNT_TOKENS_PATH = "/v1/messages/count_tokens";
47
+ const ANTHROPIC_VERSION = "2023-06-01";
48
+
49
+ /**
50
+ * Call Anthropic count_tokens.
51
+ *
52
+ * @param {object} opts
53
+ * @param {string} opts.apiKey - Anthropic API key (from env var named in config)
54
+ * @param {string} opts.model - model id, e.g. "claude-opus-4-6"
55
+ * @param {string} opts.system - system prompt text; dropped from body if ""
56
+ * @param {Array} opts.messages - messages array from transcript-parser.js
57
+ * @param {number} opts.timeoutMs - hard timeout for the whole request
58
+ * @param {string} [opts._baseUrl] - TEST ONLY: override the base URL
59
+ * @returns {Promise<{inputTokens: number} | null>} tokens on success, null on any failure
60
+ */
61
+ function countTokens(opts) {
62
+ return new Promise((resolve) => {
63
+ // Single outer try/catch — any synchronous throw below becomes `null`.
64
+ try {
65
+ if (!opts || typeof opts !== "object") {
66
+ resolve(null);
67
+ return;
68
+ }
69
+
70
+ const {
71
+ apiKey,
72
+ model,
73
+ system,
74
+ messages,
75
+ timeoutMs,
76
+ _baseUrl,
77
+ } = opts;
78
+
79
+ if (typeof apiKey !== "string" || apiKey.length === 0) {
80
+ resolve(null);
81
+ return;
82
+ }
83
+ if (typeof model !== "string" || model.length === 0) {
84
+ resolve(null);
85
+ return;
86
+ }
87
+ if (!Array.isArray(messages)) {
88
+ resolve(null);
89
+ return;
90
+ }
91
+ if (typeof timeoutMs !== "number" || !Number.isFinite(timeoutMs) || timeoutMs <= 0) {
92
+ resolve(null);
93
+ return;
94
+ }
95
+
96
+ // Build request body. Drop `system` when it's an empty string —
97
+ // the endpoint rejects `system: ""` with a 400.
98
+ const body = { model, messages };
99
+ if (typeof system === "string" && system.length > 0) {
100
+ body.system = system;
101
+ } else if (system != null && typeof system !== "string") {
102
+ // Unusual shape — do not forward.
103
+ resolve(null);
104
+ return;
105
+ }
106
+
107
+ let payload;
108
+ try {
109
+ payload = JSON.stringify(body);
110
+ } catch (_) {
111
+ resolve(null);
112
+ return;
113
+ }
114
+
115
+ // Parse base URL — test code passes http://127.0.0.1:<port>, prod uses https.
116
+ let parsed;
117
+ try {
118
+ parsed = new URL(COUNT_TOKENS_PATH, _baseUrl || DEFAULT_BASE_URL);
119
+ } catch (_) {
120
+ resolve(null);
121
+ return;
122
+ }
123
+
124
+ const isHttps = parsed.protocol === "https:";
125
+ const transport = isHttps ? https : http;
126
+
127
+ const reqOptions = {
128
+ method: "POST",
129
+ hostname: parsed.hostname,
130
+ port: parsed.port || (isHttps ? 443 : 80),
131
+ path: parsed.pathname + parsed.search,
132
+ headers: {
133
+ "x-api-key": apiKey,
134
+ "anthropic-version": ANTHROPIC_VERSION,
135
+ "content-type": "application/json",
136
+ "content-length": Buffer.byteLength(payload),
137
+ },
138
+ };
139
+
140
+ let settled = false;
141
+ const settle = (value) => {
142
+ if (settled) return;
143
+ settled = true;
144
+ resolve(value);
145
+ };
146
+
147
+ let req;
148
+ try {
149
+ req = transport.request(reqOptions, (res) => {
150
+ const status = res.statusCode || 0;
151
+ const chunks = [];
152
+ res.on("data", (chunk) => {
153
+ chunks.push(chunk);
154
+ });
155
+ res.on("end", () => {
156
+ if (status !== 200) {
157
+ // 401 / 403 / 429 / 5xx — fail open silently.
158
+ settle(null);
159
+ return;
160
+ }
161
+ let text;
162
+ try {
163
+ text = Buffer.concat(chunks).toString("utf8");
164
+ } catch (_) {
165
+ settle(null);
166
+ return;
167
+ }
168
+ let parsedBody;
169
+ try {
170
+ parsedBody = JSON.parse(text);
171
+ } catch (_) {
172
+ settle(null);
173
+ return;
174
+ }
175
+ if (!parsedBody || typeof parsedBody !== "object") {
176
+ settle(null);
177
+ return;
178
+ }
179
+ const n = Number(parsedBody.input_tokens);
180
+ if (!Number.isFinite(n)) {
181
+ settle(null);
182
+ return;
183
+ }
184
+ settle({ inputTokens: n });
185
+ });
186
+ res.on("error", () => {
187
+ settle(null);
188
+ });
189
+ });
190
+ } catch (_) {
191
+ settle(null);
192
+ return;
193
+ }
194
+
195
+ req.on("error", () => {
196
+ settle(null);
197
+ });
198
+
199
+ req.setTimeout(timeoutMs, () => {
200
+ // Destroy the socket so it doesn't linger beyond the hook's latency budget.
201
+ try {
202
+ req.destroy();
203
+ } catch (_) {
204
+ /* ignore */
205
+ }
206
+ settle(null);
207
+ });
208
+
209
+ try {
210
+ req.write(payload);
211
+ req.end();
212
+ } catch (_) {
213
+ settle(null);
214
+ }
215
+ } catch (_) {
216
+ resolve(null);
217
+ }
218
+ });
219
+ }
220
+
221
+ module.exports = { countTokens };
@@ -0,0 +1,308 @@
1
+ "use strict";
2
+
3
+ const { test } = require("node:test");
4
+ const assert = require("node:assert/strict");
5
+ const http = require("http");
6
+
7
+ const { countTokens } = require("./count-tokens-client");
8
+
9
+ /* ----------------------------- stub server helpers ----------------------------- */
10
+
11
+ /**
12
+ * Start a stub HTTP server bound to 127.0.0.1:0 (OS-assigned port).
13
+ *
14
+ * @param {(req, res, body) => void} handler
15
+ * Called on every incoming request. `body` is the full request body as string.
16
+ * The handler is responsible for writing the response (unless it intentionally
17
+ * hangs — see the timeout test).
18
+ * @returns {Promise<{server: http.Server, baseUrl: string, lastBody: {value: string|null}}>}
19
+ */
20
+ function startStub(handler) {
21
+ return new Promise((resolve, reject) => {
22
+ const lastBody = { value: null };
23
+ const server = http.createServer((req, res) => {
24
+ const chunks = [];
25
+ req.on("data", (c) => chunks.push(c));
26
+ req.on("end", () => {
27
+ const body = Buffer.concat(chunks).toString("utf8");
28
+ lastBody.value = body;
29
+ try {
30
+ handler(req, res, body);
31
+ } catch (err) {
32
+ try {
33
+ res.statusCode = 500;
34
+ res.end(String(err && err.message));
35
+ } catch (_) {
36
+ /* ignore */
37
+ }
38
+ }
39
+ });
40
+ });
41
+ server.on("error", reject);
42
+ server.listen(0, "127.0.0.1", () => {
43
+ const addr = server.address();
44
+ resolve({ server, baseUrl: `http://127.0.0.1:${addr.port}`, lastBody });
45
+ });
46
+ });
47
+ }
48
+
49
+ function closeServer(server) {
50
+ return new Promise((resolve) => {
51
+ if (!server) {
52
+ resolve();
53
+ return;
54
+ }
55
+ // Destroy any lingering sockets so hung handlers don't keep the event loop alive.
56
+ try {
57
+ server.closeAllConnections && server.closeAllConnections();
58
+ } catch (_) {
59
+ /* ignore */
60
+ }
61
+ server.close(() => resolve());
62
+ });
63
+ }
64
+
65
+ /* ---------------------------------- tests ---------------------------------- */
66
+
67
+ test("happy path → returns { inputTokens }", async () => {
68
+ const { server, baseUrl } = await startStub((req, res) => {
69
+ res.statusCode = 200;
70
+ res.setHeader("content-type", "application/json");
71
+ res.end(JSON.stringify({ input_tokens: 12345 }));
72
+ });
73
+ try {
74
+ const got = await countTokens({
75
+ apiKey: "sk-test",
76
+ model: "claude-opus-4-6",
77
+ system: "you are helpful",
78
+ messages: [{ role: "user", content: [{ type: "text", text: "hi" }] }],
79
+ timeoutMs: 2000,
80
+ _baseUrl: baseUrl,
81
+ });
82
+ assert.deepEqual(got, { inputTokens: 12345 });
83
+ } finally {
84
+ await closeServer(server);
85
+ }
86
+ });
87
+
88
+ test("401 → returns null", async () => {
89
+ const { server, baseUrl } = await startStub((req, res) => {
90
+ res.statusCode = 401;
91
+ res.setHeader("content-type", "application/json");
92
+ res.end(JSON.stringify({ error: { type: "authentication_error", message: "bad key" } }));
93
+ });
94
+ try {
95
+ const got = await countTokens({
96
+ apiKey: "sk-bad",
97
+ model: "claude-opus-4-6",
98
+ system: "",
99
+ messages: [{ role: "user", content: [{ type: "text", text: "hi" }] }],
100
+ timeoutMs: 2000,
101
+ _baseUrl: baseUrl,
102
+ });
103
+ assert.equal(got, null);
104
+ } finally {
105
+ await closeServer(server);
106
+ }
107
+ });
108
+
109
+ test("429 → returns null", async () => {
110
+ const { server, baseUrl } = await startStub((req, res) => {
111
+ res.statusCode = 429;
112
+ res.end("rate limited");
113
+ });
114
+ try {
115
+ const got = await countTokens({
116
+ apiKey: "sk-test",
117
+ model: "claude-opus-4-6",
118
+ system: "",
119
+ messages: [{ role: "user", content: [{ type: "text", text: "hi" }] }],
120
+ timeoutMs: 2000,
121
+ _baseUrl: baseUrl,
122
+ });
123
+ assert.equal(got, null);
124
+ } finally {
125
+ await closeServer(server);
126
+ }
127
+ });
128
+
129
+ test("500 → returns null", async () => {
130
+ const { server, baseUrl } = await startStub((req, res) => {
131
+ res.statusCode = 500;
132
+ res.end("internal");
133
+ });
134
+ try {
135
+ const got = await countTokens({
136
+ apiKey: "sk-test",
137
+ model: "claude-opus-4-6",
138
+ system: "",
139
+ messages: [{ role: "user", content: [{ type: "text", text: "hi" }] }],
140
+ timeoutMs: 2000,
141
+ _baseUrl: baseUrl,
142
+ });
143
+ assert.equal(got, null);
144
+ } finally {
145
+ await closeServer(server);
146
+ }
147
+ });
148
+
149
+ test("timeout → returns null", async () => {
150
+ // Handler never responds — the client's timeoutMs should fire first.
151
+ const { server, baseUrl } = await startStub(() => {
152
+ /* never calls res.end() */
153
+ });
154
+ try {
155
+ const t0 = Date.now();
156
+ const got = await countTokens({
157
+ apiKey: "sk-test",
158
+ model: "claude-opus-4-6",
159
+ system: "",
160
+ messages: [{ role: "user", content: [{ type: "text", text: "hi" }] }],
161
+ timeoutMs: 500,
162
+ _baseUrl: baseUrl,
163
+ });
164
+ const elapsed = Date.now() - t0;
165
+ assert.equal(got, null);
166
+ // Should resolve shortly after the 500ms timeout, well under the 5s test budget.
167
+ assert.ok(elapsed < 3000, `timeout path took too long: ${elapsed}ms`);
168
+ } finally {
169
+ await closeServer(server);
170
+ }
171
+ });
172
+
173
+ test("malformed response JSON → returns null", async () => {
174
+ const { server, baseUrl } = await startStub((req, res) => {
175
+ res.statusCode = 200;
176
+ res.setHeader("content-type", "application/json");
177
+ res.end('not json{');
178
+ });
179
+ try {
180
+ const got = await countTokens({
181
+ apiKey: "sk-test",
182
+ model: "claude-opus-4-6",
183
+ system: "",
184
+ messages: [{ role: "user", content: [{ type: "text", text: "hi" }] }],
185
+ timeoutMs: 2000,
186
+ _baseUrl: baseUrl,
187
+ });
188
+ assert.equal(got, null);
189
+ } finally {
190
+ await closeServer(server);
191
+ }
192
+ });
193
+
194
+ test("missing input_tokens field → returns null", async () => {
195
+ const { server, baseUrl } = await startStub((req, res) => {
196
+ res.statusCode = 200;
197
+ res.setHeader("content-type", "application/json");
198
+ res.end(JSON.stringify({ other_field: 99 }));
199
+ });
200
+ try {
201
+ const got = await countTokens({
202
+ apiKey: "sk-test",
203
+ model: "claude-opus-4-6",
204
+ system: "",
205
+ messages: [{ role: "user", content: [{ type: "text", text: "hi" }] }],
206
+ timeoutMs: 2000,
207
+ _baseUrl: baseUrl,
208
+ });
209
+ assert.equal(got, null);
210
+ } finally {
211
+ await closeServer(server);
212
+ }
213
+ });
214
+
215
+ test('empty system string → request body OMITS "system" key', async () => {
216
+ const { server, baseUrl, lastBody } = await startStub((req, res) => {
217
+ res.statusCode = 200;
218
+ res.setHeader("content-type", "application/json");
219
+ res.end(JSON.stringify({ input_tokens: 1 }));
220
+ });
221
+ try {
222
+ await countTokens({
223
+ apiKey: "sk-test",
224
+ model: "claude-opus-4-6",
225
+ system: "",
226
+ messages: [{ role: "user", content: [{ type: "text", text: "hi" }] }],
227
+ timeoutMs: 2000,
228
+ _baseUrl: baseUrl,
229
+ });
230
+ assert.ok(lastBody.value, "stub did not receive a body");
231
+ const parsed = JSON.parse(lastBody.value);
232
+ assert.equal(Object.prototype.hasOwnProperty.call(parsed, "system"), false);
233
+ assert.equal(parsed.model, "claude-opus-4-6");
234
+ assert.ok(Array.isArray(parsed.messages));
235
+ } finally {
236
+ await closeServer(server);
237
+ }
238
+ });
239
+
240
+ test('non-empty system → request body INCLUDES "system"', async () => {
241
+ const { server, baseUrl, lastBody } = await startStub((req, res) => {
242
+ res.statusCode = 200;
243
+ res.setHeader("content-type", "application/json");
244
+ res.end(JSON.stringify({ input_tokens: 2 }));
245
+ });
246
+ try {
247
+ await countTokens({
248
+ apiKey: "sk-test",
249
+ model: "claude-opus-4-6",
250
+ system: "some text",
251
+ messages: [{ role: "user", content: [{ type: "text", text: "hi" }] }],
252
+ timeoutMs: 2000,
253
+ _baseUrl: baseUrl,
254
+ });
255
+ assert.ok(lastBody.value, "stub did not receive a body");
256
+ const parsed = JSON.parse(lastBody.value);
257
+ assert.equal(parsed.system, "some text");
258
+ } finally {
259
+ await closeServer(server);
260
+ }
261
+ });
262
+
263
+ test("sends required Anthropic headers", async () => {
264
+ let seenHeaders = null;
265
+ const { server, baseUrl } = await startStub((req, res) => {
266
+ seenHeaders = req.headers;
267
+ res.statusCode = 200;
268
+ res.setHeader("content-type", "application/json");
269
+ res.end(JSON.stringify({ input_tokens: 3 }));
270
+ });
271
+ try {
272
+ await countTokens({
273
+ apiKey: "sk-abc-123",
274
+ model: "claude-opus-4-6",
275
+ system: "",
276
+ messages: [{ role: "user", content: [{ type: "text", text: "hi" }] }],
277
+ timeoutMs: 2000,
278
+ _baseUrl: baseUrl,
279
+ });
280
+ assert.ok(seenHeaders, "stub did not receive headers");
281
+ assert.equal(seenHeaders["x-api-key"], "sk-abc-123");
282
+ assert.equal(seenHeaders["anthropic-version"], "2023-06-01");
283
+ assert.equal(seenHeaders["content-type"], "application/json");
284
+ } finally {
285
+ await closeServer(server);
286
+ }
287
+ });
288
+
289
+ test("invalid opts → returns null without throwing", async () => {
290
+ assert.equal(await countTokens(null), null);
291
+ assert.equal(await countTokens({}), null);
292
+ assert.equal(
293
+ await countTokens({ apiKey: "", model: "m", messages: [], timeoutMs: 100 }),
294
+ null
295
+ );
296
+ assert.equal(
297
+ await countTokens({ apiKey: "k", model: "", messages: [], timeoutMs: 100 }),
298
+ null
299
+ );
300
+ assert.equal(
301
+ await countTokens({ apiKey: "k", model: "m", messages: "no", timeoutMs: 100 }),
302
+ null
303
+ );
304
+ assert.equal(
305
+ await countTokens({ apiKey: "k", model: "m", messages: [], timeoutMs: 0 }),
306
+ null
307
+ );
308
+ });
@@ -0,0 +1,55 @@
1
+ /**
2
+ * test-injector.js — TEST-ONLY INFRASTRUCTURE. DO NOT REQUIRE FROM PRODUCTION CODE.
3
+ *
4
+ * Loaded into the child process via NODE_OPTIONS=--require when running the
5
+ * E2E test at `scripts/gsd-t-context-meter.e2e.test.js`. Its job is to monkey-
6
+ * patch `count-tokens-client.countTokens` so the real child-process hook (which
7
+ * normally calls https://api.anthropic.com) is redirected to a local stub HTTP
8
+ * server bound on 127.0.0.1:{random-port}.
9
+ *
10
+ * Production NEVER loads this file:
11
+ * - The hook script (`scripts/gsd-t-context-meter.js`) does not require it.
12
+ * - The npm installer does not ship `NODE_OPTIONS` anywhere near it.
13
+ * - The file lives under `scripts/context-meter/` only so the E2E test can
14
+ * point `--require` at a stable absolute path; nothing in the runtime
15
+ * require graph pulls it in on its own.
16
+ *
17
+ * Activation:
18
+ * - Reads `process.env.GSD_T_CONTEXT_METER_TEST_BASE_URL`. If unset, the file
19
+ * is a no-op (and `NODE_OPTIONS=--require` with this path on a production
20
+ * invocation would still be a harmless no-op).
21
+ * - When set, resolves and requires `./count-tokens-client`, wraps its
22
+ * `countTokens` export to inject `_baseUrl` before every call, and
23
+ * reassigns the property on the same module.exports object — so any later
24
+ * `require('./count-tokens-client')` from the hook sees the patched fn.
25
+ *
26
+ * Why this exists:
27
+ * Tasks 1–4 tested `runMeter()` via dependency injection. Task 5 tests the
28
+ * real child-process hook as Claude Code would invoke it, which means no DI
29
+ * seams are available — only stdin, stdout, and env. The hook's CLI shim does
30
+ * not accept `_baseUrl` as a config param (by design: production must never
31
+ * be routable to a non-Anthropic host). So the only honest way to redirect
32
+ * HTTP in a black-box test is to monkey-patch the HTTP client *inside* the
33
+ * child process, gated on a test-only env var. That is what this file does.
34
+ *
35
+ * @module scripts/context-meter/test-injector
36
+ */
37
+
38
+ "use strict";
39
+
40
+ const baseUrl = process.env.GSD_T_CONTEXT_METER_TEST_BASE_URL;
41
+ if (baseUrl && typeof baseUrl === "string" && baseUrl.length > 0) {
42
+ try {
43
+ const clientPath = require.resolve("./count-tokens-client");
44
+ const client = require(clientPath);
45
+ const original = client.countTokens;
46
+ if (typeof original === "function") {
47
+ client.countTokens = function patchedCountTokens(opts) {
48
+ const merged = Object.assign({}, opts || {}, { _baseUrl: baseUrl });
49
+ return original(merged);
50
+ };
51
+ }
52
+ } catch (_) {
53
+ // Silent — an injector failure must never break the child process.
54
+ }
55
+ }
@@ -0,0 +1,88 @@
1
+ /**
2
+ * scripts/context-meter/threshold.js
3
+ *
4
+ * Pure-function module for the context-meter PostToolUse hook.
5
+ *
6
+ * Responsibilities:
7
+ * 1. Compute the context-window percentage from a token count + window size.
8
+ * 2. Map that percentage to a token-budget band (normal / warn / stop).
9
+ * Boundaries mirror bin/token-budget.js v3.0.0 exactly so the
10
+ * `threshold` field in the state file is consistent across consumers.
11
+ * 3. Build the exact `additionalContext` string the hook emits when the
12
+ * measured percentage meets or exceeds the configured thresholdPct.
13
+ *
14
+ * v3.0.0 (M35): The `downgrade` and `conserve` bands were REMOVED. The
15
+ * three-band model is: normal < 70 ≤ warn < 85 ≤ stop. Silent model
16
+ * degradation and silent phase-skipping violate GSD-T's quality principles.
17
+ *
18
+ * Zero side effects. Zero dependencies. CommonJS.
19
+ */
20
+
21
+ // ── Band boundaries (must match bin/token-budget.js THRESHOLDS exactly) ──────
22
+ // Lower bound inclusive, upper bound exclusive.
23
+ const BANDS = Object.freeze({
24
+ warn: 70,
25
+ stop: 85,
26
+ });
27
+
28
+ /**
29
+ * Compute context-window percentage (0–100+).
30
+ *
31
+ * Fail-safe: any non-finite, negative, or zero-window input returns 0 — the
32
+ * caller should treat 0 as "normal/safe, no action needed".
33
+ *
34
+ * Does NOT clamp above 100. If real usage reports 102.3%, return 102.3; the
35
+ * band mapping handles it (>= 95 → stop).
36
+ *
37
+ * @param {{ inputTokens: number, modelWindowSize: number }} args
38
+ * @returns {number}
39
+ */
40
+ function computePct({ inputTokens, modelWindowSize } = {}) {
41
+ if (!Number.isFinite(inputTokens) || !Number.isFinite(modelWindowSize)) {
42
+ return 0;
43
+ }
44
+ if (inputTokens < 0 || modelWindowSize <= 0) {
45
+ return 0;
46
+ }
47
+ return (inputTokens / modelWindowSize) * 100;
48
+ }
49
+
50
+ /**
51
+ * Map a percentage to a token-budget band (v3.0.0 three-band model).
52
+ *
53
+ * Boundaries (inclusive on the lower edge):
54
+ * pct < 70 → "normal"
55
+ * pct < 85 → "warn"
56
+ * pct >= 85 → "stop"
57
+ *
58
+ * Non-finite input → "normal" (fail-safe — never escalate on garbage).
59
+ *
60
+ * @param {number} pct
61
+ * @returns {"normal"|"warn"|"stop"}
62
+ */
63
+ function bandFor(pct) {
64
+ if (!Number.isFinite(pct)) return "normal";
65
+ if (pct >= BANDS.stop) return "stop";
66
+ if (pct >= BANDS.warn) return "warn";
67
+ return "normal";
68
+ }
69
+
70
+ /**
71
+ * Build the `additionalContext` string the hook emits, or null if the
72
+ * measured percentage is below the configured thresholdPct.
73
+ *
74
+ * Exact format (from .gsd-t/contracts/context-meter-contract.md line 139):
75
+ * ⚠️ Context window at {pct.toFixed(1)}% of {modelWindowSize}. Run /user:gsd-t-pause to checkpoint and clear before continuing.
76
+ *
77
+ * `modelWindowSize` is emitted as the raw integer — no commas, no "K" suffix.
78
+ *
79
+ * @param {{ pct: number, modelWindowSize: number, thresholdPct: number }} args
80
+ * @returns {string|null}
81
+ */
82
+ function buildAdditionalContext({ pct, modelWindowSize, thresholdPct } = {}) {
83
+ if (!Number.isFinite(pct) || !Number.isFinite(thresholdPct)) return null;
84
+ if (pct < thresholdPct) return null;
85
+ return `⚠️ Context window at ${pct.toFixed(1)}% of ${modelWindowSize}. Run /user:gsd-t-pause to checkpoint and clear before continuing.`;
86
+ }
87
+
88
+ module.exports = { computePct, bandFor, buildAdditionalContext, BANDS };