@tekyzinc/gsd-t 3.26.11 → 3.29.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 (34) hide show
  1. package/CHANGELOG.md +151 -0
  2. package/README.md +4 -0
  3. package/bin/context-budget-audit.cjs +17 -2
  4. package/bin/gsd-t-build-coverage.cjs +438 -0
  5. package/bin/gsd-t-ci-parity.cjs +500 -0
  6. package/bin/gsd-t-economics.cjs +37 -9
  7. package/bin/gsd-t-test-data-adapters/file-json-array.cjs +56 -0
  8. package/bin/gsd-t-test-data-adapters/localstorage-key-prefix.cjs +44 -0
  9. package/bin/gsd-t-test-data-adapters/sqlite-table-where.cjs +71 -0
  10. package/bin/gsd-t-test-data-ledger.cjs +290 -0
  11. package/bin/gsd-t-time-format.cjs +94 -0
  12. package/bin/gsd-t.js +30 -0
  13. package/bin/model-windows.cjs +99 -0
  14. package/bin/model-windows.test.cjs +75 -0
  15. package/bin/orchestrator.js +4 -1
  16. package/bin/runway-estimator.cjs +35 -5
  17. package/bin/token-budget.cjs +12 -3
  18. package/commands/gsd-t-complete-milestone.md +7 -3
  19. package/commands/gsd-t-help.md +21 -0
  20. package/commands/gsd-t-init.md +1 -1
  21. package/commands/gsd-t-verify.md +90 -0
  22. package/package.json +1 -1
  23. package/scripts/context-meter/transcript-parser.js +12 -2
  24. package/scripts/context-meter/transcript-parser.test.js +51 -4
  25. package/scripts/gsd-t-calibration-hook.js +8 -1
  26. package/scripts/gsd-t-context-meter.e2e.test.js +45 -6
  27. package/scripts/gsd-t-context-meter.js +17 -3
  28. package/scripts/gsd-t-context-meter.test.js +85 -0
  29. package/scripts/gsd-t-date-guard.js +26 -5
  30. package/scripts/gsd-t-design-review-server.js +3 -1
  31. package/templates/CLAUDE-global.md +37 -1
  32. package/templates/progress.md +6 -2
  33. package/templates/test-helpers/README.md +98 -0
  34. package/templates/test-helpers/test-data-fixture.ts +153 -0
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Adapter: sqlite-table-where
3
+ *
4
+ * Purges a row from a SQLite table by ID, with a tagged-prefix LIKE guard.
5
+ * `store` is `dbPath|table|idColumn` (three pipe-separated segments).
6
+ *
7
+ * `better-sqlite3` is dynamically required at adapter-use time — adapter
8
+ * still loads when the module isn't installed. Tests self-skip in that case.
9
+ */
10
+ const fs = require('node:fs');
11
+
12
+ const KIND = 'sqlite-table-where';
13
+
14
+ const IDENT_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
15
+
16
+ function parseStore(store) {
17
+ if (typeof store !== 'string') {
18
+ throw new Error('sqlite-table-where: store must be "dbPath|table|idColumn"');
19
+ }
20
+ const parts = store.split('|');
21
+ if (parts.length !== 3) {
22
+ throw new Error('sqlite-table-where: store must be "dbPath|table|idColumn"');
23
+ }
24
+ const [dbPath, table, idColumn] = parts.map((s) => s.trim());
25
+ if (!dbPath || !table || !idColumn) {
26
+ throw new Error('sqlite-table-where: empty segment in store');
27
+ }
28
+ if (!IDENT_RE.test(table)) {
29
+ throw new Error(`sqlite-table-where: invalid table identifier "${table}"`);
30
+ }
31
+ if (!IDENT_RE.test(idColumn)) {
32
+ throw new Error(`sqlite-table-where: invalid idColumn identifier "${idColumn}"`);
33
+ }
34
+ return { dbPath, table, idColumn };
35
+ }
36
+
37
+ function purge({ store, id, taggedPrefix }) {
38
+ const { dbPath, table, idColumn } = parseStore(store);
39
+ if (typeof id !== 'string' || id.length === 0) {
40
+ throw new Error('sqlite-table-where: id must be a non-empty string');
41
+ }
42
+ if (typeof taggedPrefix !== 'string' || taggedPrefix.length === 0) {
43
+ throw new Error('sqlite-table-where: taggedPrefix is required for SQL safety');
44
+ }
45
+ if (!id.startsWith(taggedPrefix)) {
46
+ throw new Error(`sqlite-table-where: tag prefix mismatch (id="${id}", taggedPrefix="${taggedPrefix}")`);
47
+ }
48
+ if (!fs.existsSync(dbPath)) {
49
+ return 'absent';
50
+ }
51
+
52
+ let Database;
53
+ try {
54
+ Database = require('better-sqlite3');
55
+ } catch (e) {
56
+ throw new Error('sqlite-table-where: better-sqlite3 not installed; cannot purge');
57
+ }
58
+
59
+ const db = new Database(dbPath);
60
+ try {
61
+ // Identifiers are validated against IDENT_RE; values use bind parameters.
62
+ const sql = `DELETE FROM "${table}" WHERE "${idColumn}" = ? AND "${idColumn}" LIKE ?`;
63
+ const stmt = db.prepare(sql);
64
+ const info = stmt.run(id, taggedPrefix + '%');
65
+ return info.changes > 0 ? 'purged' : 'absent';
66
+ } finally {
67
+ db.close();
68
+ }
69
+ }
70
+
71
+ module.exports = { kind: KIND, purge };
@@ -0,0 +1,290 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * gsd-t-test-data-ledger — M58 D1
4
+ *
5
+ * Append-only JSONL ledger tracking test data inserted during a Verify run,
6
+ * plus a purge engine that removes those records from the underlying store
7
+ * after the suite completes.
8
+ *
9
+ * Contract: .gsd-t/contracts/test-data-ledger-contract.md
10
+ */
11
+
12
+ const fs = require('node:fs');
13
+ const path = require('node:path');
14
+
15
+ const LEDGER_RELPATH = path.join('.gsd-t', 'test-data-ledger.jsonl');
16
+
17
+ // ─── Adapter registry ─────────────────────────────────────────────────────
18
+
19
+ const adapters = new Map();
20
+
21
+ function registerAdapter(kind, adapter) {
22
+ if (typeof kind !== 'string' || kind.length === 0) {
23
+ throw new Error('registerAdapter: kind must be a non-empty string');
24
+ }
25
+ if (!adapter || typeof adapter.purge !== 'function') {
26
+ throw new Error('registerAdapter: adapter must export a purge(...) function');
27
+ }
28
+ adapters.set(kind, adapter);
29
+ }
30
+
31
+ // Built-in adapters auto-register on module load.
32
+ registerAdapter('file-json-array', require('./gsd-t-test-data-adapters/file-json-array.cjs'));
33
+ registerAdapter('localStorage-key-prefix', require('./gsd-t-test-data-adapters/localstorage-key-prefix.cjs'));
34
+ registerAdapter('sqlite-table-where', require('./gsd-t-test-data-adapters/sqlite-table-where.cjs'));
35
+
36
+ // ─── Public API ───────────────────────────────────────────────────────────
37
+
38
+ function ledgerPathFor(projectDir) {
39
+ return path.join(projectDir, LEDGER_RELPATH);
40
+ }
41
+
42
+ function appendInsert({ projectDir, runId, kind, store, id, taggedPrefix, insertedAt }) {
43
+ if (typeof projectDir !== 'string' || projectDir.length === 0) {
44
+ throw new Error('appendInsert: projectDir is required');
45
+ }
46
+ if (typeof runId !== 'string' || runId.length === 0) {
47
+ throw new Error('appendInsert: runId is required');
48
+ }
49
+ if (typeof kind !== 'string' || kind.length === 0) {
50
+ throw new Error('appendInsert: kind is required');
51
+ }
52
+ if (typeof store !== 'string' || store.length === 0) {
53
+ throw new Error('appendInsert: store is required');
54
+ }
55
+ if (typeof id !== 'string' || id.length === 0) {
56
+ throw new Error('appendInsert: id is required');
57
+ }
58
+ const finalTaggedPrefix = typeof taggedPrefix === 'string' && taggedPrefix.length > 0
59
+ ? taggedPrefix
60
+ : 'E2E_';
61
+ if (!id.startsWith(finalTaggedPrefix)) {
62
+ throw new Error(`appendInsert: id "${id}" does not start with taggedPrefix "${finalTaggedPrefix}"`);
63
+ }
64
+ const finalInsertedAt = typeof insertedAt === 'string' && insertedAt.length > 0
65
+ ? insertedAt
66
+ : new Date().toISOString();
67
+
68
+ const row = {
69
+ runId,
70
+ kind,
71
+ store,
72
+ id,
73
+ taggedPrefix: finalTaggedPrefix,
74
+ insertedAt: finalInsertedAt,
75
+ };
76
+
77
+ const ledgerPath = ledgerPathFor(projectDir);
78
+ fs.mkdirSync(path.dirname(ledgerPath), { recursive: true });
79
+ fs.appendFileSync(ledgerPath, JSON.stringify(row) + '\n', 'utf8');
80
+ return { ok: true, ledgerPath };
81
+ }
82
+
83
+ function listInserts({ projectDir, runId }) {
84
+ if (typeof projectDir !== 'string' || projectDir.length === 0) {
85
+ throw new Error('listInserts: projectDir is required');
86
+ }
87
+ const ledgerPath = ledgerPathFor(projectDir);
88
+ if (!fs.existsSync(ledgerPath)) return [];
89
+ const raw = fs.readFileSync(ledgerPath, 'utf8');
90
+ const rows = [];
91
+ for (const line of raw.split('\n')) {
92
+ const trimmed = line.trim();
93
+ if (!trimmed) continue;
94
+ try {
95
+ const parsed = JSON.parse(trimmed);
96
+ if (runId && parsed.runId !== runId) continue;
97
+ rows.push(parsed);
98
+ } catch {
99
+ // skip malformed lines (audit-trail is permissive)
100
+ }
101
+ }
102
+ return rows;
103
+ }
104
+
105
+ async function purgeRunInserts({ projectDir, runId, dryRun }) {
106
+ if (typeof projectDir !== 'string' || projectDir.length === 0) {
107
+ throw new Error('purgeRunInserts: projectDir is required');
108
+ }
109
+ if (typeof runId !== 'string' || runId.length === 0) {
110
+ throw new Error('purgeRunInserts: runId is required');
111
+ }
112
+ const rows = listInserts({ projectDir, runId });
113
+ const purged = [];
114
+ const skipped = [];
115
+ const errors = [];
116
+
117
+ for (const row of rows) {
118
+ if (dryRun === true) {
119
+ purged.push(row); // dry-run treats every targeted row as 'would be purged'
120
+ continue;
121
+ }
122
+ const adapter = adapters.get(row.kind);
123
+ if (!adapter) {
124
+ errors.push({ record: row, message: `no adapter registered for kind "${row.kind}"` });
125
+ continue;
126
+ }
127
+ try {
128
+ const result = await adapter.purge({
129
+ store: row.store,
130
+ id: row.id,
131
+ taggedPrefix: row.taggedPrefix,
132
+ });
133
+ if (result === 'purged') {
134
+ purged.push(row);
135
+ } else if (result === 'absent') {
136
+ skipped.push(row);
137
+ } else {
138
+ errors.push({ record: row, message: `adapter returned unexpected value "${String(result)}"` });
139
+ }
140
+ } catch (e) {
141
+ errors.push({ record: row, message: e && e.message ? e.message : String(e) });
142
+ }
143
+ }
144
+
145
+ return { purged, skipped, errors };
146
+ }
147
+
148
+ // ─── CLI ──────────────────────────────────────────────────────────────────
149
+
150
+ const COLOR = {
151
+ reset: '\x1b[0m',
152
+ bold: '\x1b[1m',
153
+ red: '\x1b[31m',
154
+ green: '\x1b[32m',
155
+ yellow: '\x1b[33m',
156
+ blue: '\x1b[34m',
157
+ dim: '\x1b[2m',
158
+ };
159
+
160
+ function parseArgs(argv) {
161
+ const opts = {
162
+ mode: null, // 'list' | 'purge'
163
+ runId: null,
164
+ dryRun: false,
165
+ json: false,
166
+ projectDir: process.cwd(),
167
+ };
168
+ for (let i = 0; i < argv.length; i++) {
169
+ const a = argv[i];
170
+ if (a === '--list') opts.mode = 'list';
171
+ else if (a === '--purge') opts.mode = 'purge';
172
+ else if (a === '--dry-run') opts.dryRun = true;
173
+ else if (a === '--json') opts.json = true;
174
+ else if (a === '--run' || a === '--run-id') {
175
+ opts.runId = argv[++i] || null;
176
+ } else if (a === '--project') {
177
+ opts.projectDir = argv[++i] || process.cwd();
178
+ } else if (a === '-h' || a === '--help') {
179
+ opts.mode = 'help';
180
+ }
181
+ }
182
+ return opts;
183
+ }
184
+
185
+ function printHelp() {
186
+ process.stdout.write(`Usage: gsd-t test-data --list [--run <id>] [--json]
187
+ gsd-t test-data --purge --run <id> [--dry-run] [--json]
188
+
189
+ Options:
190
+ --list List ledger entries (optionally filtered by --run)
191
+ --purge Purge ledger entries for a given --run
192
+ --run <id> Verify run id (e.g., verify-m58-20260527T091800Z)
193
+ --dry-run With --purge: report what would be purged without calling adapters
194
+ --json Emit JSON envelope instead of pretty output
195
+ --project <dir> Project directory (defaults to CWD)
196
+ -h, --help Show this help
197
+
198
+ Exit codes:
199
+ 0 success
200
+ 4 one or more adapter errors (purge mode)
201
+ 64 CLI argument error
202
+ `);
203
+ }
204
+
205
+ async function main(argv) {
206
+ const opts = parseArgs(argv);
207
+ if (opts.mode === 'help' || !opts.mode) {
208
+ printHelp();
209
+ return opts.mode === 'help' ? 0 : 64;
210
+ }
211
+
212
+ if (opts.mode === 'list') {
213
+ const rows = listInserts({ projectDir: opts.projectDir, runId: opts.runId });
214
+ if (opts.json) {
215
+ process.stdout.write(JSON.stringify({ ok: true, rows }) + '\n');
216
+ } else {
217
+ if (rows.length === 0) {
218
+ process.stdout.write(`${COLOR.dim}No ledger entries${opts.runId ? ` for run "${opts.runId}"` : ''}.${COLOR.reset}\n`);
219
+ } else {
220
+ process.stdout.write(`${COLOR.bold}Test data ledger${opts.runId ? ` — run ${opts.runId}` : ''}${COLOR.reset}\n`);
221
+ for (const r of rows) {
222
+ process.stdout.write(` ${COLOR.blue}${r.kind}${COLOR.reset} ${r.id} ${COLOR.dim}(${r.store})${COLOR.reset}\n`);
223
+ }
224
+ process.stdout.write(`\n${COLOR.bold}Total:${COLOR.reset} ${rows.length}\n`);
225
+ }
226
+ }
227
+ return 0;
228
+ }
229
+
230
+ if (opts.mode === 'purge') {
231
+ if (!opts.runId) {
232
+ process.stderr.write('gsd-t test-data --purge requires --run <id>\n');
233
+ return 64;
234
+ }
235
+ const envelope = await purgeRunInserts({
236
+ projectDir: opts.projectDir,
237
+ runId: opts.runId,
238
+ dryRun: opts.dryRun,
239
+ });
240
+ if (opts.json) {
241
+ process.stdout.write(JSON.stringify({
242
+ ok: envelope.errors.length === 0,
243
+ runId: opts.runId,
244
+ dryRun: !!opts.dryRun,
245
+ purged: envelope.purged.length,
246
+ skipped: envelope.skipped.length,
247
+ errors: envelope.errors,
248
+ }) + '\n');
249
+ } else {
250
+ const tag = opts.dryRun ? '[DRY RUN] ' : '';
251
+ process.stdout.write(`${COLOR.bold}${tag}Purge run ${opts.runId}${COLOR.reset}\n`);
252
+ process.stdout.write(` ${COLOR.green}purged:${COLOR.reset} ${envelope.purged.length}\n`);
253
+ process.stdout.write(` ${COLOR.yellow}skipped:${COLOR.reset} ${envelope.skipped.length}\n`);
254
+ process.stdout.write(` ${COLOR.red}errors:${COLOR.reset} ${envelope.errors.length}\n`);
255
+ if (envelope.errors.length > 0) {
256
+ process.stdout.write(`\n${COLOR.red}Errors:${COLOR.reset}\n`);
257
+ for (const e of envelope.errors.slice(0, 5)) {
258
+ process.stdout.write(` - ${e.record.id} (${e.record.kind}): ${e.message}\n`);
259
+ }
260
+ if (envelope.errors.length > 5) {
261
+ process.stdout.write(` … and ${envelope.errors.length - 5} more\n`);
262
+ }
263
+ }
264
+ }
265
+ return envelope.errors.length === 0 ? 0 : 4;
266
+ }
267
+
268
+ printHelp();
269
+ return 64;
270
+ }
271
+
272
+ module.exports = {
273
+ appendInsert,
274
+ listInserts,
275
+ purgeRunInserts,
276
+ registerAdapter,
277
+ main,
278
+ ledgerPathFor,
279
+ LEDGER_RELPATH,
280
+ };
281
+
282
+ if (require.main === module) {
283
+ main(process.argv.slice(2)).then(
284
+ (code) => process.exit(code),
285
+ (err) => {
286
+ process.stderr.write(`gsd-t test-data: ${err && err.message ? err.message : String(err)}\n`);
287
+ process.exit(1);
288
+ }
289
+ );
290
+ }
@@ -0,0 +1,94 @@
1
+ /**
2
+ * gsd-t-time-format — M59
3
+ *
4
+ * Shared helpers for the v3.29.10 timestamp-precision format.
5
+ *
6
+ * Exports:
7
+ * localIsoWithOffset([date]) → "YYYY-MM-DDTHH:MM:SS±HH:MM" (local offset)
8
+ * localTimestampForProgress([date]) → "YYYY-MM-DD HH:MM TZ" (human-readable, for progress.md fields)
9
+ *
10
+ * Both helpers source the current time from `new Date()` by default. The
11
+ * `[GSD-T NOW]` UserPromptSubmit signal feeds the system clock, so these
12
+ * are correct in any GSD-T spawn.
13
+ */
14
+
15
+ const TZ_ABBR_FALLBACK = 'UTC';
16
+
17
+ function pad2(n) {
18
+ const s = String(n);
19
+ return s.length < 2 ? `0${s}` : s;
20
+ }
21
+
22
+ /**
23
+ * ISO 8601 timestamp with local timezone offset (NOT UTC `Z`).
24
+ *
25
+ * Example: `2026-05-27T10:15:30-07:00` (PDT, summer)
26
+ * `2026-12-15T10:15:30-08:00` (PST, winter)
27
+ */
28
+ function localIsoWithOffset(date) {
29
+ const d = date instanceof Date ? date : new Date();
30
+ const yyyy = d.getFullYear();
31
+ const mm = pad2(d.getMonth() + 1);
32
+ const dd = pad2(d.getDate());
33
+ const hh = pad2(d.getHours());
34
+ const mi = pad2(d.getMinutes());
35
+ const ss = pad2(d.getSeconds());
36
+
37
+ const offsetMinTotal = -d.getTimezoneOffset(); // east of UTC is positive
38
+ const sign = offsetMinTotal >= 0 ? '+' : '-';
39
+ const offsetAbs = Math.abs(offsetMinTotal);
40
+ const offH = pad2(Math.floor(offsetAbs / 60));
41
+ const offM = pad2(offsetAbs % 60);
42
+
43
+ return `${yyyy}-${mm}-${dd}T${hh}:${mi}:${ss}${sign}${offH}:${offM}`;
44
+ }
45
+
46
+ /**
47
+ * Resolve a short human-readable TZ abbreviation (e.g., "PDT", "PST").
48
+ * Uses Intl.DateTimeFormat short timezone where available; falls back to
49
+ * the numeric offset string if the platform doesn't provide one.
50
+ */
51
+ function shortTzAbbr(date) {
52
+ const d = date instanceof Date ? date : new Date();
53
+ try {
54
+ const parts = new Intl.DateTimeFormat('en-US', {
55
+ timeZoneName: 'short',
56
+ }).formatToParts(d);
57
+ const tzPart = parts.find((p) => p.type === 'timeZoneName');
58
+ if (tzPart && tzPart.value) return tzPart.value;
59
+ } catch {
60
+ /* fall through */
61
+ }
62
+ // Fallback — numeric offset like "GMT-07:00"
63
+ const offsetMin = -d.getTimezoneOffset();
64
+ const sign = offsetMin >= 0 ? '+' : '-';
65
+ const abs = Math.abs(offsetMin);
66
+ return `GMT${sign}${pad2(Math.floor(abs / 60))}:${pad2(abs % 60)}`;
67
+ }
68
+
69
+ /**
70
+ * Human-readable timestamp for progress.md visible fields:
71
+ * "YYYY-MM-DD HH:MM TZ"
72
+ *
73
+ * Example: `2026-05-27 10:15 PDT`
74
+ *
75
+ * This is the M59 format for:
76
+ * - `## Date:` line in progress.md frontmatter
77
+ * - "Completed" cell in the Completed Milestones table
78
+ * - "Date" cell in the Session Log table
79
+ */
80
+ function localTimestampForProgress(date) {
81
+ const d = date instanceof Date ? date : new Date();
82
+ const yyyy = d.getFullYear();
83
+ const mm = pad2(d.getMonth() + 1);
84
+ const dd = pad2(d.getDate());
85
+ const hh = pad2(d.getHours());
86
+ const mi = pad2(d.getMinutes());
87
+ return `${yyyy}-${mm}-${dd} ${hh}:${mi} ${shortTzAbbr(d) || TZ_ABBR_FALLBACK}`;
88
+ }
89
+
90
+ module.exports = {
91
+ localIsoWithOffset,
92
+ localTimestampForProgress,
93
+ shortTzAbbr,
94
+ };
package/bin/gsd-t.js CHANGED
@@ -1185,6 +1185,9 @@ const GLOBAL_BIN_TOOLS = [
1185
1185
  "gsd-t-verify-gate-judge.cjs",
1186
1186
  // M55 D2 substrate — parallel-cli engine (added v3.25.11 patch — missed in initial M55 D5 wire-in).
1187
1187
  "parallel-cli.cjs",
1188
+ // M57 — CI-parity verify-gate checks (structural build-coverage + containment-safe ci-parity).
1189
+ "gsd-t-build-coverage.cjs",
1190
+ "gsd-t-ci-parity.cjs",
1188
1191
  ];
