@tekyzinc/gsd-t 5.21.10 → 5.23.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,54 @@
2
2
 
3
3
  All notable changes to GSD-T are documented here. Updated with each release.
4
4
 
5
+ ## [5.23.10] - 2026-09-29
6
+
7
+ ### Added — who-calls matches the compiler on route files; graph exclude list; estimate math v2
8
+
9
+ 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.
10
+
11
+ - `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`.
12
+ - `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).
13
+ - `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.
14
+ - `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.
15
+ - `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).
16
+ - `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.
17
+ - `scripts/statusline-command.sh`: prefers `.transcript_path` from stdin.
18
+ - Tests: `test/m118-*.test.js`, `test/m119-who-calls-golden.test.js` (golden vs findReferences).
19
+
20
+ Re-run `gsd-t graph index` in each project to pick up the new caller attribution and tiers.
21
+
22
+ ## [5.22.10] - 2026-09-27
23
+
24
+ ### Added — AI-assisted T-shirt sizing for `/gsd-t-estimate`
25
+
26
+ Estimates sized every task as if a person typed the code. Measured against the Hilo Delivery
27
+ Runway build (45 tasks, ~26 solo hours: 13 David + 13 Claude), the old scale priced the same
28
+ work at 1,262–1,577 hours. A size is now the team hours a task takes with AI-assisted
29
+ development: solo AI minutes × project multiplier (greenfield solo ×1, team ×5; yellow-field
30
+ solo ×2, team ×8 isolated / ×12 big blast radius) + task switching added after the multiplier
31
+ (7.5 / 15 / 30 min), mapped to the nearest size on a smaller scale — XS 0.1 · S 0.25 · M 0.5 ·
32
+ L 1 · XL 2 · XXL 4 person-days. The multipliers live in the estimator; the sheet layout is
33
+ unchanged.
34
+
35
+ - `bin/gsd-t-estimate-sheet.cjs`: new `size` verb (`--solo-min`, `--project`, `--switch-min`);
36
+ `write` puts the AI scale in the legend (matched by label); sizes mode halts if a sized row
37
+ outside the plan would be silently re-priced; `plan-check` previews on the new scale
38
+ - `templates/estimate-sheet-spec.md`: §1.4 sizing model; legend + verb list updated
39
+ - `commands/gsd-t-estimate.md`, `templates/playbooks/tekyz-estimation-and-prd-playbook.md`,
40
+ `templates/estimate-config.json`: size in solo AI minutes; new-team familiarization off by default
41
+ - `test/estimate-sheet-writer.test.js`: +6 tests
42
+
43
+ Existing estimate sheets keep the old scale until they are re-estimated.
44
+
45
+ ### Fixed — Red Team runs on Opus 5.5 under every profile
46
+
47
+ The `standard` profile put Red Team on Sonnet, contradicting both profile contracts and the
48
+ READMEs. It now runs on `opus` (Opus 5.5) in standard, pro and premium.
49
+
50
+ - `bin/gsd-t-model-tier-policy.cjs`, `commands/gsd-t-status.md`,
51
+ `.gsd-t/contracts/model-profile-config-contract.md`, `test/m86-policy-profiles.test.js`
52
+
5
53
  ## [5.21.10] - 2026-09-22
6
54
 
7
55
  ### Changed — the top tier now points at Opus 5.5
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # GSD-T: Contract-Driven Development for Claude Code
2
2
 
3
- **v5.21.10** - A methodology for reliable, parallelizable development using Claude Code with optional Agent Teams support.
3
+ **v5.23.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)
@@ -184,7 +184,8 @@ This will replace changed command files, back up your CLAUDE.md if customized, a
184
184
  | `/gsd-t-scan` | Deep codebase analysis → techdebt.md | Manual |
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
- | `/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` — supervised, with an operator-arbitrated Estimate Red Team. No PRD (that is `/gsd-t-prd`) | Manual |
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,8 +45,36 @@ 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"];
53
+
54
+ // AI-assisted sizing model (David, 2026-09-27; calibrated on the Hilo Delivery Runway build:
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
56
+ // project type, plus a per-task switching allowance added AFTER the multiplier (switching is
57
+ // one person's pickup time — it does not grow with team size). The result maps to the nearest
58
+ // size on AI_SIZE_DAYS, which `write` puts in the sheet's legend. The multipliers live here,
59
+ // in the estimator — never on the sheet.
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.
71
+ const PROJECT_MULTIPLIER = {
72
+ "greenfield-solo": 1,
73
+ "greenfield-team": 3,
74
+ "yellowfield-solo": 2,
75
+ "yellowfield-team-isolated": 5,
76
+ "yellowfield-team-wide": 7,
77
+ };
50
78
  const PHASES = ["MVP", "Phase 1", "Phase 2", "Phase 3"];
51
79
 
52
80
  const COLOR = {
@@ -202,12 +230,13 @@ class SheetsApi {
202
230
  body: body == null ? undefined : JSON.stringify(body),
203
231
  });
204
232
  const text = await res.text();
205
- if (res.status === 429 && attempt <= 3) {
233
+ if (res.status === 429 && attempt <= 6) {
206
234
  // Rate limit (60 reads/min/user — hit 2026-09-21 auditing 19 sheets in a row). This is
207
235
  // flow control, not a masked failure: the same request is re-sent unchanged after the
208
- // documented wait, and the 4th 429 halts.
209
- const wait = 20000 * attempt;
210
- 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`);
211
240
  await new Promise((r) => setTimeout(r, wait));
