@tekyzinc/gsd-t 5.11.24 → 5.11.27

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,72 @@
2
2
 
3
3
  All notable changes to GSD-T are documented here. Updated with each release.
4
4
 
5
+ ## [5.11.27] - 2026-08-11
6
+
7
+ ### Fixed — the scan's volume probe returned a stand-in, and the whole run was built on it
8
+
9
+ HiloAviation, this morning. The probe answered `totals: {"trackedFiles": 5036}`
10
+ and `slices: [{"key": "test", "paths": ["src/"]}]` — one slice for a 4,900-file
11
+ application, keyed "test", and none of the six measurements the prompt asked for.
12
+ The schema accepted it (one slice is legal, `totals` takes any object), so the
13
+ scan read that single slice, produced 3 findings, and headed the register
14
+ "Coverage: FULL".
15
+
16
+ The probe had no retry. Every finder gets re-run when it answers `{"findings":[]}`;
17
+ the probe — which decides what every finder will ever look at — got one call, and
18
+ whatever came back became the plan.
19
+
20
+ The tell is not the slice count; a small repo really is one slice. It is that the
21
+ fields explicitly requested are absent: the shape of an answer with none of the
22
+ work behind it. So the result is now inspected before it is trusted, retried on
23
+ opus with the fault named, and a second stand-in HALTS the scan rather than
24
+ scanning it — a register that under-counts while claiming full coverage is worse
25
+ than no register.
26
+
27
+ - `templates/workflows/gsd-t-scan.workflow.js`: `probePlaceholderFaults()` +
28
+ retry-on-opus + halt; the prompt is now a reusable constant so the retry sends
29
+ the same task; the slice count and totals are logged at probe time, so a thin
30
+ plan is visible before the run rather than inferred afterwards from a thin
31
+ register.
32
+ - `test/m112-probe-placeholder.test.js`: 11 regressions, including the verbatim
33
+ payload that produced the 3-finding scan.
34
+
35
+ The whole-tree tell only fires when the measurements are missing too. A small
36
+ project genuinely is one slice covering `src/`, and halting that scan would be a
37
+ false alarm on every small repo — worse than the bug being fixed.
38
+
39
+ ## [5.11.26] - 2026-08-11
40
+
41
+ ### Fixed — a quarter of every import edge pointed at a string no file matched
42
+
43
+ A project writes `import x from "@/lib/foo"` and declares what `@/` means in its
44
+ tsconfig. Stored raw, that target resolves to nothing — the graph records an edge
45
+ pointing at `@/lib/foo`, and no file is ever named that.
46
+
47
+ **Measured on HiloAviation: 5,738 of 23,263 import edges — 25% — were
48
+ unexpanded shortcuts.** Ask "does anything import this file?" and a quarter of
49
+ the real answers are missing, so live code looks unreferenced. A reachability
50
+ rule built on that data would have called **1,919 files of a working app dead**.
51
+
52
+ The indexer now reads `paths` (and `baseUrl`) from `tsconfig.json` or
53
+ `jsconfig.json` once per build, and expands every import target before storing
54
+ it. A package import like `react` is left exactly as written — rewriting it
55
+ would invent an edge to a file that does not exist.
56
+
57
+ A config that is present but unreadable or unparseable is **announced**, never
58
+ silently skipped: skipping expansion while reporting a successful build is the
59
+ failure this fixes. A project with no such config says so too.
60
+
61
+ Config files routinely carry comments and trailing commas, so both are stripped
62
+ — but never inside a string, or a URL like `https://…` loses everything after
63
+ the `//`.
64
+
65
+ - `bin/gsd-t-graph-index.cjs`: `loadPathAliases()` + `expandAlias()`, applied where import edges are stored
66
+ - `test/m112-import-alias-expansion.test.js`: 6 tests — expansion, packages untouched, comments + URL, no config, `baseUrl`, and that a broken config is announced
67
+
68
+ **Rebuild the index to benefit:** `gsd-t graph index`. An index built before this
69
+ release still holds the raw shortcuts.
70
+
5
71
  ## [5.11.24] - 2026-08-11
6
72
 
