@tekyzinc/gsd-t 5.11.24 → 5.11.26

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 CHANGED
@@ -2,6 +2,38 @@
2
2
 
3
3
  All notable changes to GSD-T are documented here. Updated with each release.
4
4
 
5
+ ## [5.11.26] - 2026-08-11
6
+
7
+ ### Fixed — a quarter of every import edge pointed at a string no file matched
8
+
9
+ A project writes `import x from "@/lib/foo"` and declares what `@/` means in its
10
+ tsconfig. Stored raw, that target resolves to nothing — the graph records an edge
11
+ pointing at `@/lib/foo`, and no file is ever named that.
12
+
13
+ **Measured on HiloAviation: 5,738 of 23,263 import edges — 25% — were
14
+ unexpanded shortcuts.** Ask "does anything import this file?" and a quarter of
15
+ the real answers are missing, so live code looks unreferenced. A reachability
16
+ rule built on that data would have called **1,919 files of a working app dead**.
17
+
18
+ The indexer now reads `paths` (and `baseUrl`) from `tsconfig.json` or
19
+ `jsconfig.json` once per build, and expands every import target before storing
20
+ it. A package import like `react` is left exactly as written — rewriting it
21
+ would invent an edge to a file that does not exist.
22
+
23
+ A config that is present but unreadable or unparseable is **announced**, never
24
+ silently skipped: skipping expansion while reporting a successful build is the
25
+ failure this fixes. A project with no such config says so too.
26
+
27
+ Config files routinely carry comments and trailing commas, so both are stripped
28
+ — but never inside a string, or a URL like `https://…` loses everything after
29
+ the `//`.
30
+
31
+ - `bin/gsd-t-graph-index.cjs`: `loadPathAliases()` + `expandAlias()`, applied where import edges are stored
32
+ - `test/m112-import-alias-expansion.test.js`: 6 tests — expansion, packages untouched, comments + URL, no config, `baseUrl`, and that a broken config is announced
33
+
34
+ **Rebuild the index to benefit:** `gsd-t graph index`. An index built before this
35
+ release still holds the raw shortcuts.
36
+
5
37
  ## [5.11.24] - 2026-08-11
6
38
 
7
39
  ### Fixed — the code graph never reached a single scanning agent for six weeks
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # GSD-T: Contract-Driven Development for Claude Code
2
2
 
3
- **v5.11.24** - A methodology for reliable, parallelizable development using Claude Code with optional Agent Teams support.
3
+ **v5.11.26** - A methodology for reliable, parallelizable development using Claude Code with optional Agent Teams support.
4
4
 
5
5
  **Eliminates context rot** — task-level fresh dispatch (one subagent per task, ~10-20% context each) means compaction never triggers.
6
6
  **Compaction-proof debug loops** — `gsd-t headless --debug-loop` runs test-fix-retest cycles as separate `claude -p` sessions. A JSONL debug ledger persists all hypothesis/fix/learning history across fresh sessions. Anti-repetition preamble injection prevents retrying failed hypotheses. Escalation tiers (sonnet → opus → human) and a hard iteration ceiling enforced externally.
@@ -70,6 +70,89 @@ function errLog(msg){ log(`${C.red}[IDX ERR]${C.reset} ${msg}`); }
70
70
 
71
71
  // ── Source-file extensions + skip dirs (matches D2 / K2 probe) ───────────────
72
72
 
