@tekyzinc/gsd-t 5.20.12 → 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,48 @@
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
+
34
+ ## [5.20.13] - 2026-09-21
35
+
36
+ ### Fixed — Team Mix staffs the midpoint of Low and High, not Low
37
+
38
+ David's correction on the first live sheet. The Team Mix was sized to the Low figure;
39
+ Tekyz staffs the midpoint of the Low and High estimates.
40
+
41
+ - `bin/gsd-t-estimate-sheet.cjs`: `midDays = low × (1 + high factor) / 2`; each phase grid is
42
+ built and audited against `(Low Hrs + High Hrs) ÷ 2 ÷ 8` from that phase's rollups; the
43
+ grids together against the midpoint of `Total Days` and `Total Days × high factor`.
44
+ - `templates/estimate-sheet-spec.md` §2.2 / §2.6 / §5; tests updated; live write on the E2E
45
+ copy: 3 phase grids, all audit checks pass.
46
+
5
47
  ## [5.20.12] - 2026-09-21
6
48
 
7
49
  ### Fixed — estimate writer proven end-to-end; two-month remainder formula
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # GSD-T: Contract-Driven Development for Claude Code
2
2
 
3
- **v5.20.12** - 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`);
@@ -740,13 +821,19 @@ async function writeTshirtSizes(api, grid, sheetId, layout, plan, totals, phaseS
740
821
 
741
822
  // ───────────────────────── write: Team Mix (spec §2) ─────────────────────────
742
823
 
743
- /** Per-phase T-Shirt totals in PHASES order — only phases with hours > 0 (spec §2.6). */
824
+ /** Team Mix staffs the MIDPOINT of the Low and High figures (David, 2026-09-21): mid = low × (1 + highFactor) / 2. */
825
+ function midDays(lowDays, highFactor) { return round2(lowDays * (1 + highFactor) / 2); }
826
+
827
+ /** Per-phase T-Shirt totals in PHASES order — only phases with hours > 0 (spec §2.6). `totalDays` is the Low figure; `staffDays` the midpoint the Team Mix staffs. */
744
828
  function phaseTotals(plan, layout) {
745
829
  const raw = {};
746
830
  for (const s of plan.tshirt.sections) for (const it of s.items) {
747
831
  raw[it.phase] = (raw[it.phase] ? raw[it.phase] : 0) + sizeDays(it.fe, layout.legend) + sizeDays(it.be, layout.legend);
748
832
  }
749
- return PHASES.filter((ph) => raw[ph] > 0).map((ph) => ({ phase: ph, rawDays: round2(raw[ph]), totalDays: round2(raw[ph] * (1 + layout.mfTotal)) }));
833
+ return PHASES.filter((ph) => raw[ph] > 0).map((ph) => {
834
+ const low = round2(raw[ph] * (1 + layout.mfTotal));
835
+ return { phase: ph, rawDays: round2(raw[ph]), totalDays: low, highDays: round2(low * layout.highFactor), staffDays: midDays(low, layout.highFactor) };
836
+ });
750
837
  }
751
838
 
752
839
  /** One roster per phase with hours. FTE comes from teamMix.phases[<phase>].fte when given, else teamMix.fte. */
@@ -756,7 +843,7 @@ function buildRosters(plan, layout) {
756
843
  return phases.map((ph) => {
757
844
  const override = plan.teamMix.phases && plan.teamMix.phases[ph.phase] && plan.teamMix.phases[ph.phase].fte;
758
845
  const sub = override ? { ...plan, teamMix: { ...plan.teamMix, fte: override } } : plan;
759
- return { ...ph, roster: buildRoster(sub, ph.totalDays, layout.mf) };
846
+ return { ...ph, roster: buildRoster(sub, ph.staffDays, layout.mf) };
760
847
  });
761
848
  }
762
849
 
@@ -891,89 +978,95 @@ async function writeTechStack(api, plan) {
891
978
 
892
979
  function check(list, name, ok, detail) { list.push({ check: name, ok: !!ok, detail: ok ? "" : String(detail == null ? "" : detail) }); }
893
980
 
894
- /** 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. */
895
982
  function auditTshirt(grid) {
896
983
  const out = [];
897
984
  const layout = locateTshirt(grid);
985
+ const { cols } = layout;
986
+ const legendRows = { first1: layout.legendFirst1, last1: layout.legendLast1 };
898
987
  const first0 = layout.firstItemRow;
899
988
  const badSizes = [], noDv = [], badFormula = [], sectionsWithSizes = [], badSectionFmt = [];
900
989
  let lastItem0 = -1, totalRow0 = -1;
901
990
  for (let r = first0; r < rowCount(grid); r++) {
902
991
  const a = textAt(grid, r, 0);
903
992
  if (/^Total \(Days\)/i.test(a)) { totalRow0 = r; break; }
904
- if (rowIsEmpty(grid, r)) continue;
905
- 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) === "";
906
995
  if (isSection) {
907
- const feTxt = textAt(grid, r, 5);
908
- const beTxt = textAt(grid, r, 6);
909
- if (feTxt !== "" || beTxt !== "") sectionsWithSizes.push(r + 1);
996
+ if (cols.sizes.some((c) => textAt(grid, r, c) !== "")) sectionsWithSizes.push(r + 1);
910
997
  if (bgAt(grid, r, 0) !== COLOR.sectionBg) badSectionFmt.push(`row ${r + 1} bg ${bgAt(grid, r, 0)}`);
911
998
  continue;
912
999
  }
913
1000
  lastItem0 = r;
914
- for (const c of [5, 6]) {
1001
+ for (const c of cols.sizes) {
915
1002
  const v = textAt(grid, r, c).trim();
916
1003
  if (v && !SIZE_CODES.includes(v)) badSizes.push(`${colLetter(c)}${r + 1}='${v}'`);
917
1004
  }
918
- const cell = cellAt(grid, r, 4);
919
- if (!(cell && cell.dataValidation && cell.dataValidation.condition && cell.dataValidation.condition.type === "ONE_OF_LIST")) noDv.push(`E${r + 1}`);
920
- const f = itemFormulas(r + 1);
921
- [["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}`);
922
1009
  }
