@tekyzinc/gsd-t 5.20.10 → 5.20.12

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,52 @@
2
2
 
3
3
  All notable changes to GSD-T are documented here. Updated with each release.
4
4
 
5
+ ## [5.20.12] - 2026-09-21
6
+
7
+ ### Fixed — estimate writer proven end-to-end; two-month remainder formula
8
+
9
+ First live run of `gsd-t estimate-sheet write` on a copy of the Tekyz template (David's
10
+ "E2E test of the estimator" sheet): 11 items in 4 sections, a 6-person / 2-month Team Mix,
11
+ and a 4-row Technology Stack, then all 33 read-back audit checks pass — 8 seconds, no hand edits.
12
+
13
+ - `bin/gsd-t-estimate-sheet.cjs`: with exactly two month columns the remainder was written
14
+ as `=F3-SUM(I3:I3)`; Google canonicalises a one-cell range to `SUM(I3)`, so the audit's
15
+ read-back never matched. New `remainderFormula()` emits `=F3-I3` for two months, the
16
+ `SUM(first:second-to-last)` form for three or more; writer and audit share it.
17
+ - `test/estimate-sheet-writer.test.js`: the two-month case, including the writer's own
18
+ output passing its own audit.
19
+
20
+ ### Changed — David's review of the first live sheet (2026-09-21)
21
+
22
+ - T-Shirt: no blank row above or below the totals row; the totals row is the template's grey
23
+ band (`#D8DDE8`, bold) across A:L; the summary labels (Total Days / Hrs / Cost) are bold and
24
+ Total Cost is formatted as dollars. Audit checks added for each.
25
+ - Team Mix: **one grid per phase with hours** (MVP, Phase 1, …) stacked on the one tab with
26
+ exactly 2 blank rows between grids; each grid reconciles to its phase's T-Shirt days (read
27
+ from the phase rollups) and the grids together to Total Days. `teamMix.phases.<phase>.fte`
28
+ overrides the mix for one phase. `Mths` is written at full precision (displayed 0.00) so
29
+ Σ Days reconciles exactly instead of drifting with the roster size.
30
+ - Section heading blue stays `#1C4F8B` (confirmed); template copies stay a manual step (the
31
+ service account has no Drive quota; domain-wide delegation rejected as a master-key risk).
32
+
33
+ ## [5.20.11] - 2026-09-17
34
+
35
+ ### Fixed — `update-all` treated a downgrade as an upgrade and rolled 34 projects back
36
+
37
+ Releasing 5.20.10, npm's cached package listing still named 5.19.11 as `@latest` for
38
+ several minutes after publish. `upgradeGlobalBinary()` installed the older release over
39
+ the running one, printed "upgraded: v5.20.10 → v5.19.11", handed off to the OLD binary,
40
+ and that binary rewrote `~/.claude/commands` and every registered project with the
41
+ previous version. It happened twice in one release.
42
+
43
+ - `bin/gsd-t.js`: numeric `versionCmp`; a downgrade now HALTS (exit 1) before the
44
+ "upgraded" branch and names the fix (`npm cache clean --force`, wait for the tarball
45
+ to serve 200, reinstall the current release). Never hands off to an older binary.
46
+ - `commands/cpua.md`: Step 6 polls the tarball URL until it returns 200, cleans the npm
47
+ cache, installs, and checks `dist-tags.latest` equals the new version BEFORE `update-all`.
48
+ - `test/update-all-downgrade-halt.test.js`: the compare is numeric (5.9 < 5.10) and the
49
+ downgrade branch exits instead of re-exec'ing.
50
+
5
51
  ## [5.20.10] - 2026-09-17
6
52
 
7
53
  ### Changed — `/gsd-t-estimate` is the sheet only, and the sheet now has a written spec
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # GSD-T: Contract-Driven Development for Claude Code
2
2
 
3
- **v5.20.10** - A methodology for reliable, parallelizable development using Claude Code with optional Agent Teams support.
3
+ **v5.20.12** - 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.
@@ -52,6 +52,7 @@ const COLOR = {
52
52
  sage: "#D6E2DD",
53
53
  totalBg: "#E5E5E5",
54
54
  totalDaysBg: "#9EC1EF",
55
+ tshirtTotalBg: "#D8DDE8",
55
56
  white: "#FFFFFF",
56
57
  };
57
58
 
@@ -472,7 +473,9 @@ function monthPlan(totalDays, people) {
472
473
  const sumCount = people.reduce((s, p) => s + p.count, 0);
473
474
  if (!(sumCount > 0)) throw new Halt("Team Mix: total Count is 0");
474
475
  if (!(totalDays > 0)) throw new Halt(`Team Mix: total days is ${totalDays}`);
475
- const months = round2(totalDays / (sumCount * 20));
476
+ // Full precision: the sheet DISPLAYS Mths as 0.00 but computes with the exact value, so
477
+ // Σ Days reconciles to the T-Shirt total instead of drifting by ΣCount × 20 × 0.005.
478
+ const months = totalDays / (sumCount * 20);
476
479
  const whole = Math.floor(months);
477
480
  const frac = months - whole;
478
481
  const n = whole >= 1 && frac < FOLD_THRESHOLD ? whole : Math.ceil(months);
@@ -526,10 +529,10 @@ function buildRoster(plan, totalDays, mfList) {
526
529
  const rows = people.map((p) => {
527
530
  const days = round2(months * 20 * p.count);
528
531
  const hours = round2(days * 8);
529
- return { ...p, months, days, hours, monthHours: rampHours(hours, p.discipline, n) };
532
+ return { ...p, months: round2(months), monthsExact: months, days, hours, monthHours: rampHours(hours, p.discipline, n) };
530
533
  });
531
534
  const sumDays = round2(rows.reduce((s, r) => s + r.days, 0));
532
- return { people: rows, months, sumCount, n, sumDays };
535
+ return { people: rows, months: round2(months), monthsExact: months, sumCount, n, sumDays };
533
536
  }
534
537
 
535
538
  // ───────────────────────── T-Shirt totals (pure) ─────────────────────────
@@ -589,18 +592,17 @@ function tshirtRows(plan, firstRow0) {
589
592
  }
590
593
  }
591
594
  const lastItem1 = r1 - 1;
592
- const totalRow1 = lastItem1 + 2;
595
+ const totalRow1 = lastItem1 + 1; // directly under the last item — no blank row (David, 2026-09-21)
593
596
  const f1 = firstRow0 + 1;
594
- out.push({ kind: "blank", row1: lastItem1 + 1, values: Array(12).fill("") });
595
597
  out.push({
596
598
  kind: "total", row1: totalRow1,
597
599
  values: ["Total (Days)", "", "", "", "", "", "",
598
600
  `=SUM(H${f1}:H${lastItem1})`, `=SUM(I${f1}:I${lastItem1})`, `=SUM(J${f1}:J${lastItem1})`, `=SUM(K${f1}:K${lastItem1})`, `=SUM(L${f1}:L${lastItem1})`],
599
601
  });
600
- out.push({ kind: "blank", row1: totalRow1 + 1, values: Array(12).fill("") });
601
- out.push({ kind: "summary", row1: totalRow1 + 2, values: ["", "", "", "", "", "", "", "", "", "Total Days", `=J${totalRow1}`, `=J${totalRow1}*$G$4`] });
602
- out.push({ kind: "summary", row1: totalRow1 + 3, values: ["", "", "", "", "", "", "", "", "", "Total Hrs", `=J${totalRow1}*8`, `=J${totalRow1}*$G$4*8`] });
603
- out.push({ kind: "summary", row1: totalRow1 + 4, values: ["", "", "", "", "", "", "", "", "", "Total Cost", `=K${totalRow1}`, `=L${totalRow1}`] });
602
+ // summary block directly under the totals row — no blank row
603
+ out.push({ kind: "summary", row1: totalRow1 + 1, values: ["", "", "", "", "", "", "", "", "", "Total Days", `=J${totalRow1}`, `=J${totalRow1}*$G$4`] });
604
+ out.push({ kind: "summary", row1: totalRow1 + 2, values: ["", "", "", "", "", "", "", "", "", "Total Hrs", `=J${totalRow1}*8`, `=J${totalRow1}*$G$4*8`] });
605
+ out.push({ kind: "summary-cost", row1: totalRow1 + 3, values: ["", "", "", "", "", "", "", "", "", "Total Cost", `=K${totalRow1}`, `=L${totalRow1}`] });
604
606
  return { rows: out, firstItem1: f1, lastItem1, totalRow1 };
605
607
  }
606
608
 
@@ -659,10 +661,11 @@ async function writeTshirt(api, plan, opts) {
659
661
  }
660
662
  // 4. formats — item area defaults, then per-row kinds
661
663
  const reqs = [];