1189
1192
 
1190
1193
  function installGlobalBinTools() {
@@ -4559,6 +4562,33 @@ if (require.main === module) {
4559
4562
  });
4560
4563
  process.exit(res.status == null ? 1 : res.status);
4561
4564
  }
4565
+ case "build-coverage": {
4566
+ // M57 D1 — `gsd-t build-coverage` thin dispatcher to bin/gsd-t-build-coverage.cjs.
4567
+ const { spawnSync } = require("child_process");
4568
+ const js = path.join(__dirname, "gsd-t-build-coverage.cjs");
4569
+ const res = spawnSync(process.execPath, [js, ...args.slice(1)], {
4570
+ stdio: "inherit",
4571
+ });
4572
+ process.exit(res.status == null ? 1 : res.status);
4573
+ }
4574
+ case "ci-parity": {
4575
+ // M57 D2 — `gsd-t ci-parity` thin dispatcher to bin/gsd-t-ci-parity.cjs.
4576
+ const { spawnSync } = require("child_process");
4577
+ const js = path.join(__dirname, "gsd-t-ci-parity.cjs");
4578
+ const res = spawnSync(process.execPath, [js, ...args.slice(1)], {
4579
+ stdio: "inherit",
4580
+ });
4581
+ process.exit(res.status == null ? 1 : res.status);
4582
+ }
4583
+ case "test-data": {
4584
+ // M58 D1 — `gsd-t test-data --list|--purge` thin dispatcher.
4585
+ const { spawnSync } = require("child_process");
4586
+ const js = path.join(__dirname, "gsd-t-test-data-ledger.cjs");
4587
+ const res = spawnSync(process.execPath, [js, ...args.slice(1)], {
4588
+ stdio: "inherit",
4589
+ });
4590
+ process.exit(res.status == null ? 1 : res.status);
4591
+ }
4562
4592
  case "stream-feed": {
4563
4593
  doStreamFeed(args.slice(1));
4564
4594
  break;
@@ -0,0 +1,99 @@
1
+ /**
2
+ * bin/model-windows.cjs
3
+ *
4
+ * Single source of truth for Claude model → context-window size (in input
5
+ * tokens). The context meter and every downstream budget module must size the
6
+ * window from the MODEL ACTUALLY RUNNING, not a hardcoded constant.
7
+ *
8
+ * Why this exists
9
+ * ---------------
10
+ * Prior to this module every budget site hardcoded `200000` with a comment
11
+ * "claude-opus-4-6 default". Opus 4.6 and 4.7 (and Sonnet 4.x) ship a
12
+ * 1,000,000-token context window. Hardcoding 200k made the context meter
13
+ * overcount usage 5× and fire the headless handoff at ~64% of context
14
+ * REMAINING. This map fixes that at the source.
15
+ *
16
+ * Resolution strategy
17
+ * -------------------
18
+ * GSD-T jumps between models per-subagent, so a static config value is wrong.
19
+ * The orchestrator session whose transcript the meter reads, however, runs a
20
+ * single model for its lifetime, and every assistant message in the transcript
21
+ * records its `model` id. `windowForModel(modelId)` maps that id to a window.
22
+ *
23
+ * Matching is by longest-prefix so versioned ids resolve even if a future
24
+ * dated suffix appears (e.g. "claude-opus-4-7-20260115" → opus 4.x entry).
25
+ * Unknown / missing model → SAFE_DEFAULT_WINDOW (the large 1M window: a guard
26
+ * that triggers late is worse than one that never undercounts a real 1M
27
+ * session — but see note below; we deliberately pick the large default so the
28
+ * meter does NOT regress to premature handoffs on an unrecognized new model).
29
+ *
30
+ * Zero dependencies. CommonJS. Pure functions.
31
+ */
32
+
33
+ "use strict";
34
+
35
+ // The conservative fallback when a model can't be resolved. We choose the
36
+ // LARGE window (1M) on purpose: the bug we are fixing is premature handoff
37
+ // from a too-SMALL assumed window. An unknown future model is far more likely
38
+ // to have a >=1M window than a 200k one, and an over-large window degrades
39
+ // gracefully (handoff a little late) whereas an under-small one breaks the
40
+ // workflow (handoff way too early, the reported symptom).
41
+ const SAFE_DEFAULT_WINDOW = 1_000_000;
42
+
43
+ // The legacy small window, kept as a named export for the few call sites that
44
+ // must preserve old behavior explicitly (e.g. fixtures, back-compat configs).
45
+ const LEGACY_SMALL_WINDOW = 200_000;
46
+
47
+ // Longest-prefix map: key is a model-id prefix, value is the input-token
48
+ // context window for that model family. Order does not matter — resolution
49
+ // picks the LONGEST matching prefix.
50
+ const MODEL_WINDOWS = Object.freeze({
51
+ // Opus 4.6 / 4.7 — 1M context window.
52
+ "claude-opus-4-6": 1_000_000,
53
+ "claude-opus-4-7": 1_000_000,
54
+ // Generic opus-4 fallback (covers any 4.x point release not listed above).
55
+ "claude-opus-4": 1_000_000,
56
+
57
+ // Sonnet 4.x — 1M context window.
58
+ "claude-sonnet-4": 1_000_000,
59
+
60
+ // Haiku 4.x — 200k context window.
61
+ "claude-haiku-4": 200_000,
62
+
63
+ // Pre-4 families (defensive — older long sessions / replayed transcripts).
64
+ "claude-3-7-sonnet": 200_000,
65
+ "claude-3-5-sonnet": 200_000,
66
+ "claude-3-5-haiku": 200_000,
67
+ "claude-3-opus": 200_000,
68
+ });
69
+
70
+ /**
71
+ * Resolve a context-window size (input tokens) for a Claude model id.
72
+ *
73
+ * @param {string|null|undefined} modelId e.g. "claude-opus-4-7" or
74
+ * "claude-opus-4-7-20260115". Non-string / empty → SAFE_DEFAULT_WINDOW.
75
+ * @returns {number} positive integer window size
76
+ */
77
+ function windowForModel(modelId) {
78
+ if (typeof modelId !== "string" || modelId.length === 0) {
79
+ return SAFE_DEFAULT_WINDOW;
80
+ }
81
+ const id = modelId.trim().toLowerCase();
82
+
83
+ let best = null;
84
+ let bestLen = -1;
85
+ for (const prefix of Object.keys(MODEL_WINDOWS)) {
86
+ if (id.startsWith(prefix) && prefix.length > bestLen) {
87
+ best = MODEL_WINDOWS[prefix];
88
+ bestLen = prefix.length;
89
+ }
90
+ }
91
+ return best != null ? best : SAFE_DEFAULT_WINDOW;
92
+ }
93
+
94
+ module.exports = {
95
+ windowForModel,
96
+ MODEL_WINDOWS,
97
+ SAFE_DEFAULT_WINDOW,
98
+ LEGACY_SMALL_WINDOW,
99
+ };
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Tests for bin/model-windows.cjs — model → context-window resolution.
3
+ *
4
+ * The bug this fixes: the context meter hardcoded a 200k window so an Opus 4.7
5
+ * session (1M window) read as 5× over budget, firing the headless handoff at
6
+ * ~64% of context REMAINING. These tests pin the corrected windows.
7
+ */
8
+
9
+ "use strict";
10
+
11
+ const test = require("node:test");
12
+ const assert = require("node:assert/strict");
13
+
14
+ const {
15
+ windowForModel,
16
+ MODEL_WINDOWS,
17
+ SAFE_DEFAULT_WINDOW,
18
+ LEGACY_SMALL_WINDOW,
19
+ } = require("./model-windows.cjs");
20
+
21
+ test("Opus 4.7 resolves to a 1M window (the reported regression)", () => {
22
+ assert.equal(windowForModel("claude-opus-4-7"), 1_000_000);
23
+ });
24
+
25
+ test("Opus 4.6 resolves to a 1M window", () => {
26
+ assert.equal(windowForModel("claude-opus-4-6"), 1_000_000);
27
+ });
28
+
29
+ test("dated/versioned suffix still resolves via longest-prefix", () => {
30
+ assert.equal(windowForModel("claude-opus-4-7-20260115"), 1_000_000);
31
+ assert.equal(windowForModel("claude-sonnet-4-6-20251201"), 1_000_000);
32
+ });
33
+
34
+ test("Sonnet 4.x resolves to a 1M window", () => {
35
+ assert.equal(windowForModel("claude-sonnet-4-6"), 1_000_000);
36
+ assert.equal(windowForModel("claude-sonnet-4"), 1_000_000);
37
+ });
38
+
39
+ test("Haiku 4.x resolves to the 200k window", () => {
40
+ assert.equal(windowForModel("claude-haiku-4-5-20251001"), 200_000);
41
+ assert.equal(windowForModel("claude-haiku-4"), 200_000);
42
+ });
43
+
44
+ test("longest-prefix wins over a shorter generic prefix", () => {
45
+ // "claude-opus-4-7" (15) must beat "claude-opus-4" (13). Both map to 1M
46
+ // here, so assert the resolution mechanism via a value-independent check:
47
+ // a hypothetical future divergence would surface if this regressed.
48
+ assert.equal(windowForModel("claude-opus-4-7"), MODEL_WINDOWS["claude-opus-4-7"]);
49
+ });
50
+
51
+ test("case-insensitive and whitespace-tolerant", () => {
52
+ assert.equal(windowForModel(" CLAUDE-OPUS-4-7 "), 1_000_000);
53
+ });
54
+
55
+ test("unknown / missing model falls back to the SAFE large default", () => {
56
+ assert.equal(windowForModel("claude-future-99"), SAFE_DEFAULT_WINDOW);
57
+ assert.equal(windowForModel(""), SAFE_DEFAULT_WINDOW);
58
+ assert.equal(windowForModel(null), SAFE_DEFAULT_WINDOW);
59
+ assert.equal(windowForModel(undefined), SAFE_DEFAULT_WINDOW);
60
+ assert.equal(windowForModel(42), SAFE_DEFAULT_WINDOW);
61
+ });
62
+
63
+ test("SAFE_DEFAULT_WINDOW is the large (1M) window, not the legacy 200k", () => {
64
+ // Core anti-regression assertion: the fallback must NOT reintroduce the
65
+ // premature-handoff bug for an unrecognized model.
66
+ assert.equal(SAFE_DEFAULT_WINDOW, 1_000_000);
67
+ assert.equal(LEGACY_SMALL_WINDOW, 200_000);
68
+ assert.notEqual(SAFE_DEFAULT_WINDOW, LEGACY_SMALL_WINDOW);
69
+ });
70
+
71
+ test("every mapped window is a positive integer", () => {
72
+ for (const [k, v] of Object.entries(MODEL_WINDOWS)) {
73
+ assert.ok(Number.isInteger(v) && v > 0, `${k} → ${v} must be a positive int`);
74
+ }
75
+ });