923
1010
  check(out, "T-Shirt: at least one item row", lastItem0 >= 0, "no item rows below the header");
924
- check(out, "T-Shirt: size cells are bare codes (no legend text)", !badSizes.length, badSizes.join(", "));
925
- check(out, "T-Shirt: every item row has the Phase dropdown", !noDv.length, noDv.join(", "));
926
- 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(", "));
927
1014
  check(out, "T-Shirt: section rows carry no sizes", !sectionsWithSizes.length, `rows ${sectionsWithSizes.join(", ")}`);
928
- 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(", "));
929
1016
  check(out, "T-Shirt: 'Total (Days)' row exists below the items", totalRow0 > lastItem0 && lastItem0 >= 0, "not found");
930
1017
  let totalDaysCell = NaN;
931
1018
  const phaseDays = {};
932
1019
  for (const rr of layout.rollupRows) {
933
- const hrs = numAt(grid, rr, 11); // L = Low Hrs for that phase
934
- phaseDays[textAt(grid, rr, 9).trim()] = Number.isNaN(hrs) ? NaN : round2(hrs / 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);
935
1022
  }
936
1023
  if (totalRow0 > lastItem0 && lastItem0 >= 0) {
937
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}`);
938
1025
  const totFont = fontAt(grid, totalRow0, 0);
939
- 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; };
940
1032
  const sumRows = [totalRow0 + 1, totalRow0 + 2, totalRow0 + 3];
941
- const labels = sumRows.map((r) => textAt(grid, r, 9));
942
- 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(", ")}]`);
943
- check(out, "T-Shirt: summary labels are bold", sumRows.every((r) => fontAt(grid, r, 9).bold), "");
944
- const costCell = cellAt(grid, sumRows[2], 10);
945
- const costFmt = costCell && costCell.userEnteredFormat && costCell.userEnteredFormat.numberFormat;
946
- check(out, "T-Shirt: Total Cost is formatted as dollars", !!costFmt && costFmt.type === "CURRENCY", `K${sumRows[2] + 1} numberFormat ${JSON.stringify(costFmt)}`);
947
- const want = `=SUM(J${first0 + 1}:J${lastItem0 + 1})`;
948
- 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}'`);
949
- 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");
950
1041
  const badRoll = [];
