@tekyzinc/gsd-t 3.26.10 → 3.27.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.
@@ -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
 
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,24 @@ 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
+ }
4562
4583
  case "stream-feed": {
4563
4584
  doStreamFeed(args.slice(1));
4564
4585
  break;