73
+ // ── Import path shortcuts (tsconfig `paths`) ────────────────────────────────
74
+ //
75
+ // A project writes `import x from "@/lib/foo"` and declares what `@/` means in
76
+ // tsconfig: `"@/*": ["./src/*"]`. Stored raw, that target resolves to nothing —
77
+ // the graph records an edge pointing at a string no file matches.
78
+ //
79
+ // Measured on HiloAviation: 5,738 of 23,263 import edges (25%) pointed at an
80
+ // unexpanded shortcut. Ask "does anything import this file?" and a quarter of
81
+ // the real answers are missing, so live code looks unreferenced. A reachability
82
+ // rule built on that would have called 1,919 files of a working app dead.
83
+ //
84
+ // Read once per index build, applied to every import target before it is stored.
85
+ // A project with no tsconfig, or none with paths, simply has nothing to expand.
86
+ function loadPathAliases(projectDir) {
87
+ for (const name of ['tsconfig.json', 'jsconfig.json']) {
88
+ const file = path.join(projectDir, name);
89
+ if (!fs.existsSync(file)) continue;
90
+
91
+ let raw;
92
+ try {
93
+ raw = fs.readFileSync(file, 'utf8');
94
+ } catch (e) {
95
+ // Present but unreadable is a real problem, not an absent config: every
96
+ // shortcut in the project would silently go unresolved.
97
+ warn(`${name} could not be read (${e.message}) — import shortcuts will NOT be expanded`);
98
+ return null;
99
+ }
100
+
101
+ // These files routinely carry comments and trailing commas, which JSON does
102
+ // not allow. Strip them rather than fail — but never strip inside a string,
103
+ // or a URL like "https://..." loses everything after the //.
104
+ const stripped = raw
105
+ .replace(/("(?:[^"\\]|\\.)*")|\/\*[\s\S]*?\*\/|\/\/[^\n]*/g, (m, str) => str || '')
106
+ .replace(/,(\s*[}\]])/g, '$1');
107
+
108
+ let cfg;
109
+ try {
110
+ cfg = JSON.parse(stripped);
111
+ } catch (e) {
112
+ warn(`${name} is not parseable (${e.message}) — import shortcuts will NOT be expanded`);
113
+ return null;
114
+ }
115
+
116
+ const opts = (cfg && cfg.compilerOptions) || {};
117
+ const paths = opts.paths;
118
+ if (!paths || typeof paths !== 'object') continue;
119
+
120
+ const base = opts.baseUrl ? path.join(projectDir, opts.baseUrl) : projectDir;
121
+ const rules = [];
122
+ for (const [pattern, targets] of Object.entries(paths)) {
123
+ if (!Array.isArray(targets) || targets.length === 0) continue;
124
+ rules.push({
125
+ prefix: pattern.replace(/\*$/, ''), // "@/*" -> "@/"
126
+ target: String(targets[0]).replace(/\*$/, ''), // "./src/*" -> "./src/"
127
+ wildcard: pattern.endsWith('*'),
128
+ base,
129
+ });
130
+ }
131
+ if (rules.length) {
132
+ info(`import shortcuts: ${rules.length} rule(s) from ${name} (${rules.map((r) => r.prefix + '*').join(', ')})`);
133
+ return rules;
134
+ }
135
+ }
136
+ return null;
137
+ }
138
+
139
+ // Turn one import target into a project-relative path when a rule matches it.
140
+ // Returns the original string untouched when nothing matches — a package import
141
+ // like "react" is not a shortcut and must stay exactly as written.
142
+ function expandAlias(spec, rules, projectDir) {
143
+ if (!rules || !spec || typeof spec !== 'string') return spec;
144
+ for (const r of rules) {
145
+ if (!r.wildcard) {
146
+ if (spec !== r.prefix) continue;
147
+ } else if (!spec.startsWith(r.prefix)) continue;
148
+
149
+ const rest = r.wildcard ? spec.slice(r.prefix.length) : '';
150
+ const abs = path.resolve(r.base, r.target + rest);
151
+ return path.relative(projectDir, abs);
152
+ }
153
+ return spec;
154
+ }
155
+
73
156
  const PARSED_EXTS = new Set(['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs', '.py']);