7
73
  ### 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.27** - 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.27",
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",
@@ -399,7 +399,7 @@ log(`preflight ok — branch=${pre.branch}, repo=${repoName}, priorRegister=${pr
399
399
 
400
400
  // Volume probe — an agent measures the codebase (its own Bash) and carves slices.
401
401
  phase("Probe");
402
- const probe = await agent(
402
+ const PROBE_PROMPT =
403
403
  [
404
404
  `⛔ TARGET DIRECTORY IS FIXED: you MUST scan ONLY the project at the absolute path \`${projectDir}\`. Before any measurement, \`cd ${projectDir}\` (or pass that exact path to every Read/Grep/Bash). Do NOT scan your current working directory, the GSD-T package, or any other tree — every file you measure and every slice path you emit MUST be under \`${projectDir}\`. If \`${projectDir}\` does not exist or is empty, return a single slice noting that.`,
405
405
  ``,
@@ -437,14 +437,116 @@ const probe = await agent(
437
437
  ``,
438
438
  `Measure with real tooling and report in \`totals\`: files, loc, routes, tables, components, featureDomains (distinct business/feature areas). Read \`${projectDir}/package.json\` for the stack. Return JSON per the schema: totals + slices.`,
439
439
  SHAPE_RULE,
440
- ].join("\n"),
441
- { label: "volume-probe", phase: "Probe", schema: PROBE_SCHEMA, model: "sonnet" }
442
- );
440
+ ].join("\n");
441
+
442
+ // Did the probe MEASURE the codebase, or return something shaped like an answer?
443
+ //
444
+ // [RULE] probe-placeholder-is-rejected-not-scanned
445
+ //
446
+ // HiloAviation, 2026-08-11. The probe returned:
447
+ //
448
+ // totals : {"trackedFiles": 5036}
449
+ // slices : [{"key": "test", "paths": ["src/"]}]
450
+ //
451
+ // One slice for a 4,900-file application, keyed "test", and a totals object
452
+ // carrying none of the six numbers the prompt asked for. The schema accepted it
453
+ // — one slice is legal, and totals takes any object — so the scan ran that one
454
+ // slice, found 3 findings, and the register's header read "Coverage: FULL".
455
+ //
456
+ // The tell is not the slice count. It is that the fields explicitly named in the
457
+ // prompt are simply absent: the agent produced the SHAPE of an answer without
458
+ // doing the work, the same instinct that sends `{"findings":[]}` through a
459
+ // finder. So the probe result is inspected before it is trusted, and a probe
460
+ // that did not measure is retried on a stronger model rather than scanned.
461
+ function probePlaceholderFaults(result) {
462
+ const faults = [];
463
+ const totals = (result && result.totals) || {};
464
+ const slices = (result && Array.isArray(result.slices) && result.slices) || [];
465
+
466
+ // Every number the prompt asks for by name. None present means the agent
467
+ // reported whatever it had to hand instead of measuring.
468
+ const asked = ["files", "loc", "routes", "tables", "components", "featureDomains"];
469
+ const present = asked.filter((k) => totals[k] !== undefined && totals[k] !== null);
470
+ if (present.length === 0) {
471
+ faults.push(
472
+ `totals contains none of the six requested measurements (${asked.join(", ")}) — it holds ` +
473
+ `${JSON.stringify(totals).slice(0, 160)}`
474
+ );
475
+ }
476
+
477
+ // A single slice whose path is the whole source tree has decomposed nothing —
478
+ // but ONLY when the totals are missing too. A small repo genuinely is one
479
+ // slice covering src/, and halting that scan would be a false alarm on every
480
+ // small project. What separates the two is whether the agent measured: a real
481
+ // probe reports the numbers AND explains the single slice. Both tells together
482
+ // are one judgment, not two independent ones.
483
+ const WHOLE_TREE = new Set([".", "./", "*", "src", "src/", "./src", "./src/", projectDir]);
484
+ if (slices.length === 1 && present.length === 0) {
485
+ const paths = Array.isArray(slices[0].paths) ? slices[0].paths : [];
486
+ if (paths.length && paths.every((p) => WHOLE_TREE.has(String(p).trim().replace(/\/+$/, "/")) || WHOLE_TREE.has(String(p).trim()))) {
487
+ faults.push(
488
+ `the one slice "${slices[0].key}" owns the entire source tree (${JSON.stringify(paths)}) — ` +
489
+ `that is not a decomposition`
490
+ );
491
+ }
492
+ }
493
+
494
+ return faults;
495
+ }
496
+
497
+ let probe = await agent(PROBE_PROMPT, {
498
+ label: "volume-probe", phase: "Probe", schema: PROBE_SCHEMA, model: "sonnet",
499
+ });
500
+
501
+ let probeFaults = probePlaceholderFaults(probe);
502
+ if (probeFaults.length) {
503
+ log(`⚠ PROBE DID NOT MEASURE — the answer has the right shape but not the work behind it:`);
504
+ for (const f of probeFaults) log(` · ${f}`);
505
+ log(` retrying on opus with the fault named.`);
506
+
507
+ const retry = await agent(
508
+ [
509
+ PROBE_PROMPT,
510
+ ``,
511
+ `!! A PREVIOUS ATTEMPT AT THIS EXACT TASK WAS REJECTED. What was wrong with it:`,
512
+ ...probeFaults.map((f) => ` · ${f}`),
513
+ ``,
514
+ `RUN THE MEASUREMENTS. Do not answer from a guess, and do not return a minimal stand-in to see whether it is accepted — it will not be.`,
515
+ `Count the files. Count the lines. Find the routes, the database tables, the components, and the distinct business areas. Those NUMBERS go in \`totals\`.`,
516
+ `Then carve the codebase into slices by business capability, one slice per real area of the product. A single slice owning the whole tree is not an answer.`,
517
+ ].join("\n"),
518
+ { label: "volume-probe (retry on opus)", phase: "Probe", schema: PROBE_SCHEMA, model: "opus" }
519
+ );
520
+
521
+ const retryFaults = probePlaceholderFaults(retry);
522
+ if (retry && Array.isArray(retry.slices) && retry.slices.length && retryFaults.length === 0) {
523
+ log(`✓ probe recovered on opus — ${retry.slices.length} slice(s), totals=${JSON.stringify(retry.totals)}`);
524
+ probe = retry;
525
+ } else {
526
+ // Both attempts produced a stand-in. Scanning it would read a fraction of the
527
+ // codebase and print "Coverage: FULL" over the result — a register that lies
528
+ // is worse than no register.
529
+ log(`✗ PROBE FAILED — two attempts, neither measured the codebase.`);
530
+ for (const f of (retryFaults.length ? retryFaults : probeFaults)) log(` · ${f}`);
531
+ return {
532
+ status: "failed",
533
+ reason: "probe-placeholder",
534
+ message:
535
+ "The probe never measured the codebase, on either attempt. Nothing was scanned: a run on this " +
536
+ "answer would have covered a fraction of the project while reporting full coverage.",
537
+ probe: retry || probe,
538
+ };
539
+ }
540
+ }
541
+
443
542
  const rawSlices = (probe && Array.isArray(probe.slices) && probe.slices) || [];
