@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.
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.24.10] - 2026-09-29
6
+
7
+ ### Added — the code graph indexes Drizzle database tables
8
+
9
+ "Which code uses this table?" had no graph answer: tables are declared as `const X = pgTable('x', {…})`, which the indexer never recorded, and the graph-search guard then blocked grep with nothing to offer. On hilo-figma-atos the graph now holds all 431 tables and 63 enums, and `who-uses scheduleEvents` agrees with TypeScript findReferences on 179 of 181 users (the gaps are explained in the progress log).
10
+
11
+ - `bin/gsd-t-graph-edge-extract.cjs`: `table` / `enum` entities (pg / mysql / sqlite), columns, foreign keys (`.references()` and `foreignKey()`), READ / WRITE usage edges (from / joins / `db.query.T.findMany|findFirst` / insert / update / delete) attributed to the enclosing function or route handler.
12
+ - `bin/gsd-t-graph-query-cli.cjs`, `bin/gsd-t.js`: `who-uses <table> [--writes|--reads]`, `table <name>`; `body` and `blast-radius` accept tables; not-found answers say the table is not indexed.
13
+ - `scripts/gsd-t-graph-search-guard.js`: table-shaped searches point at who-uses / table / blast-radius.
14
+ - `bin/gsd-t-graph-index.cjs`, `bin/gsd-t-graph-freshness.cjs`: `graph status` counts every indexed file (hilo: 4,301 → 4,322, matching the index) with per-tier counts and exclude suggestions; freshness no longer applies folder skips to file names (`build-analytics.ts` was dropped on every query); deleted / newly-excluded files leave no stale records.
15
+ - `bin/gsd-t-graph-exclude.cjs`: GSD-T's own copied `bin/` tools are excluded by default outside the GSD-T repo.
16
+ - Tests: `test/graph-drizzle-tables.test.js` (21) + `test/fixtures/drizzle-graph/`.
17
+
18
+ Known gaps: code that reaches tables through `import { schema }` objects or CommonJS `require` is not seen; an imported non-table sharing a table's name counts as a use. Re-run `gsd-t graph index` to pick up tables.
19
+
20
+ ## [5.23.10] - 2026-09-29
21
+
22
+ ### Added — who-calls matches the compiler on route files; graph exclude list; estimate math v2
23
+
24
+ The code graph answered [] or a single `_toplevel` caller for functions called from Hono/Express route files, and labelled files compiler-accurate on one resolved edge. On hilo-figma-atos who-calls now matches TypeScript findReferences (requireLocationTenant 95/95, resolveOrgIdFromSession 547/547, verifyLocationAccess 120/120). Estimates now count the human's time in solo minutes, which removes the need to tune multipliers.
25
+
26
+ - `bin/gsd-t-graph-edge-extract.cjs`: calls in anonymous route handlers and in route-registration arguments (middleware) → `file#METHOD /path@line`; other top-level callbacks → `file#anonymous@line`; const-arrow bodies no longer walked twice; call edges carry `col`.
27
+ - `bin/gsd-t-graph-scip-upgrade.cjs`, `bin/gsd-t-scip-reader.cjs`: scip-typescript run with `--max-file-byte-size 64mb` + 8 GB heap (its 1 MB default silently skipped large files); files with no SCIP document → `tree-sitter-floor-SCIP-MISSING`; new `compiler-partial` tier (<90% of repo-name calls resolved).
28
+ - `bin/gsd-t-graph-query-cli.cjs`: `name-matched` callers for names defined once, in unresolved files; `coverage.unresolvedCallSites`; empty-and-incomplete answers name a path forward for the search guard; `graph status` lists SCIP-missing files and active excludes.
29
+ - `bin/gsd-t-graph-exclude.cjs` (new): `.gsd-t/graph-exclude.json` excludes ancillary trees (folder prefixes, `*`/`**` globs); read by indexer and freshness walker; malformed file halts.
30
+ - `bin/gsd-t.js`: `graph index` 30-min limit, killed/timed-out build is an error (was a silent exit 0 with a half-built graph).
31
+ - `bin/gsd-t-estimate-sheet.cjs`, `commands/gsd-t-estimate-rescale.md` (new command), spec, playbook, config: rescale `--suffix`; solo minutes = human + Claude (~10 / 40 min / 2–4½ hr); yellow-field team ×5 / ×7; rescale zeroes the copy's overhead by default.
32
+ - `scripts/statusline-command.sh`: prefers `.transcript_path` from stdin.
33
+ - Tests: `test/m118-*.test.js`, `test/m119-who-calls-golden.test.js` (golden vs findReferences).
34
+
35
+ Re-run `gsd-t graph index` in each project to pick up the new caller attribution and tiers.
36
+
5
37
  ## [5.22.10] - 2026-09-27
6
38
 
7
39
  ### Added — AI-assisted T-shirt sizing for `/gsd-t-estimate`
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # GSD-T: Contract-Driven Development for Claude Code
2
2
 
3
- **v5.22.10** - A methodology for reliable, parallelizable development using Claude Code with optional Agent Teams support.
3
+ **v5.24.10** - 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.
@@ -82,7 +82,7 @@ GSD-T reads all state files and tells you exactly where you left off.
82
82
  ## CLI Commands
83
83
 