74
157
  const SKIP_DIRS = new Set([
75
158
  'node_modules', '.next', 'dist', 'build', '.git',
@@ -332,10 +415,14 @@ function parse_and_put(absPath, relPath, options) {
332
415
  // Normalize edges to store schema (map from parser-floor shape to store shape)
333
416
  const storeEdges = finalEdges.map(edge => {
334
417
  if (edge.kind === 'import' || edge.kind === 'require') {
418
+ // Expand a path shortcut ("@/lib/foo") into a real project path before it
419
+ // is stored. Stored raw it points at a string no file matches, and every
420
+ // question about what-imports-what silently loses that edge.
421
+ const rawDst = edge.target || edge.dst;
335
422
  return {
336
423
  kind: 'IMPORT',
337
424
  src: edge.source || edge.src,
338
- dst: edge.target || edge.dst,
425
+ dst: expandAlias(rawDst, (options && options.aliasRules) || null, (options && options.projectDir) || '.'),
339
426
  partial: 0,
340
427
  };
341
428
  }
@@ -429,6 +516,14 @@ function build_index(repoRoot, options) {
429
516
  }
430
517
  }
431
518
 
519
+ // Read the project's import shortcuts ONCE for the whole build. Every import
520
+ // target is expanded through these before it is stored, so an edge points at a
521
+ // real path rather than at a string nothing matches.
522
+ const aliasRules = loadPathAliases(repoRoot);
523
+ if (!aliasRules) {
524
+ info('import shortcuts: none declared (no tsconfig/jsconfig paths) — import targets stored as written');
525
+ }
526
+
432
527
  const t0 = Date.now();
433
528
  let fileCount = 0;
434
529
  let entityCount = 0;
@@ -441,7 +536,7 @@ function build_index(repoRoot, options) {
441
536
  // Stream: parse + put each file one at a time (never accumulate the full set)
442
537
  for (const { absPath, relPath } of files) {
443
538
  try {
444
- const result = parse_and_put(absPath, relPath, { db, scip: scipCtx });
539
+ const result = parse_and_put(absPath, relPath, { db, scip: scipCtx, aliasRules, projectDir: repoRoot });
445
540
  fileCount++;
446
541
  entityCount += result.entities.length;
447
542
  edgeCount += result.edges.length;
@@ -0,0 +1,294 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ /**
5
+ * gsd-t-slice-budget.cjs — measure a slice plan in LINES and split what is too big.
6
+ *
7
+ * [RULE] slice-budget-measured-in-lines-not-files
8
+ * [RULE] slice-budget-splits-never-drops
9
+ * [RULE] slice-budget-reports-every-decision
10
+ *
11
+ * A reviewer is told to read every file in its slice. Whether it can depends on
12
+ * how much code it was handed — and files are a terrible proxy for that. In the
13
+ * HiloAviation codebase the median source file is 233 lines and the largest is
14
+ * 23,664: a hundred to one. "120 files" means nothing.
15
+ *
16
+ * Lines track what actually happened. Across three real scans of the same
17
+ * project:
18
+ *
19
+ * 47 slices · 54,000 lines each · 297 findings
20
+ * 28 slices · 91,000 lines each · —
21
+ * 24 slices · 106,000 lines each · 194 findings
22
+ * 1 slice · 2,545,000 lines · 3 findings
23
+ *
24
+ * Half the lines per reviewer, half again as many findings. So slices are sized
25
+ * by line count, and anything over the ceiling is SPLIT — never dropped, and
26
+ * never merged to tidy a count.
27
+ *
28
+ * ─── Usage ──────────────────────────────────────────────────────────────────
29
+ * node gsd-t-slice-budget.cjs --project <dir> --slices '<json>' [--min N] [--max N]
30
+ *
31
+ * <json> is the probe's slice list: [{ key, paths: [...], ... }]
32
+ *
33
+ * ─── Exit codes ─────────────────────────────────────────────────────────────
34
+ * 0 a plan was produced (possibly unchanged)
35
+ * 64 bad input — unreadable project, malformed slice list
36
+ *
37
+ * There is no "give up and return the original" path: a slice list that cannot
38
+ * be measured is an error, not a plan to proceed with.
39
+ *
40
+ * Zero dependencies.
41
+ */
42
+
43
+ const fs = require('fs');
44
+ const path = require('path');
45
+
46
+ const EXIT_OK = 0;
47
+ const EXIT_BAD_INPUT = 64;
48
+
49
+ // Provisional, and recorded as such. 54,000 lines per reviewer produced the best
50
+ // real scan observed; these sit deliberately below it, because that run still
51
+ // cited only 266 of 4,785 files. Nothing yet proves smaller keeps helping — the
52
+ // numbers move when a comparison run says they should.
53
+ const DEFAULT_MIN = 30000;
54
+ const DEFAULT_MAX = 50000;
55
+
56
+ const SOURCE_EXTS = new Set(['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs', '.py']);
57
+ const SKIP_DIRS = new Set([
58
+ 'node_modules', '.next', 'dist', 'build', '.git', '.cache', '__pycache__',
59
+ 'coverage', 'out', '.turbo', '.venv', 'venv', 'Pods', 'vendor', '.gradle',
60
+ ]);
61
+
62
+ /** Lines in one file. A file that cannot be read is reported, never counted as 0. */
63
+ function countLines(file) {
64
+ const text = fs.readFileSync(file, 'utf8');
65
+ if (text.length === 0) return 0;
66
+ let n = 0;
67
+ for (let i = 0; i < text.length; i++) {
68
+ if (text.charCodeAt(i) === 10) n++;
69
+ }
70
+ if (text.endsWith('\n')) return n;
71
+ return n + 1;
72
+ }
73
+
74
+ /** Every source file under a path, with its line count. */
75
+ function measurePath(projectDir, rel, problems) {
76
+ const abs = path.resolve(projectDir, rel);
77
+ const out = [];
78
+
79
+ let stat;
80
+ try {
81
+ stat = fs.statSync(abs);
82
+ } catch (e) {
83
+ problems.push(`${rel}: ${(e && e.message) || e}`);
84
+ return out;
85
+ }
86
+
87
+ if (stat.isFile()) {
88
+ if (!SOURCE_EXTS.has(path.extname(abs))) return out;
89
+ try {
90
+ out.push({ file: path.relative(projectDir, abs), lines: countLines(abs) });
91
+ } catch (e) {
92
+ problems.push(`${rel}: ${(e && e.message) || e}`);
93
+ }
94
+ return out;
95
+ }
96
+
97
+ const walk = (dir) => {
98
+ let entries;
99
+ try {
100
+ entries = fs.readdirSync(dir, { withFileTypes: true });
101
+ } catch (e) {
102
+ // A directory that cannot be read may hold most of a slice. Recorded, so
103
+ // the caller sees an incomplete measurement rather than a small number.
104
+ problems.push(`${path.relative(projectDir, dir)}: ${(e && e.message) || e}`);
105
+ return;
106
+ }
107
+ for (const ent of entries) {
108
+ if (ent.isDirectory()) {
109
+ if (SKIP_DIRS.has(ent.name)) continue;
110
+ walk(path.join(dir, ent.name));
111
+ continue;
112
+ }
113
+ if (!SOURCE_EXTS.has(path.extname(ent.name))) continue;
114
+ const full = path.join(dir, ent.name);
115
+ try {
116
+ out.push({ file: path.relative(projectDir, full), lines: countLines(full) });
117
+ } catch (e) {
118
+ problems.push(`${path.relative(projectDir, full)}: ${(e && e.message) || e}`);
119
+ }
120
+ }
121
+ };
122
+ walk(abs);
123
+ return out;
124
+ }
125
+
126
+ /**
127
+ * Split one oversized slice into parts that fit the ceiling.
128
+ *
129
+ * Files are taken largest-first so a big file settles early and the rest packs
130
+ * around it; the alternative leaves a huge file for last and forces a part far
131
+ * over budget.
132
+ *
133
+ * A single file larger than the ceiling becomes its OWN part. A file is the
134
+ * smallest thing a reviewer can read, so the budget cannot be honoured below
135
+ * that — and pretending otherwise would mean splitting a file mid-function.
136
+ */
137
+ function splitSlice(slice, files, min, max, oversizedFiles) {
138
+ const sorted = files.slice().sort((a, b) => b.lines - a.lines);
139
+ const parts = [];
140
+ let cur = [];
141
+ let curLines = 0;
142
+
143
+ for (const f of sorted) {
144
+ if (f.lines > max) {
145
+ oversizedFiles.push(f);
146
+ parts.push({ files: [f.file], lines: f.lines, soloOversizedFile: true });
147
+ continue;
148
+ }
149
+ if (curLines + f.lines > max && curLines >= min) {
150
+ parts.push({ files: cur, lines: curLines });
151
+ cur = [];
152
+ curLines = 0;
153
+ }
154
+ cur.push(f.file);
155
+ curLines += f.lines;
156
+ }
157
+ if (cur.length) parts.push({ files: cur, lines: curLines });
158
+
159
+ // One part means nothing was split — hand the slice back untouched so its
160
+ // original paths and metadata survive.
161
+ if (parts.length <= 1) {
162
+ const total = files.reduce((s, f) => s + f.lines, 0);
163
+ return [{ ...slice, _lines: total }];
164
+ }
165
+
166
+ return parts.map((p, i) => {
167
+ const part = {
168
+ ...slice,
169
+ key: `${slice.key}-part${i + 1}`,
170
+ paths: p.files,
171
+ _lines: p.lines,
172
+ _splitFrom: slice.key,
173
+ };
174
+ if (p.soloOversizedFile) part._soloOversizedFile = true;
175
+ return part;
176
+ });
177
+ }
178
+
179
+ function plan(projectDir, slices, min, max) {
180
+ const problems = [];
181
+ const oversizedFiles = [];
182
+ const out = [];
183
+ let measuredLines = 0;
184
+ let measuredFiles = 0;
185
+
186
+ for (const slice of slices) {
187
+ const paths = Array.isArray(slice.paths) ? slice.paths : [];
188
+ const files = [];
189
+ const seen = new Set();
190
+ for (const p of paths) {
191
+ for (const f of measurePath(projectDir, p, problems)) {
192
+ // A file listed under two paths of one slice is one file, counted once.
193
+ if (seen.has(f.file)) continue;
194
+ seen.add(f.file);
195
+ files.push(f);
196
+ }
197
+ }
198
+ measuredFiles += files.length;
199
+ const lines = files.reduce((s, f) => s + f.lines, 0);
200
+ measuredLines += lines;
201
+
202
+ if (lines <= max) {
203
+ out.push({ ...slice, _lines: lines });
204
+ } else {
205
+ out.push(...splitSlice(slice, files, min, max, oversizedFiles));
206
+ }
207
+ }
208
+
209
+ const sizes = out.map((s) => s._lines).sort((a, b) => a - b);
210
+ const mean = out.length ? Math.round(measuredLines / out.length) : 0;
211
+ const median = sizes.length ? sizes[Math.floor(sizes.length / 2)] : 0;
212
+ const largest = sizes.length ? sizes[sizes.length - 1] : 0;
213
+
214
+ return {
215
+ ok: true,
216
+ exitCode: EXIT_OK,
217
+ budget: { min, max, provisional: true },
218
+ before: { slices: slices.length },
219
+ after: {
220
+ slices: out.length,
221
+ files: measuredFiles,
222
+ lines: measuredLines,
223
+ meanLinesPerSlice: mean,
224
+ medianLinesPerSlice: median,
225
+ largestSlice: largest,
226
+ overBudget: out.filter((s) => s._lines > max && !s._soloOversizedFile).length,
227
+ },
228
+ // A file bigger than the ceiling cannot be split, so the budget is broken by
229
+ // the code itself. Named, so it reads as a fact about the codebase rather
230
+ // than a rule quietly ignored.
231
+ soloOversizedFiles: oversizedFiles
232
+ .sort((a, b) => b.lines - a.lines)
233
+ .map((f) => ({ file: f.file, lines: f.lines })),
234
+ problems,
235
+ slices: out,
236
+ };
237
+ }
238
+
239
+ function parseArgs(argv) {
240
+ const args = { project: process.cwd(), min: DEFAULT_MIN, max: DEFAULT_MAX };
241
+ for (let i = 2; i < argv.length; i++) {
242
+ const a = argv[i];
243
+ if (a === '--project') args.project = argv[++i];
244
+ else if (a === '--slices') args.slices = argv[++i];
245
+ else if (a === '--slices-file') args.slicesFile = argv[++i];
246
+ else if (a === '--min') args.min = parseInt(argv[++i], 10);
247
+ else if (a === '--max') args.max = parseInt(argv[++i], 10);
248
+ }
249
+ return args;
250
+ }
251
+
252
+ function main() {
253
+ const args = parseArgs(process.argv);
254
+ const fail = (reason) => {
255
+ process.stdout.write(JSON.stringify({ ok: false, exitCode: EXIT_BAD_INPUT, reason }, null, 2) + '\n');
256
+ process.exit(EXIT_BAD_INPUT);
257
+ };
258
+
259
+ const projectDir = path.resolve(args.project);
260
+ if (!fs.existsSync(projectDir)) fail(`project directory not found: ${projectDir}`);
261
+
262
+ // Each budget rule checked on its own, so the message names the one that broke.
263
+ if (!Number.isFinite(args.min)) fail(`--min must be a number, got ${args.min}`);
264
+ if (!Number.isFinite(args.max)) fail(`--max must be a number, got ${args.max}`);
265
+ if (args.min <= 0) fail(`--min must be above zero, got ${args.min}`);
266
+ if (args.max <= args.min) fail(`--max (${args.max}) must be above --min (${args.min})`);
267
+
268
+ let raw = args.slices;
269
+ if (args.slicesFile) {
270
+ try {
271
+ raw = fs.readFileSync(args.slicesFile, 'utf8');
272
+ } catch (e) {
273
+ fail(`could not read ${args.slicesFile}: ${(e && e.message) || e}`);
274
+ }
275
+ }
276
+ if (!raw) fail('no slices given — pass --slices <json> or --slices-file <path>');
277
+
278
+ let slices;
279
+ try {
280
+ slices = JSON.parse(raw);
281
+ } catch (e) {
282
+ fail(`slices is not valid JSON: ${(e && e.message) || e}`);
283
+ }
284
+ if (!Array.isArray(slices)) fail('slices must be an array');
285
+ if (slices.length === 0) fail('slices must not be empty');
286
+
287
+ const result = plan(projectDir, slices, args.min, args.max);
288
+ process.stdout.write(JSON.stringify(result, null, 2) + '\n');
289
+ process.exit(result.exitCode);
290
+ }
291
+
292
+ if (require.main === module) main();
293
+
294
+ module.exports = { plan, splitSlice, measurePath, countLines, DEFAULT_MIN, DEFAULT_MAX };
package/bin/gsd-t.js CHANGED
@@ -1786,6 +1786,9 @@ const GLOBAL_BIN_TOOLS = [
1786
1786
  // this by absolute path, so it MUST ship wherever the verify gate ships or the
1787
1787
  // schema-id check throws ENOENT. Same class as the M99 store-resolver omission below.
1788
1788
  "gsd-t-schema-id-check.cjs",
1789
+ // M112 — measures a slice plan in LINES and splits what is too big. Called by
1790
+ // the scan workflow before any reviewing starts.
1791
+ "gsd-t-slice-budget.cjs",
1789
1792
  // v5.5.10 — PseudoCode §1.1 flow-line style gate (contract v1.2.0). The verify
1790
1793
  // workflow fires it on the same doc set as the guard-map gate, so it must ship
1791
1794
  // wherever verify ships. Also in PROJECT_BIN_TOOLS below — a tool wired into a
@@ -3290,6 +3293,9 @@ const PROJECT_BIN_TOOLS = [
3290
3293
  // it via an absolute path in the Track 2 plan, so a project that has the verify gate
3291
3294
  // but NOT this file gets an ENOENT on every verify. Ships alongside the gate itself.
3292
3295
  "gsd-t-schema-id-check.cjs",
3296
+ // M112 — measures a slice plan in LINES and splits what is too big. Called by
3297
+ // the scan workflow before any reviewing starts.
3298
+ "gsd-t-slice-budget.cjs",
3293
3299
  // M82 — Competition Mode judge + its disjointness oracle dependency, so a
3294
3300
  // project's gsd-t-phase workflow can score candidate partitions via the
3295
3301
  // project-local bin (runCli prefers bin/<tool>.cjs over the global binary).
@@ -5739,6 +5745,22 @@ if (require.main === module) {
5739
5745
  process.exit(res.status == null ? 1 : res.status);
5740
5746
  }
5741
5747
 
5748
+ case "slice-budget": {
5749
+ // M112 — `gsd-t slice-budget --project <dir> --slices <json>` measures a
5750
+ // slice plan in LINES and splits what is over the ceiling. Deterministic:
5751
+ // no agent re-slices, so nothing can be lost or invented.
5752
+ const { spawnSync } = require("child_process");
5753
+ const js = path.join(__dirname, "gsd-t-slice-budget.cjs");
5754
+ if (!require("node:fs").existsSync(js)) {
5755
+ error(`gsd-t-slice-budget.cjs not found at ${js} — reinstall GSD-T`);
5756
+ process.exit(1);
5757
+ }
5758
+ const res = spawnSync(process.execPath, [js, ...args.slice(1)], {
5759
+ stdio: "inherit",
5760
+ });
5761
+ process.exit(res.status == null ? 1 : res.status);
5762
+ }
5763
+
5742
5764
  case "pseudocode-style": {
5743
5765
  // Contract v1.2.0 §1.1 — `gsd-t pseudocode-style (--doc <f> | --dir <d>)` thin
5744
5766
  // dispatcher to the PseudoCode flow-line STYLE gate. Sibling of `guard-map`:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tekyzinc/gsd-t",
3
- "version": "5.11.24",
3
+ "version": "5.11.26",
4
4
  "description": "GSD-T: Contract-Driven Development for Claude Code — 54 slash commands with headless-by-default workflow spawning, unattended supervisor relay with event stream, graph-powered code analysis, real-time agent dashboard, task telemetry, doc-ripple enforcement, backlog management, impact analysis, test sync, milestone archival, and PRD generation",
5
5
  "author": "Tekyz, Inc.",
6
6
  "license": "MIT",
@@ -478,123 +478,52 @@ if (layerShaped.length > 2) {
478
478
  // So the cap is now on SIZE, and the count follows from it. A slice too large to
479
479
  // read is SPLIT, never dropped. More agents is the correct answer to more code.
480
480
  const computedCap = computeSliceCap(probe.totals || {});
481
- const totalFiles = Number((probe.totals || {}).files || (probe.totals || {}).total_files || 0);
482
-
483
- // Files one agent can genuinely read and reason about. Above this, enumeration
484
- // degrades into sampling.
485
- const MAX_FILES_PER_SLICE = 120;
486
-
487
- let slices = rawSlices;
488
-
489
- // A count far above the structural estimate means the probe sliced per file or
490
- // per module rather than by capability — the failure the old cap existed to
491
- // catch (a 5-file repo cut into ~20 slices). Still worth NAMING, but never worth
492
- // deleting code over: it is reported and everything still runs.
493
- // maxSlicesHint used to TRUNCATE here. Nothing may be left out of a scan, so
494
- // the hint no longer deletes: it is reported and every slice still runs.
495
- if (maxSlicesOverride && rawSlices.length > maxSlicesOverride) {
496
- log(`⚠ maxSlicesHint=${maxSlicesOverride} is below the ${rawSlices.length} slices the probe found. IGNORING it — dropping slices would leave code unscanned. Running all ${rawSlices.length}.`);
497
- } else if (rawSlices.length > computedCap * 2) {
498
- log(`⚠ probe returned ${rawSlices.length} slices against a structural estimate of ~${computedCap} — it may have sliced per file/module rather than by capability. Running all ${rawSlices.length} anyway: dropping a slice would silently remove code from the scan.`);
499
- }
500
-
501
- // Slices too big to read honestly. The probe owns the split (it knows the
502
- // paths); this reports the ones that will under-read so it is visible in the
503
- // log rather than hidden in a thin finding count.
504
- if (totalFiles > 0 && slices.length > 0) {
505
- const avgFiles = Math.round(totalFiles / slices.length);
506
- if (avgFiles > MAX_FILES_PER_SLICE) {
507
- // Warning about it is not enough — the run would go on and under-read every
508
- // slice. Send the decomposition back to be split, and use the result.
509
- const wanted = Math.ceil(totalFiles / MAX_FILES_PER_SLICE);
510
- log(`⚠ SLICES TOO LARGE TO ENUMERATE — ~${avgFiles} files each across ${slices.length} slices (${totalFiles} files). The finder must read EVERY file; above ~${MAX_FILES_PER_SLICE} it samples instead, and a sampled slice reports fewer findings while looking complete. Re-slicing to ~${wanted}.`);
511
-
512
- const resliced = await gatedAgent(
513
- [
514
- `Re-slice a codebase decomposition that came out too coarse. Project: \`${projectDir}\`.`,
515
- ``,
516
- `Here are the current slices — ${slices.length} of them, averaging ~${avgFiles} files each:`,
517
- JSON.stringify(slices.map((sl) => ({ key: sl.key, paths: sl.paths, dimension: sl.dimension })), null, 1),
518
- ``,
519
- `Each slice is read by ONE agent that must open EVERY file it owns. At ~${avgFiles} files that is not possible, so those agents will sample and report a thin slice as a clean one.`,
520
- `SPLIT them so no slice exceeds ~${MAX_FILES_PER_SLICE} files. Target roughly ${wanted} slices in total.`,
521
- ``,
522
- `RULES:`,
523
- `· Split along the feature's own seams, still VERTICALLY: "billing-invoicing" and "billing-payments", never "billing-routes" and "billing-schema".`,
524
- `· EVERY path in the input must appear in exactly one output slice. Losing a path removes that code from the scan entirely.`,
525
- `· Never merge two slices to tidy the count. Splitting is the only operation here.`,
526
- `· A slice already under ~${MAX_FILES_PER_SLICE} files passes through unchanged.`,
527
- `Return the full new slice list — every slice, not only the ones you split.`,
528
- SHAPE_RULE,
529
- ].join("\n"),
530
- { label: "probe:reslice", phase: "Probe", schema: PROBE_SCHEMA, model: "opus" }
531
- );
532
-
533
- const newSlices = (resliced && Array.isArray(resliced.slices) && resliced.slices) || [];
534
- // Accept it only if it is genuinely finer AND kept the paths. A re-slice
535
- // that lost code would be worse than the coarse decomposition it replaced.
536
- const pathsBefore = new Set(slices.flatMap((sl) => sl.paths || []));
537
- const pathsAfter = new Set(newSlices.flatMap((sl) => sl.paths || []));
538
- const lost = [...pathsBefore].filter((x) => !pathsAfter.has(x));
539
-
540
- if (newSlices.length > slices.length && lost.length === 0) {
541
- log(`✓ re-sliced ${slices.length} → ${newSlices.length} slices (~${Math.round(totalFiles / newSlices.length)} files each), every path preserved`);
542
- slices = newSlices;
543
- } else {
544
- // A rejected re-slice leaves the scan under-reading every slice — that is
545
- // continuing past a failure, so it gets a second try that names exactly
546
- // what went wrong, on the same model. If that fails too, the slices are
547
- // split MECHANICALLY below: a crude split that reads every file beats a
548
- // tidy one that reads half.
549
- const why = newSlices.length <= slices.length
550
- ? `it returned ${newSlices.length} slices — no finer than the ${slices.length} it was given`
551
- : `it dropped ${lost.length} path(s): ${lost.slice(0, 6).join(", ")}${lost.length > 6 ? ", …" : ""}`;
552
- log(`⚠ re-slice attempt 1 rejected — ${why}. Retrying with the fault named.`);
553
-
554
- const retry = await gatedAgent(
555
- [
556
- `Your previous re-slice was REJECTED because ${why}.`,
557
- ``,
558
- `Split these ${slices.length} slices so none exceeds ~${MAX_FILES_PER_SLICE} files. Target ~${wanted} slices.`,
559
- JSON.stringify(slices.map((sl) => ({ key: sl.key, paths: sl.paths, dimension: sl.dimension })), null, 1),
560
- ``,
561
- `The output MUST contain more slices than the input, and EVERY input path must appear in exactly one output slice. Splitting is the only operation — never merge, never drop.`,
562
- `Split along the feature's own seams, still vertically.`,
563
- SHAPE_RULE,
564
- ].join("\n"),
565
- { label: "probe:reslice (retry)", phase: "Probe", schema: PROBE_SCHEMA, model: "opus" }
566
- );
567
-
568
- const retrySlices = (retry && Array.isArray(retry.slices) && retry.slices) || [];
569
- const retryAfter = new Set(retrySlices.flatMap((sl) => sl.paths || []));
570
- const retryLost = [...pathsBefore].filter((x) => !retryAfter.has(x));
481
+ // ── Size every slice in LINES, and split what is too big ────────────────────
482
+ //
483
+ // Files are a terrible measure of how much code a reviewer was handed: in the
484
+ // HiloAviation codebase the median source file is 233 lines and the largest is
485
+ // 23,664. The previous check counted files and asked an agent to re-slice; both
486
+ // were wrong. Lines track what actually happened across three real scans:
487
+ //
488
+ // 47 slices · 54,000 lines each · 297 findings
489
+ // 24 slices · 106,000 lines each · 194 findings
490
+ // 1 slice · 2,545,000 lines · 3 findings
491
+ //
492
+ // Splitting is DETERMINISTIC now bin/gsd-t-slice-budget.cjs measures real
493
+ // files and packs them under a ceiling. No agent re-slices, so nothing can be
494
+ // lost, invented, or returned no finer than it was given.
495
+ const budgetPlan = await runCli(
496
+ "slice-budget",
497
+ ["--project", projectDir, "--slices", JSON.stringify(rawSlices.map((sl) => ({
498
+ key: sl.key, paths: sl.paths, dimension: sl.dimension, why: sl.why,
499
+ })))],
500
+ "slice-budget"
501
+ );
571
502
 
572
- if (retrySlices.length > slices.length && retryLost.length === 0) {
573
- log(`✓ re-slice retry succeeded — ${slices.length} ${retrySlices.length} slices, every path preserved`);
574
- slices = retrySlices;
575
- } else {
576
- // Mechanical split: divide each oversized slice's own paths into chunks.
577
- // No agent, no judgement, no way to lose a path every path lands in
578
- // exactly one chunk because the chunks ARE the path list, cut up.
579
- const split = [];
580
- for (const sl of slices) {
581
- const paths = sl.paths || [];
582
- const share = Math.max(1, Math.round((paths.length / Math.max(pathsBefore.size, 1)) * totalFiles));
583
- const parts = Math.ceil(share / MAX_FILES_PER_SLICE);
584
- if (parts <= 1 || paths.length <= 1) { split.push(sl); continue; }
585
- const per = Math.ceil(paths.length / parts);
586
- for (let i = 0; i < paths.length; i += per) {
587
- split.push({ ...sl, key: `${sl.key}-part${Math.floor(i / per) + 1}`, paths: paths.slice(i, i + per) });
588
- }
589
- }
590
- log(`⚠ both re-slice attempts rejected — splitting mechanically instead: ${slices.length} → ${split.length} slices. Crude, but every path is still scanned and no slice is too large to read.`);
591
- slices = split;
592
- }
593
- }
503
+ if (budgetPlan && budgetPlan.ok && Array.isArray(budgetPlan.slices) && budgetPlan.slices.length) {
504
+ const a = budgetPlan.after || {};
505
+ if (budgetPlan.slices.length > rawSlices.length) {
506
+ log(`✂ slice budget: ${rawSlices.length} ${budgetPlan.slices.length} slices (${(a.lines || 0).toLocaleString()} lines, ~${(a.meanLinesPerSlice || 0).toLocaleString()} per slice, ceiling ${(budgetPlan.budget || {}).max}). A reviewer must read every file it owns; a slice too large is read by sampling, and a sampled slice reports few findings while looking complete.`);
507
+ } else {
508
+ log(`slice budget: ${budgetPlan.slices.length} slice(s), ~${(a.meanLinesPerSlice || 0).toLocaleString()} lines each all within the ${(budgetPlan.budget || {}).max}-line ceiling.`);
594
509
  }
510
+ for (const f of (budgetPlan.soloOversizedFiles || []).slice(0, 5)) {
511
+ log(` · ${f.file} is ${f.lines.toLocaleString()} lines — larger than the whole ceiling, so it is its own slice. The budget cannot go below one file.`);
512
+ }
513
+ for (const prob of (budgetPlan.problems || []).slice(0, 5)) {
514
+ log(` ⚠ slice budget could not measure ${prob}`);
515
+ }
516
+ slices = budgetPlan.slices;
517
+ } else {
518
+ // Measuring failed. Continuing would hand reviewers slices of unknown size —
519
+ // exactly the state that produced a 2.5-million-line "slice" reporting three
520
+ // findings under a FULL coverage header.
521
+ log(`⚠ SLICE BUDGET FAILED [${(budgetPlan && budgetPlan.reason) || "no-result"}] — slice sizes are UNMEASURED. Running the probe's slices as given; if any is oversized its reviewer will sample rather than read.`);
595
522
  }
596
523
 
597
- log(`probe derived ${rawSlices.length} slice(s); running ${slices.length} deep-finder(s)${totalFiles ? `; ~${Math.round(totalFiles / Math.max(slices.length, 1))} files/slice` : ""}; structural estimate=${computedCap}; totals=${JSON.stringify(probe.totals)}`);
524
+ const totalFiles = Number((budgetPlan && budgetPlan.after && budgetPlan.after.files) || (probe.totals || {}).files || 0);
525
+ const totalLines = Number((budgetPlan && budgetPlan.after && budgetPlan.after.lines) || 0);
526
+ log(`probe derived ${rawSlices.length} slice(s); running ${slices.length} deep-finder(s)${totalLines ? `; ${totalLines.toLocaleString()} lines, ~${Math.round(totalLines / Math.max(slices.length, 1)).toLocaleString()} per slice` : ""}${totalFiles ? `; ${totalFiles} files` : ""}; totals=${JSON.stringify(probe.totals)}`);
598
527
 
599
528
  // M94-D6: Graph-Wiring phase — ADDITIVE injection of the pre-computed structural slice.
600
529
  // Current scan architecture is KEPT FULLY INTACT (Destructive Action Guard).