951
1042
  for (const rr of layout.rollupRows) {
952
- 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}`);
953
1044
  }
954
- 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(", "));
955
1046
  let summaryAbove = false;
956
- 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;
957
1048
  check(out, "T-Shirt: no summary label inside the summed range", !summaryAbove, "a Total Days/Hrs/Cost label sits inside the summed range");
958
1049
  let raw = 0;
959
1050
  for (let r = first0; r <= lastItem0; r++) {
960
- 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]; }
961
1052
  }
962
1053
  const recomputed = round2(raw * (1 + layout.mfTotal));
963
1054
  check(out, "T-Shirt: Σ raw sizes × (1+MF) == Total Days cell", Math.abs(recomputed - totalDaysCell) < 0.02, `recomputed ${recomputed}, cell ${totalDaysCell}`);
964
1055
  }
965
- return { checks: out, layout, totalDays: totalDaysCell, lastItem1: lastItem0 + 1, phaseDays };
1056
+ return { checks: out, layout, totalDays: totalDaysCell, staffDays: Number.isNaN(totalDaysCell) ? NaN : midDays(totalDaysCell, layout.highFactor), lastItem1: lastItem0 + 1, phaseDays };
966
1057
  }
967
1058
 
968
1059
  function disciplineOfLabel(label) {
969
1060
  const l = label.toLowerCase();
970
1061
  if (/qa|test/.test(l)) return "qa";
971
- if (/project manager|\bpm\b/.test(l)) return "pm";
972
- if (/analyst/.test(l)) return "ba";
1062
+ if (/project m|\bpm\b/.test(l)) return "pm";
1063
+ if (/analys/.test(l)) return "ba";
973
1064
  if (/devops/.test(l)) return "devops";
974
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";
975
1068
  if (/front/.test(l)) return "frontend";
976
- if (/back|api/.test(l)) return "backend";
1069
+ if (/back|api|platform/.test(l)) return "backend";
977
1070
  return "";
978
1071
  }
979
1072
 
@@ -1089,7 +1182,7 @@ function auditTeamMix(grid, mfList, tshirtTotalDays, phaseDays) {
1089
1182
  const phase = expectedPhases.find((ph) => title.endsWith(ph));
1090
1183
  if (expectedPhases.length) {
1091
1184
  check(out, `${label}: title names a phase with hours`, !!phase, `title '${title}' ends with none of [${expectedPhases.join(", ")}]`);
1092
- if (phase) check(out, `${label} (${phase}): Σ Days == that phase's T-Shirt days`, Math.abs(sumDays - phaseDays[phase]) < tol, `Team Mix ${sumDays}, T-Shirt ${phaseDays[phase]} (tolerance ${round2(tol)})`);
1185
+ if (phase) check(out, `${label} (${phase}): Σ Days == midpoint of that phase's Low/High days`, Math.abs(sumDays - phaseDays[phase]) < tol, `Team Mix ${sumDays}, T-Shirt ${phaseDays[phase]} (tolerance ${round2(tol)})`);
1093
1186
  }
1094
1187
  prevBottom = totalR + 1;
1095
1188
  }
