@tekyzinc/gsd-t 2.74.13 → 3.10.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 (69) hide show
  1. package/CHANGELOG.md +165 -0
  2. package/README.md +117 -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-unattended-platform.js +381 -0
  8. package/bin/gsd-t-unattended-safety.js +766 -0
  9. package/bin/gsd-t-unattended.js +1259 -0
  10. package/bin/gsd-t.js +723 -19
  11. package/bin/handoff-lock.js +249 -0
  12. package/bin/headless-auto-spawn.js +328 -0
  13. package/bin/model-selector.js +224 -0
  14. package/bin/runway-estimator.js +242 -0
  15. package/bin/token-budget.js +96 -89
  16. package/bin/token-optimizer.js +471 -0
  17. package/bin/token-telemetry.js +246 -0
  18. package/commands/gsd-t-audit.md +3 -3
  19. package/commands/gsd-t-backlog-list.md +38 -0
  20. package/commands/gsd-t-brainstorm.md +3 -3
  21. package/commands/gsd-t-complete-milestone.md +24 -0
  22. package/commands/gsd-t-debug.md +124 -7
  23. package/commands/gsd-t-discuss.md +10 -3
  24. package/commands/gsd-t-doc-ripple.md +32 -4
  25. package/commands/gsd-t-execute.md +107 -52
  26. package/commands/gsd-t-help.md +22 -0
  27. package/commands/gsd-t-integrate.md +67 -4
  28. package/commands/gsd-t-optimization-apply.md +91 -0
  29. package/commands/gsd-t-optimization-reject.md +94 -0
  30. package/commands/gsd-t-partition.md +7 -0
  31. package/commands/gsd-t-pause.md +3 -0
  32. package/commands/gsd-t-plan.md +10 -3
  33. package/commands/gsd-t-prd.md +3 -3
  34. package/commands/gsd-t-quick.md +71 -9
  35. package/commands/gsd-t-reflect.md +3 -7
  36. package/commands/gsd-t-resume.md +86 -1
  37. package/commands/gsd-t-status.md +31 -0
  38. package/commands/gsd-t-test-sync.md +7 -0
  39. package/commands/gsd-t-unattended-stop.md +83 -0
  40. package/commands/gsd-t-unattended-watch.md +290 -0
  41. package/commands/gsd-t-unattended.md +414 -0
  42. package/commands/gsd-t-verify.md +12 -5
  43. package/commands/gsd-t-visualize.md +3 -7
  44. package/commands/gsd-t-wave.md +82 -18
  45. package/docs/GSD-T-README.md +69 -0
  46. package/docs/architecture.md +176 -4
  47. package/docs/infrastructure.md +221 -0
  48. package/docs/methodology.md +44 -0
  49. package/docs/prd-harness-evolution.md +51 -37
  50. package/docs/requirements.md +95 -0
  51. package/docs/unattended-windows-caveats.md +245 -0
  52. package/package.json +2 -2
  53. package/scripts/context-meter/count-tokens-client.js +221 -0
  54. package/scripts/context-meter/count-tokens-client.test.js +308 -0
  55. package/scripts/context-meter/test-injector.js +55 -0
  56. package/scripts/context-meter/threshold.js +88 -0
  57. package/scripts/context-meter/threshold.test.js +255 -0
  58. package/scripts/context-meter/transcript-parser.js +252 -0
  59. package/scripts/context-meter/transcript-parser.test.js +320 -0
  60. package/scripts/gsd-t-context-meter.e2e.test.js +415 -0
  61. package/scripts/gsd-t-context-meter.js +350 -0
  62. package/scripts/gsd-t-context-meter.test.js +417 -0
  63. package/scripts/gsd-t-heartbeat.js +2 -2
  64. package/scripts/gsd-t-statusline.js +23 -8
  65. package/templates/CLAUDE-global.md +17 -1
  66. package/templates/CLAUDE-project.md +26 -6
  67. package/templates/context-meter-config.json +10 -0
  68. package/templates/prompts/README.md +1 -1
  69. package/bin/task-counter.cjs +0 -161
