@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.
- package/CHANGELOG.md +116 -0
- package/README.md +71 -1
- package/bin/advisor-integration.js +93 -0
- package/bin/check-headless-sessions.js +140 -0
- package/bin/context-meter-config.cjs +101 -0
- package/bin/context-meter-config.test.cjs +101 -0
- package/bin/gsd-t.js +709 -16
- package/bin/headless-auto-spawn.js +290 -0
- package/bin/model-selector.js +224 -0
- package/bin/runway-estimator.js +242 -0
- package/bin/token-budget.js +96 -89
- package/bin/token-optimizer.js +471 -0
- package/bin/token-telemetry.js +246 -0
- package/commands/gsd-t-audit.md +3 -3
- package/commands/gsd-t-backlog-list.md +38 -0
- package/commands/gsd-t-brainstorm.md +3 -3
- package/commands/gsd-t-complete-milestone.md +24 -0
- package/commands/gsd-t-debug.md +124 -7
- package/commands/gsd-t-discuss.md +10 -3
- package/commands/gsd-t-doc-ripple.md +32 -4
- package/commands/gsd-t-execute.md +107 -52
- package/commands/gsd-t-help.md +19 -0
- package/commands/gsd-t-integrate.md +67 -4
- package/commands/gsd-t-optimization-apply.md +91 -0
- package/commands/gsd-t-optimization-reject.md +94 -0
- package/commands/gsd-t-partition.md +7 -0
- package/commands/gsd-t-pause.md +3 -0
- package/commands/gsd-t-plan.md +10 -3
- package/commands/gsd-t-prd.md +3 -3
- package/commands/gsd-t-quick.md +71 -9
- package/commands/gsd-t-reflect.md +3 -7
- package/commands/gsd-t-resume.md +36 -0
- package/commands/gsd-t-status.md +31 -0
- package/commands/gsd-t-test-sync.md +7 -0
- package/commands/gsd-t-verify.md +12 -5
- package/commands/gsd-t-visualize.md +3 -7
- package/commands/gsd-t-wave.md +82 -18
- package/docs/GSD-T-README.md +52 -0
- package/docs/architecture.md +95 -0
- package/docs/infrastructure.md +117 -0
- package/docs/methodology.md +36 -0
- package/docs/prd-harness-evolution.md +51 -37
- package/docs/requirements.md +66 -0
- package/package.json +1 -1
- package/scripts/context-meter/count-tokens-client.js +221 -0
- package/scripts/context-meter/count-tokens-client.test.js +308 -0
- package/scripts/context-meter/test-injector.js +55 -0
- package/scripts/context-meter/threshold.js +88 -0
- package/scripts/context-meter/threshold.test.js +255 -0
- package/scripts/context-meter/transcript-parser.js +252 -0
- package/scripts/context-meter/transcript-parser.test.js +320 -0
- package/scripts/gsd-t-context-meter.e2e.test.js +415 -0
- package/scripts/gsd-t-context-meter.js +350 -0
- package/scripts/gsd-t-context-meter.test.js +417 -0
- package/scripts/gsd-t-heartbeat.js +2 -2
- package/scripts/gsd-t-statusline.js +23 -8
- package/templates/CLAUDE-global.md +5 -1
- package/templates/CLAUDE-project.md +26 -6
- package/templates/context-meter-config.json +10 -0
- package/templates/prompts/README.md +1 -1
- 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 };
|