444
543
  if (!rawSlices.length) {
445
544
  log("probe returned no slices — halting");
446
545
  return { status: "failed", reason: "no-slices", probe };
447
546
  }
547
+ // What the plan actually is, before anything runs on it. A thin plan was only
548
+ // ever visible afterwards, in a thin register.
549
+ log(`probe: ${rawSlices.length} slice(s) — totals=${JSON.stringify(probe.totals || {})}`);
448
550
  // Did the probe slice the way it was told? A layer prefix is the tell.
449
551
  //
450
552
  // The instruction alone is not enough — a prompt is advice, and this axis flipped
@@ -478,123 +580,52 @@ if (layerShaped.length > 2) {
478
580
  // So the cap is now on SIZE, and the count follows from it. A slice too large to
479
581
  // read is SPLIT, never dropped. More agents is the correct answer to more code.
480
582
  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));
583
+ // ── Size every slice in LINES, and split what is too big ────────────────────
584
+ //
585
+ // Files are a terrible measure of how much code a reviewer was handed: in the
586
+ // HiloAviation codebase the median source file is 233 lines and the largest is
587
+ // 23,664. The previous check counted files and asked an agent to re-slice; both
588
+ // were wrong. Lines track what actually happened across three real scans:
589
+ //
590
+ // 47 slices · 54,000 lines each · 297 findings
591
+ // 24 slices · 106,000 lines each · 194 findings
592
+ // 1 slice · 2,545,000 lines · 3 findings
593
+ //
594
+ // Splitting is DETERMINISTIC now bin/gsd-t-slice-budget.cjs measures real
595
+ // files and packs them under a ceiling. No agent re-slices, so nothing can be
596
+ // lost, invented, or returned no finer than it was given.
597
+ const budgetPlan = await runCli(
598
+ "slice-budget",
599
+ ["--project", projectDir, "--slices", JSON.stringify(rawSlices.map((sl) => ({
600
+ key: sl.key, paths: sl.paths, dimension: sl.dimension, why: sl.why,
601
+ })))],
602
+ "slice-budget"
603
+ );
571
604
 
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
- }
605
+ if (budgetPlan && budgetPlan.ok && Array.isArray(budgetPlan.slices) && budgetPlan.slices.length) {
606
+ const a = budgetPlan.after || {};
607
+ if (budgetPlan.slices.length > rawSlices.length) {
608
+ 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.`);
609
+ } else {
610
+ log(`slice budget: ${budgetPlan.slices.length} slice(s), ~${(a.meanLinesPerSlice || 0).toLocaleString()} lines each all within the ${(budgetPlan.budget || {}).max}-line ceiling.`);
594
611
  }
612
+ for (const f of (budgetPlan.soloOversizedFiles || []).slice(0, 5)) {
613
+ 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.`);
614
+ }
615
+ for (const prob of (budgetPlan.problems || []).slice(0, 5)) {
616
+ log(` ⚠ slice budget could not measure ${prob}`);
617
+ }
618
+ slices = budgetPlan.slices;
619
+ } else {
620
+ // Measuring failed. Continuing would hand reviewers slices of unknown size —
621
+ // exactly the state that produced a 2.5-million-line "slice" reporting three
622
+ // findings under a FULL coverage header.
623
+ 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
624
  }
596
625
 
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)}`);
626
+ const totalFiles = Number((budgetPlan && budgetPlan.after && budgetPlan.after.files) || (probe.totals || {}).files || 0);
627
+ const totalLines = Number((budgetPlan && budgetPlan.after && budgetPlan.after.lines) || 0);
628
+ 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
629
 
599
630
  // M94-D6: Graph-Wiring phase — ADDITIVE injection of the pre-computed structural slice.
600
631
  // Current scan architecture is KEPT FULLY INTACT (Destructive Action Guard).