@@ -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 };
@@ -0,0 +1,255 @@
1
+ /**
2
+ * scripts/context-meter/threshold.test.js
3
+ *
4
+ * Tests for threshold.js — context-meter's pure-function band/emit module.
5
+ * Run: node --test scripts/context-meter/threshold.test.js
6
+ */
7
+
8
+ const test = require("node:test");
9
+ const assert = require("node:assert/strict");
10
+ const {
11
+ computePct,
12
+ bandFor,
13
+ buildAdditionalContext,
14
+ BANDS,
15
+ } = require("./threshold");
16
+
17
+ // ── computePct ───────────────────────────────────────────────────────────────
18
+
19
+ test("computePct — happy path 50%", () => {
20
+ assert.equal(
21
+ computePct({ inputTokens: 100000, modelWindowSize: 200000 }),
22
+ 50
23
+ );
24
+ });
25
+
26
+ test("computePct — zero window returns 0", () => {
27
+ assert.equal(
28
+ computePct({ inputTokens: 100000, modelWindowSize: 0 }),
29
+ 0
30
+ );
31
+ });
32
+
33
+ test("computePct — negative window returns 0", () => {
34
+ assert.equal(
35
+ computePct({ inputTokens: 100000, modelWindowSize: -1 }),
36
+ 0
37
+ );
38
+ });
39
+
40
+ test("computePct — negative input returns 0", () => {
41
+ assert.equal(
42
+ computePct({ inputTokens: -5, modelWindowSize: 200000 }),
43
+ 0
44
+ );
45
+ });
46
+
47
+ test("computePct — NaN input returns 0", () => {
48
+ assert.equal(
49
+ computePct({ inputTokens: NaN, modelWindowSize: 200000 }),
50
+ 0
51
+ );
52
+ });
53
+
54
+ test("computePct — NaN window returns 0", () => {
55
+ assert.equal(
56
+ computePct({ inputTokens: 100000, modelWindowSize: NaN }),
57
+ 0
58
+ );
59
+ });
60
+
61
+ test("computePct — Infinity returns 0", () => {
62
+ assert.equal(
63
+ computePct({ inputTokens: Infinity, modelWindowSize: 200000 }),
64
+ 0
65
+ );
66
+ });
67
+
68
+ test("computePct — missing args returns 0", () => {
69
+ assert.equal(computePct({}), 0);
70
+ assert.equal(computePct(), 0);
71
+ });
72
+
73
+ test("computePct — does NOT clamp above 100", () => {
74
+ assert.equal(
75
+ computePct({ inputTokens: 250000, modelWindowSize: 200000 }),
76
+ 125
77
+ );
78
+ });
79
+
80
+ test("computePct — small fraction", () => {
81
+ const result = computePct({ inputTokens: 1, modelWindowSize: 200000 });
82
+ assert.ok(result > 0 && result < 0.001);
83
+ });
84
+
85
+ // ── bandFor — boundary sweep (v3.0.0 three-band: normal/warn/stop) ───────────
86
+
87
+ test("bandFor — 0 → normal", () => {
88
+ assert.equal(bandFor(0), "normal");
89
+ });
90
+
91
+ test("bandFor — 69 → normal", () => {
92
+ assert.equal(bandFor(69), "normal");
93
+ });
94
+
95
+ test("bandFor — 69.9 → normal", () => {
96
+ assert.equal(bandFor(69.9), "normal");
97
+ });
98
+
99
+ test("bandFor — 70 → warn (inclusive lower)", () => {
100
+ assert.equal(bandFor(70), "warn");
101
+ });
102
+
103
+ test("bandFor — 71 → warn", () => {
104
+ assert.equal(bandFor(71), "warn");
105
+ });
106
+
107
+ test("bandFor — 84 → warn", () => {
108
+ assert.equal(bandFor(84), "warn");
109
+ });
110
+
111
+ test("bandFor — 84.9 → warn", () => {
112
+ assert.equal(bandFor(84.9), "warn");
113
+ });
114
+
115
+ test("bandFor — 85 → stop (inclusive lower)", () => {
116
+ assert.equal(bandFor(85), "stop");
117
+ });
118
+
119
+ test("bandFor — 86 → stop", () => {
120
+ assert.equal(bandFor(86), "stop");
121
+ });
122
+
123
+ test("bandFor — 95 → stop", () => {
124
+ assert.equal(bandFor(95), "stop");
125
+ });
126
+
127
+ test("bandFor — 150 → stop (no upper clamp)", () => {
128
+ assert.equal(bandFor(150), "stop");
129
+ });
130
+
131
+ test("bandFor — NaN → normal (fail-safe)", () => {
132
+ assert.equal(bandFor(NaN), "normal");
133
+ });
134
+
135
+ test("bandFor — Infinity → normal (fail-safe: Infinity is NOT finite)", () => {
136
+ assert.equal(bandFor(Infinity), "normal");
137
+ });
138
+
139
+ test("bandFor — undefined → normal", () => {
140
+ assert.equal(bandFor(undefined), "normal");
141
+ });
142
+
143
+ test("BANDS constant mirrors bin/token-budget.js v3.0.0 three-band model", () => {
144
+ // Guard against accidental drift from the token-budget boundaries.
145
+ assert.deepEqual(BANDS, { warn: 70, stop: 85 });
146
+ });
147
+
148
+ // ── buildAdditionalContext ───────────────────────────────────────────────────
149
+
150
+ test("buildAdditionalContext — below threshold returns null", () => {
151
+ assert.equal(
152
+ buildAdditionalContext({ pct: 50, modelWindowSize: 200000, thresholdPct: 75 }),
153
+ null
154
+ );
155
+ });
156
+
157
+ test("buildAdditionalContext — at threshold returns string", () => {
158
+ const result = buildAdditionalContext({
159
+ pct: 75,
160
+ modelWindowSize: 200000,
161
+ thresholdPct: 75,
162
+ });
163
+ assert.ok(typeof result === "string");
164
+ assert.ok(result.includes("75.0%"));
165
+ });
166
+
167
+ test("buildAdditionalContext — above threshold exact contract string", () => {
168
+ const result = buildAdditionalContext({
169
+ pct: 76.2,
170
+ modelWindowSize: 200000,
171
+ thresholdPct: 75,
172
+ });
173
+ assert.equal(
174
+ result,
175
+ "⚠️ Context window at 76.2% of 200000. Run /user:gsd-t-pause to checkpoint and clear before continuing."
176
+ );
177
+ });
178
+
179
+ test("buildAdditionalContext — decimal formatting rounds via toFixed(1)", () => {
180
+ const result = buildAdditionalContext({
181
+ pct: 76.25,
182
+ modelWindowSize: 200000,
183
+ thresholdPct: 75,
184
+ });
185
+ // toFixed(1) on 76.25 → "76.3" (banker rounds vary but V8 gives "76.3" here)
186
+ assert.ok(result.includes("76.3%") || result.includes("76.2%"));
187
+ // Either rounding is acceptable — what matters is one decimal place.
188
+ const match = result.match(/at (\d+\.\d)% of/);
189
+ assert.ok(match, "must have exactly one decimal place");
190
+ });
191
+
192
+ test("buildAdditionalContext — modelWindowSize emitted raw (no commas)", () => {
193
+ const result = buildAdditionalContext({
194
+ pct: 80,
195
+ modelWindowSize: 200000,
196
+ thresholdPct: 75,
197
+ });
198
+ assert.ok(result.includes("of 200000."));
199
+ assert.ok(!result.includes("200,000"));
200
+ assert.ok(!result.includes("200K"));
201
+ });
202
+
203
+ test("buildAdditionalContext — NaN pct returns null", () => {
204
+ assert.equal(
205
+ buildAdditionalContext({ pct: NaN, modelWindowSize: 200000, thresholdPct: 75 }),
206
+ null
207
+ );
208
+ });
209
+
210
+ test("buildAdditionalContext — NaN thresholdPct returns null", () => {
211
+ assert.equal(
212
+ buildAdditionalContext({ pct: 80, modelWindowSize: 200000, thresholdPct: NaN }),
213
+ null
214
+ );
215
+ });
216
+
217
+ test("buildAdditionalContext — missing args returns null", () => {
218
+ assert.equal(buildAdditionalContext({}), null);
219
+ assert.equal(buildAdditionalContext(), null);
220
+ });
221
+
222
+ test("buildAdditionalContext — zero pct vs zero threshold emits", () => {
223
+ // 0 >= 0 is true — edge case: if thresholdPct is 0, every call emits.
224
+ const result = buildAdditionalContext({
225
+ pct: 0,
226
+ modelWindowSize: 200000,
227
+ thresholdPct: 0,
228
+ });
229
+ assert.ok(typeof result === "string");
230
+ assert.ok(result.includes("0.0%"));
231
+ });
232
+
233
+ test("buildAdditionalContext — pct over 100% still formats correctly", () => {
234
+ const result = buildAdditionalContext({
235
+ pct: 102.3,
236
+ modelWindowSize: 200000,
237
+ thresholdPct: 75,
238
+ });
239
+ assert.equal(
240
+ result,
241
+ "⚠️ Context window at 102.3% of 200000. Run /user:gsd-t-pause to checkpoint and clear before continuing."
242
+ );
243
+ });
244
+
245
+ test("buildAdditionalContext — different modelWindowSize (1M)", () => {
246
+ const result = buildAdditionalContext({
247
+ pct: 80,
248
+ modelWindowSize: 1000000,
249
+ thresholdPct: 75,
250
+ });
251
+ assert.equal(
252
+ result,
253
+ "⚠️ Context window at 80.0% of 1000000. Run /user:gsd-t-pause to checkpoint and clear before continuing."
254
+ );
255
+ });