@@ -1100,7 +1193,7 @@ function auditTeamMix(grid, mfList, tshirtTotalDays, phaseDays) {
1100
1193
  check(out, "Team Mix: nothing formatted below the last grid", !strayBelow.length, strayBelow.slice(0, 8).join(", "));
1101
1194
  }
1102
1195
  if (!Number.isNaN(tshirtTotalDays) && headers.length) {
1103
- check(out, "Team Mix: Σ Days over all grids == T-Shirt Total Days", Math.abs(sumDaysAll - tshirtTotalDays) < Math.max(0.15, tolAll), `Team Mix ${round2(sumDaysAll)}, T-Shirt ${tshirtTotalDays} (tolerance ${round2(Math.max(0.15, tolAll))})`);
1196
+ check(out, "Team Mix: Σ Days over all grids == midpoint of T-Shirt Low/High days", Math.abs(sumDaysAll - tshirtTotalDays) < Math.max(0.15, tolAll), `Team Mix ${round2(sumDaysAll)}, T-Shirt midpoint ${tshirtTotalDays} (tolerance ${round2(Math.max(0.15, tolAll))})`);
1104
1197
  }
1105
1198
  return { checks: out, grids: headers.length };
1106
1199
  }
@@ -1134,7 +1227,7 @@ async function runAudit(api) {
1134
1227
  const checks = [];
1135
1228
  const tshirt = auditTshirt(await api.grid(TAB_TSHIRT));
1136
1229
  checks.push(...tshirt.checks);
1137
- checks.push(...auditTeamMix(await api.grid(TAB_TEAM), tshirt.layout.mf, tshirt.totalDays, tshirt.phaseDays).checks);
1230
+ checks.push(...auditTeamMix(await api.grid(TAB_TEAM), tshirt.layout.mf, tshirt.staffDays, tshirt.phaseDays).checks);
1138
1231
  checks.push(...auditTechStack(await api.grid(TAB_TECH)).checks);
1139
1232
  if (tabs.has(TAB_OVERVIEW)) checks.push(...auditOverview(await api.grid(TAB_OVERVIEW)).checks);
1140
1233
  else check(checks, "Overview tab exists (the estimates index imports its cells)", false, "missing");
@@ -1191,7 +1284,7 @@ async function verbRead(api, tab) {
1191
1284
  }
1192
1285
 
1193
1286
  function rostersTable(rosters) {
1194
- return rosters.map((r) => `── ${r.phase}: ${r.totalDays} d ──\n${rosterTable(r.roster)}`).join("\n");
1287
+ return rosters.map((r) => `── ${r.phase}: low ${r.totalDays} d · high ${r.highDays} d → staffing the midpoint ${r.staffDays} d ──\n${rosterTable(r.roster)}`).join("\n");
1195
1288
  }
1196
1289
 
1197
1290
  async function verbPlanCheck(api, plan) {
@@ -1201,6 +1294,193 @@ async function verbPlanCheck(api, plan) {
1201
1294
  return { totals, mf: layout.mf, mfTotal: round2(layout.mfTotal), highFactor: layout.highFactor, rate: layout.rate, rosters, table: rostersTable(rosters) };
1202
1295
  }
1203
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
+
1204
1484
  async function verbWrite(api, plan, opts) {
1205
1485
  const ts = await writeTshirt(api, plan, opts);
1206
1486
  const rosters = buildRosters(plan, ts.layout);
@@ -1208,7 +1488,7 @@ async function verbWrite(api, plan, opts) {
1208
1488
  const tech = await writeTechStack(api, plan);
1209
1489
  const result = {
1210
1490
  tshirt: { mode: plan.tshirt.mode, itemsWritten: ts.itemsWritten, firstItem1: ts.firstItem1, lastItem1: ts.lastItem1, totalRow1: ts.totalRow1, totals: ts.totals },
1211
- teamMix: { grids: tm.grids, phases: rosters.map((r) => ({ phase: r.phase, totalDays: r.totalDays, people: r.roster.people.length, months: r.roster.months, columns: r.roster.n, sumDays: r.roster.sumDays })), table: rostersTable(rosters), lastRow: tm.lastRow },
1491
+ teamMix: { grids: tm.grids, 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), lastRow: tm.lastRow },
1212
1492
  techStack: tech,
1213
1493
  };
1214
1494
  if (!opts.noAudit) result.audit = await runAudit(api);
@@ -1221,7 +1501,7 @@ function parseArgs(argv) {
1221
1501
  const out = { _: [] };
1222
1502
  for (let i = 0; i < argv.length; i++) {
1223
1503
  const a = argv[i];
1224
- 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;
1225
1505
  else if (a.startsWith("--")) { out[a.slice(2)] = argv[i + 1]; i++; }
1226
1506
  else out._.push(a);
1227
1507
  }
@@ -1233,7 +1513,7 @@ function printChecks(audit) {
1233
1513
  console.log(`${audit.ok ? "AUDIT PASS" : `AUDIT FAIL (${audit.failed})`} — ${audit.title}`);
1234
1514
  }
1235
1515
 
1236
- 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]";
1237
1517
 
1238
1518
  /** Runs a verb; returns the exit code. Throws Halt (or any error) — the runner below turns that into exit 4/64. */
1239
1519
  async function main(args) {
@@ -1249,6 +1529,26 @@ async function main(args) {
1249
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)); }
1250
1530
  return 0;
1251
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
+ }
1252
1552
  if (verb === "audit") {
1253
1553
  const a = await runAudit(api);
1254
1554
  if (json) console.log(JSON.stringify({ ok: a.ok, exitCode: a.ok ? 0 : 4, ...a }, null, 2)); else printChecks(a);
@@ -1271,7 +1571,7 @@ async function main(args) {
1271
1571
  if (json) console.log(JSON.stringify({ ok, exitCode: ok ? 0 : 4, ...r }, null, 2));
1272
1572
  else {
1273
1573
  console.log(`T-Shirt (${r.tshirt.mode}): ${r.tshirt.itemsWritten} items, rows ${r.tshirt.firstItem1}–${r.tshirt.lastItem1}, totals row ${r.tshirt.totalRow1}; total ${r.tshirt.totals.totalDays} d, $${r.tshirt.totals.lowDollars} – $${r.tshirt.totals.highDollars}`);
1274
- for (const ph of r.teamMix.phases) console.log(`Team Mix — ${ph.phase}: ${ph.people} people, ${ph.months} months, ${ph.columns} columns, Σ Days ${ph.sumDays}`);
1574
+ for (const ph of r.teamMix.phases) console.log(`Team Mix — ${ph.phase}: ${ph.people} people, ${ph.months} months, ${ph.columns} columns, Σ Days ${ph.sumDays} (midpoint of ${ph.lowDays}–${ph.highDays})`);
1275
1575
  console.log(r.teamMix.table);
1276
1576
  console.log(`Technology Stack: ${r.techStack.rows} rows`);
1277
1577
  if (r.audit) printChecks(r.audit);
@@ -1291,7 +1591,7 @@ function haltAndExit(e, json) {
1291
1591
 
1292
1592
  module.exports = {
1293
1593
  validatePlan, splitRoster, rosterViolations, mfCoverageViolations, monthPlan, resampleWeights, rampHours, buildRoster,
1294
- tshirtTotals, phaseTotals, buildRosters, 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,
1295
1595
  auditTshirt, auditTeamMix, auditTechStack, auditOverview, colLetter, hexToColor, colorToHex, sheetIdFromArg,
1296
1596
  constants: { SIZE_CODES, PHASES, COLOR, RAMP, ROLE_LABEL, SOFT_CEILING, FOLD_THRESHOLD, TAB_TSHIRT, TAB_TEAM, TAB_TECH, PLAN_SCHEMA },
1297
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.12",
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",
@@ -130,14 +130,15 @@ The old `Month / Days / Tot Days / Hrs` layout is retired: `Days` IS the per-per
130
130
 
131
131
  ### 2.2 The math
132
132
 
133
- - `months = totalDays / (Σ Count × 20)` — solve so `Σ Days` equals the T-Shirt `Total Days`.
134
- - 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.
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
+ - `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 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.
135
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).
136
137
  - The remainder formula makes each row sum exactly. Never write a fractional last month as a value.
137
138
 
138
139
  ### 2.6 One grid per phase
139
140
 
140
- Every phase (`MVP` / `Phase 1` / `Phase 2` / `Phase 3`) whose T-Shirt items have hours > 0 gets its own Team Mix grid, in phase order, on the same tab, with 2 blank unformatted rows between grids. Each grid's `Σ Days` reconciles to **that phase's** T-Shirt days (the phase rollup `Low Hrs ÷ 8`), and the grids together reconcile to `Total Days`. Months and column count are computed per grid. The team mix is the same for every phase unless the plan gives `teamMix.phases.<phase>.fte`.
141
+ Every phase (`MVP` / `Phase 1` / `Phase 2` / `Phase 3`) whose T-Shirt items have hours > 0 gets its own Team Mix grid, in phase order, on the same tab, with 2 blank unformatted rows between grids. Each grid's `Σ Days` reconciles to **the midpoint of that phase's Low and High days** (`(Low Hrs + High Hrs) ÷ 2 ÷ 8` from the phase rollups), and the grids together reconcile to the midpoint of `Total Days` and `Total Days × high factor`. Months and column count are computed per grid. The team mix is the same for every phase unless the plan gives `teamMix.phases.<phase>.fte`.
141
142
 
142
143
  ### 2.3 Roster shape — one row per PERSON
143
144
 
@@ -171,6 +172,8 @@ Monthly hours follow when the work happens. Normalise the weights, multiply by t
171
172
  | DevOps | 0.45 / 0.85 / 1.70 | back-loaded — mirror of BA |
172
173
  | Project Manager | 1.00 / 1.00 / 1.00 | flat |
173
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 |
174
177
 
175
178
  - A flat `160 / 160 / 147` for every role is the tell that no ramping was applied.
176
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.
@@ -226,7 +229,7 @@ TEAM MIX (each grid)
226
229
  [ ] font Arial 10 everywhere; sage on exactly E, F, Total Hrs of role rows; monthly cells white
227
230
  [ ] Total row (#E5E5E5) and Total Days row (#9EC1EF) span every month column + Total Hrs
228
231
  [ ] nothing formatted outside the data area
229
- [ ] each grid's Σ Days == that phase's T-Shirt days; all grids together == Total Days
232
+ [ ] each grid's Σ Days == midpoint of that phase's Low/High days; all grids together == midpoint of Total Days and Total Days × high factor
230
233
  TECH STACK
231
234
  [ ] at least the categories that apply are filled; none says TBD
232
235
  OVERVIEW
@@ -245,8 +248,17 @@ gsd-t estimate-sheet read --sheet <id|url> [--tab <name>] # read-before-
245
248
  gsd-t estimate-sheet plan-check --sheet <id|url> --plan plan.json # validate + the roster it WOULD write (the Step 4 pause)
246
249
  gsd-t estimate-sheet write --sheet <id|url> --plan plan.json [--replace] # T-Shirt + Team Mix + Tech Stack, then audit
247
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
248
258
  ```
249
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
+
250
262
  The plan (judgment only):
251
263
 
252
264
  ```json
@@ -263,5 +275,5 @@ The plan (judgment only):
263
275
  ```
264
276
 
265
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.
266
- - `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.
267
279
  - The MF list, legend, rate and high factor are READ from the sheet; the plan never carries them.