212
241
  return this.call(method, url, body, attempt + 1);
213
242
  }
@@ -247,6 +276,15 @@ class SheetsApi {
247
276
  });
248
277
  }
249
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
+
250
288
  async clearValues(tab, a1) {
251
289
  const rng = encodeURIComponent(`'${tab}'!${a1}`);
252
290
  return this.call("POST", `${this.base}/values/${rng}:clear`, {});
@@ -347,10 +385,11 @@ function locateTshirt(grid) {
347
385
  cols.sizeLabels = cols.sizes.map((c) => headerText[c]);
348
386
  // legend: the XS..XXL rows in column A
349
387
  const legend = {};
388
+ const legendRow1 = {};
350
389
  let legendFirst = -1, legendLast = -1;
351
390
  for (let r = 0; r < headerRow; r++) {
352
- const m = textAt(grid, r, 0).trim().match(/^(XS|S|M|L|XL|XXL)\s*-/);
353
- if (m) { legend[m[1]] = numAt(grid, r, 1); if (legendFirst < 0) legendFirst = r; legendLast = r; }
391
+ const m = textAt(grid, r, 0).trim().match(/^(XXS|XS|S|M|L|XL|XXL)\s*-/);
392
+ if (m) { legend[m[1]] = numAt(grid, r, 1); legendRow1[m[1]] = r + 1; if (legendFirst < 0) legendFirst = r; legendLast = r; }
354
393
  }
355
394
  for (const code of SIZE_CODES) {
356
395
  if (!(code in legend) || Number.isNaN(legend[code])) throw new Halt(`${TAB_TSHIRT}: size legend missing '${code}' (looked for 'XS - …' labels in column A above the header row)`);
@@ -391,7 +430,7 @@ function locateTshirt(grid) {
391
430
  const lowDol = findCell(grid, "Low ($)", headerRow, 20), highDol = findCell(grid, "High ($)", headerRow, 20);
392
431
  if (!lowHrs || !highHrs || !lowDol || !highDol) throw new Halt(`${TAB_TSHIRT}: rollup headers 'Low ($)' / 'Low Hrs' / 'High ($)' / 'High Hrs' not all found`);
393
432
  cols.rollupPhase = mvp.c; cols.lowHrs = lowHrs.c; cols.highHrs = highHrs.c; cols.lowDol = lowDol.c; cols.highDol = highDol.c;
394
- return { headerRow, firstItemRow: headerRow + 1, legend, 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 };
395
434
  }
396
435
 
397
436
  /** The standard template's column map (what `write` produces). */
@@ -604,6 +643,35 @@ function buildRoster(plan, totalDays, mfList) {
604
643
 
605
644
  // ───────────────────────── T-Shirt totals (pure) ─────────────────────────
606
645
 
646
+ /** Switching allowance (minutes) for a task of `teamMin` minutes: small 7.5, medium 15, large 30. */
647
+ function defaultSwitchMin(teamMin) { return teamMin < 120 ? 7.5 : teamMin < 480 ? 15 : 30; }
648
+
649
+ /**
650
+ * The estimator's per-task math: solo AI minutes × project multiplier + switching (after the
651
+ * multiplier), then the nearest AI_SIZE_DAYS size (geometric midpoints between neighbours).
652
+ */
653
+ function aiTaskSize({ soloMin, project, switchMin, xxs }) {
654
+ const mult = PROJECT_MULTIPLIER[project];
655
+ if (mult == null) throw new Halt(`--project must be one of ${Object.keys(PROJECT_MULTIPLIER).join(" | ")}`, 64);
656
+ if (typeof soloMin !== "number" || !(soloMin > 0)) throw new Halt("--solo-min must be a positive number of minutes", 64);
657
+ const teamMin = soloMin * mult;
658
+ const sw = switchMin == null ? defaultSwitchMin(teamMin) : switchMin;
659
+ if (typeof sw !== "number" || sw < 0) throw new Halt("--switch-min must be a number of minutes ≥ 0", 64);
660
+ const hours = (teamMin + sw) / 60;
661
+ const days = hours / 8;
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; }
668
+ }
669
+ return { soloMin, project, multiplier: mult, switchMin: sw, hours: round2(hours), days: round2(days), size, sizeDays: scale[size] };
670
+ }
671
+
672
+ /** True when the sheet's legend already carries the AI scale. */
673
+ function legendIsAi(legend) { return SIZE_CODES.every((c) => legend[c] === AI_SIZE_DAYS[c]); }
674
+
607
675
  function sizeDays(code, legend) {
608
676
  const v = sizeOf(code);
609
677
  if (v === "") return 0;
@@ -625,7 +693,8 @@ function tshirtTotals(plan, layout) {
625
693
  function itemFormulasFor(r, cols, legend) {
626
694
  const L = colLetter;
627
695
  const lg = `$A$${legend.first1}:$A$${legend.last1}`, lv = `$B$${legend.first1}:$B$${legend.last1}`;
628
- 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("+") + ")";
629
698
  const abs = (cell) => `$${L(cell.c)}$${cell.r + 1}`;
630
699
  const f = {
631
700
  H: days,
@@ -721,11 +790,15 @@ async function writeTshirt(api, plan, opts) {
721
790
  const layout = locateTshirt(grid);
722
791
  const std = ["phase", "days", "mfactor", "total", "low", "high"].every((k) => layout.cols[k] === STANDARD_COLS[k]) && layout.cols.sizes.length === 2 && layout.legendFirst1 === 4 && layout.headerRow === 12;
723
792
  if (!std) throw new Halt(`${TAB_TSHIRT}: this sheet is not the current template layout (header row ${layout.headerRow + 1}, size columns ${layout.cols.sizeLabels.join("/")}) — 'write' supports the current template only; 'teammix' and 'audit' work on both`);
793
+ // New estimates use the AI-assisted scale: the legend is rewritten to AI_SIZE_DAYS (after the
794
+ // checks below pass, so a halted run changes nothing), and totals are computed on it.
795
+ const legendChange = !legendIsAi(layout.legend);
796
+ layout.legend = { ...AI_SIZE_DAYS };
724
797
  const totals = tshirtTotals(plan, layout);
725
798
  const phaseSrc = findPhaseSource(grid, 0);
726
799
  if (phaseSrc < 0) throw new Halt(`${TAB_TSHIRT}: no Phase dropdown source cell found in column E — add a ONE_OF_LIST validation (MVP / Phase 1 / Phase 2 / Phase 3) to E${layout.firstItemRow + 1} and re-run`);
727
800
 
728
- if (plan.tshirt.mode === "sizes") return writeTshirtSizes(api, grid, sheetId, layout, plan, totals, phaseSrc);
801
+ if (plan.tshirt.mode === "sizes") return writeTshirtSizes(api, grid, sheetId, layout, plan, totals, phaseSrc, legendChange);
729
802
 
730
803
  const first0 = layout.firstItemRow;
731
804
  let occupied = 0;
@@ -735,6 +808,7 @@ async function writeTshirt(api, plan, opts) {
735
808
  }
736
809
  const built = tshirtRows(plan, first0);
737
810
  const lastWritten1 = built.rows[built.rows.length - 1].row1;
811
+ if (legendChange) await writeAiLegend(api, layout);
738
812
 
739
813
  // 1. clear values + formats + merges in the item area (clear-then-paint)
740
814
  const clearTo = Math.max(lastWritten1 + 5, rowCount(grid) + 1);
@@ -792,7 +866,12 @@ async function writeTshirt(api, plan, opts) {
792
866
  }
793
867
 
794
868
  /** sizes mode — rows exist (gap-analysis sheet); fill E:L on rows matched by "(id)" in column C. */
795
- async function writeTshirtSizes(api, grid, sheetId, layout, plan, totals, phaseSrc) {
869
+ /** Put AI_SIZE_DAYS into the legend's value column, row by row by label (never by position). */
870
+ async function writeAiLegend(api, layout) {
871
+ for (const c of SIZE_CODES) await api.putValues(TAB_TSHIRT, `B${layout.legendRow1[c]}`, [[AI_SIZE_DAYS[c]]]);
872
+ }
873
+
874
+ async function writeTshirtSizes(api, grid, sheetId, layout, plan, totals, phaseSrc, legendChange) {
796
875
  const rowsById = new Map();
797
876
  for (let r = layout.firstItemRow; r < rowCount(grid); r++) {
798
877
  const m = textAt(grid, r, 2).match(/\(([A-Za-z]+-\d+(?:\.\d+)*)\)\s*$/);
@@ -809,6 +888,18 @@ async function writeTshirtSizes(api, grid, sheetId, layout, plan, totals, phaseS
809
888
  writes.push({ r0, phase: it.phase, values: [it.phase, sizeOf(it.fe), sizeOf(it.be), f.H, f.I, f.J, f.K, f.L] });
810
889
  }
811
890
  if (missing.length) throw new Halt(`${TAB_TSHIRT}: no row carries these ids in column C — ${missing.join(", ")}. Rows are matched BY NAME (the "(id)" suffix), never by position.`, 4, missing);
891
+ if (legendChange) {
892
+ // Changing the legend re-prices EVERY sized row on the tab. A sized row the plan does not
893
+ // cover would silently move to the new scale — halt instead, naming the rows.
894
+ const planned = new Set(writes.map((w) => w.r0));
895
+ const stray = [];
896
+ for (const [id, r0] of rowsById) {
897
+ if (planned.has(r0)) continue;
898
+ if (layout.cols.sizes.some((c) => textAt(grid, r0, c).trim())) stray.push(id);
899
+ }
900
+ if (stray.length) throw new Halt(`${TAB_TSHIRT}: the sheet is on the old day scale and these sized rows are not in the plan — ${stray.join(", ")}. Moving the legend to the AI-assisted scale would re-price them silently. Size every row in the plan, or clear those rows first.`, 4, stray);
901
+ await writeAiLegend(api, layout);
902
+ }
812
903
  let totalRow0 = -1;
813
904
  for (let r = lastItem0 + 1; r < rowCount(grid); r++) if (/^Total \(Days\)/i.test(textAt(grid, r, 0))) { totalRow0 = r; break; }
814
905
  if (totalRow0 < 0) throw new Halt(`${TAB_TSHIRT}: no 'Total (Days)' row found below the items — add it (spec §1.3) and re-run`);
@@ -936,8 +1027,8 @@ function teamMixFormatReqs(sheetId, v) {
936
1027
  }
937
1028
 
938
1029
  /** Write every phase grid on the one Team Mix tab: clear-then-paint, GRID_GAP blank rows between grids. */
939
- async function writeTeamMix(api, plan, rosters) {
940
- const grid = await api.grid(TAB_TEAM);
1030
+ async function writeTeamMix(api, plan, rosters, tab = TAB_TEAM) {
1031
+ const grid = await api.grid(tab);
941
1032
  const sheetId = grid.properties.sheetId;
942
1033
  const grids = [];
943
1034
  let top = 0;
@@ -948,7 +1039,7 @@ async function writeTeamMix(api, plan, rosters) {
948
1039
  }
949
1040
  const lastRow = grids[grids.length - 1].v.bottom;
950
1041
  const clearRows = Math.max(60, rowCount(grid) + 2, lastRow + 10);
951
- await api.clearValues(TAB_TEAM, `A1:Z${clearRows}`);
1042
+ await api.clearValues(tab, `A1:Z${clearRows}`);
952
1043
  await api.batch([
953
1044
  { unmergeCells: { range: gridRange(sheetId, 0, clearRows, 0, 26) } },
954
1045
  fmtReq(sheetId, 0, clearRows, 0, 26, {}, "userEnteredFormat"),
@@ -956,7 +1047,7 @@ async function writeTeamMix(api, plan, rosters) {
956
1047
  const reqs = [];
957
1048
  let maxN = 0;
958
1049
  for (const g of grids) {
959
- 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);
960
1051
  reqs.push(...teamMixFormatReqs(sheetId, g.v));
961
1052
  maxN = Math.max(maxN, g.v.totalC - g.v.firstMonthC);
962
1053
  }
@@ -998,7 +1089,7 @@ function auditTshirt(grid) {
998
1089
  const out = [];
999
1090
  const layout = locateTshirt(grid);
1000
1091
  const { cols } = layout;
1001
- const legendRows = { first1: layout.legendFirst1, last1: layout.legendLast1 };
1092
+ const legendRows = { first1: layout.legendFirst1, last1: layout.legendLast1, exact: layout.legendExact };
1002
1093
  const first0 = layout.firstItemRow;
1003
1094
  const badSizes = [], noDv = [], badFormula = [], sectionsWithSizes = [], badSectionFmt = [], noWrap = [], notTop = [];
1004
1095
  let lastItem0 = -1, totalRow0 = -1;
@@ -1015,7 +1106,7 @@ function auditTshirt(grid) {
1015
1106
  lastItem0 = r;
1016
1107
  for (const c of cols.sizes) {
1017
1108
  const v = textAt(grid, r, c).trim();
1018
- 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}'`);
1019
1110
  }
1020
1111
  const cell = cellAt(grid, r, cols.phase);
1021
1112
  if (!(cell && cell.dataValidation && cell.dataValidation.condition && cell.dataValidation.condition.type === "ONE_OF_LIST")) noDv.push(`${colLetter(cols.phase)}${r + 1}`);
@@ -1314,6 +1405,7 @@ function rostersTable(rosters) {
1314
1405
 
1315
1406
  async function verbPlanCheck(api, plan) {
1316
1407
  const layout = locateTshirt(await api.grid(TAB_TSHIRT));
1408
+ layout.legend = { ...AI_SIZE_DAYS }; // preview what `write` produces — it puts the AI scale in the legend
1317
1409
  const totals = tshirtTotals(plan, layout);
1318
1410
  const rosters = buildRosters(plan, layout);
1319
1411
  return { totals, mf: layout.mf, mfTotal: round2(layout.mfTotal), highFactor: layout.highFactor, rate: layout.rate, rosters, table: rostersTable(rosters) };
@@ -1591,13 +1683,151 @@ async function verbWrite(api, plan, opts) {
1591
1683
  return result;
1592
1684
  }
1593
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
+
1594
1824
  // ───────────────────────── CLI ─────────────────────────
1595
1825
 
1596
1826
  function parseArgs(argv) {
1597
1827
  const out = { _: [] };
1598
1828
  for (let i = 0; i < argv.length; i++) {
1599
1829
  const a = argv[i];
1600
- 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;
1601
1831
  else if (a.startsWith("--")) { out[a.slice(2)] = argv[i + 1]; i++; }
1602
1832
  else out._.push(a);
1603
1833
  }
@@ -1609,7 +1839,7 @@ function printChecks(audit) {
1609
1839
  console.log(`${audit.ok ? "AUDIT PASS" : `AUDIT FAIL (${audit.failed})`} — ${audit.title}`);
1610
1840
  }
1611
1841
 
1612
- const USAGE = "usage: gsd-t estimate-sheet <read|plan-check|write|teammix|format|phases|titles|audit|plan-schema> --sheet <id|url> [--tab <name>] [--plan <plan.json>] [--replace] [--fte '{\"backend\":1.5}'] [--title <t>] [--dry-run] [--no-audit] [--key <path>] [--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]";
1613
1843
 
1614
1844
  /** Runs a verb; returns the exit code. Throws Halt (or any error) — the runner below turns that into exit 4/64. */
1615
1845
  async function main(args) {
@@ -1617,6 +1847,12 @@ async function main(args) {
1617
1847
  const json = !!args.json;
1618
1848
  if (!verb || verb === "help") { console.log(USAGE); return 0; }
1619
1849
  if (verb === "plan-schema") { console.log(JSON.stringify(PLAN_SCHEMA, null, 2)); return 0; }
1850
+ if (verb === "size") {
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 });
1852
+ if (json) console.log(JSON.stringify({ ok: true, exitCode: 0, ...r }, null, 2));
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)`);
1854
+ return 0;
1855
+ }
1620
1856
  const sheetId = sheetIdFromArg(args.sheet);
1621
1857
  const api = new SheetsApi(await getToken(args.key), sheetId);
1622
1858
  if (verb === "read") {
@@ -1665,6 +1901,20 @@ async function main(args) {
1665
1901
  if (json) console.log(JSON.stringify({ ok: a.ok, exitCode: a.ok ? 0 : 4, ...a }, null, 2)); else printChecks(a);
1666
1902
  return a.ok ? 0 : 4;
1667
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
+ }
1668
1918
  const plan = loadPlan(args.plan);
1669
1919
  if (verb === "plan-check") {
1670
1920
  const r = await verbPlanCheck(api, plan);
@@ -1701,10 +1951,10 @@ function haltAndExit(e, json) {
1701
1951
  }
1702
1952
 
1703
1953
  module.exports = {
1704
- validatePlan, splitRoster, rosterViolations, mfCoverageViolations, monthPlan, resampleWeights, rampHours, buildRoster,
1954
+ validatePlan, sizedRows, rescaleViolations, verbRescale, aiTaskSize, legendIsAi, defaultSwitchMin, splitRoster, rosterViolations, mfCoverageViolations, monthPlan, resampleWeights, rampHours, buildRoster,
1705
1955
  tshirtTotals, phaseTotals, buildRosters, midDays, deriveFteFromTeamMix, phaseTotalsFromSheet, verbTeamMix, phaseGapMap, verbPhases, verbTitles, itemFormulasFor, rollupFormulasFor, findCell, itemFormulas, rollupFormulas, tshirtRows, teamMixValues, teamMixFormatReqs, remainderFormula, locateTshirt, findPhaseSource,
1706
1956
  auditTshirt, auditTeamMix, auditTechStack, auditOverview, colLetter, hexToColor, colorToHex, sheetIdFromArg,
1707
- constants: { SIZE_CODES, 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 },
1708
1958
  Halt, SheetsApi, getToken, runAudit, main,
1709
1959
  };
1710
1960