662
- reqs.push(fmtReq(sheetId, first0, lastWritten1, 0, 12, FMT_ITEM_TEXT, "userEnteredFormat(textFormat,horizontalAlignment)"));
663
- reqs.push(fmtReq(sheetId, first0, lastWritten1, 4, 7, FMT_ITEM_SIZE, "userEnteredFormat(horizontalAlignment,textFormat)"));
664
- reqs.push(fmtReq(sheetId, first0, lastWritten1, 7, 10, FMT_ITEM_NUM, FMT_FIELDS_ALL));
665
- reqs.push(fmtReq(sheetId, first0, lastWritten1, 10, 12, FMT_ITEM_CUR, FMT_FIELDS_ALL));
664
+ const lastItem0 = built.lastItem1; // exclusive end for item-area formats (0-based end = lastItem1)
665
+ reqs.push(fmtReq(sheetId, first0, lastItem0, 0, 12, FMT_ITEM_TEXT, "userEnteredFormat(textFormat,horizontalAlignment)"));
666
+ reqs.push(fmtReq(sheetId, first0, lastItem0, 4, 7, FMT_ITEM_SIZE, "userEnteredFormat(horizontalAlignment,textFormat)"));
667
+ reqs.push(fmtReq(sheetId, first0, lastItem0, 7, 10, FMT_ITEM_NUM, FMT_FIELDS_ALL));
668
+ reqs.push(fmtReq(sheetId, first0, lastItem0, 10, 12, FMT_ITEM_CUR, FMT_FIELDS_ALL));
666
669
  for (const row of built.rows) {
667
670
  const r0 = row.row1 - 1;
668
671
  if (row.kind === "section") {
@@ -671,9 +674,14 @@ async function writeTshirt(api, plan, opts) {
671
674
  { backgroundColor: hexToColor(COLOR.sectionBg), textFormat: { fontFamily: "Arial", fontSize: 10, bold: true, foregroundColor: hexToColor(COLOR.white) }, horizontalAlignment: "LEFT" },
672
675
  "userEnteredFormat(backgroundColor,textFormat,horizontalAlignment)"));
673
676
  } else if (row.kind === "total") {
674
- reqs.push(fmtReq(sheetId, r0, r0 + 1, 0, 12, { textFormat: { fontFamily: "Calibri", fontSize: 10, bold: true } }, "userEnteredFormat.textFormat"));
675
- } else if (row.kind === "summary") {
676
- reqs.push(fmtReq(sheetId, r0, r0 + 1, 9, 12, { textFormat: { fontFamily: "Calibri", fontSize: 10, bold: true } }, "userEnteredFormat.textFormat"));
677
+ // the template's totals row: grey band across A:L, bold 10, numbers in General format, right-aligned
678
+ reqs.push(fmtReq(sheetId, r0, r0 + 1, 0, 12, { backgroundColor: hexToColor(COLOR.tshirtTotalBg), textFormat: { fontSize: 10, bold: true }, horizontalAlignment: "LEFT" }, "userEnteredFormat(backgroundColor,textFormat,horizontalAlignment)"));
679
+ reqs.push(fmtReq(sheetId, r0, r0 + 1, 7, 12, { horizontalAlignment: "RIGHT" }, "userEnteredFormat.horizontalAlignment"));
680
+ } else if (row.kind === "summary" || row.kind === "summary-cost") {
681
+ reqs.push(fmtReq(sheetId, r0, r0 + 1, 9, 12, { textFormat: { fontSize: 10, bold: true } }, "userEnteredFormat.textFormat"));
682
+ reqs.push(fmtReq(sheetId, r0, r0 + 1, 10, 12,
683
+ row.kind === "summary-cost" ? { horizontalAlignment: "RIGHT", numberFormat: { type: "CURRENCY", pattern: "$#,##0.00" } } : { horizontalAlignment: "RIGHT", numberFormat: { type: "NUMBER", pattern: "0.00" } },
684
+ "userEnteredFormat(horizontalAlignment,numberFormat)"));
677
685
  } else if (row.kind === "item") {
678
686
  reqs.push(copyPhaseReq(sheetId, phaseSrc, r0)); // carries validation + chip format; value re-put below
679
687
  }
