@tekyzinc/gsd-t 5.20.13 → 5.20.14

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,35 @@
2
2
 
3
3
  All notable changes to GSD-T are documented here. Updated with each release.
4
4
 
5
+ ## [5.20.14] - 2026-09-21
6
+
7
+ ### Added — `gsd-t estimate-sheet teammix` rebuilds any estimate's Team Mix; the reader handles both template layouts
8
+
9
+ Reviewing the 19 estimates linked from the Hilo index found ten on an older T-Shirt layout
10
+ (Project/Client rows on top, legend from row 8, up to four size columns, header row 17) and an
11
+ older Team Mix model (Count = peak-month hours ÷ 160). Five estimates have more than one phase
12
+ and needed the per-phase Team Mix.
13
+
14
+ - `bin/gsd-t-estimate-sheet.cjs`: `locateTshirt` finds every header cell BY LABEL and returns a
15
+ column map; item/rollup formulas and the audit are generated from the map. New `teammix` verb:
16
+ derives the roster from the sheet's existing grid (entered Counts summed per discipline; a
17
+ peak-utilisation roster splits the sheet's total FTE by each role's hours), reads phase
18
+ Low/High hours from the rollups, and rewrites one grid per phase; `--fte` overrides, `--dry-run`
19
+ previews, unmappable roles halt. Disciplines `design` and `mobile` added. `write` refuses the
20
+ older layout by name. Fold rule now checks a full-timer still fits under 172 hrs. 429 rate
21
+ limits back off and retry the identical request (3×), then halt.
22
+ - Applied to the five multi-phase Hilo estimates (Acron, Role-Based Permissions, Part 141 & 61,
23
+ Part 121, Aeroplanned); all Team Mix checks pass on each. Aeroplanned had no PM/BA row — added
24
+ at 0.20 / 0.10.
25
+ - `test/estimate-sheet-writer.test.js`: older-layout reader, roster derivation (both models),
26
+ fold-vs-ceiling.
27
+ - `format` verb: normalises a T-Shirt tab's formatting only — section rows to the spec blue
28
+ (unmerging each sheet's own merge range first), blank rows before the totals row deleted, the
29
+ grey bold totals band, and the standard summary block (bold labels, Total Cost in dollars)
30
+ rewritten directly under it; recognises the four old summary variants and inserts rows when
31
+ another table sits close below. Applied to all 19 Hilo estimates; every totals/summary check
32
+ now passes on all 19.
33
+
5
34
  ## [5.20.13] - 2026-09-21
6
35
 
7
36
  ### Fixed — Team Mix staffs the midpoint of Low and High, not Low
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # GSD-T: Contract-Driven Development for Claude Code
2
2
 
3
- **v5.20.13** - A methodology for reliable, parallelizable development using Claude Code with optional Agent Teams support.
3
+ **v5.20.14** - 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.
@@ -23,6 +23,8 @@
23
23
  * read --sheet <id|url> [--tab <name>] dump layout (read-before-write)
24
24
  * plan-check --sheet <id|url> --plan <plan.json> validate plan, print the roster it WOULD write
25
25
  * write --sheet <id|url> --plan <plan.json> [--replace] write T-Shirt + Team Mix + Tech Stack, then audit
26
+ * teammix --sheet <id|url> [--fte <json>] [--dry-run] rebuild the Team Mix (one grid per phase) from the sheet's own roster + rollups
27
+ * format --sheet <id|url> [--dry-run] normalise T-Shirt formatting only (section rows, totals band, summary block); values untouched
26
28
  * audit --sheet <id|url> the spec §5 checklist, by read-back
27
29
  * plan-schema print the plan shape
28
30
  * Flags: --json (envelope only) --key <path> --no-audit (write only; for debugging)
@@ -74,6 +76,8 @@ const RAMP = {
74
76
  devops: [0.45, 0.85, 1.7],
75
77
  pm: [1, 1, 1],
76
78
  techlead: [1.3, 0.8, 0.9],
79
+ design: [1.6, 0.9, 0.5], // Design / UX: heaviest at the start, a tail for revisions
80
+ mobile: [1.35, 1.05, 0.6], // same shape as frontend
77
81
  };
78
82
 
79
83
  const ROLE_LABEL = {
@@ -84,6 +88,8 @@ const ROLE_LABEL = {
84
88
  ba: "Business Analyst",
85
89
  devops: "DevOps Engineer",
86
90
  techlead: "Tech Lead / Architect",
91
+ design: "Design / UX",
92
+ mobile: "Mobile Engineer",
87
93
  };
88
94
 
89
95
  // MF factor label → the discipline that must staff it (spec §2.4). Factors with
@@ -186,7 +192,7 @@ class SheetsApi {
186
192
  this.base = `https://sheets.googleapis.com/v4/spreadsheets/${sheetId}`;
187
193
  }
188
194
 
189
- async call(method, url, body) {
195
+ async call(method, url, body, attempt = 1) {
190
196
  // Spec §0: NEVER send X-Goog-User-Project — on these sheets it CAUSES a 403.
191
197
  const res = await fetch(url, {
192
198
  method,
@@ -194,6 +200,15 @@ class SheetsApi {
194
200
  body: body == null ? undefined : JSON.stringify(body),
195
201
  });
196
202
  const text = await res.text();
203
+ if (res.status === 429 && attempt <= 3) {
204
+ // Rate limit (60 reads/min/user — hit 2026-09-21 auditing 19 sheets in a row). This is
205
+ // flow control, not a masked failure: the same request is re-sent unchanged after the
206
+ // documented wait, and the 4th 429 halts.
207
+ const wait = 20000 * attempt;
208
+ console.error(` … Sheets API 429 (rate limit) — waiting ${wait / 1000}s, retry ${attempt}/3`);
209
+ await new Promise((r) => setTimeout(r, wait));
210
+ return this.call(method, url, body, attempt + 1);
211
+ }
197
212
  if (!res.ok) {
198
213
  const short = text.slice(0, 400);
199
214
  if (res.status === 403) {
@@ -300,54 +315,100 @@ function rowIsEmpty(grid, r, cols = 12) {
300
315
  return true;
301
316
  }
302
317
 
303
- /** Locate the T-Shirt tab's fixed header block (spec §1.1). HALTS if it is not the template. */
304
- function locateTshirt(grid) {
305
- let headerRow = -1;
306
- for (let r = 0; r < Math.min(rowCount(grid), 40); r++) {
307
- if (/^Module\/Functionality$/i.test(textAt(grid, r, 0).trim())) { headerRow = r; break; }
318
+ /** Find the first cell whose text equals `label` (case-insensitive, trimmed) in the top `maxRows` rows. */
319
+ function findCell(grid, label, maxRows = 25, maxCols = 20) {
320
+ const want = label.toLowerCase();
321
+ for (let r = 0; r < Math.min(rowCount(grid), maxRows); r++) {
322
+ for (let c = 0; c < maxCols; c++) if (textAt(grid, r, c).trim().toLowerCase() === want) return { r, c };
308
323
  }
309
- if (headerRow < 0) throw new Halt(`${TAB_TSHIRT}: column header row ('Module/Functionality' in A) not found — is this the Tekyz template?`);
324
+ return null;
325
+ }
326
+
327
+ /**
328
+ * Locate the T-Shirt tab's header block BY LABEL, not by fixed row (spec §1.1). Two layouts
329
+ * are in the wild: the current template (Date row 1, legend A4:B9, header row 13, two size
330
+ * columns) and an older one (Project/Client/Date rows 3–5, legend A8:B13, header row 17, up
331
+ * to four size columns). Everything the writer and audit need is returned as a column map.
332
+ * HALTS on anything it cannot find — never guesses a coordinate.
333
+ */
334
+ function locateTshirt(grid) {
335
+ const hdr = findCell(grid, "Module/Functionality", 40, 3);
336
+ if (!hdr || hdr.c !== 0) throw new Halt(`${TAB_TSHIRT}: column header row ('Module/Functionality' in A) not found — is this the Tekyz template?`);
337
+ const headerRow = hdr.r;
338
+ const headerText = [];
339
+ for (let c = 0; c < 20; c++) headerText.push(textAt(grid, headerRow, c).trim());
340
+ const colOf = (label) => { const c = headerText.findIndex((x) => x.toLowerCase() === label.toLowerCase()); if (c < 0) throw new Halt(`${TAB_TSHIRT}: header row ${headerRow + 1} has no '${label}' column`); return c; };
341
+ const cols = { phase: colOf("Phase"), days: colOf("Days"), mfactor: colOf("MFactor Days"), total: colOf("Total Days"), low: colOf("LOW $"), high: colOf("HIGH $") };
342
+ cols.sizes = [];
343
+ for (let c = cols.phase + 1; c < cols.days; c++) cols.sizes.push(c);
344
+ if (!cols.sizes.length) throw new Halt(`${TAB_TSHIRT}: no size columns between 'Phase' and 'Days'`);
345
+ cols.sizeLabels = cols.sizes.map((c) => headerText[c]);
346
+ // legend: the XS..XXL rows in column A
310
347
  const legend = {};
311
- for (let r = 0; r < 20; r++) {
348
+ let legendFirst = -1, legendLast = -1;
349
+ for (let r = 0; r < headerRow; r++) {
312
350
  const m = textAt(grid, r, 0).trim().match(/^(XS|S|M|L|XL|XXL)\s*-/);
313
- if (m) legend[m[1]] = numAt(grid, r, 1);
351
+ if (m) { legend[m[1]] = numAt(grid, r, 1); if (legendFirst < 0) legendFirst = r; legendLast = r; }
314
352
  }
315
353
  for (const code of SIZE_CODES) {
316
- if (!(code in legend) || Number.isNaN(legend[code])) throw new Halt(`${TAB_TSHIRT}: size legend missing '${code}' in A4:B9`);
354
+ 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)`);
317
355
  }
356
+ // MF list: under 'Multiplication Factor' down to 'Total MF'
357
+ const mfHdr = findCell(grid, "Multiplication Factor", headerRow, 12);
358
+ if (!mfHdr) throw new Halt(`${TAB_TSHIRT}: 'Multiplication Factor' header not found`);
318
359
  const mf = [];
319
360
  let mfTotalCell = null;
320
- for (let r = 0; r < 20; r++) {
321
- const label = textAt(grid, r, 4).trim();
361
+ for (let r = mfHdr.r + 1; r < headerRow; r++) {
362
+ const label = textAt(grid, r, mfHdr.c).trim();
322
363
  if (!label) continue;
323
- if (/^Total MF$/i.test(label)) { mfTotalCell = { r, c: 5, value: numAt(grid, r, 5) }; continue; }
324
- if (/^Multiplication Factor$/i.test(label)) continue;
325
- const v = numAt(grid, r, 5);
364
+ if (/^Total MF$/i.test(label)) { mfTotalCell = { r, c: mfHdr.c + 1, value: numAt(grid, r, mfHdr.c + 1) }; break; }
365
+ const v = numAt(grid, r, mfHdr.c + 1);
326
366
  if (!Number.isNaN(v)) mf.push({ label, value: v, row: r + 1 });
327
367
  }
328
- if (!mf.length) throw new Halt(`${TAB_TSHIRT}: multiplication-factor list (E4:F9) not found`);
329
- if (!mfTotalCell) throw new Halt(`${TAB_TSHIRT}: 'Total MF' cell not found`);
368
+ if (!mf.length) throw new Halt(`${TAB_TSHIRT}: multiplication-factor list under '${colLetter(mfHdr.c)}${mfHdr.r + 1}' is empty`);
369
+ if (!mfTotalCell) throw new Halt(`${TAB_TSHIRT}: 'Total MF' cell not found under the MF list`);
330
370
  const mfTotal = mf.reduce((s, f) => s + f.value, 0);
331
- const highFactor = numAt(grid, 3, 6);
332
- const rate = numAt(grid, 3, 7);
333
- if (Number.isNaN(highFactor)) throw new Halt(`${TAB_TSHIRT}: High $ Factor (G4) is not a number`);
334
- if (Number.isNaN(rate)) throw new Halt(`${TAB_TSHIRT}: Avg. Hrly Rate (H4) is not a number`);
371
+ const hfHdr = findCell(grid, "High $ Factor", headerRow, 14);
372
+ if (!hfHdr) throw new Halt(`${TAB_TSHIRT}: 'High $ Factor' header not found`);
373
+ const highFactor = numAt(grid, hfHdr.r + 1, hfHdr.c);
374
+ if (Number.isNaN(highFactor)) throw new Halt(`${TAB_TSHIRT}: High $ Factor (${colLetter(hfHdr.c)}${hfHdr.r + 2}) is not a number`);
375
+ const rateHdr = findCell(grid, "Avg. Hrly Rate", headerRow, 14);
376
+ if (!rateHdr) throw new Halt(`${TAB_TSHIRT}: 'Avg. Hrly Rate' header not found`);
377
+ const rate = numAt(grid, rateHdr.r + 1, rateHdr.c);
378
+ if (Number.isNaN(rate)) throw new Halt(`${TAB_TSHIRT}: Avg. Hrly Rate (${colLetter(rateHdr.c)}${rateHdr.r + 2}) is not a number`);
379
+ cols.highFactor = { r: hfHdr.r + 1, c: hfHdr.c };
380
+ cols.rate = { r: rateHdr.r + 1, c: rateHdr.c };
381
+ cols.mfTotal = { r: mfTotalCell.r, c: mfTotalCell.c };
382
+ // phase rollups: the column holding 'MVP' above the header; Low/High Hrs columns from their headers
383
+ const mvp = findCell(grid, "MVP", headerRow, 16);
384
+ if (!mvp) throw new Halt(`${TAB_TSHIRT}: phase rollup block ('MVP' label above the header row) not found`);
335
385
  const rollupRows = [];
336
- for (let r = 0; r < 12; r++) if (PHASES.includes(textAt(grid, r, 9).trim())) rollupRows.push(r);
337
- if (rollupRows.length !== 4) throw new Halt(`${TAB_TSHIRT}: expected 4 phase rollup rows in column J (MVP, Phase 1–3), found ${rollupRows.length}`);
338
- return { headerRow, firstItemRow: headerRow + 1, legend, mf, mfTotal, mfTotalCell, highFactor, rate, rollupRows };
386
+ for (let r = 0; r < headerRow; r++) if (PHASES.includes(textAt(grid, r, mvp.c).trim())) rollupRows.push(r);
387
+ if (rollupRows.length !== 4) throw new Halt(`${TAB_TSHIRT}: expected 4 phase rollup rows in column ${colLetter(mvp.c)} (MVP, Phase 1–3), found ${rollupRows.length}`);
388
+ const lowHrs = findCell(grid, "Low Hrs", headerRow, 20), highHrs = findCell(grid, "High Hrs", headerRow, 20);
389
+ const lowDol = findCell(grid, "Low ($)", headerRow, 20), highDol = findCell(grid, "High ($)", headerRow, 20);
390
+ if (!lowHrs || !highHrs || !lowDol || !highDol) throw new Halt(`${TAB_TSHIRT}: rollup headers 'Low ($)' / 'Low Hrs' / 'High ($)' / 'High Hrs' not all found`);
391
+ cols.rollupPhase = mvp.c; cols.lowHrs = lowHrs.c; cols.highHrs = highHrs.c; cols.lowDol = lowDol.c; cols.highDol = highDol.c;
392
+ return { headerRow, firstItemRow: headerRow + 1, legend, legendFirst1: legendFirst + 1, legendLast1: legendLast + 1, mf, mfTotal, mfTotalCell, highFactor, rate, rollupRows, cols };
339
393
  }
340
394
 
341
- /** Row index of a cell in column E with a ONE_OF_LIST validation — the Phase dropdown source; -1 if none. */
342
- function findPhaseSource(grid, fromRow) {
395
+ /** The standard template's column map (what `write` produces). */
396
+ const STANDARD_COLS = { phase: 4, sizes: [5, 6], days: 7, mfactor: 8, total: 9, low: 10, high: 11, rollupPhase: 9, lowHrs: 11, highHrs: 13, lowDol: 10, highDol: 12, highFactor: { r: 3, c: 6 }, rate: { r: 3, c: 7 }, mfTotal: { r: 9, c: 5 } };
397
+ const STANDARD_LEGEND = { first1: 4, last1: 9 };
398
+
399
+ /** Row index of a cell in the Phase column with a ONE_OF_LIST validation — the Phase dropdown source; -1 if none. */
400
+ function findPhaseSourceIn(grid, fromRow, phaseCol) {
343
401
  for (let r = fromRow; r < rowCount(grid); r++) {
344
- const cell = cellAt(grid, r, 4);
402
+ const cell = cellAt(grid, r, phaseCol);
345
403
  const dv = cell && cell.dataValidation;
346
404
  if (dv && dv.condition && dv.condition.type === "ONE_OF_LIST") return r;
347
405
  }
348
406
  return -1;
349
407
  }
350
408
 
409
+ /** Row index of a cell in column E with a ONE_OF_LIST validation — the Phase dropdown source; -1 if none. */
410
+ function findPhaseSource(grid, fromRow) { return findPhaseSourceIn(grid, fromRow, 4); }
411
+
351
412
  // ───────────────────────── plan validation (pure) ─────────────────────────
352
413
 
353
414
  const PLAN_SCHEMA = {
@@ -478,7 +539,11 @@ function monthPlan(totalDays, people) {
478
539
  const months = totalDays / (sumCount * 20);
479
540
  const whole = Math.floor(months);
480
541
  const frac = months - whole;
481
- const n = whole >= 1 && frac < FOLD_THRESHOLD ? whole : Math.ceil(months);
542
+ // Fold a small tail into the last full month ONLY if a full-time person still fits under the
543
+ // soft ceiling: months × 160 ≤ whole × 172. (Acron, 2026-09-21: 1.098 months folded to one
544
+ // column put Backend 1 at 175.7 hrs — over the ceiling — so the fold must check the hours.)
545
+ const fits = months * 160 <= whole * SOFT_CEILING + 1e-9;
546
+ const n = whole >= 1 && frac < FOLD_THRESHOLD && fits ? whole : Math.ceil(months);
482
547
  return { months, sumCount, n: Math.max(1, n) };
483
548
  }
484
549
 
@@ -554,25 +619,39 @@ function tshirtTotals(plan, layout) {
554
619
 
555
620
  // ───────────────────────── T-Shirt formulas (pure, spec §1.2–1.3) ─────────────────────────
556
621
 
557
- function itemFormulas(r) {
558
- return {
559
- H: `=(IF(F${r}="",0,SUMIF($A$4:$A$9,LEFT(F${r},2)&"*",$B$4:$B$9))+IF(G${r}="",0,SUMIF($A$4:$A$9,LEFT(G${r},2)&"*",$B$4:$B$9)))`,
560
- I: `=H${r}*$F$10`,
561
- J: `=H${r}+I${r}`,
562
- K: `=J${r}*8*$H$4`,
563
- L: `=K${r}*$G$4`,
622
+ /** Item-row formulas for any column map (spec §1.2). Keys are the STANDARD letters H..L; `cells` carries the real columns. */
623
+ function itemFormulasFor(r, cols, legend) {
624
+ const L = colLetter;
625
+ const lg = `$A$${legend.first1}:$A$${legend.last1}`, lv = `$B$${legend.first1}:$B$${legend.last1}`;
626
+ const days = "=(" + cols.sizes.map((c) => `IF(${L(c)}${r}="",0,SUMIF(${lg},LEFT(${L(c)}${r},2)&"*",${lv}))`).join("+") + ")";
627
+ const abs = (cell) => `$${L(cell.c)}$${cell.r + 1}`;
628
+ const f = {
629
+ H: days,
630
+ I: `=${L(cols.days)}${r}*${abs(cols.mfTotal)}`,
631
+ J: `=${L(cols.days)}${r}+${L(cols.mfactor)}${r}`,
632
+ K: `=${L(cols.total)}${r}*8*${abs(cols.rate)}`,
633
+ L: `=${L(cols.low)}${r}*${abs(cols.highFactor)}`,
564
634
  };
635
+ f.cells = [[cols.days, f.H], [cols.mfactor, f.I], [cols.total, f.J], [cols.low, f.K], [cols.high, f.L]];
636
+ return f;
565
637
  }
566
638
 
567
- function rollupFormulas(rollupRow1, firstRow1, lastRow1) {
568
- const e = `$E$${firstRow1}:$E$${lastRow1}`;
639
+ /** The standard template's item formulas (what `write` produces). */
640
+ function itemFormulas(r) { return itemFormulasFor(r, STANDARD_COLS, STANDARD_LEGEND); }
641
+
642
+ function rollupFormulasFor(rollupRow1, firstRow1, lastRow1, cols) {
643
+ const L = colLetter;
644
+ const e = `$${L(cols.phase)}$${firstRow1}:$${L(cols.phase)}$${lastRow1}`;
645
+ const ph = `$${L(cols.rollupPhase)}${rollupRow1}`;
646
+ const rate = `$${L(cols.rate.c)}$${cols.rate.r + 1}`;
569
647
  return [
570
- `=SUMIF(${e},$J${rollupRow1},$K$${firstRow1}:$K$${lastRow1})`,
571
- `=SUMIF(${e},$J${rollupRow1},$J$${firstRow1}:$J$${lastRow1})*8`,
572
- `=SUMIF(${e},$J${rollupRow1},$L$${firstRow1}:$L$${lastRow1})`,
573
- `=IF($H$4=0,0,M${rollupRow1}/$H$4)`,
648
+ `=SUMIF(${e},${ph},$${L(cols.low)}$${firstRow1}:$${L(cols.low)}$${lastRow1})`,
649
+ `=SUMIF(${e},${ph},$${L(cols.total)}$${firstRow1}:$${L(cols.total)}$${lastRow1})*8`,
650
+ `=SUMIF(${e},${ph},$${L(cols.high)}$${firstRow1}:$${L(cols.high)}$${lastRow1})`,
651
+ `=IF(${rate}=0,0,${L(cols.highDol)}${rollupRow1}/${rate})`,
574
652
  ];
575
653
  }
654
+ function rollupFormulas(rollupRow1, firstRow1, lastRow1) { return rollupFormulasFor(rollupRow1, firstRow1, lastRow1, STANDARD_COLS); }
576
655
 
577
656
  /** Rows for items mode, starting at firstRow0 (0-based). */
578
657
  function tshirtRows(plan, firstRow0) {
@@ -631,6 +710,8 @@ async function writeTshirt(api, plan, opts) {
631
710
  const grid = await api.grid(TAB_TSHIRT);
632
711
  const sheetId = grid.properties.sheetId;
633
712
  const layout = locateTshirt(grid);
713
+ 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;
714
+ 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`);
634
715
  const totals = tshirtTotals(plan, layout);
635
716
  const phaseSrc = findPhaseSource(grid, 0);
636
717
  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`);
@@ -897,74 +978,77 @@ async function writeTechStack(api, plan) {
897
978
 
898
979
  function check(list, name, ok, detail) { list.push({ check: name, ok: !!ok, detail: ok ? "" : String(detail == null ? "" : detail) }); }
899
980
 
900
- /** T-Shirt audit. The template header block is REQUIRED — locateTshirt halts if it is missing. */
981
+ /** T-Shirt audit — layout-aware via locateTshirt's column map. HALTS if the header block is missing. */
901
982
  function auditTshirt(grid) {
902
983
  const out = [];
903
984
  const layout = locateTshirt(grid);
985
+ const { cols } = layout;
986
+ const legendRows = { first1: layout.legendFirst1, last1: layout.legendLast1 };
904
987
  const first0 = layout.firstItemRow;
905
988
  const badSizes = [], noDv = [], badFormula = [], sectionsWithSizes = [], badSectionFmt = [];
906
989
  let lastItem0 = -1, totalRow0 = -1;
907
990
  for (let r = first0; r < rowCount(grid); r++) {
908
991
  const a = textAt(grid, r, 0);
909
992
  if (/^Total \(Days\)/i.test(a)) { totalRow0 = r; break; }
910
- if (rowIsEmpty(grid, r)) continue;
911
- const isSection = a !== "" && textAt(grid, r, 2) === "" && textAt(grid, r, 7) === "";
993
+ if (rowIsEmpty(grid, r, cols.high + 1)) continue;
994
+ const isSection = a !== "" && textAt(grid, r, 2) === "" && textAt(grid, r, cols.days) === "";
912
995
  if (isSection) {
913
- const feTxt = textAt(grid, r, 5);
914
- const beTxt = textAt(grid, r, 6);
915
- if (feTxt !== "" || beTxt !== "") sectionsWithSizes.push(r + 1);
996
+ if (cols.sizes.some((c) => textAt(grid, r, c) !== "")) sectionsWithSizes.push(r + 1);
916
997
  if (bgAt(grid, r, 0) !== COLOR.sectionBg) badSectionFmt.push(`row ${r + 1} bg ${bgAt(grid, r, 0)}`);
917
998
  continue;
918
999
  }
919
1000
  lastItem0 = r;
920
- for (const c of [5, 6]) {
1001
+ for (const c of cols.sizes) {
921
1002
  const v = textAt(grid, r, c).trim();
922
1003
  if (v && !SIZE_CODES.includes(v)) badSizes.push(`${colLetter(c)}${r + 1}='${v}'`);
923
1004
  }
924
- const cell = cellAt(grid, r, 4);
925
- if (!(cell && cell.dataValidation && cell.dataValidation.condition && cell.dataValidation.condition.type === "ONE_OF_LIST")) noDv.push(`E${r + 1}`);
926
- const f = itemFormulas(r + 1);
927
- [["H", 7], ["I", 8], ["J", 9], ["K", 10], ["L", 11]].forEach(([k, c]) => { if (formulaAt(grid, r, c) !== f[k]) badFormula.push(`${k}${r + 1}`); });
1005
+ const cell = cellAt(grid, r, cols.phase);
1006
+ if (!(cell && cell.dataValidation && cell.dataValidation.condition && cell.dataValidation.condition.type === "ONE_OF_LIST")) noDv.push(`${colLetter(cols.phase)}${r + 1}`);
1007
+ const f = itemFormulasFor(r + 1, cols, legendRows);
1008
+ for (const [c, want] of f.cells) if (formulaAt(grid, r, c) !== want) badFormula.push(`${colLetter(c)}${r + 1}`);
928
1009
  }
929
1010
  check(out, "T-Shirt: at least one item row", lastItem0 >= 0, "no item rows below the header");
930
- check(out, "T-Shirt: size cells are bare codes (no legend text)", !badSizes.length, badSizes.join(", "));
931
- check(out, "T-Shirt: every item row has the Phase dropdown", !noDv.length, noDv.join(", "));
932
- check(out, "T-Shirt: H:L are the spec formulas on every item row", !badFormula.length, badFormula.slice(0, 12).join(", "));
1011
+ check(out, "T-Shirt: size cells are bare codes (no legend text, no '-')", !badSizes.length, badSizes.slice(0, 10).join(", "));
1012
+ check(out, "T-Shirt: every item row has the Phase dropdown", !noDv.length, noDv.slice(0, 10).join(", "));
1013
+ check(out, "T-Shirt: Days..HIGH $ are the spec formulas on every item row", !badFormula.length, badFormula.slice(0, 12).join(", "));
933
1014
  check(out, "T-Shirt: section rows carry no sizes", !sectionsWithSizes.length, `rows ${sectionsWithSizes.join(", ")}`);
934
- check(out, "T-Shirt: section rows are styled (bg #1C4F8B)", !badSectionFmt.length, badSectionFmt.join(", "));
1015
+ check(out, "T-Shirt: section rows are styled (bg #1C4F8B)", !badSectionFmt.length, badSectionFmt.slice(0, 6).join(", "));
935
1016
  check(out, "T-Shirt: 'Total (Days)' row exists below the items", totalRow0 > lastItem0 && lastItem0 >= 0, "not found");
936
1017
  let totalDaysCell = NaN;
937
- // Team Mix reconciles to the MIDPOINT of each phase's Low Hrs (L) and High Hrs (N), in days
938
1018
  const phaseDays = {};
939
1019
  for (const rr of layout.rollupRows) {
940
- const low = numAt(grid, rr, 11), high = numAt(grid, rr, 13);
941
- phaseDays[textAt(grid, rr, 9).trim()] = Number.isNaN(low) || Number.isNaN(high) ? NaN : round2((low + high) / 2 / 8);
1020
+ const low = numAt(grid, rr, cols.lowHrs), high = numAt(grid, rr, cols.highHrs);
1021
+ phaseDays[textAt(grid, rr, cols.rollupPhase).trim()] = Number.isNaN(low) || Number.isNaN(high) ? NaN : round2((low + high) / 2 / 8);
942
1022
  }
943
1023
  if (totalRow0 > lastItem0 && lastItem0 >= 0) {
944
1024
  check(out, "T-Shirt: totals row sits directly under the last item (no blank row)", totalRow0 === lastItem0 + 1, `last item row ${lastItem0 + 1}, totals row ${totalRow0 + 1}`);
945
1025
  const totFont = fontAt(grid, totalRow0, 0);
946
- check(out, "T-Shirt: totals row is the template band (bg #D8DDE8, bold) across A:L", [0, 4, 9, 11].every((c) => bgAt(grid, totalRow0, c) === COLOR.tshirtTotalBg) && totFont.bold, `A bg ${bgAt(grid, totalRow0, 0)}, L bg ${bgAt(grid, totalRow0, 11)}, bold ${totFont.bold}`);
1026
+ check(out, "T-Shirt: totals row is the template band (bg #D8DDE8, bold) across the table", [0, cols.phase, cols.total, cols.high].every((c) => bgAt(grid, totalRow0, c) === COLOR.tshirtTotalBg) && totFont.bold, `A bg ${bgAt(grid, totalRow0, 0)}, ${colLetter(cols.high)} bg ${bgAt(grid, totalRow0, cols.high)}, bold ${totFont.bold}`);
1027
+ const want = `=SUM(${colLetter(cols.total)}${first0 + 1}:${colLetter(cols.total)}${lastItem0 + 1})`;
1028
+ check(out, "T-Shirt: totals row sums the full item range", formulaAt(grid, totalRow0, cols.total) === want, `${colLetter(cols.total)}${totalRow0 + 1} is '${formulaAt(grid, totalRow0, cols.total)}', want '${want}'`);
1029
+ totalDaysCell = numAt(grid, totalRow0, cols.total);
1030
+ // summary block: the three labels directly under the totals row, anywhere in the row
1031
+ const findLabel = (r) => { for (let c = 0; c < cols.high + 1; c++) { const v = textAt(grid, r, c).trim(); if (/^Total (Days|Hrs|Cost)$/i.test(v)) return { c, v }; } return null; };
947
1032
  const sumRows = [totalRow0 + 1, totalRow0 + 2, totalRow0 + 3];
948
- const labels = sumRows.map((r) => textAt(grid, r, 9));
949
- check(out, "T-Shirt: summary block (Total Days / Total Hrs / Total Cost) sits directly under the totals row", labels[0] === "Total Days" && labels[1] === "Total Hrs" && labels[2] === "Total Cost", `J${sumRows[0] + 1}..J${sumRows[2] + 1} = [${labels.join(", ")}]`);
950
- check(out, "T-Shirt: summary labels are bold", sumRows.every((r) => fontAt(grid, r, 9).bold), "");
951
- const costCell = cellAt(grid, sumRows[2], 10);
952
- const costFmt = costCell && costCell.userEnteredFormat && costCell.userEnteredFormat.numberFormat;
953
- check(out, "T-Shirt: Total Cost is formatted as dollars", !!costFmt && costFmt.type === "CURRENCY", `K${sumRows[2] + 1} numberFormat ${JSON.stringify(costFmt)}`);
954
- const want = `=SUM(J${first0 + 1}:J${lastItem0 + 1})`;
955
- check(out, "T-Shirt: totals row sums the full item range", formulaAt(grid, totalRow0, 9) === want, `J${totalRow0 + 1} is '${formulaAt(grid, totalRow0, 9)}', want '${want}'`);
956
- totalDaysCell = numAt(grid, totalRow0, 9);
1033
+ const labels = sumRows.map(findLabel);
1034
+ check(out, "T-Shirt: summary block (Total Days / Total Hrs / Total Cost) sits directly under the totals row", labels[0] && labels[0].v === "Total Days" && labels[1] && labels[1].v === "Total Hrs" && labels[2] && labels[2].v === "Total Cost", `rows ${sumRows[0] + 1}–${sumRows[2] + 1} = [${labels.map((l) => (l ? l.v : "")).join(", ")}]`);
1035
+ check(out, "T-Shirt: summary labels are bold", labels.every((l) => l && fontAt(grid, sumRows[labels.indexOf(l)], l.c).bold), "");
1036
+ if (labels[2]) {
1037
+ const costCell = cellAt(grid, sumRows[2], labels[2].c + 1);
1038
+ const costFmt = costCell && costCell.userEnteredFormat && costCell.userEnteredFormat.numberFormat;
1039
+ check(out, "T-Shirt: Total Cost is formatted as dollars", !!costFmt && costFmt.type === "CURRENCY", `${colLetter(labels[2].c + 1)}${sumRows[2] + 1} numberFormat ${JSON.stringify(costFmt)}`);
1040
+ } else check(out, "T-Shirt: Total Cost is formatted as dollars", false, "no Total Cost row");
957
1041
  const badRoll = [];
958
1042
  for (const rr of layout.rollupRows) {
959
- if (formulaAt(grid, rr, 10) !== rollupFormulas(rr + 1, first0 + 1, lastItem0 + 1)[0]) badRoll.push(`K${rr + 1}`);
1043
+ if (formulaAt(grid, rr, cols.lowDol) !== rollupFormulasFor(rr + 1, first0 + 1, lastItem0 + 1, cols)[0]) badRoll.push(`${colLetter(cols.lowDol)}${rr + 1}`);
960
1044
  }
961
- check(out, "T-Shirt: phase rollups (K4:N7) reference the full item range", !badRoll.length, badRoll.join(", "));
1045
+ check(out, "T-Shirt: phase rollups reference the full item range", !badRoll.length, badRoll.join(", "));
962
1046
  let summaryAbove = false;
963
- for (let r = first0; r < totalRow0; r++) if (/^Total (Days|Hrs|Cost)$/i.test(textAt(grid, r, 9))) summaryAbove = true;
1047
+ for (let r = first0; r < totalRow0; r++) if (findLabel(r)) summaryAbove = true;
964
1048
  check(out, "T-Shirt: no summary label inside the summed range", !summaryAbove, "a Total Days/Hrs/Cost label sits inside the summed range");
965
1049
  let raw = 0;
966
1050
  for (let r = first0; r <= lastItem0; r++) {
967
- for (const c of [5, 6]) { const v = textAt(grid, r, c).trim(); if (v && layout.legend[v] != null) raw += layout.legend[v]; }
1051
+ for (const c of cols.sizes) { const v = textAt(grid, r, c).trim(); if (v && layout.legend[v] != null) raw += layout.legend[v]; }
968
1052
  }
969
1053
  const recomputed = round2(raw * (1 + layout.mfTotal));
970
1054
  check(out, "T-Shirt: Σ raw sizes × (1+MF) == Total Days cell", Math.abs(recomputed - totalDaysCell) < 0.02, `recomputed ${recomputed}, cell ${totalDaysCell}`);
@@ -975,12 +1059,14 @@ function auditTshirt(grid) {
975
1059
  function disciplineOfLabel(label) {
976
1060
  const l = label.toLowerCase();
977
1061
  if (/qa|test/.test(l)) return "qa";
978
- if (/project manager|\bpm\b/.test(l)) return "pm";
979
- if (/analyst/.test(l)) return "ba";
1062
+ if (/project m|\bpm\b/.test(l)) return "pm";
1063
+ if (/analys/.test(l)) return "ba";
980
1064
  if (/devops/.test(l)) return "devops";
981
1065
  if (/lead|architect/.test(l)) return "techlead";
1066
+ if (/design|\bux\b/.test(l)) return "design";
1067
+ if (/mobile|ios|android/.test(l)) return "mobile";
982
1068
  if (/front/.test(l)) return "frontend";
983
- if (/back|api/.test(l)) return "backend";
1069
+ if (/back|api|platform/.test(l)) return "backend";
984
1070
  return "";
985
1071
  }
986
1072
 
@@ -1208,6 +1294,193 @@ async function verbPlanCheck(api, plan) {
1208
1294
  return { totals, mf: layout.mf, mfTotal: round2(layout.mfTotal), highFactor: layout.highFactor, rate: layout.rate, rosters, table: rostersTable(rosters) };
1209
1295
  }
1210
1296
 
1297
+ /**
1298
+ * Derive the per-discipline FTE from a sheet's EXISTING Team Mix (first grid): each person
1299
+ * row's label → discipline, Count summed per discipline. HALTS on a label it cannot classify
1300
+ * or a grid it cannot find — a rebuild must never guess a roster.
1301
+ */
1302
+ function deriveFteFromTeamMix(grid) {
1303
+ let header = -1;
1304
+ for (let r = 0; r < rowCount(grid); r++) if (textAt(grid, r, 0) === "Skill set") { header = r; break; }
1305
+ if (header < 0) throw new Halt(`${TAB_TEAM}: no 'Skill set' header row — cannot derive the roster`);
1306
+ // Two roster models exist. Current: Count is an ENTERED FTE → sum per discipline. Older:
1307
+ // Count is a FORMULA `=ROUND(MAX(months)/160,2)` (peak-month utilisation), so summing it
1308
+ // overstates the team; there, keep the sheet's total FTE and split it by each role's share
1309
+ // of Total Hrs. The model is read from the first Count cell, never assumed.
1310
+ const firstCount = formulaAt(grid, header + 1, 1);
1311
+ const peakModel = /MAX\(/i.test(firstCount);
1312
+ let totalHrsCol = -1;
1313
+ for (let c = 0; c < 30; c++) if (textAt(grid, header, c).trim() === "Total Hrs") totalHrsCol = c;
1314
+ if (peakModel && totalHrsCol < 0) throw new Halt(`${TAB_TEAM}: peak-utilisation roster but no 'Total Hrs' column to split by`);
1315
+ const people = [];
1316
+ const unknown = [];
1317
+ let totalFte = NaN;
1318
+ for (let r = header + 1; r < rowCount(grid); r++) {
1319
+ const a = textAt(grid, r, 0);
1320
+ if (a === "" || /^Total/i.test(a)) break;
1321
+ if (/^Tot\b/i.test(a)) { totalFte = numAt(grid, r, 1); continue; } // 'Tot FTE' / 'Tot Dys/Hrs' summary line inside the roster
1322
+ const d = disciplineOfLabel(a);
1323
+ if (!d) { unknown.push(a); continue; }
1324
+ const count = numAt(grid, r, 1);
1325
+ const hours = peakModel ? numAt(grid, r, totalHrsCol) : NaN;
1326
+ if (Number.isNaN(count)) throw new Halt(`${TAB_TEAM}: '${a}' has no numeric Count in column B`);
1327
+ if (peakModel && Number.isNaN(hours)) throw new Halt(`${TAB_TEAM}: '${a}' has no numeric Total Hrs`);
1328
+ people.push({ d, count, hours });
1329
+ }
1330
+ if (unknown.length) throw new Halt(`${TAB_TEAM}: cannot map these roles to a discipline — ${unknown.join(", ")}. Known: ${Object.keys(ROLE_LABEL).join(", ")}`);
1331
+ if (!people.length) throw new Halt(`${TAB_TEAM}: no person rows under the header`);
1332
+ const fte = {};
1333
+ if (peakModel) {
1334
+ const sumFte = Number.isNaN(totalFte) ? people.reduce((s, p) => s + p.count, 0) : totalFte;
1335
+ const sumHrs = people.reduce((s, p) => s + p.hours, 0);
1336
+ if (!(sumHrs > 0)) throw new Halt(`${TAB_TEAM}: Total Hrs sum to 0 — cannot split the roster`);
1337
+ for (const p of people) fte[p.d] = round2((fte[p.d] ? fte[p.d] : 0) + sumFte * p.hours / sumHrs);
1338
+ } else {
1339
+ for (const p of people) fte[p.d] = round2((fte[p.d] ? fte[p.d] : 0) + p.count);
1340
+ }
1341
+ for (const d of Object.keys(fte)) if (!(fte[d] > 0)) delete fte[d];
1342
+ return fte;
1343
+ }
1344
+
1345
+ /** Per-phase Low/High days read from the T-Shirt rollups (phases with hours > 0), in PHASES order. */
1346
+ function phaseTotalsFromSheet(grid, layout) {
1347
+ const out = [];
1348
+ for (const rr of layout.rollupRows) {
1349
+ const ph = textAt(grid, rr, layout.cols.rollupPhase).trim();
1350
+ const low = numAt(grid, rr, layout.cols.lowHrs), high = numAt(grid, rr, layout.cols.highHrs);
1351
+ if (Number.isNaN(low) || Number.isNaN(high)) throw new Halt(`${TAB_TSHIRT}: rollup row ${rr + 1} (${ph}) has no Low Hrs / High Hrs values`);
1352
+ if (low > 0) out.push({ phase: ph, totalDays: round2(low / 8), highDays: round2(high / 8), staffDays: round2((low + high) / 2 / 8) });
1353
+ }
1354
+ if (!out.length) throw new Halt(`${TAB_TSHIRT}: no phase has hours — nothing to staff`);
1355
+ return PHASES.filter((p) => out.some((o) => o.phase === p)).map((p) => out.find((o) => o.phase === p));
1356
+ }
1357
+
1358
+ /** Rebuild the Team Mix tab (one grid per phase) from the sheet's own roster + rollups. No plan needed. */
1359
+ async function verbTeamMix(api, opts) {
1360
+ const tshirt = await api.grid(TAB_TSHIRT);
1361
+ const layout = locateTshirt(tshirt);
1362
+ const team = await api.grid(TAB_TEAM);
1363
+ const fte = opts.fte ? opts.fte : deriveFteFromTeamMix(team);
1364
+ const title = opts.title ? opts.title : textAt(team, 0, 0).replace(/\s+—\s+(MVP|Phase \d)$/, "");
1365
+ if (!title) throw new Halt(`${TAB_TEAM}: A1 has no title to carry over — pass --title`);
1366
+ const plan = { title, teamMix: { fte } };
1367
+ const rosters = phaseTotalsFromSheet(tshirt, layout).map((ph) => ({ ...ph, roster: buildRoster(plan, ph.staffDays, layout.mf) }));
1368
+ if (opts.dryRun) return { title, fte, rosters, table: rostersTable(rosters) };
1369
+ const tm = await writeTeamMix(api, plan, rosters);
1370
+ const result = { title, fte, phases: rosters.map((r) => ({ phase: r.phase, lowDays: r.totalDays, highDays: r.highDays, staffDays: r.staffDays, people: r.roster.people.length, months: r.roster.months, columns: r.roster.n, sumDays: r.roster.sumDays })), table: rostersTable(rosters), grids: tm.grids };
1371
+ if (!opts.noAudit) result.audit = await runAudit(api);
1372
+ return result;
1373
+ }
1374
+
1375
+ /**
1376
+ * Normalise a T-Shirt tab's FORMATTING to the spec without touching any size, phase, item text
1377
+ * or formula (spec §1.2 section rows, §1.3 totals row + summary block). Layout-aware via the
1378
+ * column map, so it works on both template generations. What it does, in order:
1379
+ * 1. section heading rows → merged A:<HIGH $>, bg #1C4F8B, white bold Arial 10
1380
+ * 2. blank rows between the last item and the totals row → deleted
1381
+ * 3. totals row → bg #D8DDE8, bold 10, across the table
1382
+ * 4. summary block (Total Days / Total Hrs / Total Cost) → rewritten directly under the totals
1383
+ * row in the standard shape; blank rows in between deleted; labels bold; Cost in dollars
1384
+ * HALTS when the rows under the totals row hold anything other than the old summary block or
1385
+ * blanks — it never overwrites content it did not expect.
1386
+ */
1387
+ async function verbFormat(api, opts) {
1388
+ const grid = await api.grid(TAB_TSHIRT);
1389
+ const sheetId = grid.properties.sheetId;
1390
+ const layout = locateTshirt(grid);
1391
+ const { cols } = layout;
1392
+ const width = cols.high + 1;
1393
+ const L = colLetter;
1394
+ const first0 = layout.firstItemRow;
1395
+ const sections = [];
1396
+ let lastItem0 = -1, totalRow0 = -1;
1397
+ for (let r = first0; r < rowCount(grid); r++) {
1398
+ const a = textAt(grid, r, 0);
1399
+ if (/^Total \(Days\)/i.test(a)) { totalRow0 = r; break; }
1400
+ if (rowIsEmpty(grid, r, width)) continue;
1401
+ if (a !== "" && textAt(grid, r, 2) === "" && textAt(grid, r, cols.days) === "") sections.push(r);
1402
+ else lastItem0 = r;
1403
+ }
1404
+ if (lastItem0 < 0) throw new Halt(`${TAB_TSHIRT}: no item rows`);
1405
+ if (totalRow0 < 0) throw new Halt(`${TAB_TSHIRT}: no 'Total (Days)' row below the items — add it first`);
1406
+ // the rows under the totals row must be the old summary block or blank
1407
+ // Old summary variants seen across the 19 Hilo sheets (2026-09-21): the standard 3 rows;
1408
+ // a 'Low | High' header row above them; 'Total Cost (Low)' / '(High)' split rows; nothing at
1409
+ // all. Anything else under the totals row (a second table, notes) ends the block and is
1410
+ // NEVER touched — the new block goes in the free rows above it, inserting rows if needed.
1411
+ const summaryRe = /^(Total (Days|Hrs|Cost)( \((Low|High)\))?|Low|High)$/i;
1412
+ const totRef = new RegExp(`\\b[A-Z]{1,2}\\$?${totalRow0 + 1}\\b`);
1413
+ let summaryEnd0 = totalRow0; // last row of the old summary block (or the totals row if none)
1414
+ let nextContent0 = -1; // first row of foreign content below the block, if any
1415
+ for (let r = totalRow0 + 1; r < Math.min(rowCount(grid), totalRow0 + 10); r++) {
1416
+ let rowHas = false, foreign = false;
1417
+ for (let c = 0; c < width; c++) {
1418
+ const v = textAt(grid, r, c).trim();
1419
+ if (v === "") continue;
1420
+ rowHas = true;
1421
+ const ok = summaryRe.test(v) || (v.startsWith("=") && (totRef.test(v) || new RegExp(`\\b[A-Z]{1,2}(${totalRow0 + 2}|${totalRow0 + 3}|${totalRow0 + 4})\\b`).test(v)));
1422
+ if (!ok) foreign = true;
1423
+ }
1424
+ if (foreign) { nextContent0 = r; break; }
1425
+ if (rowHas) summaryEnd0 = r;
1426
+ }
1427
+ const blankBefore = totalRow0 - lastItem0 - 1;
1428
+ const freeAfter = (nextContent0 < 0 ? Infinity : nextContent0 - 1) - totalRow0; // rows available under the totals row
1429
+ const plan = { sections: sections.map((r) => r + 1), lastItem1: lastItem0 + 1, totalRow1: totalRow0 + 1, blankRowsBeforeTotals: blankBefore, oldSummaryRows: summaryEnd0 > totalRow0 ? `${totalRow0 + 2}–${summaryEnd0 + 1}` : "none", foreignContentAtRow: nextContent0 < 0 ? null : nextContent0 + 1, rowsToInsertForSummary: Math.max(0, 3 - Math.min(freeAfter, 3)) };
1430
+ if (opts.dryRun) return { layout: { headerRow1: layout.headerRow + 1, sizeColumns: cols.sizeLabels, totalCol: L(cols.total) }, plan };
1431
+
1432
+ // 1. section rows (before any row deletion — indices are current)
1433
+ const reqs = [];
1434
+ const merges = Array.isArray(grid.merges) ? grid.merges : [];
1435
+ for (const r0 of sections) {
1436
+ // Google only unmerges an EXACT existing range — use the sheet's own merge list (two Hilo
1437
+ // sheets had section rows merged wider than the table, and A:<HIGH $> 400'd).
1438
+ for (const m of merges) if (m.startRowIndex <= r0 && m.endRowIndex > r0) reqs.push({ unmergeCells: { range: { sheetId, startRowIndex: m.startRowIndex, endRowIndex: m.endRowIndex, startColumnIndex: m.startColumnIndex, endColumnIndex: m.endColumnIndex } } });
1439
+ reqs.push({ mergeCells: { range: gridRange(sheetId, r0, r0 + 1, 0, width), mergeType: "MERGE_ALL" } });
1440
+ reqs.push(fmtReq(sheetId, r0, r0 + 1, 0, width,
1441
+ { backgroundColor: hexToColor(COLOR.sectionBg), textFormat: { fontFamily: "Arial", fontSize: 10, bold: true, foregroundColor: hexToColor(COLOR.white) }, horizontalAlignment: "LEFT" },
1442
+ "userEnteredFormat(backgroundColor,textFormat,horizontalAlignment)"));
1443
+ }
1444
+ if (reqs.length) await api.batch(reqs);
1445
+ // 2. clear the old summary block (values + formats), then delete the blank rows before the totals row
1446
+ if (summaryEnd0 > totalRow0) {
1447
+ await api.clearValues(TAB_TSHIRT, `A${totalRow0 + 2}:${L(width - 1)}${summaryEnd0 + 1}`);
1448
+ await api.batch([fmtReq(sheetId, totalRow0 + 1, summaryEnd0 + 1, 0, width, {}, "userEnteredFormat")]);
1449
+ }
1450
+ let tot0 = totalRow0;
1451
+ if (blankBefore > 0) {
1452
+ await api.batch([{ deleteDimension: { range: { sheetId, dimension: "ROWS", startIndex: lastItem0 + 1, endIndex: totalRow0 } } }]);
1453
+ tot0 = lastItem0 + 1;
1454
+ }
1455
+ // make room for the 3 summary rows when other content sits close under the totals row
1456
+ if (plan.rowsToInsertForSummary > 0) {
1457
+ const at = tot0 + 1 + (freeAfter === Infinity ? 3 : Math.max(0, freeAfter)); // insert right before the foreign content
1458
+ await api.batch([{ insertDimension: { range: { sheetId, dimension: "ROWS", startIndex: at, endIndex: at + plan.rowsToInsertForSummary }, inheritFromBefore: false } }]);
1459
+ }
1460
+ // 3. totals row band
1461
+ await api.batch([
1462
+ fmtReq(sheetId, tot0, tot0 + 1, 0, width, { backgroundColor: hexToColor(COLOR.tshirtTotalBg), textFormat: { fontSize: 10, bold: true }, horizontalAlignment: "LEFT" }, "userEnteredFormat(backgroundColor,textFormat,horizontalAlignment)"),
1463
+ fmtReq(sheetId, tot0, tot0 + 1, cols.days, width, { horizontalAlignment: "RIGHT" }, "userEnteredFormat.horizontalAlignment"),
1464
+ ]);
1465
+ // 4. summary block directly under the totals row
1466
+ const t1 = tot0 + 1;
1467
+ const hf = `$${L(cols.highFactor.c)}$${cols.highFactor.r + 1}`;
1468
+ const rows = [
1469
+ ["Total Days", `=${L(cols.total)}${t1}`, `=${L(cols.total)}${t1}*${hf}`],
1470
+ ["Total Hrs", `=${L(cols.total)}${t1}*8`, `=${L(cols.total)}${t1}*${hf}*8`],
1471
+ ["Total Cost", `=${L(cols.low)}${t1}`, `=${L(cols.high)}${t1}`],
1472
+ ];
1473
+ await api.putValues(TAB_TSHIRT, `${L(cols.total)}${t1 + 1}:${L(cols.total + 2)}${t1 + 3}`, rows);
1474
+ await api.batch([
1475
+ fmtReq(sheetId, t1, t1 + 3, cols.total, cols.total + 3, { textFormat: { fontSize: 10, bold: true } }, "userEnteredFormat.textFormat"),
1476
+ fmtReq(sheetId, t1, t1 + 2, cols.total + 1, cols.total + 3, { horizontalAlignment: "RIGHT", numberFormat: { type: "NUMBER", pattern: "0.00" } }, "userEnteredFormat(horizontalAlignment,numberFormat)"),
1477
+ fmtReq(sheetId, t1 + 2, t1 + 3, cols.total + 1, cols.total + 3, { horizontalAlignment: "RIGHT", numberFormat: { type: "CURRENCY", pattern: "$#,##0.00" } }, "userEnteredFormat(horizontalAlignment,numberFormat)"),
1478
+ ]);
1479
+ const result = { plan, totalRow1: t1 };
1480
+ if (!opts.noAudit) result.audit = await runAudit(api);
1481
+ return result;
1482
+ }
1483
+
1211
1484
  async function verbWrite(api, plan, opts) {
1212
1485
  const ts = await writeTshirt(api, plan, opts);
1213
1486
  const rosters = buildRosters(plan, ts.layout);
@@ -1228,7 +1501,7 @@ function parseArgs(argv) {
1228
1501
  const out = { _: [] };
1229
1502
  for (let i = 0; i < argv.length; i++) {
1230
1503
  const a = argv[i];
1231
- if (a === "--json" || a === "--replace" || a === "--no-audit") out[a.slice(2)] = true;
1504
+ if (a === "--json" || a === "--replace" || a === "--no-audit" || a === "--dry-run") out[a.slice(2)] = true;
1232
1505
  else if (a.startsWith("--")) { out[a.slice(2)] = argv[i + 1]; i++; }
1233
1506
  else out._.push(a);
1234
1507
  }
@@ -1240,7 +1513,7 @@ function printChecks(audit) {
1240
1513
  console.log(`${audit.ok ? "AUDIT PASS" : `AUDIT FAIL (${audit.failed})`} — ${audit.title}`);
1241
1514
  }
1242
1515
 
1243
- const USAGE = "usage: gsd-t estimate-sheet <read|plan-check|write|audit|plan-schema> --sheet <id|url> [--tab <name>] [--plan <plan.json>] [--replace] [--no-audit] [--key <path>] [--json]";
1516
+ const USAGE = "usage: gsd-t estimate-sheet <read|plan-check|write|teammix|format|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]";
1244
1517
 
1245
1518
  /** Runs a verb; returns the exit code. Throws Halt (or any error) — the runner below turns that into exit 4/64. */
1246
1519
  async function main(args) {
@@ -1256,6 +1529,26 @@ async function main(args) {
1256
1529
  else { console.log(r.title); r.tabs.forEach((t) => console.log(` tab ${JSON.stringify(t.title)} id=${t.sheetId}`)); (r.rows ? r.rows : []).forEach((l) => console.log(l)); }
1257
1530
  return 0;
1258
1531
  }
1532
+ if (verb === "teammix") {
1533
+ // rebuild the Team Mix from the sheet's own roster + phase rollups (no plan file)
1534
+ let fte;
1535
+ if (args.fte) { try { fte = JSON.parse(args.fte); } catch (e) { throw new Halt(`--fte must be JSON like {"backend":1.5,"qa":0.4}: ${e.message}`, 64); } }
1536
+ const r = await verbTeamMix(api, { fte, title: args.title, dryRun: !!args["dry-run"], noAudit: !!args["no-audit"] });
1537
+ const ok = !r.audit || r.audit.ok;
1538
+ if (json) console.log(JSON.stringify({ ok, exitCode: ok ? 0 : 4, ...r }, null, 2));
1539
+ else {
1540
+ console.log(`title: ${r.title}`); console.log(`fte: ${JSON.stringify(r.fte)}`); console.log(r.table);
1541
+ if (r.audit) printChecks(r.audit); else if (args["dry-run"]) console.log("(dry run — nothing written)");
1542
+ }
1543
+ return ok ? 0 : 4;
1544
+ }
1545
+ if (verb === "format") {
1546
+ const r = await verbFormat(api, { dryRun: !!args["dry-run"], noAudit: !!args["no-audit"] });
1547
+ const ok = !r.audit || r.audit.ok;
1548
+ if (json) console.log(JSON.stringify({ ok, exitCode: ok ? 0 : 4, ...r }, null, 2));
1549
+ else { console.log(JSON.stringify(r.plan)); if (r.audit) printChecks(r.audit); else if (args["dry-run"]) console.log("(dry run — nothing written)"); }
1550
+ return ok ? 0 : 4;
1551
+ }
1259
1552
  if (verb === "audit") {
1260
1553
  const a = await runAudit(api);
1261
1554
  if (json) console.log(JSON.stringify({ ok: a.ok, exitCode: a.ok ? 0 : 4, ...a }, null, 2)); else printChecks(a);
@@ -1298,7 +1591,7 @@ function haltAndExit(e, json) {
1298
1591
 
1299
1592
  module.exports = {
1300
1593
  validatePlan, splitRoster, rosterViolations, mfCoverageViolations, monthPlan, resampleWeights, rampHours, buildRoster,
1301
- tshirtTotals, phaseTotals, buildRosters, midDays, itemFormulas, rollupFormulas, tshirtRows, teamMixValues, teamMixFormatReqs, remainderFormula, locateTshirt, findPhaseSource,
1594
+ tshirtTotals, phaseTotals, buildRosters, midDays, deriveFteFromTeamMix, phaseTotalsFromSheet, verbTeamMix, itemFormulasFor, rollupFormulasFor, findCell, itemFormulas, rollupFormulas, tshirtRows, teamMixValues, teamMixFormatReqs, remainderFormula, locateTshirt, findPhaseSource,
1302
1595
  auditTshirt, auditTeamMix, auditTechStack, auditOverview, colLetter, hexToColor, colorToHex, sheetIdFromArg,
1303
1596
  constants: { SIZE_CODES, PHASES, COLOR, RAMP, ROLE_LABEL, SOFT_CEILING, FOLD_THRESHOLD, TAB_TSHIRT, TAB_TEAM, TAB_TECH, PLAN_SCHEMA },
1304
1597
  Halt, SheetsApi, getToken, runAudit, main,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tekyzinc/gsd-t",
3
- "version": "5.20.13",
3
+ "version": "5.20.14",
4
4
  "description": "GSD-T: Contract-Driven Development for Claude Code — 54 slash commands with headless-by-default workflow spawning, unattended supervisor relay with event stream, graph-powered code analysis, real-time agent dashboard, task telemetry, doc-ripple enforcement, backlog management, impact analysis, test sync, milestone archival, and PRD generation",
5
5
  "author": "Tekyz, Inc.",
6
6
  "license": "MIT",
@@ -132,7 +132,7 @@ The old `Month / Days / Tot Days / Hrs` layout is retired: `Days` IS the per-per
132
132
 
133
133
  - **The Team Mix staffs the MIDPOINT of the Low and High figures** (David, 2026-09-21): `staffDays = Low Total Days × (1 + high factor) / 2` — per phase, from that phase's `Low Hrs` and `High Hrs` rollups. Never the Low figure alone.
134
134
  - `months = staffDays / (Σ Count × 20)` — solve so `Σ Days` equals that midpoint.
135
- - Month columns `N = ceil(months)`, EXCEPT when the fractional tail is under ~0.1 month: fold it into the last full month (soft ceiling ~172 hrs) instead of opening a near-empty column.
135
+ - Month columns `N = ceil(months)`, EXCEPT when the fractional tail is under ~0.1 month AND a full-time person still fits under the soft ceiling (`months × 160 ≤ N × 172`): then fold it into the last full month instead of opening a near-empty column.
136
136
  - **Recompute `months` and `N` after ANY roster change.** Adding a role raises Σ Count, shortens the duration, and turns the remainder column negative (−12.80 hrs was the symptom).
137
137
  - The remainder formula makes each row sum exactly. Never write a fractional last month as a value.
138
138
 
@@ -172,6 +172,8 @@ Monthly hours follow when the work happens. Normalise the weights, multiply by t
172
172
  | DevOps | 0.45 / 0.85 / 1.70 | back-loaded — mirror of BA |
173
173
  | Project Manager | 1.00 / 1.00 / 1.00 | flat |
174
174
  | Tech Lead / Architect | 1.30 / 0.80 / 0.90 | design-heavy, uptick at integration |
175
+ | Design / UX | 1.60 / 0.90 / 0.50 | heaviest at the start, a tail for revisions |
176
+ | Mobile | 1.35 / 1.05 / 0.60 | same shape as frontend |
175
177
 
176
178
  - A flat `160 / 160 / 147` for every role is the tell that no ramping was applied.
177
179
  - **The only ceiling is 160 hrs per person per month, soft to ~172.** There is NO `Count × 160` per-row ceiling — a 0.40 QA can work 106 hrs in their heavy month; that is what ramping means.
@@ -246,8 +248,17 @@ gsd-t estimate-sheet read --sheet <id|url> [--tab <name>] # read-before-
246
248
  gsd-t estimate-sheet plan-check --sheet <id|url> --plan plan.json # validate + the roster it WOULD write (the Step 4 pause)
247
249
  gsd-t estimate-sheet write --sheet <id|url> --plan plan.json [--replace] # T-Shirt + Team Mix + Tech Stack, then audit
248
250
  gsd-t estimate-sheet audit --sheet <id|url> # §5 checklist by read-back
251
+ gsd-t estimate-sheet format --sheet <id|url> [--dry-run] # normalise T-Shirt FORMATTING only: section rows (§1.2), grey totals band directly under
252
+ # the last item, standard summary block directly under it (§1.3); no size, phase, text or item formula is touched;
253
+ # rows under the totals row that are not the old summary block are never overwritten (rows are inserted above them)
254
+ gsd-t estimate-sheet teammix --sheet <id|url> [--fte '{"backend":1.5,…}'] [--title <t>] [--dry-run]
255
+ # rebuild the Team Mix (one grid per phase) from the sheet's OWN roster and phase rollups — no plan needed;
256
+ # the roster is derived from the existing grid (entered Counts summed per discipline; older peak-utilisation
257
+ # rosters split the sheet's total FTE by each role's hours); --fte overrides it; halts on a role it cannot map
249
258
  ```
250
259
 
260
+ Both the current template and the older layout (Project/Client rows on top, legend from row 8, up to four size columns, header row 17) are READ by `audit` and `teammix` — every coordinate comes from the labels, never a fixed row. `write` produces the current template only.
261
+
251
262
  The plan (judgment only):
252
263
 
253
264
  ```json
@@ -264,5 +275,5 @@ The plan (judgment only):
264
275
  ```
265
276
 
266
277
  - `tshirt.mode` `items` writes whole rows below the header (halts if rows exist unless `--replace`); `sizes` fills `E:L` on rows that already exist (a gap-analysis sheet), matched by the `(id)` suffix in column C — never by position.
267
- - `teamMix.fte` is per-discipline FTE (`backend` `frontend` `qa` `pm` `ba` `devops` `techlead`). The tool splits it into people (saturate then spill), computes months and the column count, ramps by discipline, writes the remainder formula, and refuses a roster that leaves a weighted MF factor unstaffed. It writes **one grid per phase with hours** (§2.6); `teamMix.phases: { "Phase 1": { "fte": {…} } }` overrides the mix for one phase.
278
+ - `teamMix.fte` is per-discipline FTE (`backend` `frontend` `qa` `pm` `ba` `devops` `techlead` `design` `mobile`). The tool splits it into people (saturate then spill), computes months and the column count, ramps by discipline, writes the remainder formula, and refuses a roster that leaves a weighted MF factor unstaffed. It writes **one grid per phase with hours** (§2.6); `teamMix.phases: { "Phase 1": { "fte": {…} } }` overrides the mix for one phase.
268
279
  - The MF list, legend, rate and high factor are READ from the sheet; the plan never carries them.