@tekyzinc/gsd-t 5.20.11 → 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,34 @@
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
+
5
33
  ## [5.20.11] - 2026-09-17
6
34
 
7
35
  ### Fixed — `update-all` treated a downgrade as an upgrade and rolled 34 projects back
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # GSD-T: Contract-Driven Development for Claude Code
2
2
 
3
- **v5.20.11** - 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,
@@ -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.11",
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.