@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,500 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ /**
5
+ * GSD-T ci-parity (M57 D2)
6
+ *
7
+ * Reproduces a project's actual CI build locally instead of relying on
8
+ * potentially warm-cache local tsc/test parity.
9
+ *
10
+ * Auto-detects CI config via locked detection precedence (user decision — do
11
+ * NOT reorder): cloudbuild.yaml → .github/workflows/*.yml → Dockerfile RUN →
12
+ * package.json scripts (build, typecheck, test) → none.
13
+ *
14
+ * Clears build caches before running detected commands so stale incremental
15
+ * artifacts cannot mask regressions (the TimeTracking v1.10.12 SC2 failure
16
+ * class: ~8 noImplicitAny errors passed warm-cache local tsc, failed CI cold
17
+ * build).
18
+ *
19
+ * When a Dockerfile is present, runs a real `docker build` — presence is the
20
+ * sole trigger; no opt-in flag (locked user decision).
21
+ *
22
+ * Contract: .gsd-t/contracts/ci-parity-contract.md v1.0.0 STABLE
23
+ *
24
+ * Parse limits (no external YAML lib; minimal line/regex scanning):
25
+ * - cloudbuild.yaml: only reads `args:` array elements under `steps:`.
26
+ * Multi-line folded/block scalars are not supported. Only simple
27
+ * `args: [...]` one-liner arrays or `- 'val'` list forms are handled.
28
+ * - .github/workflows/*.yml: only reads `run:` lines from the FIRST job's
29
+ * steps. Multi-line pipe/fold blocks (`run: |`) capture only the first
30
+ * continuation line. `uses:` steps (no `run:`) are skipped.
31
+ * - Dockerfile: reads `RUN` lines (single-line only; backslash-continuations
32
+ * are joined into one command).
33
+ * These limits are intentional trade-offs to keep the module at zero
34
+ * external runtime deps.
35
+ */
36
+
37
+ const fs = require('fs');
38
+ const path = require('path');
39
+ const child_process = require('child_process');
40
+
41
+ const DEFAULT_TIMEOUT_MS = 120_000;
42
+
43
+ // ── Detection ─────────────────────────────────────────────────────────────────
44
+
45
+ /** Parse cloudbuild.yaml steps[].args → array of command strings. */
46
+ function detectCloudbuild(projectDir) {
47
+ const f = path.join(projectDir, 'cloudbuild.yaml');
48
+ if (!fs.existsSync(f)) return null;
49
+ const lines = fs.readFileSync(f, 'utf8').split('\n');
50
+ const cmds = [];
51
+ let inArgs = false;
52
+ let argParts = [];
53
+
54
+ for (const raw of lines) {
55
+ const line = raw.trimEnd();
56
+ // Inline array: ` args: ['node', '-e', '...']`
57
+ const inlineM = line.match(/^\s+args:\s*\[(.+)\]/);
58
+ if (inlineM) {
59
+ const cmd = inlineM[1]
60
+ .split(',')
61
+ .map((t) => t.trim().replace(/^['"]|['"]$/g, ''))
62
+ .filter(Boolean)
63
+ .join(' ');
64
+ if (cmd) cmds.push(cmd);
65
+ inArgs = false;
66
+ argParts = [];
67
+ continue;
68
+ }
69
+ // Start of args block: ` args:`
70
+ if (/^\s+args:\s*$/.test(line)) {
71
+ inArgs = true;
72
+ argParts = [];
73
+ continue;
74
+ }
75
+ if (inArgs) {
76
+ // List item under args
77
+ const itemM = line.match(/^\s+-\s+['"]?(.+?)['"]?\s*$/);
78
+ if (itemM) {
79
+ argParts.push(itemM[1]);
80
+ continue;
81
+ }
82
+ // Not a list item → end of args block
83
+ if (argParts.length) {
84
+ cmds.push(argParts.join(' '));
85
+ argParts = [];
86
+ }
87
+ inArgs = false;
88
+ }
89
+ }
90
+ if (inArgs && argParts.length) cmds.push(argParts.join(' '));
91
+ return cmds.length ? cmds : [];
92
+ }
93
+
94
+ /** Parse first job's steps[].run from .github/workflows/*.yml. */
95
+ function detectWorkflows(projectDir) {
96
+ const wfDir = path.join(projectDir, '.github', 'workflows');
97
+ if (!fs.existsSync(wfDir)) return null;
98
+ const files = fs.readdirSync(wfDir).filter((f) => /\.ya?ml$/.test(f));
99
+ if (!files.length) return null;
100
+ const lines = fs.readFileSync(path.join(wfDir, files[0]), 'utf8').split('\n');
101
+ const cmds = [];
102
+ let inJobs = false;
103
+ let inSteps = false;
104
+ let firstJobDone = false;
105
+ let jobDepth = 0;
106
+ let pendingRun = null;
107
+
108
+ for (const raw of lines) {
109
+ const line = raw.trimEnd();
110
+ if (/^jobs:/.test(line)) { inJobs = true; continue; }
111
+ if (!inJobs) continue;
112
+ if (firstJobDone) continue;
113
+
114
+ // Detect first-job boundary (4-space indent key)
115
+ if (/^ \w[\w-]*:/.test(line)) { jobDepth++; if (jobDepth > 1) { firstJobDone = true; continue; } }
116
+ if (/^\s+steps:/.test(line)) { inSteps = true; continue; }
117
+ if (!inSteps) continue;
118
+
119
+ // Inline run: ` - run: cmd`
120
+ const inlineRunM = line.match(/^\s+-\s+run:\s+(.+)$/);
121
+ if (inlineRunM) { cmds.push(inlineRunM[1].trim()); pendingRun = null; continue; }
122
+ // Block run marker: ` run: |` or ` run: >`
123
+ const blockRunM = line.match(/^\s+run:\s*[|>]?\s*$/);
124
+ if (blockRunM) { pendingRun = true; continue; }
125
+ // Inline run value: ` run: cmd`
126
+ const plainRunM = line.match(/^\s+run:\s+(.+)$/);
127
+ if (plainRunM && !blockRunM) { cmds.push(plainRunM[1].trim()); pendingRun = null; continue; }
128
+ // Continuation line for block run (indented more than run:)
129
+ if (pendingRun) {
130
+ const contM = line.match(/^\s{10,}(.+)$/);
131
+ if (contM) { cmds.push(contM[1].trim()); pendingRun = null; continue; }
132
+ if (line.trim() === '') continue;
133
+ pendingRun = null;
134
+ }
135
+ }
136
+ return cmds.length ? cmds : [];
137
+ }
138
+
139
+ /** Parse RUN lines from a Dockerfile (backslash-continuations joined). */
140
+ function detectDockerfileRun(projectDir) {
141
+ const f = path.join(projectDir, 'Dockerfile');
142
+ if (!fs.existsSync(f)) return null;
143
+ const lines = fs.readFileSync(f, 'utf8').split('\n');
144
+ const cmds = [];
145
+ let buf = '';
146
+
147
+ for (const raw of lines) {
148
+ const line = raw.trimEnd();
149
+ if (/^RUN\s+/.test(line)) {
150
+ buf = line.replace(/^RUN\s+/, '').replace(/\\$/, '').trim();
151
+ } else if (buf && /^\s/.test(line)) {
152
+ buf += ' ' + line.replace(/\\$/, '').trim();
153
+ } else {
154
+ if (buf) { cmds.push(buf); buf = ''; }
155
+ }
156
+ }
157
+ if (buf) cmds.push(buf);
158
+ return cmds.length ? cmds : [];
159
+ }
160
+
161
+ /** Extract package.json scripts: build, typecheck, test (in that order). */
162
+ function detectPackageScripts(projectDir) {
163
+ const f = path.join(projectDir, 'package.json');
164
+ if (!fs.existsSync(f)) return null;
165
+ let pkg;
166
+ try { pkg = JSON.parse(fs.readFileSync(f, 'utf8')); } catch { return null; }
167
+ const scripts = (pkg && pkg.scripts) || {};
168
+ const ORDER = ['build', 'typecheck', 'test'];
169
+ const found = ORDER.filter((k) => scripts[k]);
170
+ if (!found.length) return null;
171
+ return found.map((k) => `npm run ${k}`);
172
+ }
173
+
174
+ /**
175
+ * Detect CI source and return { source, commands }.
176
+ * Precedence: cloudbuild → workflows → dockerfile-run → package-scripts → none.
177
+ */
178
+ function detectCi(projectDir) {
179
+ const cb = detectCloudbuild(projectDir);
180
+ if (cb !== null) return { source: 'cloudbuild', commands: cb };
181
+
182
+ const wf = detectWorkflows(projectDir);
183
+ if (wf !== null) return { source: 'workflows', commands: wf };
184
+
185
+ const df = detectDockerfileRun(projectDir);
186
+ if (df !== null) return { source: 'dockerfile-run', commands: df };
187
+
188
+ const ps = detectPackageScripts(projectDir);
189
+ if (ps !== null) return { source: 'package-scripts', commands: ps };
190
+
191
+ return { source: 'none', commands: [] };
192
+ }
193
+
194
+ // ── Cache clearing ─────────────────────────────────────────────────────────────
195
+
196
+ function collectFiles(dir, pred, depth, out) {
197
+ if (depth <= 0) return;
198
+ let entries;
199
+ try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
200
+ for (const e of entries) {
201
+ const full = path.join(dir, e.name);
202
+ if (e.isDirectory()) {
203
+ if (e.name === 'node_modules') continue;
204
+ collectFiles(full, pred, depth - 1, out);
205
+ } else if (pred(e.name)) {
206
+ out.push(full);
207
+ }
208
+ }
209
+ }
210
+
211
+ /** Parse tsBuildInfoFile and outDir from tsconfig*.json files. */
212
+ function parseTsconfigArtifacts(projectDir) {
213
+ const paths = [];
214
+ let entries;
215
+ try { entries = fs.readdirSync(projectDir); } catch { return paths; }
216
+ for (const name of entries) {
217
+ if (!/^tsconfig.*\.json$/.test(name)) continue;
218
+ let cfg;
219
+ try { cfg = JSON.parse(fs.readFileSync(path.join(projectDir, name), 'utf8')); } catch { continue; }
220
+ const opts = (cfg && cfg.compilerOptions) || {};
221
+ if (opts.tsBuildInfoFile) {
222
+ paths.push(path.resolve(projectDir, opts.tsBuildInfoFile));
223
+ }
224
+ if (opts.outDir) {
225
+ paths.push(path.resolve(projectDir, opts.outDir));
226
+ }
227
+ }
228
+ return paths;
229
+ }
230
+
231
+ /**
232
+ * Containment predicate (Destructive Action Guard — LOCKED).
233
+ *
234
+ * A config-derived path may be deleted ONLY when its resolved absolute path
235
+ * is a STRICT DESCENDANT of projectRoot:
236
+ *
237
+ * resolved.startsWith(projectRoot + path.sep) && resolved !== projectRoot
238
+ *
239
+ * Both halves are load-bearing:
240
+ * - resolves OUTSIDE projectRoot (`../victim`, absolute) → REFUSE
241
+ * (BUG-1: `tsconfig outDir:"../victim"` force-deleted a sibling).
242
+ * - resolves EQUAL TO projectRoot (`.`, `./`, `src/..`, `./foo/../`)
243
+ * → REFUSE (BUG-8: a regression that force-deleted the entire repo —
244
+ * deleting the project root is never a cache-clear).
245
+ * - the `+ path.sep` guards the prefix-collision edge: a sibling whose
246
+ * name is `projectRoot + "-evil"` must NOT pass `startsWith(projectRoot)`.
247
+ *
248
+ * See memory: feedback_destructive_path_ops_containment.md.
249
+ */
250
+ function _isSafeToDelete(targetPath, projectRoot) {
251
+ const resolved = path.resolve(targetPath);
252
+ const root = path.resolve(projectRoot);
253
+ return resolved !== root && resolved.startsWith(root + path.sep);
254
+ }
255
+
256
+ /**
257
+ * Delete a config-derived path, but ONLY if it passes the containment
258
+ * predicate. Refusals are recorded (never thrown, never deleted) so the
259
+ * caller can surface them in the envelope and still clear legitimate caches.
260
+ */
261
+ function removePathContained(p, projectRoot, refused) {
262
+ if (!_isSafeToDelete(p, projectRoot)) {
263
+ refused.push(path.resolve(p));
264
+ return;
265
+ }
266
+ try {
267
+ const st = fs.statSync(p);
268
+ if (st.isDirectory()) fs.rmSync(p, { recursive: true, force: true });
269
+ else fs.unlinkSync(p);
270
+ } catch { /* best-effort: a missing path is fine */ }
271
+ }
272
+
273
+ /**
274
+ * Clear build caches before running detected commands (mandatory path —
275
+ * runCiParity calls this unconditionally before any detected command).
276
+ * Returns { refusedPaths } so a containment refusal is observable.
277
+ */
278
+ function clearBuildCaches(projectDir) {
279
+ const root = path.resolve(projectDir);
280
+ const refused = [];
281
+
282
+ // 1. *.tsbuildinfo files (constructed under projectDir, but guarded too)
283
+ const tsBuildInfoFiles = [];
284
+ collectFiles(projectDir, (name) => name.endsWith('.tsbuildinfo'), 6, tsBuildInfoFiles);
285
+ for (const f of tsBuildInfoFiles) removePathContained(f, root, refused);
286
+
287
+ // 2. node_modules/.cache
288
+ removePathContained(path.join(projectDir, 'node_modules', '.cache'), root, refused);
289
+
290
+ // 3. tsconfig-referenced outDir / tsBuildInfoFile — the dangerous,
291
+ // user-controlled paths. Each MUST pass _isSafeToDelete.
292
+ for (const p of parseTsconfigArtifacts(projectDir)) removePathContained(p, root, refused);
293
+
294
+ return { refusedPaths: refused };
295
+ }
296
+
297
+ // ── Command runner ─────────────────────────────────────────────────────────────
298
+
299
+ /** Run a single shell command with bounded timeout. Returns {cmd, exitCode, ok, output}. */
300
+ function runCommand(cmd, projectDir, timeoutMs) {
301
+ let result;
302
+ try {
303
+ result = child_process.spawnSync('sh', ['-c', cmd], {
304
+ cwd: projectDir,
305
+ timeout: timeoutMs,
306
+ encoding: 'utf8',
307
+ stdio: ['ignore', 'pipe', 'pipe'],
308
+ });
309
+ } catch (err) {
310
+ return { cmd, exitCode: 1, ok: false, output: String(err) };
311
+ }
312
+ const exitCode = result.status != null ? result.status : 1;
313
+ return { cmd, exitCode, ok: exitCode === 0, output: (result.stdout || '') + (result.stderr || '') };
314
+ }
315
+
316
+ // ── Docker ─────────────────────────────────────────────────────────────────────
317
+
318
+ /** Check whether the `docker` binary is available on PATH. */
319
+ function dockerAvailable() {
320
+ try {
321
+ const r = child_process.spawnSync('docker', ['--version'], {
322
+ stdio: ['ignore', 'pipe', 'pipe'],
323
+ timeout: 5000,
324
+ });
325
+ return r.status === 0;
326
+ } catch { return false; }
327
+ }
328
+
329
+ /**
330
+ * Run `docker build` in projectDir with a bounded timeout.
331
+ * Returns { ok, exitCode, output }.
332
+ */
333
+ function runDockerBuild(projectDir, timeoutMs) {
334
+ const tag = `gsd-t-ci-parity-${Date.now()}`;
335
+ let result;
336
+ try {
337
+ result = child_process.spawnSync(
338
+ 'docker', ['build', '--no-cache', '-t', tag, '.'],
339
+ {
340
+ cwd: projectDir,
341
+ timeout: timeoutMs,
342
+ encoding: 'utf8',
343
+ stdio: ['ignore', 'pipe', 'pipe'],
344
+ }
345
+ );
346
+ } catch (err) {
347
+ return { ok: false, exitCode: 1, output: String(err) };
348
+ }
349
+ // Best-effort cleanup of created image
350
+ try {
351
+ child_process.spawnSync('docker', ['rmi', '-f', tag], {
352
+ stdio: 'ignore', timeout: 10000,
353
+ });
354
+ } catch { /* ignore */ }
355
+ const exitCode = result.status != null ? result.status : 1;
356
+ return { ok: exitCode === 0, exitCode, output: (result.stdout || '') + (result.stderr || '') };
357
+ }
358
+
359
+ // ── Public API ─────────────────────────────────────────────────────────────────
360
+
361
+ /**
362
+ * Run CI parity check for a project.
363
+ *
364
+ * @param {object} opts
365
+ * @param {string} opts.projectDir
366
+ * @param {number} [opts.timeoutMs]
367
+ * @returns {{ ok: boolean, detectedSource: string, commands: Array, dockerBuilt: boolean, dockerSkippedReason?: string, note?: string }}
368
+ */
369
+ function runCiParity(opts) {
370
+ const projectDir = (opts && opts.projectDir) || process.cwd();
371
+ const timeoutMs = (opts && opts.timeoutMs) || DEFAULT_TIMEOUT_MS;
372
+
373
+ const { source, commands } = detectCi(projectDir);
374
+
375
+ // Cache-clear is on the MANDATORY path — it runs before any command AND
376
+ // before the no-CI/docker-only path, so a Dockerfile-only project still
377
+ // gets a clean cache (closes BUG-2: the prior early-return skipped it).
378
+ const { refusedPaths } = clearBuildCaches(projectDir);
379
+
380
+ // No CI config found — not a failure
381
+ if (source === 'none') {
382
+ const hasDockerfile = fs.existsSync(path.join(projectDir, 'Dockerfile'));
383
+ const dockerResult = hasDockerfile
384
+ ? runDockerStep(projectDir, timeoutMs)
385
+ : { dockerBuilt: false, dockerSkippedReason: 'no-dockerfile' };
386
+ const env = {
387
+ ok: !dockerResult.dockerFailed,
388
+ detectedSource: 'none',
389
+ commands: [],
390
+ cacheCleared: true,
391
+ ...dockerResult,
392
+ note: 'No CI config detected (no cloudbuild.yaml, .github/workflows, Dockerfile RUN lines, or package.json scripts)',
393
+ };
394
+ if (refusedPaths.length) env.refusedPaths = refusedPaths;
395
+ return env;
396
+ }
397
+
398
+ // Run all detected commands
399
+ const commandResults = [];
400
+ let allOk = true;
401
+ for (const cmd of commands) {
402
+ const r = runCommand(cmd, projectDir, timeoutMs);
403
+ commandResults.push({ cmd: r.cmd, exitCode: r.exitCode, ok: r.ok });
404
+ if (!r.ok) allOk = false;
405
+ }
406
+
407
+ // Docker step (presence-triggered, not a failure if docker unavailable)
408
+ const hasDockerfile = fs.existsSync(path.join(projectDir, 'Dockerfile'));
409
+ const dockerResult = hasDockerfile
410
+ ? runDockerStep(projectDir, timeoutMs)
411
+ : { dockerBuilt: false, dockerSkippedReason: 'no-dockerfile' };
412
+
413
+ if (dockerResult.dockerFailed) allOk = false;
414
+
415
+ const envelope = {
416
+ ok: allOk,
417
+ detectedSource: source,
418
+ commands: commandResults,
419
+ cacheCleared: true,
420
+ dockerBuilt: dockerResult.dockerBuilt,
421
+ };
422
+ if (refusedPaths.length) envelope.refusedPaths = refusedPaths;
423
+ if (dockerResult.dockerSkippedReason) envelope.dockerSkippedReason = dockerResult.dockerSkippedReason;
424
+ if (!allOk && commands.length === 0) envelope.note = 'No commands detected';
425
+ return envelope;
426
+ }
427
+
428
+ /**
429
+ * Run docker build step (T3 logic).
430
+ * Returns { dockerBuilt, dockerFailed?, dockerSkippedReason? }
431
+ */
432
+ function runDockerStep(projectDir, timeoutMs) {
433
+ if (!dockerAvailable()) {
434
+ return { dockerBuilt: false, dockerSkippedReason: 'docker-unavailable', dockerFailed: false };
435
+ }
436
+ const r = runDockerBuild(projectDir, timeoutMs);
437
+ return {
438
+ dockerBuilt: r.ok,
439
+ dockerFailed: !r.ok,
440
+ };
441
+ }
442
+
443
+ module.exports = { runCiParity, detectCi, clearBuildCaches, _isSafeToDelete };
444
+
445
+ // ── CLI entry ──────────────────────────────────────────────────────────────────
446
+
447
+ if (require.main === module) {
448
+ const argv = process.argv.slice(2);
449
+ let jsonMode = false;
450
+ let projectDir = process.cwd();
451
+ let timeoutMs = DEFAULT_TIMEOUT_MS;
452
+
453
+ for (let i = 0; i < argv.length; i++) {
454
+ const a = argv[i];
455
+ if (a === '--json') { jsonMode = true; continue; }
456
+ if (a === '--project-dir' && argv[i + 1]) { projectDir = argv[++i]; continue; }
457
+ if (a === '--timeout-ms' && argv[i + 1]) { timeoutMs = Number(argv[++i]); continue; }
458
+ if (a === '--help' || a === '-h') {
459
+ process.stdout.write(
460
+ 'Usage: gsd-t ci-parity [--project-dir <dir>] [--timeout-ms <ms>] [--json]\n' +
461
+ 'Exit 0 = all CI commands + docker build passed\n' +
462
+ 'Exit 4 = failure\n' +
463
+ 'Exit 2 = usage error\n'
464
+ );
465
+ process.exit(0);
466
+ }
467
+ }
468
+
469
+ let result;
470
+ try {
471
+ result = runCiParity({ projectDir, timeoutMs });
472
+ } catch (err) {
473
+ if (jsonMode) {
474
+ process.stdout.write(JSON.stringify({ ok: false, error: String(err) }) + '\n');
475
+ } else {
476
+ process.stderr.write('ci-parity error: ' + String(err) + '\n');
477
+ }
478
+ process.exit(4);
479
+ }
480
+
481
+ if (jsonMode) {
482
+ process.stdout.write(JSON.stringify(result) + '\n');
483
+ } else {
484
+ const status = result.ok ? 'PASS' : 'FAIL';
485
+ process.stdout.write(`[ci-parity] ${status} detectedSource=${result.detectedSource}\n`);
486
+ for (const c of result.commands) {
487
+ process.stdout.write(` [${c.ok ? 'ok' : 'FAIL'}] ${c.cmd} (exit ${c.exitCode})\n`);
488
+ }
489
+ if (result.dockerBuilt) {
490
+ process.stdout.write(' [ok] docker build\n');
491
+ } else if (result.dockerSkippedReason) {
492
+ process.stdout.write(` [skip] docker build: ${result.dockerSkippedReason}\n`);
493
+ } else if (!result.dockerBuilt) {
494
+ process.stdout.write(' [FAIL] docker build\n');
495
+ }
496
+ if (result.note) process.stdout.write(` note: ${result.note}\n`);
497
+ }
498
+
499
+ process.exit(result.ok ? 0 : 4);
500
+ }
@@ -18,13 +18,40 @@ const path = require("node:path");
18
18
 
19
19
  // ─── Constants ────────────────────────────────────────────────────────────
20
20
 
21
+ const { SAFE_DEFAULT_WINDOW } = require("./model-windows.cjs");
22
+
23
+ /**
24
+ * Effective CW ceiling in tokens. This IS the model context window, used for
25
+ * `tokens / ceiling` percentages and parallel-mode gate decisions. Resolved
26
+ * model-aware (see resolveCwCeiling) — the old hardcoded 200K was correct only
27
+ * for pre-4 models and overstated every CW% 5× on Opus/Sonnet (1M window),
28
+ * skewing parallel-mode gates. Kept as the safe fallback when no meter state.
29
+ * - bin/token-budget.cjs (FALLBACK_WINDOW = model-windows safe)
30
+ * - bin/context-meter-config.cjs (modelWindowSize default 200000, now a fallback)
31
+ * - bin/runway-estimator.cjs (DEFAULT_MODEL_CONTEXT_CAP = model-windows safe)
32
+ */
33
+ const CW_CEILING_TOKENS = SAFE_DEFAULT_WINDOW;
34
+ const METER_STATE_REL = ".gsd-t/.context-meter-state.json";
35
+ const METER_STATE_STALE_MS = 5 * 60 * 1000;
36
+
21
37
  /**
22
- * Effective CW ceiling in tokens. Matches:
23
- * - bin/token-budget.cjs (200000)
24
- * - bin/context-meter-config.cjs (modelWindowSize: 200000)
25
- * - bin/runway-estimator.cjs (DEFAULT_MODEL_CONTEXT_CAP = 200000)
38
+ * Resolve the effective CW ceiling for a project. Prefers a fresh Context
39
+ * Meter reading (model-aware modelWindowSize, written by the meter hook from
40
+ * the running model), else the safe large default.
26
41
  */
27
- const CW_CEILING_TOKENS = 200000;
42
+ function resolveCwCeiling(projectDir) {
43
+ try {
44
+ const fp = path.join(projectDir || ".", METER_STATE_REL);
45
+ const s = JSON.parse(fs.readFileSync(fp, "utf8"));
46
+ if (s && typeof s.modelWindowSize === "number" && s.modelWindowSize > 0 && s.timestamp) {
47
+ const age = Date.now() - Date.parse(s.timestamp);
48
+ if (!isNaN(age) && age >= 0 && age <= METER_STATE_STALE_MS) {
49
+ return s.modelWindowSize;
50
+ }
51
+ }
52
+ } catch (_) { /* fall through */ }
53
+ return CW_CEILING_TOKENS;
54
+ }
28
55
 
29
56
  /** Confidence tier cutoffs (exact-match row counts). */
30
57
  const HIGH_CONFIDENCE_MIN = 5; // ≥5 exact matches
@@ -95,7 +122,7 @@ function loadCorpus(projectDir) {
95
122
  }
96
123
 
97
124
  const globalMedian = median(allTotals);
98
- const globalPct = tokensToCwPct(globalMedian);
125
+ const globalPct = tokensToCwPct(globalMedian, resolveCwCeiling(projectDir));
99
126
 
100
127
  const idx = { rows, exact, byDomain, byCommand, globalMedian, globalPct };
101
128
  _corpusCache.set(projectDir, idx);
@@ -122,9 +149,10 @@ function median(arr) {
122
149
  return a.length % 2 === 1 ? a[mid] : (a[mid - 1] + a[mid]) / 2;
123
150
  }
124
151
 
125
- function tokensToCwPct(tokens) {
152
+ function tokensToCwPct(tokens, ceiling) {
126
153
  if (!Number.isFinite(tokens) || tokens <= 0) return 0;
127
- return (tokens / CW_CEILING_TOKENS) * 100;
154
+ const c = Number.isFinite(ceiling) && ceiling > 0 ? ceiling : CW_CEILING_TOKENS;
155
+ return (tokens / c) * 100;
128
156
  }
129
157
 
130
158
  // ─── Event emission (best-effort) ─────────────────────────────────────────
@@ -247,7 +275,7 @@ function estimateTaskFootprint(opts) {
247
275
  const corpus = loadCorpus(projectDir);
248
276
 
249
277
  const { estimatedTokens, matchedRows, confidence } = lookupInCorpus(taskNode, corpus);
250
- const estimatedCwPct = tokensToCwPct(estimatedTokens);
278
+ const estimatedCwPct = tokensToCwPct(estimatedTokens, resolveCwCeiling(projectDir));
251
279
 
252
280
  const { parallelOk, split, workerCount } = decideGates(mode, estimatedCwPct, confidence);
253
281
 
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Adapter: file-json-array
3
+ *
4
+ * Purges a record from a JSON file containing an array.
5
+ * `store` is the file path; `id` is the value of the `id` field on the matching row.
6
+ *
7
+ * Refuses to delete a record whose `id` does not start with `taggedPrefix`.
8
+ * Atomic rewrite (write-temp + rename).
9
+ */
10
+ const fs = require('node:fs');
11
+ const path = require('node:path');
12
+
13
+ const KIND = 'file-json-array';
14
+
15
+ function purge({ store, id, taggedPrefix }) {
16
+ if (typeof store !== 'string' || store.length === 0) {
17
+ throw new Error('file-json-array: store must be a non-empty file path');
18
+ }
19
+ if (typeof id !== 'string' || id.length === 0) {
20
+ throw new Error('file-json-array: id must be a non-empty string');
21
+ }
22
+ if (typeof taggedPrefix === 'string' && taggedPrefix.length > 0 && !id.startsWith(taggedPrefix)) {
23
+ throw new Error(`file-json-array: tag prefix mismatch (id="${id}", taggedPrefix="${taggedPrefix}")`);
24
+ }
25
+
26
+ if (!fs.existsSync(store)) {
27
+ return 'absent';
28
+ }
29
+ let raw;
30
+ try {
31
+ raw = fs.readFileSync(store, 'utf8');
32
+ } catch (e) {
33
+ throw new Error(`file-json-array: read failed: ${e.message}`);
34
+ }
35
+ let arr;
36
+ try {
37
+ arr = JSON.parse(raw);
38
+ } catch (e) {
39
+ throw new Error(`file-json-array: parse failed: ${e.message}`);
40
+ }
41
+ if (!Array.isArray(arr)) {
42
+ throw new Error('file-json-array: store contents are not an array');
43
+ }
44
+ const before = arr.length;
45
+ const next = arr.filter((row) => !(row && typeof row === 'object' && row.id === id));
46
+ if (next.length === before) {
47
+ return 'absent';
48
+ }
49
+ // Atomic rewrite
50
+ const tmp = `${store}.tmp.${process.pid}.${Date.now()}`;
51
+ fs.writeFileSync(tmp, JSON.stringify(next, null, 2), 'utf8');
52
+ fs.renameSync(tmp, store);
53
+ return 'purged';
54
+ }
55
+
56
+ module.exports = { kind: KIND, purge };
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Adapter: localStorage-key-prefix
3
+ *
4
+ * Purges a key from browser localStorage. Designed to be called from
5
+ * Playwright host side via `page.evaluate`.
6
+ *
7
+ * Caller passes `page` via `purge({ page, store, id, taggedPrefix })`.
8
+ * `store` is the key prefix; `id` is the suffix. Final key = store + id.
9
+ *
10
+ * When `page` is omitted, the adapter returns 'absent' rather than throwing
11
+ * — this lets ledger.purgeRunInserts run cleanly when no live browser is
12
+ * present (e.g., after Playwright tears down). Verify-step semantics: if
13
+ * Playwright is gone, the data is gone too (unless persisted server-side,
14
+ * which other adapters handle).
15
+ */
16
+ const KIND = 'localStorage-key-prefix';
17
+
18
+ async function purge({ page, store, id, taggedPrefix }) {
19
+ if (typeof store !== 'string' || store.length === 0) {
20
+ throw new Error('localStorage-key-prefix: store must be a non-empty key prefix');
21
+ }
22
+ if (typeof id !== 'string' || id.length === 0) {
23
+ throw new Error('localStorage-key-prefix: id must be a non-empty string');
24
+ }
25
+ if (typeof taggedPrefix === 'string' && taggedPrefix.length > 0 && !id.startsWith(taggedPrefix)) {
26
+ throw new Error(`localStorage-key-prefix: tag prefix mismatch (id="${id}", taggedPrefix="${taggedPrefix}")`);
27
+ }
28
+
29
+ // Browser-side cleanup requires a live page. If absent, treat as 'absent'.
30
+ if (!page || typeof page.evaluate !== 'function') {
31
+ return 'absent';
32
+ }
33
+
34
+ const key = store + id;
35
+ const result = await page.evaluate((k) => {
36
+ if (typeof window === 'undefined' || !window.localStorage) return 'absent';
37
+ if (window.localStorage.getItem(k) === null) return 'absent';
38
+ window.localStorage.removeItem(k);
39
+ return 'purged';
40
+ }, key);
41
+ return result;
42
+ }
43
+
44
+ module.exports = { kind: KIND, purge };