84
84
  ```bash
85
- npx @tekyzinc/gsd-t install # Install commands + global CLAUDE.md (51 commands)
85
+ npx @tekyzinc/gsd-t install # Install commands + global CLAUDE.md (52 commands)
86
86
  npx @tekyzinc/gsd-t update # Update global commands + CLAUDE.md
87
87
  npx @tekyzinc/gsd-t update-all # Update globally + all registered project CLAUDE.md files
88
88
  npx @tekyzinc/gsd-t init [name] # Scaffold GSD-T project (auto-registers)
@@ -185,6 +185,7 @@ This will replace changed command files, back up your CLAUDE.md if customized, a
185
185
  | `/gsd-t-gap-analysis` | Requirements gap analysis — spec vs. existing code | Manual |
186
186
  | `/gsd-t-promote-debt` | Convert techdebt items to milestones | Manual |
187
187
  | `/gsd-t-estimate` | Turn any work doc (scan, gap-analysis sheet, requirements, feature/app spec) into a Tekyz client estimate Google Sheet — T-Shirt Size + Team Mix + Technology Stack, written by the deterministic `gsd-t estimate-sheet` tool from a JSON plan and read-back-audited against `templates/estimate-sheet-spec.md`; sizes assume AI-assisted development (solo AI minutes × project multiplier) — supervised, with an operator-arbitrated Estimate Red Team. No PRD (that is `/gsd-t-prd`) | Manual |
188
+ | `/gsd-t-estimate-rescale` | Re-price an existing estimate sheet on the AI-assisted scale into copied `T-Shirt Size Estimate (Rescale)` / `Team Mix (Rescale)` tabs — the originals and the estimates index are never touched; supervised per-row sizing, XXS (0.5 hr) size, read-back audit | Manual |
188
189
  | `/gsd-t-stories` | Generate a dev-team handoff doc in the Tekyz user-stories format (stories + workflows + acceptance criteria + Mermaid flow diagrams + mapped test cases) from any source | Manual |
189
190
  | `/gsd-t-demo-videos` | Narrated screen-recording walkthroughs of a running app — coverage plan, UI-driven seeding, batched TTS with a measured one-voice gate, continuous recording, mux, silence trim | Manual |
190
191
  | `/gsd-t-populate` | Auto-populate docs from existing codebase | Manual |
@@ -45,22 +45,35 @@ const TAB_TSHIRT = "T-Shirt Size Estimate";
45
45
  const TAB_TEAM = "Team Mix";
46
46
  const TAB_TECH = "Technology Stack";
47
47
  const TAB_OVERVIEW = "Overview";
48
+ // rescale (v5.23.10): the AI-assisted re-estimate lives in COPIES of the two tabs; the originals are never written.
49
+ const TAB_TSHIRT_AI = "T-Shirt Size Estimate (Rescale)";
50
+ const TAB_TEAM_AI = "Team Mix (Rescale)";
48
51
 
49
52
  const SIZE_CODES = ["XS", "S", "M", "L", "XL", "XXL"];
50
53
 
51
54
  // AI-assisted sizing model (David, 2026-09-27; calibrated on the Hilo Delivery Runway build:
52
- // 45 tasks, ~26 solo hours). A task is sized in SOLO AI-assisted minutes, multiplied by the
55
+ // 45 tasks, ~26 solo hours = 13 human + 13 Claude). A task is sized in SOLO AI-assisted minutes (human + Claude time), multiplied by the
53
56
  // project type, plus a per-task switching allowance added AFTER the multiplier (switching is
54
57
  // one person's pickup time — it does not grow with team size). The result maps to the nearest
55
58
  // size on AI_SIZE_DAYS, which `write` puts in the sheet's legend. The multipliers live here,
56
59
  // in the estimator — never on the sheet.
57
60
  const AI_SIZE_DAYS = { XS: 0.1, S: 0.25, M: 0.5, L: 1, XL: 2, XXL: 4 };
61
+ // XXS (0.5 hr) exists on RESCALE tabs only (David, 2026-09-27) — solo tasks under an hour otherwise
62
+ // round up to XS. A legend carrying XXS needs an EXACT lookup: the 2-letter prefix match the
63
+ // template uses would read "XX*" as XXS + XXL.
64
+ const XXS_DAYS = 0.0625;
65
+ const XXS_LABEL = "XXS - Extra Extra Small";
66
+ // Multipliers set by David 2026-09-28 (×5 isolated / ×7 wide). The same-day recalibrations to ×8/×11 and
67
+ // ×11/×15 were tuning that compensated for solo minutes that left out the human's time; with solo minutes
68
+ // counting human + Claude (Runway: 13 + 13 = 26 h), the untuned values land ATOS at 46% of the original
69
+ // effort vs the tuned 54% (Summary 4, 2026-09-29), so they are the default again. They INCLUDE team
70
+ // overhead — reviews, QA, coordination — so no separate overhead factor belongs on top of them.
58
71
  const PROJECT_MULTIPLIER = {
59
72
  "greenfield-solo": 1,
60
- "greenfield-team": 5,
73
+ "greenfield-team": 3,
61
74
  "yellowfield-solo": 2,
62
- "yellowfield-team-isolated": 8,
63
- "yellowfield-team-wide": 12,
75
+ "yellowfield-team-isolated": 5,
76
+ "yellowfield-team-wide": 7,
64
77
  };
65
78
  const PHASES = ["MVP", "Phase 1", "Phase 2", "Phase 3"];
66
79
 
@@ -217,12 +230,13 @@ class SheetsApi {
217
230
  body: body == null ? undefined : JSON.stringify(body),
218
231
  });
219
232
  const text = await res.text();
220
- if (res.status === 429 && attempt <= 3) {
233
+ if (res.status === 429 && attempt <= 6) {
221
234
  // Rate limit (60 reads/min/user — hit 2026-09-21 auditing 19 sheets in a row). This is
222
235
  // flow control, not a masked failure: the same request is re-sent unchanged after the
223
- // documented wait, and the 4th 429 halts.
224
- const wait = 20000 * attempt;
225
- console.error(` … Sheets API 429 (rate limit) — waiting ${wait / 1000}s, retry ${attempt}/3`);
236
+ // documented wait, and the 7th 429 halts.
237
+ // 6 tries, up to 90s apart: several estimates rescaled in parallel share one quota (2026-09-27)
238
+ const wait = Math.min(20000 * attempt, 90000);
239
+ console.error(` … Sheets API 429 (rate limit) — waiting ${wait / 1000}s, retry ${attempt}/6`);
226
240
  await new Promise((r) => setTimeout(r, wait));
227
241
  return this.call(method, url, body, attempt + 1);
228
242
  }
@@ -262,6 +276,15 @@ class SheetsApi {
262
276
  });
263
277
  }
264
278
 
279
+ /** Many ranges in ONE request (values:batchUpdate) — one write against the per-minute quota instead of N. */
280
+ async putValuesBatch(entries) {
281
+ if (!entries.length) return {};
282
+ return this.call("POST", `${this.base}/values:batchUpdate`, {
283
+ valueInputOption: "USER_ENTERED",
284
+ data: entries.map((e) => ({ range: `'${e.tab}'!${e.a1}`, majorDimension: "ROWS", values: e.values })),
285
+ });
286
+ }
287
+
265
288
  async clearValues(tab, a1) {
266
289
  const rng = encodeURIComponent(`'${tab}'!${a1}`);
267
290
  return this.call("POST", `${this.base}/values/${rng}:clear`, {});
@@ -365,7 +388,7 @@ function locateTshirt(grid) {
365
388
  const legendRow1 = {};
366
389
  let legendFirst = -1, legendLast = -1;
367
390
  for (let r = 0; r < headerRow; r++) {
368
- const m = textAt(grid, r, 0).trim().match(/^(XS|S|M|L|XL|XXL)\s*-/);
391
+ const m = textAt(grid, r, 0).trim().match(/^(XXS|XS|S|M|L|XL|XXL)\s*-/);
369
392
  if (m) { legend[m[1]] = numAt(grid, r, 1); legendRow1[m[1]] = r + 1; if (legendFirst < 0) legendFirst = r; legendLast = r; }
370
393
  }
371
394
  for (const code of SIZE_CODES) {
@@ -407,7 +430,7 @@ function locateTshirt(grid) {
407
430
  const lowDol = findCell(grid, "Low ($)", headerRow, 20), highDol = findCell(grid, "High ($)", headerRow, 20);
408
431
  if (!lowHrs || !highHrs || !lowDol || !highDol) throw new Halt(`${TAB_TSHIRT}: rollup headers 'Low ($)' / 'Low Hrs' / 'High ($)' / 'High Hrs' not all found`);
409
432
  cols.rollupPhase = mvp.c; cols.lowHrs = lowHrs.c; cols.highHrs = highHrs.c; cols.lowDol = lowDol.c; cols.highDol = highDol.c;
410
- return { headerRow, firstItemRow: headerRow + 1, legend, legendRow1, legendFirst1: legendFirst + 1, legendLast1: legendLast + 1, mf, mfTotal, mfTotalCell, highFactor, rate, rollupRows, cols };
433
+ return { headerRow, firstItemRow: headerRow + 1, legend, legendRow1, legendExact: legend.XXS != null, legendFirst1: legendFirst + 1, legendLast1: legendLast + 1, mf, mfTotal, mfTotalCell, highFactor, rate, rollupRows, cols };
411
434
  }
412
435
 
413
436
  /** The standard template's column map (what `write` produces). */
@@ -627,7 +650,7 @@ function defaultSwitchMin(teamMin) { return teamMin < 120 ? 7.5 : teamMin < 480
627
650
  * The estimator's per-task math: solo AI minutes × project multiplier + switching (after the
628
651
  * multiplier), then the nearest AI_SIZE_DAYS size (geometric midpoints between neighbours).
629
652
  */
630
- function aiTaskSize({ soloMin, project, switchMin }) {
653
+ function aiTaskSize({ soloMin, project, switchMin, xxs }) {
631
654
  const mult = PROJECT_MULTIPLIER[project];
632
655
  if (mult == null) throw new Halt(`--project must be one of ${Object.keys(PROJECT_MULTIPLIER).join(" | ")}`, 64);
633
656
  if (typeof soloMin !== "number" || !(soloMin > 0)) throw new Halt("--solo-min must be a positive number of minutes", 64);
@@ -636,12 +659,14 @@ function aiTaskSize({ soloMin, project, switchMin }) {
636
659
  if (typeof sw !== "number" || sw < 0) throw new Halt("--switch-min must be a number of minutes ≥ 0", 64);
637
660
  const hours = (teamMin + sw) / 60;
638
661
  const days = hours / 8;
639
- let size = SIZE_CODES[SIZE_CODES.length - 1];
640
- for (let i = 0; i < SIZE_CODES.length - 1; i++) {
641
- const cut = Math.sqrt(AI_SIZE_DAYS[SIZE_CODES[i]] * AI_SIZE_DAYS[SIZE_CODES[i + 1]]);
642
- if (days < cut) { size = SIZE_CODES[i]; break; }
662
+ const scale = xxs ? { XXS: XXS_DAYS, ...AI_SIZE_DAYS } : AI_SIZE_DAYS;
663
+ const codes = Object.keys(scale);
664
+ let size = codes[codes.length - 1];
665
+ for (let i = 0; i < codes.length - 1; i++) {
666
+ const cut = Math.sqrt(scale[codes[i]] * scale[codes[i + 1]]);
667
+ if (days < cut) { size = codes[i]; break; }
643
668
  }
644
- return { soloMin, project, multiplier: mult, switchMin: sw, hours: round2(hours), days: round2(days), size, sizeDays: AI_SIZE_DAYS[size] };
669
+ return { soloMin, project, multiplier: mult, switchMin: sw, hours: round2(hours), days: round2(days), size, sizeDays: scale[size] };
645
670
  }
646
671
 
647
672
  /** True when the sheet's legend already carries the AI scale. */
@@ -668,7 +693,8 @@ function tshirtTotals(plan, layout) {
668
693
  function itemFormulasFor(r, cols, legend) {
669
694
  const L = colLetter;
670
695
  const lg = `$A$${legend.first1}:$A$${legend.last1}`, lv = `$B$${legend.first1}:$B$${legend.last1}`;
671
- const days = "=(" + cols.sizes.map((c) => `IF(${L(c)}${r}="",0,SUMIF(${lg},LEFT(${L(c)}${r},2)&"*",${lv}))`).join("+") + ")";
696
+ const key = (c) => legend.exact ? `${L(c)}${r}&" -*"` : `LEFT(${L(c)}${r},2)&"*"`;
697
+ const days = "=(" + cols.sizes.map((c) => `IF(${L(c)}${r}="",0,SUMIF(${lg},${key(c)},${lv}))`).join("+") + ")";
672
698
  const abs = (cell) => `$${L(cell.c)}$${cell.r + 1}`;
673
699
  const f = {
674
700
  H: days,
@@ -1001,8 +1027,8 @@ function teamMixFormatReqs(sheetId, v) {
1001
1027
  }
1002
1028
 
1003
1029
  /** Write every phase grid on the one Team Mix tab: clear-then-paint, GRID_GAP blank rows between grids. */
1004
- async function writeTeamMix(api, plan, rosters) {
1005
- const grid = await api.grid(TAB_TEAM);
1030
+ async function writeTeamMix(api, plan, rosters, tab = TAB_TEAM) {
1031
+ const grid = await api.grid(tab);
1006
1032
  const sheetId = grid.properties.sheetId;
1007
1033
  const grids = [];
1008
1034
  let top = 0;
@@ -1013,7 +1039,7 @@ async function writeTeamMix(api, plan, rosters) {
1013
1039
  }
1014
1040
  const lastRow = grids[grids.length - 1].v.bottom;
1015
1041
  const clearRows = Math.max(60, rowCount(grid) + 2, lastRow + 10);
1016
- await api.clearValues(TAB_TEAM, `A1:Z${clearRows}`);
1042
+ await api.clearValues(tab, `A1:Z${clearRows}`);
1017
1043
  await api.batch([
1018
1044
  { unmergeCells: { range: gridRange(sheetId, 0, clearRows, 0, 26) } },
1019
1045
  fmtReq(sheetId, 0, clearRows, 0, 26, {}, "userEnteredFormat"),
@@ -1021,7 +1047,7 @@ async function writeTeamMix(api, plan, rosters) {
1021
1047
  const reqs = [];
1022
1048
  let maxN = 0;
1023
1049
  for (const g of grids) {
1024
- await api.putValues(TAB_TEAM, `A${g.v.titleR}:${colLetter(g.v.width - 1)}${g.v.bottom}`, g.v.rows);
1050
+ await api.putValues(tab, `A${g.v.titleR}:${colLetter(g.v.width - 1)}${g.v.bottom}`, g.v.rows);
1025
1051
  reqs.push(...teamMixFormatReqs(sheetId, g.v));
1026
1052
  maxN = Math.max(maxN, g.v.totalC - g.v.firstMonthC);
1027
1053
  }
@@ -1063,7 +1089,7 @@ function auditTshirt(grid) {
1063
1089
  const out = [];
1064
1090
  const layout = locateTshirt(grid);
1065
1091
  const { cols } = layout;
1066
- const legendRows = { first1: layout.legendFirst1, last1: layout.legendLast1 };
1092
+ const legendRows = { first1: layout.legendFirst1, last1: layout.legendLast1, exact: layout.legendExact };
1067
1093
  const first0 = layout.firstItemRow;
1068
1094
  const badSizes = [], noDv = [], badFormula = [], sectionsWithSizes = [], badSectionFmt = [], noWrap = [], notTop = [];
1069
1095
  let lastItem0 = -1, totalRow0 = -1;
@@ -1080,7 +1106,7 @@ function auditTshirt(grid) {
1080
1106
  lastItem0 = r;
1081
1107
  for (const c of cols.sizes) {
1082
1108
  const v = textAt(grid, r, c).trim();
1083
- if (v && !SIZE_CODES.includes(v)) badSizes.push(`${colLetter(c)}${r + 1}='${v}'`);
1109
+ if (v && !(SIZE_CODES.includes(v) || (v === "XXS" && layout.legendExact))) badSizes.push(`${colLetter(c)}${r + 1}='${v}'`);
1084
1110
  }
1085
1111
  const cell = cellAt(grid, r, cols.phase);
1086
1112
  if (!(cell && cell.dataValidation && cell.dataValidation.condition && cell.dataValidation.condition.type === "ONE_OF_LIST")) noDv.push(`${colLetter(cols.phase)}${r + 1}`);
@@ -1657,13 +1683,151 @@ async function verbWrite(api, plan, opts) {
1657
1683
  return result;
1658
1684
  }
1659
1685
 
1686
+ // ───────────────────────── rescale (AI-assisted re-estimate into copied tabs) ─────────────────────────
1687
+
1688
+ /** Every sized item row on a T-Shirt grid: row (1-based), text, phase and its size codes in size-column order. */
1689
+ function sizedRows(grid, layout) {
1690
+ const out = [];
1691
+ for (let r = layout.firstItemRow; r < rowCount(grid); r++) {
1692
+ if (/^Total \(Days\)/i.test(textAt(grid, r, 0).trim())) break;
1693
+ const sizes = layout.cols.sizes.map((c) => textAt(grid, r, c).trim());
1694
+ if (!sizes.some((v) => v)) continue;
1695
+ out.push({ row: r + 1, module: textAt(grid, r, 0).trim(), userType: textAt(grid, r, 1).trim(), functionality: textAt(grid, r, 2).trim(), requirement: textAt(grid, r, 3).trim(), phase: textAt(grid, r, layout.cols.phase).trim(), sizes });
1696
+ }
1697
+ return out;
1698
+ }
1699
+
1700
+ /**
1701
+ * Validate a rescale plan against the source rows. Every sized row must be re-sized exactly once,
1702
+ * matched by row AND its Functionality text (a row that moved since the list was taken HALTS).
1703
+ */
1704
+ function rescaleViolations(plan, rows, sizeCount) {
1705
+ const errors = [];
1706
+ const items = plan && Array.isArray(plan.items) ? plan.items : null;
1707
+ if (!items || !items.length) return ["items: non-empty array required — [{ row, functionality, sizes: [...] }]"];
1708
+ const byRow = new Map(rows.map((r) => [r.row, r]));
1709
+ const seen = new Set();
1710
+ items.forEach((it, i) => {
1711
+ const where = `items[${i}]`;
1712
+ const src = byRow.get(it.row);
1713
+ if (!src) { errors.push(`${where}: row ${it.row} is not a sized item row on the source tab`); return; }
1714
+ if (seen.has(it.row)) errors.push(`${where}: row ${it.row} appears twice`);
1715
+ seen.add(it.row);
1716
+ if (String(it.functionality == null ? "" : it.functionality).trim() !== src.functionality) errors.push(`${where}: row ${it.row} Functionality does not match the sheet ('${src.functionality.slice(0, 60)}') — re-list the rows and rebuild the plan`);
1717
+ if (!Array.isArray(it.sizes) || it.sizes.length !== sizeCount) { errors.push(`${where}.sizes: array of ${sizeCount} size code(s) required, in size-column order`); return; }
1718
+ it.sizes.forEach((v, k) => { const c = sizeOf(v); if (c !== "" && c !== "XXS" && !SIZE_CODES.includes(c)) errors.push(`${where}.sizes[${k}]: '${v}' is not a bare size code (XXS ${SIZE_CODES.join(" ")})`); });
1719
+ if (it.sizes.every((v) => sizeOf(v) === "")) errors.push(`${where}: every size is blank — an item with no size is a defect`);
1720
+ });
1721
+ const missing = rows.filter((r) => !seen.has(r.row)).map((r) => r.row);
1722
+ if (missing.length) errors.push(`rows not re-sized: ${missing.join(", ")} — every sized row must be in the plan, or its copy would silently re-price on the new scale`);
1723
+ return errors;
1724
+ }
1725
+
1726
+ async function verbRescale(api, opts) {
1727
+ const meta = await api.meta();
1728
+ const byTitle = new Map(meta.sheets.map((s) => [s.properties.title, s.properties]));
1729
+ for (const t of [TAB_TSHIRT, TAB_TEAM]) if (!byTitle.has(t)) throw new Halt(`tab '${t}' is missing — this is not a Tekyz estimate sheet`);
1730
+ const srcGrid = await api.grid(TAB_TSHIRT);
1731
+ const layout = locateTshirt(srcGrid);
1732
+ const rows = sizedRows(srcGrid, layout);
1733
+ if (opts.list) return { sizeColumns: layout.cols.sizeLabels, legend: layout.legend, mfTotal: round2(layout.mfTotal), rows };
1734
+
1735
+ const errors = rescaleViolations(opts.plan, rows, layout.cols.sizes.length);
1736
+ if (errors.length) throw new Halt(`rescale plan rejected:\n - ${errors.join("\n - ")}`, 4, errors);
1737
+ // --suffix names a separate pair of copies (e.g. "AI v2") so a second re-estimate never
1738
+ // overwrites the (Rescale) tabs other sheets already import by name.
1739
+ const suffix = opts.suffix == null ? "Rescale" : String(opts.suffix).trim();
1740
+ if (!suffix || /[()'!]/.test(suffix)) throw new Halt("--suffix must be plain text (no parentheses, quotes or '!')", 64);
1741
+ const T_TS = `${TAB_TSHIRT} (${suffix})`, T_TM = `${TAB_TEAM} (${suffix})`;
1742
+ const aiScale = { XXS: XXS_DAYS, ...AI_SIZE_DAYS };
1743
+ const aiRaw = opts.plan.items.reduce((sum, it) => sum + it.sizes.reduce((a, v) => a + (sizeOf(v) ? aiScale[sizeOf(v)] : 0), 0), 0);
1744
+ const oldRaw = rows.reduce((sum, r) => sum + r.sizes.reduce((a, v) => a + (layout.legend[v] || 0), 0), 0);
1745
+ const noOverhead = opts.plan.noOverhead !== false; // default ON: the multipliers include team overhead
1746
+ const newMf = noOverhead ? 0 : layout.mfTotal;
1747
+ const preview = { items: rows.length, oldLowHours: round2(oldRaw * (1 + layout.mfTotal) * 8), newLowHours: round2(aiRaw * (1 + newMf) * 8) };
1748
+ const exists = [T_TS, T_TM].filter((t) => byTitle.has(t));
1749
+ if (exists.length && !opts.replace) throw new Halt(`tab(s) ${exists.map((t) => `'${t}'`).join(", ")} already exist — pass --replace to rebuild them (the original tabs are never touched)`);
1750
+ if (opts.dryRun) return { dryRun: true, ...preview };
1751
+
1752
+ // 1. copies. A rebuild (--replace) refreshes the existing (Rescale) tabs IN PLACE — never delete and
1753
+ // re-create them: other sheets import these tabs by name (IMPORTRANGE), and a view that refreshes while
1754
+ // a tab is missing caches #REF! (David, 2026-09-28). The original tabs are only ever the copy source.
1755
+ const src = byTitle.get(TAB_TSHIRT);
1756
+ if (byTitle.has(T_TS)) {
1757
+ const dst = byTitle.get(T_TS).sheetId;
1758
+ const rowsN = Math.max(src.gridProperties.rowCount, byTitle.get(T_TS).gridProperties.rowCount);
1759
+ const colsN = Math.max(src.gridProperties.columnCount, byTitle.get(T_TS).gridProperties.columnCount);
1760
+ const widths = (srcGrid.data && srcGrid.data[0] && srcGrid.data[0].columnMetadata ? srcGrid.data[0].columnMetadata : []).map((c) => c.pixelSize);
1761
+ await api.batch([
1762
+ { updateSheetProperties: { properties: { sheetId: dst, gridProperties: { rowCount: rowsN, columnCount: colsN, frozenRowCount: src.gridProperties.frozenRowCount || 0 } }, fields: "gridProperties(rowCount,columnCount,frozenRowCount)" } },
1763
+ { unmergeCells: { range: gridRange(dst, 0, rowsN, 0, colsN) } },
1764
+ { updateCells: { range: gridRange(dst, 0, rowsN, 0, colsN), fields: "*" } },
1765
+ { copyPaste: { source: gridRange(src.sheetId, 0, src.gridProperties.rowCount, 0, src.gridProperties.columnCount), destination: gridRange(dst, 0, src.gridProperties.rowCount, 0, src.gridProperties.columnCount), pasteType: "PASTE_NORMAL" } },
1766
+ ...widths.map((w, c) => ({ updateDimensionProperties: { range: { sheetId: dst, dimension: "COLUMNS", startIndex: c, endIndex: c + 1 }, properties: { pixelSize: w }, fields: "pixelSize" } })),
1767
+ ]);
1768
+ } else {
1769
+ await api.batch([{ duplicateSheet: { sourceSheetId: src.sheetId, newSheetName: T_TS, insertSheetIndex: src.index + 1 } }]);
1770
+ }
1771
+ if (!byTitle.has(T_TM)) {
1772
+ const meta2 = await api.meta();
1773
+ const teamIdx = meta2.sheets.find((s) => s.properties.title === TAB_TEAM).properties.index;
1774
+ await api.batch([{ duplicateSheet: { sourceSheetId: byTitle.get(TAB_TEAM).sheetId, newSheetName: T_TM, insertSheetIndex: teamIdx + 1 } }]);
1775
+ } // an existing Team Mix (Rescale) is cleared and rewritten by writeTeamMix below, in place
1776
+
1777
+ // 2. the copied T-Shirt tab: AI legend (+ XXS in the row under XXL), exact-match Days formulas, new sizes
1778
+ const xxsRow1 = layout.legendRow1.XXS || layout.legendLast1 + 1;
1779
+ if (!layout.legendRow1.XXS && (textAt(srcGrid, xxsRow1 - 1, 0) || textAt(srcGrid, xxsRow1 - 1, 1))) {
1780
+ throw new Halt(`${TAB_TSHIRT}: row ${xxsRow1} under the size legend is not empty (A${xxsRow1}/B${xxsRow1}) — there is nowhere to add XXS without overwriting it`);
1781
+ }
1782
+ const writes = SIZE_CODES.map((c) => ({ tab: T_TS, a1: `B${layout.legendRow1[c]}`, values: [[AI_SIZE_DAYS[c]]] }));
1783
+ writes.push({ tab: T_TS, a1: `A${xxsRow1}:B${xxsRow1}`, values: [[XXS_LABEL, XXS_DAYS]] });
1784
+ const first = layout.cols.sizes[0], last = layout.cols.sizes[layout.cols.sizes.length - 1];
1785
+ for (const it of opts.plan.items) writes.push({ tab: T_TS, a1: `${colLetter(first)}${it.row}:${colLetter(last)}${it.row}`, values: [it.sizes.map(sizeOf)] });
1786
+ const legendAi = { first1: Math.min(layout.legendFirst1, xxsRow1), last1: Math.max(layout.legendLast1, xxsRow1), exact: true };
1787
+ const hRows = [];
1788
+ for (let r = layout.firstItemRow; r < rowCount(srcGrid); r++) {
1789
+ if (/^Total \(Days\)/i.test(textAt(srcGrid, r, 0).trim())) break;
1790
+ if (formulaAt(srcGrid, r, layout.cols.days)) hRows.push(r + 1);
1791
+ }
1792
+ for (const r1 of hRows) writes.push({ tab: T_TS, a1: `${colLetter(layout.cols.days)}${r1}`, values: [[itemFormulasFor(r1, layout.cols, legendAi).H]] });
1793
+ // plan.noOverhead: the multipliers already include team overhead (David, 2026-09-28) — zero the
1794
+ // COPY's overhead factors so nothing is charged twice. The original tab's factors are never touched.
1795
+ if (noOverhead) for (const f of layout.mf) writes.push({ tab: T_TS, a1: `${colLetter(layout.cols.mfTotal.c)}${f.row}`, values: [[0]] });
1796
+ await api.putValuesBatch(writes); // legend + XXS + sizes + Days formulas (+ zeroed overhead): one request
1797
+ // the copy meets the spec's formatting on its own (C/D wrap, every cell top-aligned) — a template
1798
+ // that predates those rules would otherwise hand its failures to the new tabs
1799
+ const aiTshirtId = (await api.meta()).sheets.find((x) => x.properties.title === T_TS).properties.sheetId;
1800
+ const lastRow1 = rows[rows.length - 1].row;
1801
+ const xxlRow0 = layout.legendRow1.XXL - 1;
1802
+ await api.batch([
1803
+ { copyPaste: { source: gridRange(aiTshirtId, xxlRow0, xxlRow0 + 1, 0, 2), destination: gridRange(aiTshirtId, xxsRow1 - 1, xxsRow1, 0, 2), pasteType: "PASTE_FORMAT" } },
1804
+ wrapReq(aiTshirtId, layout.firstItemRow, lastRow1, 2, 4), topAlignReq(aiTshirtId, lastRow1 + 5),
1805
+ ]);
1806
+
1807
+ // 3. the copied Team Mix: same roster as the original, staffed from the copy's phase rollups
1808
+ const aiGrid = await api.grid(T_TS);
1809
+ const aiLayout = locateTshirt(aiGrid);
1810
+ const fte = deriveFteFromTeamMix(await api.grid(TAB_TEAM));
1811
+ const tmPlan = { teamMix: { fte } };
1812
+ const rosters = phaseTotalsFromSheet(aiGrid, aiLayout).map((ph) => ({ ...ph, roster: buildRoster(tmPlan, ph.staffDays, aiLayout.mf) }));
1813
+ await writeTeamMix(api, tmPlan, rosters, T_TM);
1814
+
1815
+ // 4. audit the copies by read-back
1816
+ const checks = [];
1817
+ const ts = auditTshirt(await api.grid(T_TS));
1818
+ checks.push(...ts.checks);
1819
+ checks.push(...auditTeamMix(await api.grid(T_TM), ts.layout.mf, ts.staffDays, ts.phaseDays).checks);
1820
+ const failed = checks.filter((c) => !c.ok);
1821
+ return { ...preview, fte, table: rostersTable(rosters), audit: { title: `${meta.properties.title} — (${suffix}) tabs`, checks, failed: failed.length, ok: failed.length === 0 } };
1822
+ }
1823
+
1660
1824
  // ───────────────────────── CLI ─────────────────────────
1661
1825
 
1662
1826
  function parseArgs(argv) {
1663
1827
  const out = { _: [] };
1664
1828
  for (let i = 0; i < argv.length; i++) {
1665
1829
  const a = argv[i];
1666
- if (a === "--json" || a === "--replace" || a === "--no-audit" || a === "--dry-run") out[a.slice(2)] = true;
1830
+ if (a === "--json" || a === "--replace" || a === "--no-audit" || a === "--dry-run" || a === "--list" || a === "--xxs") out[a.slice(2)] = true;
1667
1831
  else if (a.startsWith("--")) { out[a.slice(2)] = argv[i + 1]; i++; }
1668
1832
  else out._.push(a);
1669
1833
  }
@@ -1675,7 +1839,7 @@ function printChecks(audit) {
1675
1839
  console.log(`${audit.ok ? "AUDIT PASS" : `AUDIT FAIL (${audit.failed})`} — ${audit.title}`);
1676
1840
  }
1677
1841
 
1678
- const USAGE = "usage: gsd-t estimate-sheet <read|plan-check|write|teammix|format|phases|titles|audit|plan-schema|size> --sheet <id|url> [--tab <name>] [--plan <plan.json>] [--replace] [--fte '{\"backend\":1.5}'] [--title <t>] [--dry-run] [--no-audit] [--key <path>] [--json]\n gsd-t estimate-sheet size --solo-min <n> --project <greenfield-solo|greenfield-team|yellowfield-solo|yellowfield-team-isolated|yellowfield-team-wide> [--switch-min <n>] [--json]";
1842
+ const USAGE = "usage: gsd-t estimate-sheet <read|plan-check|write|teammix|format|phases|titles|audit|plan-schema|size|rescale> --sheet <id|url> [--tab <name>] [--plan <plan.json>] [--replace] [--fte '{\"backend\":1.5}'] [--title <t>] [--dry-run] [--no-audit] [--key <path>] [--json]\n gsd-t estimate-sheet size --solo-min <n> --project <greenfield-solo|greenfield-team|yellowfield-solo|yellowfield-team-isolated|yellowfield-team-wide> [--switch-min <n>] [--xxs] [--json]\n gsd-t estimate-sheet rescale --sheet <id|url> (--list | --plan <rescale.json> [--dry-run] [--replace] [--suffix <name>]) [--json]";
1679
1843
 
1680
1844
  /** Runs a verb; returns the exit code. Throws Halt (or any error) — the runner below turns that into exit 4/64. */
1681
1845
  async function main(args) {
@@ -1684,7 +1848,7 @@ async function main(args) {
1684
1848
  if (!verb || verb === "help") { console.log(USAGE); return 0; }
1685
1849
  if (verb === "plan-schema") { console.log(JSON.stringify(PLAN_SCHEMA, null, 2)); return 0; }
1686
1850
  if (verb === "size") {
1687
- const r = aiTaskSize({ soloMin: Number(args["solo-min"]), project: args.project, switchMin: args["switch-min"] == null ? undefined : Number(args["switch-min"]) });
1851
+ const r = aiTaskSize({ soloMin: Number(args["solo-min"]), project: args.project, switchMin: args["switch-min"] == null ? undefined : Number(args["switch-min"]), xxs: !!args.xxs });
1688
1852
  if (json) console.log(JSON.stringify({ ok: true, exitCode: 0, ...r }, null, 2));
1689
1853
  else console.log(`${r.soloMin} solo min × ${r.multiplier} (${r.project}) + ${r.switchMin} min switching = ${r.hours} h (${r.days} d) → ${r.size} (${r.sizeDays} d)`);
1690
1854
  return 0;
@@ -1737,6 +1901,20 @@ async function main(args) {
1737
1901
  if (json) console.log(JSON.stringify({ ok: a.ok, exitCode: a.ok ? 0 : 4, ...a }, null, 2)); else printChecks(a);
1738
1902
  return a.ok ? 0 : 4;
1739
1903
  }
1904
+ if (verb === "rescale") {
1905
+ const list = !!args.list;
1906
+ let plan = null;
1907
+ if (!list) { if (!args.plan) throw new Halt("rescale needs --plan <rescale.json> (or --list to print the rows to re-size)", 64); try { plan = JSON.parse(fs.readFileSync(args.plan, "utf8")); } catch (e) { throw new Halt(`cannot read --plan ${args.plan}: ${e.message}`, 64); } }
1908
+ const r = await verbRescale(api, { list, plan, replace: !!args.replace, dryRun: !!args["dry-run"], suffix: args.suffix });
1909
+ const ok = !r.audit || r.audit.ok;
1910
+ if (json || list) console.log(JSON.stringify({ ok, exitCode: ok ? 0 : 4, ...r }, null, 2));
1911
+ else {
1912
+ console.log(`${r.items} items: low ${r.oldLowHours} h on the original tabs → ${r.newLowHours} h on the (${args.suffix || "Rescale"}) tabs`);
1913
+ if (r.dryRun) console.log("(dry run — nothing written)");
1914
+ else { console.log(r.table); printChecks(r.audit); }
1915
+ }
1916
+ return ok ? 0 : 4;
1917
+ }
1740
1918
  const plan = loadPlan(args.plan);
1741
1919
  if (verb === "plan-check") {
1742
1920
  const r = await verbPlanCheck(api, plan);
@@ -1773,10 +1951,10 @@ function haltAndExit(e, json) {
1773
1951
  }
1774
1952
 
1775
1953
  module.exports = {
1776
- validatePlan, aiTaskSize, legendIsAi, defaultSwitchMin, splitRoster, rosterViolations, mfCoverageViolations, monthPlan, resampleWeights, rampHours, buildRoster,
1954
+ validatePlan, sizedRows, rescaleViolations, verbRescale, aiTaskSize, legendIsAi, defaultSwitchMin, splitRoster, rosterViolations, mfCoverageViolations, monthPlan, resampleWeights, rampHours, buildRoster,
1777
1955
  tshirtTotals, phaseTotals, buildRosters, midDays, deriveFteFromTeamMix, phaseTotalsFromSheet, verbTeamMix, phaseGapMap, verbPhases, verbTitles, itemFormulasFor, rollupFormulasFor, findCell, itemFormulas, rollupFormulas, tshirtRows, teamMixValues, teamMixFormatReqs, remainderFormula, locateTshirt, findPhaseSource,
1778
1956
  auditTshirt, auditTeamMix, auditTechStack, auditOverview, colLetter, hexToColor, colorToHex, sheetIdFromArg,
1779
- constants: { SIZE_CODES, AI_SIZE_DAYS, PROJECT_MULTIPLIER, PHASES, COLOR, RAMP, ROLE_LABEL, SOFT_CEILING, FOLD_THRESHOLD, TAB_TSHIRT, TAB_TEAM, TAB_TECH, PLAN_SCHEMA },
1957
+ constants: { TAB_TSHIRT_AI, TAB_TEAM_AI, XXS_DAYS, SIZE_CODES, AI_SIZE_DAYS, PROJECT_MULTIPLIER, PHASES, COLOR, RAMP, ROLE_LABEL, SOFT_CEILING, FOLD_THRESHOLD, TAB_TSHIRT, TAB_TEAM, TAB_TECH, PLAN_SCHEMA },
1780
1958
  Halt, SheetsApi, getToken, runAudit, main,
1781
1959
  };
1782
1960