@tekyzinc/gsd-t 5.22.10 → 5.24.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.
@@ -134,6 +134,14 @@ function _resetScipCache(override) {
134
134
  * is confirmed present and invocable. Phase-2 will replace the edges with
135
135
  * SCIP-derived ones. This is the documented UPGRADE FLOOR behavior.
136
136
  */
137
+ const SCIP_MAX_FILE_BYTES = '64mb';
138
+ const SCIP_HEAP_MB = 8192;
139
+ const SCIP_MISSING_TIER = 'tree-sitter-floor-SCIP-MISSING';
140
+ // A file SCIP indexed but where fewer than this share of its repo-resolvable calls
141
+ // resolved. [RULE] scip-tier-proportional
142
+ const COMPILER_PARTIAL_TIER = 'compiler-partial';
143
+ const COMPILER_ACCURATE_MIN_FRACTION = 0.9;
144
+
137
145
  function runScipTypescript(projectRoot, outPath) {
138
146
  // M95: emit to a REAL file (outPath) so the index can be READ and call edges
139
147
  // resolved — not /dev/null. The old /dev/null path only proved invocability.
@@ -145,10 +153,22 @@ function runScipTypescript(projectRoot, outPath) {
145
153
  // --infer-tsconfig: resolve a tsconfig even when one isn't at the repo root
146
154
  // (monorepos / nested layouts — e.g. web/tsconfig.json). Without it, projects
147
155
  // whose tsconfig lives in a subdir got 0 resolved call edges. [RULE] scip-infer-nested-tsconfig
148
- const scip = spawnSync('scip-typescript', ['index', '--infer-tsconfig', '--output', out, '.'], {
156
+ // --max-file-byte-size: scip-typescript SKIPS every file over 1mb by default,
157
+ // silently — no warning, no document in the index. A 50k-line route file
158
+ // (hilo-figma-atos routes-locations.ts, 1.9MB) was never resolved, so every
159
+ // call in it stayed unresolved and who-calls answered []. Raise the cap well
160
+ // past any hand-written source file. [RULE] scip-no-silent-large-file-skip
161
+ // A larger heap keeps a big project from dying mid-run once those files are in.
162
+ const nodeOpts = process.env.NODE_OPTIONS || '';
163
+ const env = /max-old-space-size/.test(nodeOpts)
164
+ ? process.env
165
+ : { ...process.env, NODE_OPTIONS: `${nodeOpts} --max-old-space-size=${SCIP_HEAP_MB}`.trim() };
166
+ const scip = spawnSync('scip-typescript',
167
+ ['index', '--infer-tsconfig', '--max-file-byte-size', SCIP_MAX_FILE_BYTES, '--output', out, '.'], {
149
168
  cwd: projectRoot,
150
169
  encoding: 'utf8',
151
170
  timeout: 180_000,
171
+ env,
152
172
  });
153
173
  if (scip.status === 0) return { ok: true, scipPath: out };
154
174
  return { ok: false, error: scip.stderr || `exit code ${scip.status}` };
@@ -314,7 +334,7 @@ function findTsProjectDirs(repoRoot) {
314
334
  * resolveFileEdges: (relPath, edges) => { edges, resolved: number } }}
315
335
  */
316
336
  function buildScipResolver(repoRoot, opts = {}) {
317
- const { readScipIndex } = require('./gsd-t-scip-reader.cjs');
337
+ const { readScipIndex, scipPositionKey } = require('./gsd-t-scip-reader.cjs');
318
338
  const avail = detectScip();
319
339
 
320
340
  // Detect which languages have source present (so we only run the relevant
@@ -325,10 +345,21 @@ function buildScipResolver(repoRoot, opts = {}) {
325
345
  // is language-agnostic (Python symbols use the same `name().` descriptor form),
326
346
  // so TS and Python refs merge into one fileRefs map keyed by repo-relative path.
327
347
  const fileRefs = new Map(); // relPath → [{symbol, funcId, line}]
348
+ const scipDocs = new Set(); // every file any indexer produced a document for
349
+ const defNames = new Set(); // every name SCIP saw DEFINED in the repo — what a call could resolve to
350
+ const occurrencePositions = new Map(); // relPath → Set<positionKey> of every SCIP occurrence
328
351
  const ranIndexers = [];
329
352
 
330
353
  function mergeRead(read) {
331
354
  if (!read || !read.ok) return;
355
+ if (read.docPaths) for (const d of read.docPaths) scipDocs.add(d);
356
+ if (read.symbolToDef) for (const fid of read.symbolToDef.values()) defNames.add(fid.split('#').pop());
357
+ if (read.occurrencePositions) {
358
+ for (const [f, pos] of read.occurrencePositions) {
359
+ if (occurrencePositions.has(f)) for (const k of pos) occurrencePositions.get(f).add(k);
360
+ else occurrencePositions.set(f, pos);
361
+ }
362
+ }
332
363
  for (const [file, refs] of read.fileRefs) {
333
364
  if (fileRefs.has(file)) fileRefs.get(file).push(...refs);
334
365
  else fileRefs.set(file, refs.slice());
@@ -383,8 +414,7 @@ function buildScipResolver(repoRoot, opts = {}) {
383
414
  * resolves to a real funcId, rewrite dst to that funcId.
384
415
  */
385
416
  function resolveFileEdges(relPath, edges) {
386
- const refs = fileRefs.get(relPath);
387
- if (!refs || !refs.length) return { edges, resolved: 0 };
417
+ const refs = fileRefs.get(relPath) || [];
388
418
 
389
419
  // name → resolved funcId (last writer wins; SCIP refs in this file)
390
420
  const nameToFuncId = new Map();
@@ -394,6 +424,12 @@ function buildScipResolver(repoRoot, opts = {}) {
394
424
  }
395
425
 
396
426
  let resolved = 0;
427
+ // missed = a call to a name the repo defines, at a position where SCIP put NO
428
+ // symbol — the compiler never looked at it. A call SCIP resolved to a library
429
+ // (drizzle's `text()`) or a local (`const [x, setX] = useState()`) is not a
430
+ // miss: SCIP answered, the answer just is not a repo function.
431
+ const positions = occurrencePositions.get(relPath);
432
+ let missed = 0;
397
433
  const out = edges.map((edge) => {
398
434
  const dst = edge.target || edge.dst || '';
399
435
  const kind = edge.kind;
@@ -401,12 +437,18 @@ function buildScipResolver(repoRoot, opts = {}) {
401
437
  if (!isCall || !dst.startsWith('UNRESOLVED#')) return edge;
402
438
  const calleeName = dst.slice('UNRESOLVED#'.length);
403
439
  const funcId = nameToFuncId.get(calleeName);
404
- if (!funcId) return edge; // still unresolved → stays floor
440
+ if (!funcId) { // still unresolved → stays floor
441
+ const seen = positions && Number.isInteger(edge.col) && positions.has(scipPositionKey(edge.line - 1, edge.col));
442
+ if (defNames.has(calleeName) && !seen) missed++;
443
+ return edge;
444
+ }
405
445
  resolved++;
406
446
  // rewrite dst to the resolved funcId, mark scip-derived
407
447
  return { ...edge, target: funcId, dst: funcId, scipResolved: true };
408
448
  });
409
- return { edges: out, resolved };
449
+ // resolvable = resolved + missed. A call to a library (`c.json`) or a local
450
+ // never counts against the file. [RULE] scip-tier-proportional
451
+ return { edges: out, resolved, resolvable: resolved + missed };
410
452
  }
411
453
 
412
454
  // tsProjects reports which tsconfig projects actually contributed refs. A
@@ -419,6 +461,10 @@ function buildScipResolver(repoRoot, opts = {}) {
419
461
  indexedFiles: fileRefs.size,
420
462
  scipPath: resolveScipPath('index.scip', repoRoot),
421
463
  resolveFileEdges,
464
+ // [RULE] scip-missing-file-detected-never-silent — lets the upgrader tell a
465
+ // file the indexer never produced a document for from one it indexed.
466
+ coversLanguage: (lang) => ranIndexers.includes(lang),
467
+ hasScipDoc: (relPath) => scipDocs.has(relPath),
422
468
  };
423
469
  }
424
470
 
@@ -492,8 +538,17 @@ function tryScipUpgrade(absPath, relPath, entities, edges, options) {
492
538
  return { upgraded: false, tier, entities, edges };
493
539
  }
494
540
 
541
+ // The indexer ran for this language but produced no document for this file
542
+ // (skipped for size, outside every tsconfig, or dropped mid-run). Its call
543
+ // targets are unknown — say so in the tier, never label it plain floor or,
544
+ // worse, compiler-accurate. [RULE] scip-missing-file-detected-never-silent
545
+ if (typeof resolver.hasScipDoc === 'function' && typeof resolver.coversLanguage === 'function' &&
546
+ resolver.coversLanguage(lang) && !resolver.hasScipDoc(relPath)) {
547
+ return { upgraded: false, tier: SCIP_MISSING_TIER, entities, edges };
548
+ }
549
+
495
550
  // Resolve this file's UNRESOLVED# call edges against the SCIP index.
496
- const { edges: resolvedEdges, resolved } = resolver.resolveFileEdges(relPath, edges);
551
+ const { edges: resolvedEdges, resolved, resolvable = 0 } = resolver.resolveFileEdges(relPath, edges);
497
552
 
498
553
  // Rust cross-crate edges stay flagged partial.
499
554
  // [RULE] rust-cross-crate-flagged-partial
@@ -505,15 +560,36 @@ function tryScipUpgrade(absPath, relPath, entities, edges, options) {
505
560
  });
506
561
 
507
562
  // [RULE] scip-tier-honest: label compiler-accurate ONLY when SCIP actually
508
- // resolved ≥1 edge in this file OR the file has no call edges to resolve (a
509
- // pure-definition file SCIP indexed cleanly). A file whose calls all stayed
563
+ // resolved enough edges (proportionally). A file whose calls all stayed
510
564
  // UNRESOLVED is NOT compiler-accurate — it's floor.
565
+ // [RULE] scip-tier-proportional: the fraction of resolvable calls that actually
566
+ // resolved determines the tier. If ≥COMPILER_ACCURATE_MIN_FRACTION resolved,
567
+ // it's accurate; otherwise partial (still better than floor). A file with no
568
+ // resolvable edges (only locals/library calls) that SCIP indexed cleanly
569
+ // is compiler-accurate. [ISSUE] user reported: routes-locations.ts 80% UNRESOLVED
570
+ // yet labeled compiler-accurate because ONE edge resolved.
511
571
  const hadCallEdges = edges.some(e => (e.kind === 'call-site' || e.kind === 'CALL'));
512
- const isAccurate = !hadCallEdges || resolved > 0;
513
- const tier = isAccurate ? 'compiler-accurate' : 'tree-sitter-floor';
572
+ let tier = 'tree-sitter-floor';
573
+ if (!hadCallEdges) {
574
+ // Pure-definition file SCIP indexed cleanly → compiler-accurate
575
+ tier = 'compiler-accurate';
576
+ } else if (resolvable > 0) {
577
+ // File has resolvable calls — judge by proportion resolved
578
+ if (resolved / resolvable >= COMPILER_ACCURATE_MIN_FRACTION) {
579
+ // Enough resolvable calls resolved → accurate
580
+ tier = 'compiler-accurate';
581
+ } else if (resolved > 0) {
582
+ // Some but not enough resolvable calls resolved → partial
583
+ tier = COMPILER_PARTIAL_TIER;
584
+ }
585
+ // else: no edges resolved (0/resolvable) → tree-sitter-floor (default)
586
+ }
587
+ // else: no resolvable calls (all external/local) — stay tree-sitter-floor (default)
588
+
589
+ const upgraded = tier === 'compiler-accurate';
514
590
 
515
591
  return {
516
- upgraded: isAccurate,
592
+ upgraded,
517
593
  tier,
518
594
  entities,
519
595
  edges: finalEdges,
@@ -584,4 +660,8 @@ module.exports = {
584
660
  _resetScipCache,
585
661
  isRustCrossCrateEdge,
586
662
  EXT_TO_LANG,
663
+ SCIP_MISSING_TIER,
664
+ SCIP_MAX_FILE_BYTES,
665
+ COMPILER_PARTIAL_TIER,
666
+ COMPILER_ACCURATE_MIN_FRACTION,
587
667
  };
@@ -151,7 +151,8 @@ function isBuildOutputPath(relPath) {
151
151
  * @param {string} [pathPrefix] repo-relative dir the index was produced from
152
152
  * (e.g. "server"); "" or "." for the repo root
153
153
  * @returns {{ ok: true, symbolToDef: Map<string,string>,
154
- * fileRefs: Map<string, Array<{symbol:string, funcId:string, line:number}>> }
154
+ * fileRefs: Map<string, Array<{symbol:string, funcId:string, line:number}>>,
155
+ * docPaths: Set<string> }
155
156
  * | { ok: false, reason: string }}
156
157
  */
157
158
  function readScipIndex(scipPath, pathPrefix) {
@@ -177,6 +178,18 @@ function readScipIndex(scipPath, pathPrefix) {
177
178
 
178
179
  const symbolToDef = new Map(); // scipSymbol → funcId (relPath#name)
179
180
  const fileRefs = new Map(); // relPath → [{symbol, line}]
181
+ // Every file the indexer produced a document for — including files with no
182
+ // resolvable reference, which never appear in fileRefs. Without this set a
183
+ // file the indexer SKIPPED (scip-typescript drops files over its byte-size cap)
184
+ // is indistinguishable from one it indexed and found nothing in.
185
+ // [RULE] scip-missing-file-detected-never-silent
186
+ const docPaths = new Set();
187
+ // Every position (line, column) SCIP put ANY symbol at, per file — external,
188
+ // local, or repo. A call site whose callee sits at one of these was SEEN by the
189
+ // compiler (resolved to something, even if not a repo function); one that
190
+ // does not was never looked at. Numbers, not strings: a large repo has
191
+ // millions of occurrences. [RULE] scip-tier-proportional
192
+ const occurrencePositions = new Map();
180
193
 
181
194
  const docs = obj.documents || [];
182
195
 
@@ -185,6 +198,7 @@ function readScipIndex(scipPath, pathPrefix) {
185
198
  const rawPath = doc.relative_path;
186
199
  if (!rawPath || isBuildOutputPath(rawPath)) continue;
187
200
  const relPath = reroot(rawPath);
201
+ docPaths.add(relPath);
188
202
  for (const occ of doc.occurrences || []) {
189
203
  const isDef = (occ.symbol_roles & SYMBOL_ROLE_DEFINITION) !== 0;
190
204
  if (!isDef) continue;
@@ -196,12 +210,19 @@ function readScipIndex(scipPath, pathPrefix) {
196
210
  }
197
211
 
198
212
  // Second pass: collect every REFERENCE occurrence per file, resolved to the def.
213
+ // A reference is kept whatever scope encloses it — named function, anonymous
214
+ // route handler, or top-level callback. The CALLER identity comes from the
215
+ // tree-sitter floor (which synthesizes one for anonymous scopes); SCIP only
216
+ // resolves the TARGET. [RULE] anonymous-caller-synthesized-never-dropped
199
217
  for (const doc of docs) {
200
218
  const rawPath = doc.relative_path;
201
219
  if (!rawPath || isBuildOutputPath(rawPath)) continue;
202
220
  const relPath = reroot(rawPath);
203
221
  const refs = [];
222
+ const positions = new Set();
223
+ occurrencePositions.set(relPath, positions);
204
224
  for (const occ of doc.occurrences || []) {
225
+ if (Array.isArray(occ.range) && occ.range.length >= 2) positions.add(scipPositionKey(occ.range[0], occ.range[1]));
205
226
  const isDef = (occ.symbol_roles & SYMBOL_ROLE_DEFINITION) !== 0;
206
227
  if (isDef) continue; // refs only
207
228
  const name = funcNameFromSymbol(occ.symbol);
@@ -214,11 +235,17 @@ function readScipIndex(scipPath, pathPrefix) {
214
235
  if (refs.length) fileRefs.set(relPath, refs);
215
236
  }
216
237
 
217
- return { ok: true, symbolToDef, fileRefs };
238
+ return { ok: true, symbolToDef, fileRefs, docPaths, occurrencePositions };
239
+ }
240
+
241
+ /** 0-based line + column → one number (columns never reach 1e6). */
242
+ function scipPositionKey(line0, col) {
243
+ return line0 * 1e6 + col;
218
244
  }
219
245
 
220
246
  module.exports = {
221
247
  loadScipProto,
222
248
  funcNameFromSymbol,
223
249
  readScipIndex,
250
+ scipPositionKey,
224
251
  };
package/bin/gsd-t.js CHANGED
@@ -3708,6 +3708,8 @@ const PROJECT_BIN_TOOLS = [
3708
3708
  // graph dead → grep fallback (found by the Binvoice architect run 2026-07-12).
3709
3709
  // Same class as [[project_global_bin_propagation_gap]] / [[project_m96_graph_runs_in_projects]].
3710
3710
  "gsd-t-graph-store-resolver.cjs",
3711
+ // Required by the indexer + freshness walker (project graph exclude list).
3712
+ "gsd-t-graph-exclude.cjs",
3711
3713
  // M96 — multi-location resolver for the store engine (better-sqlite3), so a
3712
3714
  // copied tool finds the engine from the GSD-T global package, not the project's
3713
3715
  // own (usually absent) node_modules. Fail-loud with remediation if all miss.
@@ -4724,14 +4726,22 @@ function doGraphIndex() {
4724
4726
  heading("GSD-T Graph — Index");
4725
4727
  const { spawnSync } = require("child_process");
4726
4728
  const idxPath = require("path").join(__dirname, "gsd-t-graph-index.cjs");
4729
+ // A large repo's SCIP run legitimately takes many minutes (hilo-figma-atos: >5).
4730
+ // The old 5-minute cap killed the indexer mid-build and — because a killed child
4731
+ // has status null — reported nothing and exited 0, leaving a half-built graph
4732
+ // that looked complete. A kill or spawn error is a failure, said out loud.
4727
4733
  const result = spawnSync(process.execPath, [idxPath, "build", "--repo", process.cwd()], {
4728
4734
  encoding: "utf8",
4729
4735
  cwd: process.cwd(),
4730
4736
  stdio: ["ignore", "inherit", "inherit"],
4731
- timeout: 300000,
4737
+ timeout: 30 * 60 * 1000,
4732
4738
  });
4733
- if (result.status !== 0 && result.status !== null) {
4734
- error("Graph index build failed — see output above");
4739
+ if (result.error || result.signal || result.status !== 0) {
4740
+ const why = result.error ? result.error.message
4741
+ : result.signal ? `killed by ${result.signal} (timeout 30 min?) — the graph is INCOMPLETE`
4742
+ : `exit ${result.status}`;
4743
+ error(`Graph index build failed: ${why}`);
4744
+ process.exitCode = 1;
4735
4745
  }
4736
4746
  }
4737
4747
 
@@ -4756,7 +4766,31 @@ function doGraphStatus() {
4756
4766
  return;
4757
4767
  }
4758
4768
  success(`Graph index: ${envelope.fileCount || 0} files`);
4759
- if (envelope.tier) info(`Tier: ${envelope.tier}`);
4769
+ // Per-tier file counts (what the build reported), then the worst tier present.
4770
+ const tiers = envelope.tiers ? Object.entries(envelope.tiers).sort((a, b) => b[1] - a[1]) : [];
4771
+ if (tiers.length) info(`Tiers: ${tiers.map(([t, n]) => `${t} ${n}`).join(" · ")}`);
4772
+ if (envelope.tier) info(`Lowest tier present: ${envelope.tier}`);
4773
+ if (envelope.tableCount || envelope.enumCount) info(`Database tables: ${envelope.tableCount}, enums: ${envelope.enumCount} (gsd-t graph table <name> · who-uses <name>)`);
4774
+ // [RULE] scip-missing-file-detected-never-silent — files the SCIP indexer never
4775
+ // produced a document for: their call edges stay unresolved, so who-calls is blind there.
4776
+ const miss = envelope.scipMissing;
4777
+ if (miss && miss.count > 0) {
4778
+ warn(`${miss.count} file(s) not in SCIP (call edges unresolved): ${miss.files.join(", ")}${miss.count > miss.files.length ? ", …" : ""}`);
4779
+ info("Re-run: gsd-t graph index (then check again)");
4780
+ }
4781
+ const ex = envelope.excludes;
4782
+ if (ex && ex.patterns && ex.patterns.length) {
4783
+ info(`Excluded by ${ex.source}: ${ex.patterns.join(", ")}`);
4784
+ } else {
4785
+ info("Excludes: none (add folders to .gsd-t/graph-exclude.json — { \"exclude\": [\"design/\"] })");
4786
+ }
4787
+ if (ex && ex.defaults && ex.defaults.length) info(`Excluded by default: ${ex.defaults.join(", ")}`);
4788
+ // Suggestions only — a folder is never dropped from the graph without the user listing it.
4789
+ const sug = envelope.excludeSuggestions;
4790
+ if (sug && sug.length) {
4791
+ info(`Folders that may not be the app (${sug[0].reason}) — add to .gsd-t/graph-exclude.json if so:`);
4792
+ for (const x of sug.slice(0, 15)) log(` ${x.folder} (${x.files} files)`);
4793
+ }
4760
4794
  if (envelope.storeSize !== undefined) info(`Store size: ${envelope.storeSize} bytes`);
4761
4795
  if (envelope.detail) log(JSON.stringify(envelope, null, 2));
4762
4796
  }
@@ -4770,7 +4804,7 @@ function doGraphQuery(args) {
4770
4804
  const verb = args[0];
4771
4805
  if (!verb) {
4772
4806
  error("Usage: gsd-t graph query <verb> [target]");
4773
- info("Verbs: status, who-imports, who-calls, blast-radius, cluster, dead-code, orphan, dangling, test-impl");
4807
+ info("Verbs: status, who-imports, who-calls, body, blast-radius, who-uses [--writes|--reads], table, cluster, dead-code, orphan, dangling, test-impl");
4774
4808
  return;
4775
4809
  }
4776
4810
  const envelope = _graphQueryCli([verb].concat(args.slice(1)));
@@ -4795,6 +4829,9 @@ function doGraph(args) {
4795
4829
  case "who-calls": { const e = _graphQueryCli(["who-calls", args[1] || ""]); log(JSON.stringify(e, null, 2)); break; }
4796
4830
  case "blast-radius": { const e = _graphQueryCli(["blast-radius", args[1] || ""]); log(JSON.stringify(e, null, 2)); break; }
4797
4831
  case "body": { const e = _graphQueryCli(["body", args[1] || ""]); log(JSON.stringify(e, null, 2)); break; }
4832
+ // Database tables (Drizzle pgTable/mysqlTable/sqliteTable + pgEnum).
4833
+ case "who-uses": { const e = _graphQueryCli(["who-uses"].concat(args.slice(1))); log(JSON.stringify(e, null, 2)); break; }
4834
+ case "table": { const e = _graphQueryCli(["table", args[1] || ""]); log(JSON.stringify(e, null, 2)); break; }
4798
4835
  case "tasks": doGraphTaskOutput(args[1] || "table"); break;
4799
4836
  case "metrics": { // M99 D3-T2: append-only arm — rollup the telemetry ledger
4800
4837
  const _metricsRollup = require("./gsd-t-graph-metrics-rollup.cjs");
@@ -4843,7 +4880,7 @@ function doGraph(args) {
4843
4880
  }
4844
4881
  default:
4845
4882
  error(`Unknown graph subcommand: ${sub}`);
4846
- info("Usage: gsd-t graph [index|status|query|who-imports|who-calls|blast-radius|body|tasks|metrics|wiring-log]");
4883
+ info("Usage: gsd-t graph [index|status|query|who-imports|who-calls|blast-radius|body|who-uses|table|tasks|metrics|wiring-log]");
4847
4884
  info(" gsd-t graph --output json|table (task DAG)");
4848
4885
  }
4849
4886
  }
@@ -0,0 +1,64 @@
1
+ # GSD-T: Estimate Rescale — Re-price an Existing Estimate on the AI-Assisted Scale
2
+
3
+ You are re-estimating an **existing** Tekyz estimate sheet with the AI-assisted sizing model, **without touching the original**. The tool copies the sheet's `T-Shirt Size Estimate` and `Team Mix` tabs into two new tabs — **`T-Shirt Size Estimate (Rescale)`** and **`Team Mix (Rescale)`** — and re-sizes the copies. The original tabs, the Overview tab and the Hilo Estimates Summaries index (which reads the Overview) are never written. `$ARGUMENTS` carries `--sheet <url>` and optionally the project type (`--project <type>`).
4
+
5
+ **THE SHEET IS WRITTEN BY A TOOL, NOT BY HAND.** `gsd-t estimate-sheet rescale` (`bin/gsd-t-estimate-sheet.cjs`, project-local `bin/` first, else the global `gsd-t`) lists the rows, validates your plan, makes the copies, writes the sizes, rebuilds the Team Mix and audits the copies by reading back — halting on any violation. **You never PUT a cell yourself.** Your output is judgment only: a size per row. Spec: `~/.claude/templates/estimate-sheet-spec.md` §1.4 (the sizing model) and §7 (rescale).
6
+
7
+ > **Client-billed work.** Dollar figures here are client deliverables, not GSD-T build cost.
8
+
9
+ ## Human-in-the-Loop (SUPERVISED)
10
+
11
+ **Step 2 (sizing) PAUSES for review** before anything is written. Steps 1, 3 and 4 flow but show their result.
12
+
13
+ ## Step 1: Inputs + the rows to re-size (MECHANICAL · show result)
14
+
15
+ 1. Resolve the sheet from `--sheet <url>`; otherwise ask for the URL. A `403` means the sheet is not shared with the service account (`gsd-t-sheets-writer@ai-estimator-415612.iam.gserviceaccount.com`) — ask the operator to share it as Editor, then re-run.
16
+ 2. **Confirm the project type** (spec §1.4) — it sets every row's multiplier:
17
+
18
+ | Project | `--project` | Multiplier |
19
+ |---|---|---|
20
+ | Greenfield, solo | `greenfield-solo` | × 1 |
21
+ | Greenfield, team | `greenfield-team` | × 3 |
22
+ | Yellow-field (existing app), solo | `yellowfield-solo` | × 2 |
23
+ | Yellow-field, team — isolated change | `yellowfield-team-isolated` | × 5 |
24
+ | Yellow-field, team — big blast radius | `yellowfield-team-wide` | × 7 |
25
+
26
+ A yellow-field team estimate chooses isolated vs wide **per row**, from the code graph of the app being changed (`gsd-t graph blast-radius <file-or-symbol>` in that repo) — not a guess. No graph for the app → say so and ask the operator which rows are wide.
27
+ 3. List the rows: `gsd-t estimate-sheet rescale --sheet <url> --list`. It prints every sized item row (row number, module, functionality, requirement, phase, current sizes), the size-column labels, the current legend and the sheet's overhead factor. Show the operator the count and the current Low hours.
28
+
29
+ ## Step 2: Re-size every row — JUDGMENT · PAUSE FOR REVIEW
30
+
31
+ Nobody hand-writes code. For each row and each size column:
32
+
33
+ 1. Estimate the **solo AI-assisted minutes** — one person directing Claude, greenfield, counting the person's time AND Claude's time. Runway rate (26 h = 13 human + 13 Claude): ~10 min for a trivial change, ~40 min for a typical screen element or endpoint, 2–4½ hrs for the heaviest pieces. Read the Functionality and Low Level Requirements; do not scale the old size mechanically — the old sizes assumed hand-coding.
34
+ 2. Run `gsd-t estimate-sheet size --solo-min <n> --project <type> --xxs` — it multiplies, adds task switching after the multiplier, and prints the size. `--xxs` is always on here: the (Rescale) tab carries **XXS (0.5 hr)** so sub-hour work does not round up to XS. Count switching once per row: pass `--switch-min 0` for the row's smaller column.
35
+ 3. Build the plan — one entry per listed row, `functionality` copied **exactly** from the list (the tool matches row AND text, and halts on a row that moved):
36
+
37
+ ```json
38
+ { "items": [ { "row": 15, "functionality": "Route the existing permission resolver into every unguarded surface", "sizes": ["S", "M"] } ] }
39
+ ```
40
+
41
+ `sizes` follows the size-column order the list printed; `""` for a column with no work. Every sized row must be in the plan — a missing row halts, because its copy would silently re-price on the new scale.
42
+ 4. Write it to `.gsd-t/estimate-rescale-plan.json` and preview: `gsd-t estimate-sheet rescale --sheet <url> --plan .gsd-t/estimate-rescale-plan.json --dry-run` (old Low hours → new Low hours; nothing written).
43
+ 5. **PAUSE:** show the operator the per-row table (row · functionality · solo min · multiplier · size) and the before → after Low hours. Wait for `continue` or corrections.
44
+
45
+ ## Step 3: Write the (Rescale) tabs (MECHANICAL · show result)
46
+
47
+ ```bash
48
+ gsd-t estimate-sheet rescale --sheet <url> --plan .gsd-t/estimate-rescale-plan.json # add --replace to rebuild existing (Rescale) tabs
49
+ ```
50
+
51
+ It copies the two tabs next to their originals, puts the AI-assisted scale in the copy's legend (XXS 0.0625 · XS 0.1 · S 0.25 · M 0.5 · L 1 · XL 2 · XXL 4 days; XXS in the row under XXL), switches the copy's Days formulas to an exact size lookup (the template's two-letter prefix would read `XX*` as XXS + XXL), writes the sizes, rebuilds `Team Mix (Rescale)` with the original tab's roster staffed from the copy's phase rollups, and audits the copies. `--suffix "<name>"` writes a separately named pair (`… (<name>)`) instead of `(Rescale)`. Existing (Rescale) tabs halt the run unless `--replace` — and `--replace` deletes only the two (Rescale) tabs. **Exit 4 = a ✗ — fix and re-run. Exit 64 = auth/API/input halt.** Show the tool's output verbatim.
52
+
53
+ ## Step 4: Report
54
+
55
+ Sheet URL · project type · rows re-sized · Low hours before → after · the Team Mix (Rescale) roster and months · the audit result. State plainly that the original tabs and the Summary index still show the old figures.
56
+
57
+ ## Document Ripple
58
+
59
+ - The Google Sheet (external) — only the two `(Rescale)` tabs are created or rebuilt.
60
+ - `.gsd-t/estimate-rescale-plan.json` — the sizing judgment, kept so the re-estimate is reproducible.
61
+
62
+ ## ▶ Next Up
63
+
64
+ Standalone command — no auto-successor.
@@ -26,7 +26,7 @@ Read from `$ARGUMENTS` or `.gsd-t/estimate-config.json` if present; otherwise us
26
26
  | `rate` | `$50/hr` | Blended hourly rate for the LOW figure. |
27
27
  | `hoursPerDay` | `8` | Hours per person-day. |
28
28
  | `sizeScale` | `XS 0.1 · S 0.25 · M 0.5 · L 1 · XL 2 · XXL 4` | AI-assisted T-shirt → person-days (spec §1.4). `write` puts it in the sheet legend. |
29
- | `projectMultiplier` | greenfield solo ×1 · team ×5 · yellow-field solo ×2 · team ×8 isolated / ×12 wide | Solo AI minutes × this. Internal to the estimator — never on the sheet. |
29
+ | `projectMultiplier` | greenfield solo ×1 · team ×3 · yellow-field solo ×2 · team ×5 isolated / ×7 wide | Solo AI minutes × this. Includes team overhead. Internal to the estimator — never on the sheet. |
30
30
  | `totalMF` | `0.7` | Overhead multiplier. **The sheet's own MF list (`E4:F9`) wins when a sheet exists** — read it, never overwrite it. Hilo sheets run `0.9` (QA .3 · PM .1 · Analysis .1 · Deployment .05 · StdUps/Mtgs .15 · Buffer .2). |
31
31
  | `highFactor` | `1.25` | HIGH = LOW × this (the sheet's `G4` wins when a sheet exists). |
32
32
  | `sheetTemplateId` | (blank) | Optional template to clone; normally blank — the operator supplies the target sheet. |
@@ -58,7 +58,7 @@ Client-facing line-items carry **sequential, rational numbering starting at 1**.
58
58
 
59
59
  For each in-scope item build a row per spec §1.2 — `A` Module · `B` User Type · `C` Functionality (**with the item id**) · `D` Low-Level Requirement · `E` Phase · `F` Web Portal size · `G` Backend/API size. `H:L` are formulas, never values.
60
60
 
61
- - **Size in solo AI minutes, then let the tool pick the size** (spec §1.4). For each column (FE, BE) estimate the SOLO AI-assisted minutes — one person directing Claude — then run `gsd-t estimate-sheet size --solo-min <n> --project <type>`: it multiplies by the project type (greenfield solo ×1 · team ×5 · yellow-field solo ×2 · team ×8 isolated / ×12 big blast radius), adds task switching after the multiplier, and prints the size. Blast radius is measured with `gsd-t graph blast-radius`, not guessed. Count switching once per item (pass `--switch-min 0` for the smaller column).
61
+ - **Size in solo AI minutes, then let the tool pick the size** (spec §1.4). For each column (FE, BE) estimate the SOLO AI-assisted minutes — one person directing Claude, counting the person's time AND Claude's time (Runway: ~10 min trivial · ~40 min typical · 2–4½ hr heaviest) — then run `gsd-t estimate-sheet size --solo-min <n> --project <type>`: it multiplies by the project type (greenfield solo ×1 · team ×3 · yellow-field solo ×2 · team ×5 isolated / ×7 big blast radius), adds task switching after the multiplier, and prints the size. Blast radius is measured with `gsd-t graph blast-radius`, not guessed. Count switching once per item (pass `--switch-min 0` for the smaller column).
62
62
  - **Bare codes in `F:G`** — `XS` `S` `M` `L` `XL` `XXL`. Never the legend text (`"XS - Extra Small"`). Scale: **XS 0.1 · S 0.25 · M 0.5 · L 1 · XL 2 · XXL 4** person-days.
63
63
  - The sheet computes: `Days = F+G` → `MFactor Days = Days × Total MF` → `Total Days` → `LOW $ = Total × 8 × rate` → `HIGH $ = LOW × high factor`. The overhead factors and high factor are per-project settings the operator adjusts by hand.
64
64
  - **Cluster by fix-shape to size fast**: "add existing guard to N routes" (XS–S, repeated) vs "new backend surface" (M, +FE) vs "config / single route" (XS). Size the cluster once, apply to members.
@@ -383,6 +383,12 @@ Use these when user asks for help on a specific command:
383
383
  - **Updates**: the Tekyz estimate Google Sheet (three tabs, written and read-back-audited by `gsd-t estimate-sheet` from `.gsd-t/estimate-plan.json`) + optional `share/<Repo>-estimate-redteam-notes.md` (and, if renumbered, the source doc/docs/scan files)
384
384
  - **Use when**: You need a client-facing paid estimate (T-shirt sizing, dollar range, staffed team by month) from a scan, a gap analysis, or a requirements/feature/app spec. **SUPERVISED** — judgment phases (sizing, adjustments, Team Mix, Red Team) pause for your review; **you are the final arbiter** of an Estimate Red Team that challenges the numbers. Accepts `--sheet <url>`. Sizes are AI-assisted (solo AI minutes × project multiplier + task switching → `gsd-t estimate-sheet size`; spec §1.4). Rate + factors are parameterized (default Tekyz; the sheet's own MF list wins). Playbook: `~/.claude/playbooks/tekyz-estimation-and-prd-playbook.md`
385
385
 
386
+ ### estimate-rescale
387
+ - **Summary**: Re-price an EXISTING Tekyz estimate sheet on the AI-assisted scale without touching it — copies the T-Shirt and Team Mix tabs into `T-Shirt Size Estimate (Rescale)` / `Team Mix (Rescale)`, re-sizes every row (solo AI minutes × project multiplier + task switching, with an XXS 0.5 hr size), rebuilds the Team Mix and audits the copies
388
+ - **Auto-invoked**: No
389
+ - **Updates**: the two `(Rescale)` tabs on the sheet (original tabs, Overview and the estimates index are never written) + `.gsd-t/estimate-rescale-plan.json`
390
+ - **Use when**: An estimate was sized on the old hand-coding day scale and you want the AI-assisted figure beside it. **SUPERVISED** — the per-row sizing pauses for your review. Accepts `--sheet <url>` and `--project <greenfield-solo|greenfield-team|yellowfield-solo|yellowfield-team-isolated|yellowfield-team-wide>`. Spec: `~/.claude/templates/estimate-sheet-spec.md` §1.4 and §7
391
+
386
392
  ### stories
387
393
  - **Summary**: Generate a dev-team handoff document in the Tekyz user-stories format — discrete user stories with workflows, grouped acceptance criteria, per-story flow diagrams (Mermaid rendered to embedded images), and mapped test-case tables — from any source (scan register, requirements doc, design contract, or a reverse-engineered codebase)
388
394
  - **Auto-invoked**: No
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tekyzinc/gsd-t",
3
- "version": "5.22.10",
3
+ "version": "5.24.10",
4
4
  "description": "GSD-T: Contract-Driven Development for Claude Code \u2014 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",
@@ -281,8 +281,47 @@ function buildNoGraphReason(found, cls) {
281
281
  ].join("\n");
282
282
  }
283
283
 
284
+ // --- Database-table questions ---------------------------------------------
285
+ //
286
+ // `grep -rn "insert(scheduleEvents"` or `grep pgTable` asks about a database
287
+ // table. The graph indexes Drizzle tables (who-uses / table / blast-radius), so
288
+ // the block names those verbs and the table instead of sending the caller to
289
+ // who-calls, which has no answer for a table. [RULE] search-guard-routes-table-questions
290
+
291
+ const TABLE_BUILDER_SHAPE = /\b(pgTable|mysqlTable|sqliteTable|pgEnum)\b/;
292
+ const TABLE_OP_SHAPE = /\.?\b(from|innerJoin|leftJoin|rightJoin|fullJoin|insert|update|delete|references)\s*\\?\(\s*(?:\\?\(\s*\\?\)\s*=>\s*)?([A-Za-z_$][\w$]*)/;
293
+ const QUERY_API_SHAPE = /\bquery\\?\.([A-Za-z_$][\w$]*)/;
294
+
295
+ /** → { table: name|null, why } when the pattern reads as a table question; else null. */
296
+ function tableQuestion(pattern) {
297
+ const p = String(pattern);
298
+ const op = TABLE_OP_SHAPE.exec(p);
299
+ if (op) return { table: op[2], why: "a Drizzle table operation (" + op[1] + ")" };
300
+ const q = QUERY_API_SHAPE.exec(p);
301
+ if (q && /\bdb\b|\btx\b/.test(p)) return { table: q[1], why: "a Drizzle relational query (db.query.<table>)" };
302
+ if (TABLE_BUILDER_SHAPE.test(p)) return { table: null, why: "a Drizzle table declaration" };
303
+ return null;
304
+ }
305
+
306
+ function tableRouting(tq) {
307
+ const t = tq.table === null ? "<table>" : tq.table;
308
+ return [
309
+ "This reads as a database-table question (" + tq.why + "). The graph indexes tables:",
310
+ "",
311
+ " gsd-t graph who-uses " + t + " - every function / route / method that uses it",
312
+ " gsd-t graph who-uses " + t + " --writes - only inserts / updates / deletes (--reads for reads)",
313
+ " gsd-t graph table " + t + " - its columns and foreign keys, both directions",
314
+ " gsd-t graph blast-radius " + t + " - tables that reference it + the code that uses it",
315
+ "",
316
+ "A table is found by its code name (scheduleEvents) or its SQL name (schedule_events).",
317
+ "",
318
+ ];
319
+ }
320
+
284
321
  function buildStructuralReason(found, cls) {
285
322
  const lines = [];
323
+ const tq = tableQuestion(found.pattern);
324
+ if (tq !== null) lines.push(...tableRouting(tq));
286
325
 
287
326
  const symbol = cls.symbol === null ? found.pattern : cls.symbol;
288
327
  const verb = cls.verb === null ? "who-calls" : cls.verb;
@@ -298,6 +337,7 @@ function buildStructuralReason(found, cls) {
298
337
  " gsd-t graph " + verb + " " + symbol,
299
338
  "",
300
339
  "Other verbs: who-imports, who-calls, defines, blast-radius, body.",
340
+ "A database table (Drizzle)? gsd-t graph who-uses " + symbol + " / gsd-t graph table " + symbol,
301
341
  "",
302
342
  "If this really is a text search - a phrase in prose, a key in config, a string in a",
303
343
  "document - scope it to the files the graph does not index, and it will run:",
@@ -308,7 +348,8 @@ function buildStructuralReason(found, cls) {
308
348
  }
309
349
 
310
350
  function buildUnclearReason(found, cls) {
311
- return [
351
+ const tq = tableQuestion(found.pattern);
352
+ return (tq === null ? [] : tableRouting(tq)).concat([
312
353
  "This search could be asking about code structure, and that has to be settled before",
313
354
  "it runs - a guess in either direction is how the graph rule stopped having teeth.",
314
355
  "",
@@ -320,12 +361,65 @@ function buildUnclearReason(found, cls) {
320
361
  " Structure (who calls, who imports, where defined):",
321
362
  " gsd-t graph who-calls <symbol>",
322
363
  "",
364
+ " A database table (who reads or writes it, its columns and foreign keys):",
365
+ " gsd-t graph who-uses <table> / gsd-t graph table <table>",
366
+ "",
323
367
  " Text in files the graph does not index (.md, .json, .sql, config, prose):",
324
368
  " add --include='*.md' (or the right extensions) and run it again",
325
- ].join("\n");
369
+ ]).join("\n");
326
370
  }
327
371
 
328
372
 
373
+ // --- A path forward after an incomplete, empty graph answer ----------------
374
+ //
375
+ // When the graph just answered [] and said its answer was incomplete, blocking
376
+ // the grep with "ask the graph" sends the caller back to the answer that failed
377
+ // them - a dead end. The query CLI records that answer; this names what IS
378
+ // allowed next: open the files holding the unresolved call sites (Read is not
379
+ // blocked), and re-index so SCIP can resolve them. This is a message, not a
380
+ // bypass - the search itself stays blocked.
381
+ // [RULE] incomplete-empty-answer-names-a-path-forward
382
+
383
+ const INCOMPLETE_MARKER_MAX_AGE_MS = 30 * 60 * 1000;
384
+
385
+ /** Throws when the marker exists but cannot be read - the caller denies with that. */
386
+ function incompleteAnswerNote(projectDir) {
387
+ const file = path.join(projectDir, ".gsd-t", "graphDB", "last-incomplete-answer.json");
388
+ if (!fs.existsSync(file)) return "";
389
+ const m = JSON.parse(fs.readFileSync(file, "utf8"));
390
+ if (!m || Date.now() - Date.parse(m.ts) > INCOMPLETE_MARKER_MAX_AGE_MS) return "";
391
+ const lines = [
392
+ "",
393
+ "",
394
+ "The graph's last answer (" + m.verb + " " + m.target + ") was EMPTY and marked incomplete:",
395
+ " " + (m.note || "some call edges are unresolved"),
396
+ "",
397
+ "Allowed path forward:",
398
+ ];
399
+ const sites = m.unresolvedCallSites;
400
+ if (sites && Array.isArray(sites.files) && sites.files.length) {
401
+ lines.push(" 1. Open these files with the Read tool - they hold " + sites.count +
402
+ " unresolved call site(s) naming the target:");
403
+ for (const f of sites.files.slice(0, 10)) lines.push(" " + f);
404
+ if (sites.files.length > 10) lines.push(" ... (" + sites.files.length + " files; full list in the query's coverage.unresolvedCallSites)");
405
+ } else {
406
+ lines.push(" 1. gsd-t graph body <symbol> - read the definition, then Read the files that import its module");
407
+ lines.push(" (gsd-t graph who-imports <file>)");
408
+ }
409
+ lines.push(" 2. gsd-t graph status - lists files missing from SCIP; gsd-t graph index re-resolves them");
410
+ return lines.join("\n");
411
+ }
412
+
413
+ /** The note, or a deny naming why the marker could not be read. */
414
+ function pathForwardOrDeny(projectDir) {
415
+ try {
416
+ return incompleteAnswerNote(projectDir);
417
+ } catch (e) {
418
+ deny("The graph's last incomplete-answer record (.gsd-t/graphDB/last-incomplete-answer.json) " +
419
+ "could not be read: " + e.message + "\n\nDelete it and re-run the graph query.");
420
+ }
421
+ }
422
+
329
423
  // --- Recording the decision ------------------------------------------------
330
424
  //
331
425
  // One line per block, into the same ledger the graph's own tooling writes. The
@@ -473,13 +567,13 @@ function decide(raw) {
473
567
 
474
568
  if (cls.verdict === "structural") {
475
569
  recordBlock(projectDir, f.program, f.pattern, "structural");
476
- if (hasGraph) deny(buildStructuralReason(f, cls));
570
+ if (hasGraph) deny(buildStructuralReason(f, cls) + pathForwardOrDeny(projectDir));
477
571
  else deny(buildNoGraphReason(f, cls));
478
572
  return;
479
573
  }
480
574
  if (cls.verdict === "unclear") {
481
575
  recordBlock(projectDir, f.program, f.pattern, "unclear");
482
- deny(buildUnclearReason(f, cls));
576
+ deny(buildUnclearReason(f, cls) + (hasGraph ? pathForwardOrDeny(projectDir) : ""));
483
577
  return;
484
578
  }
485
579
  }