@@ -732,7 +740,42 @@ async function writeTshirtSizes(api, grid, sheetId, layout, plan, totals, phaseS
732
740
 
733
741
  // ───────────────────────── write: Team Mix (spec §2) ─────────────────────────
734
742
 
735
- function teamMixValues(plan, roster) {
743
+ /** Per-phase T-Shirt totals in PHASES order — only phases with hours > 0 (spec §2.6). */
744
+ function phaseTotals(plan, layout) {
745
+ const raw = {};
746
+ for (const s of plan.tshirt.sections) for (const it of s.items) {
747
+ raw[it.phase] = (raw[it.phase] ? raw[it.phase] : 0) + sizeDays(it.fe, layout.legend) + sizeDays(it.be, layout.legend);
748
+ }
749
+ return PHASES.filter((ph) => raw[ph] > 0).map((ph) => ({ phase: ph, rawDays: round2(raw[ph]), totalDays: round2(raw[ph] * (1 + layout.mfTotal)) }));
750
+ }
751
+
752
+ /** One roster per phase with hours. FTE comes from teamMix.phases[<phase>].fte when given, else teamMix.fte. */
753
+ function buildRosters(plan, layout) {
754
+ const phases = phaseTotals(plan, layout);
755
+ if (!phases.length) throw new Halt("no phase has any hours — nothing to staff");
756
+ return phases.map((ph) => {
757
+ const override = plan.teamMix.phases && plan.teamMix.phases[ph.phase] && plan.teamMix.phases[ph.phase].fte;
758
+ const sub = override ? { ...plan, teamMix: { ...plan.teamMix, fte: override } } : plan;
759
+ return { ...ph, roster: buildRoster(sub, ph.totalDays, layout.mf) };
760
+ });
761
+ }
762
+
763
+ /**
764
+ * The last month's REMAINDER formula (spec §2.1). n=1: the whole total; n=2: `=F-I`
765
+ * (Google rewrites a one-cell range `SUM(I3:I3)` to `SUM(I3)`, so the read-back
766
+ * would never match — write the canonical form); n>2: `=F-SUM(first:second-to-last)`.
767
+ */
768
+ function remainderFormula(r1, firstMonthC, n) {
769
+ if (n === 1) return `=F${r1}`;
770
+ if (n === 2) return `=F${r1}-${colLetter(firstMonthC)}${r1}`;
771
+ return `=F${r1}-SUM(${colLetter(firstMonthC)}${r1}:${colLetter(firstMonthC + n - 2)}${r1})`;
772
+ }
773
+
774
+ /**
775
+ * The cell values of ONE Team Mix grid starting at 0-based row `top` (spec §2.1):
776
+ * title, header, one row per person, Total, Total Days. Formulas use absolute rows.
777
+ */
778
+ function teamMixValues(plan, roster, phase, top) {
736
779
  const n = roster.n;
737
780
  const firstMonthC = 8; // I
738
781
  const totalC = firstMonthC + n; // Total Hrs column index — derived, never remembered
@@ -742,18 +785,18 @@ function teamMixValues(plan, roster) {
742
785
  for (let m = 1; m <= n; m++) header.push(`Mon ${m}`);
743
786
  header.push("Total Hrs");
744
787
  const rows = [];
745
- rows.push([plan.title, ...Array(width - 1).fill("")]);
788
+ rows.push([phase ? `${plan.title} — ${phase}` : plan.title, ...Array(width - 1).fill("")]);
746
789
  rows.push(header);
790
+ const firstP = top + 3; // 1-based row of the first person
747
791
  roster.people.forEach((p, i) => {
748
- const r1 = 3 + i;
749
- const row = [p.label, p.count, "", roster.months, `=D${r1}*20*B${r1}`, `=E${r1}*8`, "", p.label];
792
+ const r1 = firstP + i;
793
+ const row = [p.label, p.count, "", roster.monthsExact, `=D${r1}*20*B${r1}`, `=E${r1}*8`, "", p.label];
750
794
  for (let m = 0; m < n - 1; m++) row.push(p.monthHours[m]);
751
- row.push(n === 1 ? `=F${r1}` : `=F${r1}-SUM(${L(firstMonthC)}${r1}:${L(firstMonthC + n - 2)}${r1})`);
795
+ row.push(remainderFormula(r1, firstMonthC, n));
752
796
  row.push(`=SUM(${L(firstMonthC)}${r1}:${L(totalC - 1)}${r1})`);
753
797
  rows.push(row);
754
798
  });
755
- const firstP = 3;
756
- const lastP = 2 + roster.people.length;
799
+ const lastP = firstP + roster.people.length - 1;
757
800
  const totalR = lastP + 1;
758
801
  const total = ["Total", `=SUM(B${firstP}:B${lastP})`, "", "", `=SUM(E${firstP}:E${lastP})`, `=SUM(F${firstP}:F${lastP})`, "", "Total Hours"];
759
802
  for (let c = firstMonthC; c <= totalC; c++) total.push(`=SUM(${L(c)}${firstP}:${L(c)}${lastP})`);
@@ -761,45 +804,65 @@ function teamMixValues(plan, roster) {
761
804
  const totalDays = ["", "", "", "", "", "", "", "Total Days"];
762
805
  for (let c = firstMonthC; c <= totalC; c++) totalDays.push(`=${L(c)}${totalR}/8`);
763
806
  rows.push(totalDays);
764
- return { rows, width, totalC, firstMonthC, totalR, firstP, lastP };
807
+ return { rows, width, totalC, firstMonthC, top, titleR: top + 1, headerR: top + 2, firstP, lastP, totalR, totalDaysR: totalR + 1, bottom: totalR + 1 };
808
+ }
809
+
810
+ const GRID_GAP = 2; // blank rows between phase grids (spec §2.6)
811
+
812
+ /** Format requests for one grid (spec §2.1 styling), computed from the grid's own shape. */
813
+ function teamMixFormatReqs(sheetId, v) {
814
+ const W = v.width;
815
+ const arial = (bold) => ({ fontFamily: "Arial", fontSize: 10, bold });
816
+ const white = { backgroundColor: hexToColor(COLOR.white) };
817
+ const reqs = [];
818
+ const t0 = v.titleR - 1, h0 = v.headerR - 1, p0 = v.firstP - 1, p1 = v.lastP, tot0 = v.totalR - 1;
819
+ reqs.push({ mergeCells: { range: gridRange(sheetId, t0, t0 + 1, 0, W), mergeType: "MERGE_ALL" } });
820
+ reqs.push(fmtReq(sheetId, t0, t0 + 1, 0, W, { backgroundColor: hexToColor(COLOR.teamTitleBg), textFormat: { fontFamily: "Montserrat", fontSize: 20, bold: true, foregroundColor: hexToColor(COLOR.white) }, horizontalAlignment: "CENTER" }, "userEnteredFormat(backgroundColor,textFormat,horizontalAlignment)"));
821
+ reqs.push(fmtReq(sheetId, h0, v.bottom, 0, W, { textFormat: arial(false), horizontalAlignment: "RIGHT", numberFormat: { type: "NUMBER", pattern: "0.00" } }, FMT_FIELDS_ALL));
822
+ reqs.push(fmtReq(sheetId, h0, h0 + 1, 0, W, { backgroundColor: hexToColor(COLOR.teamHeaderBg), textFormat: { ...arial(true), foregroundColor: hexToColor(COLOR.white) } }, "userEnteredFormat(backgroundColor,textFormat)"));
823
+ reqs.push(fmtReq(sheetId, h0, h0 + 1, 2, 3, white, "userEnteredFormat.backgroundColor"));
824
+ reqs.push(fmtReq(sheetId, h0, h0 + 1, 6, 7, white, "userEnteredFormat.backgroundColor"));
825
+ reqs.push(fmtReq(sheetId, h0, v.bottom, 0, 1, { horizontalAlignment: "LEFT" }, "userEnteredFormat.horizontalAlignment"));
826
+ reqs.push(fmtReq(sheetId, h0, v.bottom, 7, 8, { horizontalAlignment: "LEFT" }, "userEnteredFormat.horizontalAlignment"));
827
+ reqs.push(fmtReq(sheetId, h0, h0 + 1, v.totalC, v.totalC + 1, { horizontalAlignment: "LEFT" }, "userEnteredFormat.horizontalAlignment"));
828
+ const sage = { backgroundColor: hexToColor(COLOR.sage), textFormat: arial(true) };
829
+ reqs.push(fmtReq(sheetId, p0, p1, 4, 6, sage, "userEnteredFormat(backgroundColor,textFormat)"));
830
+ reqs.push(fmtReq(sheetId, p0, p1, v.totalC, v.totalC + 1, sage, "userEnteredFormat(backgroundColor,textFormat)"));
831
+ reqs.push(fmtReq(sheetId, tot0, tot0 + 1, 0, W, { backgroundColor: hexToColor(COLOR.totalBg), textFormat: arial(true) }, "userEnteredFormat(backgroundColor,textFormat)"));
832
+ reqs.push(fmtReq(sheetId, tot0, tot0 + 1, 2, 3, white, "userEnteredFormat.backgroundColor"));
833
+ reqs.push(fmtReq(sheetId, tot0, tot0 + 1, 6, 7, white, "userEnteredFormat.backgroundColor"));
834
+ reqs.push(fmtReq(sheetId, tot0 + 1, tot0 + 2, 7, W, { backgroundColor: hexToColor(COLOR.totalDaysBg), textFormat: arial(true) }, "userEnteredFormat(backgroundColor,textFormat)"));
835
+ return reqs;
765
836
  }
766
837
 
767
- async function writeTeamMix(api, plan, roster) {
838
+ /** Write every phase grid on the one Team Mix tab: clear-then-paint, GRID_GAP blank rows between grids. */
839
+ async function writeTeamMix(api, plan, rosters) {
768
840
  const grid = await api.grid(TAB_TEAM);
769
841
  const sheetId = grid.properties.sheetId;
770
- const v = teamMixValues(plan, roster);
771
- const clearRows = Math.max(60, rowCount(grid) + 2);
842
+ const grids = [];
843
+ let top = 0;
844
+ for (const r of rosters) {
845
+ const v = teamMixValues(plan, r.roster, rosters.length > 1 || r.phase ? r.phase : "", top);
846
+ grids.push({ phase: r.phase, v });
847
+ top = v.bottom + GRID_GAP;
848
+ }
849
+ const lastRow = grids[grids.length - 1].v.bottom;
850
+ const clearRows = Math.max(60, rowCount(grid) + 2, lastRow + 10);
772
851
  await api.clearValues(TAB_TEAM, `A1:Z${clearRows}`);
773
852
  await api.batch([
774
853
  { unmergeCells: { range: gridRange(sheetId, 0, clearRows, 0, 26) } },
775
854
  fmtReq(sheetId, 0, clearRows, 0, 26, {}, "userEnteredFormat"),
776
855
  ]);
777
- await api.putValues(TAB_TEAM, `A1:${colLetter(v.width - 1)}${v.rows.length}`, v.rows);
778
- const W = v.width;
779
- const arial = (bold) => ({ fontFamily: "Arial", fontSize: 10, bold });
780
- const white = { backgroundColor: hexToColor(COLOR.white) };
781
856
  const reqs = [];
782
- reqs.push({ mergeCells: { range: gridRange(sheetId, 0, 1, 0, W), mergeType: "MERGE_ALL" } });
783
- reqs.push(fmtReq(sheetId, 0, 1, 0, W, { backgroundColor: hexToColor(COLOR.teamTitleBg), textFormat: { fontFamily: "Montserrat", fontSize: 20, bold: true, foregroundColor: hexToColor(COLOR.white) }, horizontalAlignment: "CENTER" }, "userEnteredFormat(backgroundColor,textFormat,horizontalAlignment)"));
784
- reqs.push(fmtReq(sheetId, 1, v.rows.length, 0, W, { textFormat: arial(false), horizontalAlignment: "RIGHT", numberFormat: { type: "NUMBER", pattern: "0.00" } }, FMT_FIELDS_ALL));
785
- reqs.push(fmtReq(sheetId, 1, 2, 0, W, { backgroundColor: hexToColor(COLOR.teamHeaderBg), textFormat: { ...arial(true), foregroundColor: hexToColor(COLOR.white) } }, "userEnteredFormat(backgroundColor,textFormat)"));
786
- reqs.push(fmtReq(sheetId, 1, 2, 2, 3, white, "userEnteredFormat.backgroundColor"));
787
- reqs.push(fmtReq(sheetId, 1, 2, 6, 7, white, "userEnteredFormat.backgroundColor"));
788
- reqs.push(fmtReq(sheetId, 1, v.rows.length, 0, 1, { horizontalAlignment: "LEFT" }, "userEnteredFormat.horizontalAlignment"));
789
- reqs.push(fmtReq(sheetId, 1, v.rows.length, 7, 8, { horizontalAlignment: "LEFT" }, "userEnteredFormat.horizontalAlignment"));
790
- reqs.push(fmtReq(sheetId, 1, 2, v.totalC, v.totalC + 1, { horizontalAlignment: "LEFT" }, "userEnteredFormat.horizontalAlignment"));
791
- const p0 = v.firstP - 1, p1 = v.lastP;
792
- const sage = { backgroundColor: hexToColor(COLOR.sage), textFormat: arial(true) };
793
- reqs.push(fmtReq(sheetId, p0, p1, 4, 6, sage, "userEnteredFormat(backgroundColor,textFormat)"));
794
- reqs.push(fmtReq(sheetId, p0, p1, v.totalC, v.totalC + 1, sage, "userEnteredFormat(backgroundColor,textFormat)"));
795
- const t0 = v.totalR - 1;
796
- reqs.push(fmtReq(sheetId, t0, t0 + 1, 0, W, { backgroundColor: hexToColor(COLOR.totalBg), textFormat: arial(true) }, "userEnteredFormat(backgroundColor,textFormat)"));
797
- reqs.push(fmtReq(sheetId, t0, t0 + 1, 2, 3, white, "userEnteredFormat.backgroundColor"));
798
- reqs.push(fmtReq(sheetId, t0, t0 + 1, 6, 7, white, "userEnteredFormat.backgroundColor"));
799
- reqs.push(fmtReq(sheetId, t0 + 1, t0 + 2, 7, W, { backgroundColor: hexToColor(COLOR.totalDaysBg), textFormat: arial(true) }, "userEnteredFormat(backgroundColor,textFormat)"));
800
- reqs.push(...widthReqs(sheetId, [...TEAM_WIDTHS_FIXED, ...Array(roster.n).fill(TEAM_MONTH_WIDTH), TEAM_TOTAL_WIDTH]));
857
+ let maxN = 0;
858
+ for (const g of grids) {
859
+ await api.putValues(TAB_TEAM, `A${g.v.titleR}:${colLetter(g.v.width - 1)}${g.v.bottom}`, g.v.rows);
860
+ reqs.push(...teamMixFormatReqs(sheetId, g.v));
861
+ maxN = Math.max(maxN, g.v.totalC - g.v.firstMonthC);
862
+ }
863
+ reqs.push(...widthReqs(sheetId, [...TEAM_WIDTHS_FIXED, ...Array(maxN).fill(TEAM_MONTH_WIDTH), TEAM_TOTAL_WIDTH]));
801
864
  await api.batch(reqs);
802
- return v;
865
+ return { grids: grids.map((g) => ({ phase: g.phase, titleR: g.v.titleR, bottom: g.v.bottom, rows: g.v.rows.length })), lastRow };
803
866
  }
804
867
 
805
868
  // ───────────────────────── write: Technology Stack (spec §3) ─────────────────────────
@@ -865,7 +928,22 @@ function auditTshirt(grid) {
865
928
  check(out, "T-Shirt: section rows are styled (bg #1C4F8B)", !badSectionFmt.length, badSectionFmt.join(", "));
866
929
  check(out, "T-Shirt: 'Total (Days)' row exists below the items", totalRow0 > lastItem0 && lastItem0 >= 0, "not found");
867
930
  let totalDaysCell = NaN;
931
+ const phaseDays = {};
932
+ 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);
935
+ }
868
936
  if (totalRow0 > lastItem0 && lastItem0 >= 0) {
937
+ 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
+ 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}`);
940
+ 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)}`);
869
947
  const want = `=SUM(J${first0 + 1}:J${lastItem0 + 1})`;
870
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}'`);
871
949
  totalDaysCell = numAt(grid, totalRow0, 9);
@@ -876,7 +954,7 @@ function auditTshirt(grid) {
876
954
  check(out, "T-Shirt: phase rollups (K4:N7) reference the full item range", !badRoll.length, badRoll.join(", "));
877
955
  let summaryAbove = false;
878
956
  for (let r = first0; r < totalRow0; r++) if (/^Total (Days|Hrs|Cost)$/i.test(textAt(grid, r, 9))) summaryAbove = true;
879
- check(out, "T-Shirt: summary block is below the totals row", !summaryAbove, "a Total Days/Hrs/Cost label sits inside the summed range");
957
+ check(out, "T-Shirt: no summary label inside the summed range", !summaryAbove, "a Total Days/Hrs/Cost label sits inside the summed range");
880
958
  let raw = 0;
881
959
  for (let r = first0; r <= lastItem0; r++) {
882
960
  for (const c of [5, 6]) { const v = textAt(grid, r, c).trim(); if (v && layout.legend[v] != null) raw += layout.legend[v]; }
@@ -884,7 +962,7 @@ function auditTshirt(grid) {
884
962
  const recomputed = round2(raw * (1 + layout.mfTotal));
885
963
  check(out, "T-Shirt: Σ raw sizes × (1+MF) == Total Days cell", Math.abs(recomputed - totalDaysCell) < 0.02, `recomputed ${recomputed}, cell ${totalDaysCell}`);
886
964
  }
887
- return { checks: out, layout, totalDays: totalDaysCell, lastItem1: lastItem0 + 1 };
965
+ return { checks: out, layout, totalDays: totalDaysCell, lastItem1: lastItem0 + 1, phaseDays };
888
966
  }
889
967
 
890
968
  function disciplineOfLabel(label) {
@@ -899,95 +977,132 @@ function disciplineOfLabel(label) {
899
977
  return "";
900
978
  }
901
979
 
902
- /** Team Mix audit. mfList (from the T-Shirt tab) is REQUIRED; tshirtTotalDays may be NaN only when the T-Shirt has no totals row. */
903
- function auditTeamMix(grid, mfList, tshirtTotalDays) {
980
+ /**
981
+ * Team Mix audit — one grid per phase with hours (spec §2.6). mfList (from the T-Shirt
982
+ * tab) is REQUIRED. phaseDays = { phase: days } read from the T-Shirt rollups (the
983
+ * independent figure each grid must reconcile to); tshirtTotalDays for the sum.
984
+ */
985
+ function auditTeamMix(grid, mfList, tshirtTotalDays, phaseDays) {
904
986
  const out = [];
905
- const hdr = [];
906
- for (let c = 0; c < 30; c++) hdr.push(textAt(grid, 1, c));
907
- check(out, "Team Mix: row 2 is the header (A2='Skill set')", hdr[0] === "Skill set", `A2='${hdr[0]}'`);
908
- check(out, "Team Mix: row 3 is a person, not a second header", textAt(grid, 2, 0) !== "Skill set" && textAt(grid, 2, 0) !== "", `A3='${textAt(grid, 2, 0)}'`);
909
- const monthCols = [];
910
- hdr.forEach((h, c) => { if (/^Mon \d+$/.test(h)) monthCols.push(c); });
911
- const totalC = hdr.indexOf("Total Hrs");
912
- const lastM = monthCols.length ? monthCols[monthCols.length - 1] : -1;
913
- check(out, "Team Mix: month headers are Mon 1..N, contiguous, then Total Hrs", monthCols.length > 0 && totalC === lastM + 1 && monthCols.every((c, i) => hdr[c] === `Mon ${i + 1}` && c === monthCols[0] + i), `months [${monthCols.map((c) => hdr[c]).join(",")}], Total Hrs at column ${totalC}`);
914
- check(out, "Team Mix: header columns are Skill set/Count/Mths/Days/Hrs/Resource", hdr[1] === "Count" && hdr[3] === "Mths" && hdr[4] === "Days" && hdr[5] === "Hrs" && hdr[7] === "Resource", hdr.slice(0, 8).join("|"));
915
- const people = [];
916
- let totalR = -1;
917
- for (let r = 2; r < rowCount(grid); r++) {
918
- const a = textAt(grid, r, 0);
919
- if (/^Total$/i.test(a)) { totalR = r; break; }
920
- if (a === "") break;
921
- people.push({ r, label: a, count: numAt(grid, r, 1), discipline: disciplineOfLabel(a) });
922
- }
923
- check(out, "Team Mix: a 'Total' row follows the people", totalR > 2, "no Total row");
924
- check(out, "Team Mix: every person has a numeric Count", people.every((p) => !Number.isNaN(p.count)), people.filter((p) => Number.isNaN(p.count)).map((p) => p.label).join(", "));
925
- check(out, "Team Mix: no Count > 1.00", people.every((p) => p.count <= 1 + 1e-9), people.filter((p) => p.count > 1).map((p) => `${p.label}=${p.count}`).join(", "));
926
- const groups = {};
927
- people.forEach((p) => { const k = p.label.replace(/\s+\d+$/, ""); (groups[k] = groups[k] ? groups[k] : []).push(p); });
928
- const smeared = [];
929
- for (const [k, rows] of Object.entries(groups)) rows.slice(0, -1).forEach((p) => { if (Math.abs(p.count - 1) > 1e-9) smeared.push(`${k}: ${p.count} before a later row`); });
930
- check(out, "Team Mix: per discipline, all rows but the last are 1.00 (saturate then spill)", !smeared.length, smeared.join("; "));
931
- const cov = mfCoverageViolations(mfList, people.filter((p) => p.discipline));
932
- check(out, "Team Mix: every non-zero MF factor has a person", !cov.length, cov.join("; "));
933
- const badF = [], negative = [], over = [];
934
- if (monthCols.length && totalC > 0) {
935
- const firstM = monthCols[0];
936
- for (const p of people) {
937
- const r1 = p.r + 1;
938
- if (formulaAt(grid, p.r, 4) !== `=D${r1}*20*B${r1}`) badF.push(`E${r1}`);
939
- if (formulaAt(grid, p.r, 5) !== `=E${r1}*8`) badF.push(`F${r1}`);
940
- if (monthCols.length > 1) {
941
- const want = `=F${r1}-SUM(${colLetter(firstM)}${r1}:${colLetter(lastM - 1)}${r1})`;
942
- if (formulaAt(grid, p.r, lastM) !== want) badF.push(`${colLetter(lastM)}${r1} (remainder)`);
987
+ // locate grids: every row whose A == 'Skill set' is a header; its title is the row above
988
+ const headers = [];
989
+ for (let r = 0; r < rowCount(grid); r++) if (textAt(grid, r, 0) === "Skill set") headers.push(r);
990
+ check(out, "Team Mix: at least one grid (a 'Skill set' header row)", headers.length > 0, "no header row found");
991
+ const expectedPhases = phaseDays ? Object.keys(phaseDays).filter((ph) => phaseDays[ph] > 0) : [];
992
+ if (expectedPhases.length) check(out, "Team Mix: one grid per phase with hours", headers.length === expectedPhases.length, `${headers.length} grid(s) for phases [${expectedPhases.join(", ")}]`);
993
+ let sumDaysAll = 0;
994
+ let tolAll = 0;
995
+ let prevBottom = -1;
996
+ headers.forEach((h, gi) => {
997
+ const label = headers.length > 1 ? `Team Mix grid ${gi + 1}` : "Team Mix";
998
+ const title = textAt(grid, h - 1, 0);
999
+ check(out, `${label}: title row directly above the header`, h >= 1 && title !== "" && title !== "Skill set", `row ${h} above header is '${title}'`);
1000
+ if (gi > 0) {
1001
+ const gap = h - 1 - (prevBottom + 1);
1002
+ check(out, `${label}: exactly ${GRID_GAP} blank rows before it`, gap === GRID_GAP, `${gap} row(s) between the previous grid and this title`);
1003
+ let strayGap = [];
1004
+ for (let r = prevBottom + 1; r < h - 1; r++) for (let c = 0; c < 26; c++) if (textAt(grid, r, c) !== "" || bgAt(grid, r, c) !== COLOR.white) strayGap.push(`${colLetter(c)}${r + 1}`);
1005
+ check(out, `${label}: the gap rows are empty and unformatted`, !strayGap.length, strayGap.slice(0, 6).join(", "));
1006
+ }
1007
+ const hdr = [];
1008
+ for (let c = 0; c < 30; c++) hdr.push(textAt(grid, h, c));
1009
+ check(out, `${label}: row after the header is a person, not a second header`, textAt(grid, h + 1, 0) !== "Skill set" && textAt(grid, h + 1, 0) !== "", `A${h + 2}='${textAt(grid, h + 1, 0)}'`);
1010
+ const monthCols = [];
1011
+ hdr.forEach((x, c) => { if (/^Mon \d+$/.test(x)) monthCols.push(c); });
1012
+ const totalC = hdr.indexOf("Total Hrs");
1013
+ const lastM = monthCols.length ? monthCols[monthCols.length - 1] : -1;
1014
+ check(out, `${label}: month headers are Mon 1..N, contiguous, then Total Hrs`, monthCols.length > 0 && totalC === lastM + 1 && monthCols.every((c, i) => hdr[c] === `Mon ${i + 1}` && c === monthCols[0] + i), `months [${monthCols.map((c) => hdr[c]).join(",")}], Total Hrs at column ${totalC}`);
1015
+ check(out, `${label}: header columns are Skill set/Count/Mths/Days/Hrs/Resource`, hdr[1] === "Count" && hdr[3] === "Mths" && hdr[4] === "Days" && hdr[5] === "Hrs" && hdr[7] === "Resource", hdr.slice(0, 8).join("|"));
1016
+ const people = [];
1017
+ let totalR = -1;
1018
+ for (let r = h + 1; r < rowCount(grid); r++) {
1019
+ const a = textAt(grid, r, 0);
1020
+ if (/^Total$/i.test(a)) { totalR = r; break; }
1021
+ if (a === "") break;
1022
+ people.push({ r, label: a, count: numAt(grid, r, 1), discipline: disciplineOfLabel(a) });
1023
+ }
1024
+ check(out, `${label}: a 'Total' row follows the people`, totalR > h, "no Total row");
1025
+ check(out, `${label}: every person has a numeric Count`, people.every((p) => !Number.isNaN(p.count)), people.filter((p) => Number.isNaN(p.count)).map((p) => p.label).join(", "));
1026
+ check(out, `${label}: no Count > 1.00`, people.every((p) => p.count <= 1 + 1e-9), people.filter((p) => p.count > 1).map((p) => `${p.label}=${p.count}`).join(", "));
1027
+ const groups = {};
1028
+ people.forEach((p) => { const k = p.label.replace(/\s+\d+$/, ""); (groups[k] = groups[k] ? groups[k] : []).push(p); });
1029
+ const smeared = [];
1030
+ for (const [k, rows] of Object.entries(groups)) rows.slice(0, -1).forEach((p) => { if (Math.abs(p.count - 1) > 1e-9) smeared.push(`${k}: ${p.count} before a later row`); });
1031
+ check(out, `${label}: per discipline, all rows but the last are 1.00 (saturate then spill)`, !smeared.length, smeared.join("; "));
1032
+ const cov = mfCoverageViolations(mfList, people.filter((p) => p.discipline));
1033
+ check(out, `${label}: every non-zero MF factor has a person`, !cov.length, cov.join("; "));
1034
+ const badF = [], negative = [], over = [];
1035
+ if (monthCols.length && totalC > 0) {
1036
+ const firstM = monthCols[0];
1037
+ for (const p of people) {
1038
+ const r1 = p.r + 1;
1039
+ if (formulaAt(grid, p.r, 4) !== `=D${r1}*20*B${r1}`) badF.push(`E${r1}`);
1040
+ if (formulaAt(grid, p.r, 5) !== `=E${r1}*8`) badF.push(`F${r1}`);
1041
+ if (monthCols.length > 1 && formulaAt(grid, p.r, lastM) !== remainderFormula(r1, firstM, monthCols.length)) badF.push(`${colLetter(lastM)}${r1} (remainder)`);
1042
+ if (!formulaAt(grid, p.r, totalC).startsWith("=")) badF.push(`${colLetter(totalC)}${r1}`);
1043
+ for (const c of monthCols) {
1044
+ const hv = numAt(grid, p.r, c);
1045
+ if (Number.isNaN(hv)) negative.push(`${colLetter(c)}${r1}=not a number`);
1046
+ else if (hv < -1e-9) negative.push(`${colLetter(c)}${r1}=${hv}`);
1047
+ else if (hv > SOFT_CEILING + 1e-9) over.push(`${colLetter(c)}${r1}=${hv}`);
1048
+ }
943
1049
  }
944
- if (!formulaAt(grid, p.r, totalC).startsWith("=")) badF.push(`${colLetter(totalC)}${r1}`);
945
- for (const c of monthCols) {
946
- const h = numAt(grid, p.r, c);
947
- if (Number.isNaN(h)) negative.push(`${colLetter(c)}${r1}=not a number`);
948
- else if (h < -1e-9) negative.push(`${colLetter(c)}${r1}=${h}`);
949
- else if (h > SOFT_CEILING + 1e-9) over.push(`${colLetter(c)}${r1}=${h}`);
1050
+ }
1051
+ check(out, `${label}: Days/Hrs/remainder/Total Hrs are formulas`, !badF.length, badF.join(", "));
1052
+ check(out, `${label}: no negative or non-numeric month`, !negative.length, negative.join(", "));
1053
+ check(out, `${label}: no month cell above ${SOFT_CEILING} hrs`, !over.length, over.join(", "));
1054
+ if (monthCols.length > 1 && people.length) {
1055
+ const flat = people.filter((p) => p.discipline !== "pm").every((p) => {
1056
+ const vals = monthCols.map((c) => round1(numAt(grid, p.r, c)));
1057
+ return vals.every((x) => Math.abs(x - vals[0]) < 0.05);
1058
+ });
1059
+ check(out, `${label}: ramp applied (roles are not all flat)`, !flat, "every non-PM role has identical hours each month");
1060
+ }
1061
+ const badFont = [], badSage = [], whiteMissing = [];
1062
+ for (const p of people) {
1063
+ for (let c = 0; c <= totalC; c++) {
1064
+ const f = fontAt(grid, p.r, c);
1065
+ if (f.family && f.family !== "Arial") badFont.push(`${colLetter(c)}${p.r + 1}=${f.family}`);
1066
+ const bg = bgAt(grid, p.r, c);
1067
+ const shouldSage = c === 4 || c === 5 || c === totalC;
1068
+ if (shouldSage && bg !== COLOR.sage) badSage.push(`${colLetter(c)}${p.r + 1} bg ${bg}`);
1069
+ if (!shouldSage && bg !== COLOR.white) whiteMissing.push(`${colLetter(c)}${p.r + 1} bg ${bg}`);
950
1070
  }
951
1071
  }
952
- }
953
- check(out, "Team Mix: Days/Hrs/remainder/Total Hrs are formulas", !badF.length, badF.join(", "));
954
- check(out, "Team Mix: no negative or non-numeric month", !negative.length, negative.join(", "));
955
- check(out, `Team Mix: no month cell above ${SOFT_CEILING} hrs`, !over.length, over.join(", "));
956
- if (monthCols.length > 1 && people.length) {
957
- const flat = people.filter((p) => p.discipline !== "pm").every((p) => {
958
- const vals = monthCols.map((c) => round1(numAt(grid, p.r, c)));
959
- return vals.every((v) => Math.abs(v - vals[0]) < 0.05);
960
- });
961
- check(out, "Team Mix: ramp applied (roles are not all flat)", !flat, "every non-PM role has identical hours each month");
962
- }
963
- const badFont = [], badSage = [], whiteMissing = [];
964
- for (const p of people) {
965
- for (let c = 0; c <= totalC; c++) {
966
- const f = fontAt(grid, p.r, c);
967
- if (f.family && f.family !== "Arial") badFont.push(`${colLetter(c)}${p.r + 1}=${f.family}`);
968
- const bg = bgAt(grid, p.r, c);
969
- const shouldSage = c === 4 || c === 5 || c === totalC;
970
- if (shouldSage && bg !== COLOR.sage) badSage.push(`${colLetter(c)}${p.r + 1} bg ${bg}`);
971
- if (!shouldSage && bg !== COLOR.white) whiteMissing.push(`${colLetter(c)}${p.r + 1} bg ${bg}`);
1072
+ check(out, `${label}: body font is Arial (never Calibri)`, !badFont.length, badFont.slice(0, 8).join(", "));
1073
+ check(out, `${label}: sage on exactly Days, Hrs, Total Hrs of role rows`, !badSage.length && !whiteMissing.length, [...badSage, ...whiteMissing].slice(0, 8).join(", "));
1074
+ if (totalR > 0 && totalC > 0) {
1075
+ const bandBad = [];
1076
+ for (const c of [...monthCols, totalC, 0, 1]) if (bgAt(grid, totalR, c) !== COLOR.totalBg) bandBad.push(`${colLetter(c)}${totalR + 1}`);
1077
+ for (const c of [...monthCols, totalC, 7]) if (bgAt(grid, totalR + 1, c) !== COLOR.totalDaysBg) bandBad.push(`${colLetter(c)}${totalR + 2}`);
1078
+ check(out, `${label}: Total (#E5E5E5) and Total Days (#9EC1EF) bands span every month + Total Hrs`, !bandBad.length, bandBad.join(", "));
1079
+ const strayRight = [];
1080
+ for (let r = h - 1; r <= totalR + 1; r++) for (let c = totalC + 1; c < 26; c++) if (bgAt(grid, r, c) !== COLOR.white) strayRight.push(`${colLetter(c)}${r + 1}`);
1081
+ check(out, `${label}: nothing formatted right of the grid`, !strayRight.length, strayRight.slice(0, 8).join(", "));
1082
+ const sumDays = numAt(grid, totalR, 4);
1083
+ const sumCount = numAt(grid, totalR, 1);
1084
+ sumDaysAll += Number.isNaN(sumDays) ? 0 : sumDays;
1085
+ // Mths is written to 2 decimals (the sheet convention), so Σ Days can drift by up to
1086
+ // ΣCount × 20 × 0.005 from the exact figure — the tolerance follows the roster size.
1087
+ const tol = 0.05 + 0.1 * (Number.isNaN(sumCount) ? 1 : sumCount);
1088
+ tolAll += tol;
1089
+ const phase = expectedPhases.find((ph) => title.endsWith(ph));
1090
+ if (expectedPhases.length) {
1091
+ 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)})`);
1093
+ }
1094
+ prevBottom = totalR + 1;
972
1095
  }
1096
+ });
1097
+ if (prevBottom >= 0) {
1098
+ const strayBelow = [];
1099
+ for (let r = prevBottom + 1; r < Math.min(rowCount(grid), prevBottom + 40); r++) for (let c = 0; c < 26; c++) if (bgAt(grid, r, c) !== COLOR.white) strayBelow.push(`${colLetter(c)}${r + 1}`);
1100
+ check(out, "Team Mix: nothing formatted below the last grid", !strayBelow.length, strayBelow.slice(0, 8).join(", "));
973
1101
  }
974
- check(out, "Team Mix: body font is Arial (never Calibri)", !badFont.length, badFont.slice(0, 8).join(", "));
975
- check(out, "Team Mix: sage on exactly Days, Hrs, Total Hrs of role rows", !badSage.length && !whiteMissing.length, [...badSage, ...whiteMissing].slice(0, 8).join(", "));
976
- if (totalR > 0 && totalC > 0) {
977
- const bandBad = [];
978
- for (const c of [...monthCols, totalC, 0, 1]) if (bgAt(grid, totalR, c) !== COLOR.totalBg) bandBad.push(`${colLetter(c)}${totalR + 1}`);
979
- for (const c of [...monthCols, totalC, 7]) if (bgAt(grid, totalR + 1, c) !== COLOR.totalDaysBg) bandBad.push(`${colLetter(c)}${totalR + 2}`);
980
- check(out, "Team Mix: Total (#E5E5E5) and Total Days (#9EC1EF) bands span every month + Total Hrs", !bandBad.length, bandBad.join(", "));
981
- const stray = [];
982
- for (let r = totalR + 2; r < Math.min(rowCount(grid), totalR + 40); r++) for (let c = 0; c < 26; c++) if (bgAt(grid, r, c) !== COLOR.white) stray.push(`${colLetter(c)}${r + 1}`);
983
- for (let r = 0; r <= totalR + 1; r++) for (let c = totalC + 1; c < 26; c++) if (bgAt(grid, r, c) !== COLOR.white) stray.push(`${colLetter(c)}${r + 1}`);
984
- check(out, "Team Mix: nothing formatted outside the data area", !stray.length, stray.slice(0, 8).join(", "));
985
- const sumDays = numAt(grid, totalR, 4);
986
- if (!Number.isNaN(tshirtTotalDays)) {
987
- check(out, "Team Mix: Σ Days == T-Shirt Total Days", Math.abs(sumDays - tshirtTotalDays) < 0.15, `Team Mix ${sumDays}, T-Shirt ${tshirtTotalDays}`);
988
- }
1102
+ 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))})`);
989
1104
  }
990
- return { checks: out, people: people.length, months: monthCols.length };
1105
+ return { checks: out, grids: headers.length };
991
1106
  }
992
1107
 
993
1108
  function auditTechStack(grid) {
@@ -1019,7 +1134,7 @@ async function runAudit(api) {
1019
1134
  const checks = [];
1020
1135
  const tshirt = auditTshirt(await api.grid(TAB_TSHIRT));
1021
1136
  checks.push(...tshirt.checks);
1022
- checks.push(...auditTeamMix(await api.grid(TAB_TEAM), tshirt.layout.mf, tshirt.totalDays).checks);
1137
+ checks.push(...auditTeamMix(await api.grid(TAB_TEAM), tshirt.layout.mf, tshirt.totalDays, tshirt.phaseDays).checks);
1023
1138
  checks.push(...auditTechStack(await api.grid(TAB_TECH)).checks);
1024
1139
  if (tabs.has(TAB_OVERVIEW)) checks.push(...auditOverview(await api.grid(TAB_OVERVIEW)).checks);
1025
1140
  else check(checks, "Overview tab exists (the estimates index imports its cells)", false, "missing");
@@ -1075,21 +1190,25 @@ async function verbRead(api, tab) {
1075
1190
  return out;
1076
1191
  }
1077
1192
 
1193
+ function rostersTable(rosters) {
1194
+ return rosters.map((r) => `── ${r.phase}: ${r.totalDays} d ──\n${rosterTable(r.roster)}`).join("\n");
1195
+ }
1196
+
1078
1197
  async function verbPlanCheck(api, plan) {
1079
1198
  const layout = locateTshirt(await api.grid(TAB_TSHIRT));
1080
1199
  const totals = tshirtTotals(plan, layout);
1081
- const roster = buildRoster(plan, totals.totalDays, layout.mf);
1082
- return { totals, mf: layout.mf, mfTotal: round2(layout.mfTotal), highFactor: layout.highFactor, rate: layout.rate, roster, table: rosterTable(roster) };
1200
+ const rosters = buildRosters(plan, layout);
1201
+ return { totals, mf: layout.mf, mfTotal: round2(layout.mfTotal), highFactor: layout.highFactor, rate: layout.rate, rosters, table: rostersTable(rosters) };
1083
1202
  }
1084
1203
 
1085
1204
  async function verbWrite(api, plan, opts) {
1086
1205
  const ts = await writeTshirt(api, plan, opts);
1087
- const roster = buildRoster(plan, ts.totals.totalDays, ts.layout.mf);
1088
- const tm = await writeTeamMix(api, plan, roster);
1206
+ const rosters = buildRosters(plan, ts.layout);
1207
+ const tm = await writeTeamMix(api, plan, rosters);
1089
1208
  const tech = await writeTechStack(api, plan);
1090
1209
  const result = {
1091
1210
  tshirt: { mode: plan.tshirt.mode, itemsWritten: ts.itemsWritten, firstItem1: ts.firstItem1, lastItem1: ts.lastItem1, totalRow1: ts.totalRow1, totals: ts.totals },
1092
- teamMix: { people: roster.people.length, months: roster.months, columns: roster.n, sumDays: roster.sumDays, table: rosterTable(roster), rowsWritten: tm.rows.length },
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 },
1093
1212
  techStack: tech,
1094
1213
  };
1095
1214
  if (!opts.noAudit) result.audit = await runAudit(api);
@@ -1152,7 +1271,7 @@ async function main(args) {
1152
1271
  if (json) console.log(JSON.stringify({ ok, exitCode: ok ? 0 : 4, ...r }, null, 2));
1153
1272
  else {
1154
1273
  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}`);
1155
- console.log(`Team Mix: ${r.teamMix.people} people, ${r.teamMix.months} months, ${r.teamMix.columns} columns, Σ Days ${r.teamMix.sumDays}`);
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}`);
1156
1275
  console.log(r.teamMix.table);
1157
1276
  console.log(`Technology Stack: ${r.techStack.rows} rows`);
1158
1277
  if (r.audit) printChecks(r.audit);
@@ -1172,7 +1291,7 @@ function haltAndExit(e, json) {
1172
1291
 
1173
1292
  module.exports = {
1174
1293
  validatePlan, splitRoster, rosterViolations, mfCoverageViolations, monthPlan, resampleWeights, rampHours, buildRoster,
1175
- tshirtTotals, itemFormulas, rollupFormulas, tshirtRows, teamMixValues, locateTshirt, findPhaseSource,
1294
+ tshirtTotals, phaseTotals, buildRosters, itemFormulas, rollupFormulas, tshirtRows, teamMixValues, teamMixFormatReqs, remainderFormula, locateTshirt, findPhaseSource,
1176
1295
  auditTshirt, auditTeamMix, auditTechStack, auditOverview, colLetter, hexToColor, colorToHex, sheetIdFromArg,
1177
1296
  constants: { SIZE_CODES, PHASES, COLOR, RAMP, ROLE_LABEL, SOFT_CEILING, FOLD_THRESHOLD, TAB_TSHIRT, TAB_TEAM, TAB_TECH, PLAN_SCHEMA },
1178
1297
  Halt, SheetsApi, getToken, runAudit, main,
package/bin/gsd-t.js CHANGED
@@ -3466,6 +3466,20 @@ async function doUpdateAll() {
3466
3466
  );
3467
3467
  }
3468
3468
 
3469
+ // Numeric dotted-version compare: negative when a < b, 0 when equal, positive when a > b.
3470
+ // GSD-T versions are plain Major.Minor.Patch integers (patch always ≥ 10), so a
3471
+ // segment-wise numeric compare is exact; no prerelease tags exist.
3472
+ function versionCmp(a, b) {
3473
+ const pa = String(a).split(".").map((n) => parseInt(n, 10));
3474
+ const pb = String(b).split(".").map((n) => parseInt(n, 10));
3475
+ for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
3476
+ const x = Number.isNaN(pa[i]) ? 0 : pa[i];
3477
+ const y = Number.isNaN(pb[i]) ? 0 : pb[i];
3478
+ if (x !== y) return x - y;
3479
+ }
3480
+ return 0;
3481
+ }
3482
+
3469
3483
  // Upgrade the globally-installed @tekyzinc/gsd-t to @latest. Returns
3470
3484
  // { upgraded: bool, reexec: bool, error?: string }.
3471
3485
  // - reexec=true when the on-disk version after `npm install -g` is newer than
@@ -3499,6 +3513,19 @@ async function upgradeGlobalBinary() {
3499
3513
  // Best-effort; fall through.
3500
3514
  }
3501
3515
 
3516
+ if (newVersion && versionCmp(newVersion, PKG_VERSION) < 0) {
3517
+ // v5.20.10 (2026-09-17): right after `npm publish`, npm's cached package
3518
+ // listing still named the PREVIOUS version as @latest, so this step
3519
+ // installed 5.19.11 over a running 5.20.10, reported it as an "upgrade",
3520
+ // handed off to the OLD binary, and that binary overwrote ~/.claude/commands
3521
+ // and every project with the old release. A downgrade is never an upgrade —
3522
+ // HALT and say how to fix it, never continue on a stale listing.
3523
+ error(`HALT: npm installed v${newVersion} over the running v${PKG_VERSION} — @latest resolved to an OLDER release.`);
3524
+ info("npm's cached package listing is stale. Run `npm cache clean --force`, wait until");
3525
+ info(`\`curl -sI https://registry.npmjs.org/@tekyzinc/gsd-t/-/gsd-t-${PKG_VERSION}.tgz\` returns 200, then re-run update-all.`);
3526
+ info(`Restore the current release first: npm install -g @tekyzinc/gsd-t@${PKG_VERSION}`);
3527
+ process.exit(1);
3528
+ }
3502
3529
  if (newVersion && newVersion !== PKG_VERSION) {
3503
3530
  success(`Global binary upgraded: v${PKG_VERSION} → v${newVersion}`);
3504
3531
  info("Handing off to the newly-installed binary for propagation...");
package/commands/cpua.md CHANGED
@@ -120,6 +120,19 @@ G="$(npm root -g)/@tekyzinc/gsd-t"
120
120
  # the pre-publish package listing cached and `@tekyzinc/gsd-t@{NEW}` returns ETARGET
121
121
  # for minutes while `npm view` already shows the version (v5.17.14, 2026-09-03).
122
122
  # The tarball URL bypasses the cached listing and is deterministic.
123
+ # 1b. WAIT until the registry actually SERVES the tarball, then CLEAR npm's cached listing.
124
+ # v5.20.10 (2026-09-17): `npm publish` printed "+ @tekyzinc/gsd-t@5.20.10" and `npm view`
125
+ # showed it, but the tarball URL 404'd for ~4 minutes. Worse, npm's cached package
126
+ # listing still said latest = the PREVIOUS version, so `gsd-t update-all` (which runs
127
+ # `npm install -g @tekyzinc/gsd-t@latest`) DOWNGRADED the global, handed off to the old
128
+ # binary, and that binary rewrote ~/.claude/commands and 34 projects with the old release
129
+ # — twice. Poll the tarball, then clean the cache, then install. Never skip either.
130
+ for i in $(seq 1 60); do
131
+ [ "$(curl -s -o /dev/null -w '%{http_code}' "https://registry.npmjs.org/@tekyzinc/gsd-t/-/gsd-t-{NEW_VERSION}.tgz")" = "200" ] && break
132
+ sleep 10
133
+ done
134
+ [ "$(curl -s -o /dev/null -w '%{http_code}' "https://registry.npmjs.org/@tekyzinc/gsd-t/-/gsd-t-{NEW_VERSION}.tgz")" = "200" ] || { echo "HALT: tarball still 404 after 10 min"; exit 1; }
135
+ npm cache clean --force
123
136
  npm install -g "https://registry.npmjs.org/@tekyzinc/gsd-t/-/gsd-t-{NEW_VERSION}.tgz"
124
137
  # 2. VERIFY ON DISK — never trust npm's exit code for this step. On v5.17.13 `npm install -g`
125
138
  # reported success and left the OLD version in place; `update-all` then ran from the
@@ -140,7 +153,11 @@ fi
140
153
  # file and the command files; only then does update-all see the new release.
141
154
  gsd-t install 2>&1 | tail -5
142
155
  [ "$(cat ~/.claude/.gsd-t-version)" = "{NEW_VERSION}" ] || { echo "HALT: ~/.claude/.gsd-t-version did not advance"; exit 1; }
143
- # 4. Propagate, then prove the global is STILL the new version and a NEW file reached a project.
156
+ # 4. Prove `@latest` now resolves to the NEW version (update-all installs `@latest`; a stale
157
+ # listing here is the downgrade path — since v5.20.11 update-all HALTS on a downgrade
158
+ # instead of handing off, but do not rely on the halt: check first).
159
+ [ "$(npm view @tekyzinc/gsd-t dist-tags.latest)" = "{NEW_VERSION}" ] || { echo "HALT: registry latest is not {NEW_VERSION}"; exit 1; }
160
+ # 5. Propagate, then prove the global is STILL the new version and a NEW file reached a project.
144
161
  gsd-t update-all 2>&1 | tail -30
145
162
  [ "$(node -p "require('$G/package.json').version")" = "{NEW_VERSION}" ] || { echo "HALT: global package reverted during update-all"; exit 1; }
146
163
  ```
@@ -85,7 +85,7 @@ Reorder items into domains. Insert a **section-heading row** before each group p
85
85
 
86
86
  ## Step 4: Team Mix — JUDGMENT · PAUSE FOR REVIEW
87
87
 
88
- Your judgment is ONE thing: the FTE per discipline (`teamMix.fte` in the plan — `backend` `frontend` `qa` `pm` `ba`, optionally `techlead` `devops`). Everything else on the tab is computed by the tool per spec §2: the split into people (saturate at 1.00, then spill), months, the column count, the ramp by discipline, the remainder formula.
88
+ Your judgment is ONE thing: the FTE per discipline (`teamMix.fte` in the plan — `backend` `frontend` `qa` `pm` `ba`, optionally `techlead` `devops`; per-phase override via `teamMix.phases.<phase>.fte`). Everything else on the tab is computed by the tool per spec §2: the split into people (saturate at 1.00, then spill), months, the column count, the ramp by discipline, the remainder formula — and **one grid per phase with hours** (MVP, Phase 1, …), stacked on the one tab with 2 blank rows between.
89
89
 
90
90
  1. **Staff every weighted MF factor** — QA → `qa`, PM → `pm`, Analysis → `ba`. Deployment / standups / buffer are absorbed by the engineers and lead. Typical fractions: PM 0.20–0.25 · BA 0.10 · Tech Lead 0.25 · QA 0.40–0.50. The tool HALTS on a roster that leaves a factor unstaffed — do not argue with it; add the person.
91
91
  2. **Run `gsd-t estimate-sheet plan-check --sheet <url> --plan <plan.json>`.** It prints the MF list it read, the T-Shirt total, and the roster table it WOULD write (person · Count · Mths · Days · Hrs · Mon 1..N with the remainder) — or halts with the violation.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tekyzinc/gsd-t",
3
- "version": "5.20.10",
3
+ "version": "5.20.12",
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",
@@ -76,35 +76,36 @@ Column widths: `[150, 120, 300, 430, 122, 90, 90, 61, 53, 76, 81, 81]`.
76
76
  | `K` | `=J{r}*8*$H$4` | nf `$#,##0.00` |
77
77
  | `L` | `=K{r}*$G$4` | nf `$#,##0.00` |
78
78
 
79
- ### 1.3 Totals and summary (BELOW the last item)
79
+ ### 1.3 Totals and summary (directly under the last item — NO blank rows)
80
80
 
81
81
  ```
82
82
  <last item row>
83
- (blank row)
84
- Total (Days) | … | H =SUM(H14:H<last>) | I =SUM(I…) | J =SUM(J…) | K =SUM(K…) | L =SUM(L…)
85
- (blank row)
86
- J: Total Days | K: =J<tot> | L: =J<tot>*$G$4
87
- J: Total Hrs | K: =J<tot>*8 | L: =J<tot>*$G$4*8
88
- J: Total Cost | K: =K<tot> | L: =L<tot>
83
+ Total (Days) | … | H =SUM(H14:H<last>) | I =SUM(I…) | J =SUM(J…) | K =SUM(K…) | L =SUM(L…) ← bg #D8DDE8, bold 10, A:L
84
+ J: Total Days | K: =J<tot> | L: =J<tot>*$G$4 ← label bold, 0.00
85
+ J: Total Hrs | K: =J<tot>*8 | L: =J<tot>*$G$4*8 ← label bold, 0.00
86
+ J: Total Cost | K: =K<tot> | L: =L<tot> ← label bold, $#,##0.00
89
87
  ```
90
88
 
91
- **The summary block sits BELOW the totals row, never inside the summed range** (the template
92
- ships it at rows 18–20, which becomes circular `#REF!` once items extend past it — move it).
89
+ - The totals row is the template's grey band (`#D8DDE8`, bold, size 10) across `A:L`; its numbers stay in General format (`79.25`, `158.5`, `63400`).
90
+ - No blank row above or below the totals row (David, 2026-09-21).
91
+ - **The summary block never sits inside the summed range** (the template ships it at rows 18–20, which becomes circular `#REF!` once items extend past it).
93
92
 
94
93
  ---
95
94
 
96
- ## 2. Team Mix tab (David's 2026-09-17 layout)
95
+ ## 2. Team Mix tab (David's 2026-09-17 layout · one grid per phase, 2026-09-21)
97
96
 
98
- ### 2.1 Layout
97
+ ### 2.1 Layout of ONE grid
99
98
 
100
99
  ```
101
- Row 1 Title (merged A:<Total Hrs col>)
102
- Row 2 Header (row 2 and ONLY row 2)
103
- Row 3.. one row PER PERSON
104
- Row T Total (T = 3 + nroles)
100
+ Row t Title "<estimate title> — <phase>" (merged A:<Total Hrs col>)
101
+ Row t+1 Header (the ONLY header row in the grid)
102
+ Row t+2.. one row PER PERSON
103
+ Row T Total (T = t + 2 + nroles)
105
104
  Row T+1 Total Days (right half only, H onward)
106
105
  ```
107
106
 
107
+ **One grid per phase with hours** (§2.6): the first grid starts at row 1; every further grid starts **exactly 2 blank rows** below the previous grid's Total Days row. All grids live on the one Team Mix tab.
108
+
108
109
  | Col | Header | Content | Style |
109
110
  |---|---|---|---|
110
111
  | `A` | `Skill set` | person label, e.g. `Backend / API Engineer 1` | left |
@@ -134,6 +135,10 @@ The old `Month / Days / Tot Days / Hrs` layout is retired: `Days` IS the per-per
134
135
  - **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).
135
136
  - The remainder formula makes each row sum exactly. Never write a fractional last month as a value.
136
137
 
138
+ ### 2.6 One grid per phase
139
+
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
+
137
142
  ### 2.3 Roster shape — one row per PERSON
138
143
 
139
144
  1. **No row over Count 1.00.** `Backend Engineer 1.40` is two people → two rows.
@@ -207,10 +212,11 @@ T-SHIRT
207
212
  [ ] every item row's H:L are the §1.2 formulas (no values)
208
213
  [ ] section heading rows are merged A:L, bg #1C4F8B, and hold no sizes
209
214
  [ ] totals row + phase rollups (K4:N7) + summary block reference <last item row>
210
- [ ] summary block is BELOW the totals row
215
+ [ ] totals row directly under the last item, grey band #D8DDE8 bold across A:L; summary block directly under it, labels bold, Total Cost in dollars
211
216
  [ ] Σ raw sizes × (1+MF) == Total Days cell (recomputed independently)
212
- TEAM MIX
213
- [ ] header is row 2 only; row 3 is a person
217
+ TEAM MIX (each grid)
218
+ [ ] one grid per phase with hours; the title ends with the phase name; exactly 2 empty, unformatted rows between grids
219
+ [ ] the header is the row under the title; the row under the header is a person
214
220
  [ ] header months read Mon 1..Mon N with N == month column count; last header is Total Hrs
215
221
  [ ] no Count > 1.00; per discipline, all rows but the last are 1.00
216
222
  [ ] every non-zero MF factor has a matching person row
@@ -220,7 +226,7 @@ TEAM MIX
220
226
  [ ] font Arial 10 everywhere; sage on exactly E, F, Total Hrs of role rows; monthly cells white
221
227
  [ ] Total row (#E5E5E5) and Total Days row (#9EC1EF) span every month column + Total Hrs
222
228
  [ ] nothing formatted outside the data area
223
- [ ] Σ Days == T-Shirt Total Days
229
+ [ ] each grid's Σ Days == that phase's T-Shirt days; all grids together == Total Days
224
230
  TECH STACK
225
231
  [ ] at least the categories that apply are filled; none says TBD
226
232
  OVERVIEW
@@ -257,5 +263,5 @@ The plan (judgment only):
257
263
  ```
258
264
 
259
265
  - `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.
260
- - `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.
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.
261
267
  - The MF list, legend, rate and high factor are READ from the sheet; the plan never carries them.