@tekyzinc/gsd-t 5.20.11 → 5.20.13

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,47 @@
2
2
 
3
3
  All notable changes to GSD-T are documented here. Updated with each release.
4
4
 
5
+ ## [5.20.13] - 2026-09-21
6
+
7
+ ### Fixed — Team Mix staffs the midpoint of Low and High, not Low
8
+
9
+ David's correction on the first live sheet. The Team Mix was sized to the Low figure;
10
+ Tekyz staffs the midpoint of the Low and High estimates.
11
+
12
+ - `bin/gsd-t-estimate-sheet.cjs`: `midDays = low × (1 + high factor) / 2`; each phase grid is
13
+ built and audited against `(Low Hrs + High Hrs) ÷ 2 ÷ 8` from that phase's rollups; the
14
+ grids together against the midpoint of `Total Days` and `Total Days × high factor`.
15
+ - `templates/estimate-sheet-spec.md` §2.2 / §2.6 / §5; tests updated; live write on the E2E
16
+ copy: 3 phase grids, all audit checks pass.
17
+
18
+ ## [5.20.12] - 2026-09-21
19
+
20
+ ### Fixed — estimate writer proven end-to-end; two-month remainder formula
21
+
22
+ First live run of `gsd-t estimate-sheet write` on a copy of the Tekyz template (David's
23
+ "E2E test of the estimator" sheet): 11 items in 4 sections, a 6-person / 2-month Team Mix,
24
+ and a 4-row Technology Stack, then all 33 read-back audit checks pass — 8 seconds, no hand edits.
25
+
26
+ - `bin/gsd-t-estimate-sheet.cjs`: with exactly two month columns the remainder was written
27
+ as `=F3-SUM(I3:I3)`; Google canonicalises a one-cell range to `SUM(I3)`, so the audit's
28
+ read-back never matched. New `remainderFormula()` emits `=F3-I3` for two months, the
29
+ `SUM(first:second-to-last)` form for three or more; writer and audit share it.
30
+ - `test/estimate-sheet-writer.test.js`: the two-month case, including the writer's own
31
+ output passing its own audit.
32
+
33
+ ### Changed — David's review of the first live sheet (2026-09-21)
34
+
35
+ - T-Shirt: no blank row above or below the totals row; the totals row is the template's grey
36
+ band (`#D8DDE8`, bold) across A:L; the summary labels (Total Days / Hrs / Cost) are bold and
37
+ Total Cost is formatted as dollars. Audit checks added for each.
38
+ - Team Mix: **one grid per phase with hours** (MVP, Phase 1, …) stacked on the one tab with
39
+ exactly 2 blank rows between grids; each grid reconciles to its phase's T-Shirt days (read
40
+ from the phase rollups) and the grids together to Total Days. `teamMix.phases.<phase>.fte`
41
+ overrides the mix for one phase. `Mths` is written at full precision (displayed 0.00) so
42
+ Σ Days reconciles exactly instead of drifting with the roster size.
43
+ - Section heading blue stays `#1C4F8B` (confirmed); template copies stay a manual step (the
44
+ service account has no Drive quota; domain-wide delegation rejected as a master-key risk).
45
+
5
46
  ## [5.20.11] - 2026-09-17
6
47
 
7
48
  ### 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.13** - 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,48 @@ 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
+ /** Team Mix staffs the MIDPOINT of the Low and High figures (David, 2026-09-21): mid = low × (1 + highFactor) / 2. */
744
+ function midDays(lowDays, highFactor) { return round2(lowDays * (1 + highFactor) / 2); }
745
+
746
+ /** Per-phase T-Shirt totals in PHASES order — only phases with hours > 0 (spec §2.6). `totalDays` is the Low figure; `staffDays` the midpoint the Team Mix staffs. */
747
+ function phaseTotals(plan, layout) {
748
+ const raw = {};
749
+ for (const s of plan.tshirt.sections) for (const it of s.items) {
750
+ raw[it.phase] = (raw[it.phase] ? raw[it.phase] : 0) + sizeDays(it.fe, layout.legend) + sizeDays(it.be, layout.legend);
751
+ }
752
+ return PHASES.filter((ph) => raw[ph] > 0).map((ph) => {
753
+ const low = round2(raw[ph] * (1 + layout.mfTotal));
754
+ return { phase: ph, rawDays: round2(raw[ph]), totalDays: low, highDays: round2(low * layout.highFactor), staffDays: midDays(low, layout.highFactor) };
755
+ });
756
+ }
757
+
758
+ /** One roster per phase with hours. FTE comes from teamMix.phases[<phase>].fte when given, else teamMix.fte. */
759
+ function buildRosters(plan, layout) {
760
+ const phases = phaseTotals(plan, layout);
761
+ if (!phases.length) throw new Halt("no phase has any hours — nothing to staff");
762
+ return phases.map((ph) => {
763
+ const override = plan.teamMix.phases && plan.teamMix.phases[ph.phase] && plan.teamMix.phases[ph.phase].fte;
764
+ const sub = override ? { ...plan, teamMix: { ...plan.teamMix, fte: override } } : plan;
765
+ return { ...ph, roster: buildRoster(sub, ph.staffDays, layout.mf) };
766
+ });
767
+ }
768
+
769
+ /**
770
+ * The last month's REMAINDER formula (spec §2.1). n=1: the whole total; n=2: `=F-I`
771
+ * (Google rewrites a one-cell range `SUM(I3:I3)` to `SUM(I3)`, so the read-back
772
+ * would never match — write the canonical form); n>2: `=F-SUM(first:second-to-last)`.
773
+ */
774
+ function remainderFormula(r1, firstMonthC, n) {
775
+ if (n === 1) return `=F${r1}`;
776
+ if (n === 2) return `=F${r1}-${colLetter(firstMonthC)}${r1}`;
777
+ return `=F${r1}-SUM(${colLetter(firstMonthC)}${r1}:${colLetter(firstMonthC + n - 2)}${r1})`;
778
+ }
779
+
780
+ /**
781
+ * The cell values of ONE Team Mix grid starting at 0-based row `top` (spec §2.1):
782
+ * title, header, one row per person, Total, Total Days. Formulas use absolute rows.
783
+ */
784
+ function teamMixValues(plan, roster, phase, top) {
736
785
  const n = roster.n;
737
786
  const firstMonthC = 8; // I
738
787
  const totalC = firstMonthC + n; // Total Hrs column index — derived, never remembered
@@ -742,18 +791,18 @@ function teamMixValues(plan, roster) {
742
791
  for (let m = 1; m <= n; m++) header.push(`Mon ${m}`);
743
792
  header.push("Total Hrs");
744
793
  const rows = [];
745
- rows.push([plan.title, ...Array(width - 1).fill("")]);
794
+ rows.push([phase ? `${plan.title} — ${phase}` : plan.title, ...Array(width - 1).fill("")]);
746
795
  rows.push(header);
796
+ const firstP = top + 3; // 1-based row of the first person
747
797
  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];
798
+ const r1 = firstP + i;
799
+ const row = [p.label, p.count, "", roster.monthsExact, `=D${r1}*20*B${r1}`, `=E${r1}*8`, "", p.label];
750
800
  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})`);
801
+ row.push(remainderFormula(r1, firstMonthC, n));
752
802
  row.push(`=SUM(${L(firstMonthC)}${r1}:${L(totalC - 1)}${r1})`);
753
803
  rows.push(row);
754
804
  });
755
- const firstP = 3;
756
- const lastP = 2 + roster.people.length;
805
+ const lastP = firstP + roster.people.length - 1;
757
806
  const totalR = lastP + 1;
758
807
  const total = ["Total", `=SUM(B${firstP}:B${lastP})`, "", "", `=SUM(E${firstP}:E${lastP})`, `=SUM(F${firstP}:F${lastP})`, "", "Total Hours"];
759
808
  for (let c = firstMonthC; c <= totalC; c++) total.push(`=SUM(${L(c)}${firstP}:${L(c)}${lastP})`);
@@ -761,45 +810,65 @@ function teamMixValues(plan, roster) {
761
810
  const totalDays = ["", "", "", "", "", "", "", "Total Days"];
762
811
  for (let c = firstMonthC; c <= totalC; c++) totalDays.push(`=${L(c)}${totalR}/8`);
763
812
  rows.push(totalDays);
764
- return { rows, width, totalC, firstMonthC, totalR, firstP, lastP };
813
+ return { rows, width, totalC, firstMonthC, top, titleR: top + 1, headerR: top + 2, firstP, lastP, totalR, totalDaysR: totalR + 1, bottom: totalR + 1 };
765
814
  }
766
815
 
767
- async function writeTeamMix(api, plan, roster) {
816
+ const GRID_GAP = 2; // blank rows between phase grids (spec §2.6)
817
+
818
+ /** Format requests for one grid (spec §2.1 styling), computed from the grid's own shape. */
819
+ function teamMixFormatReqs(sheetId, v) {
820
+ const W = v.width;
821
+ const arial = (bold) => ({ fontFamily: "Arial", fontSize: 10, bold });
822
+ const white = { backgroundColor: hexToColor(COLOR.white) };
823
+ const reqs = [];
824
+ const t0 = v.titleR - 1, h0 = v.headerR - 1, p0 = v.firstP - 1, p1 = v.lastP, tot0 = v.totalR - 1;
825
+ reqs.push({ mergeCells: { range: gridRange(sheetId, t0, t0 + 1, 0, W), mergeType: "MERGE_ALL" } });
826
+ 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)"));
827
+ reqs.push(fmtReq(sheetId, h0, v.bottom, 0, W, { textFormat: arial(false), horizontalAlignment: "RIGHT", numberFormat: { type: "NUMBER", pattern: "0.00" } }, FMT_FIELDS_ALL));
828
+ reqs.push(fmtReq(sheetId, h0, h0 + 1, 0, W, { backgroundColor: hexToColor(COLOR.teamHeaderBg), textFormat: { ...arial(true), foregroundColor: hexToColor(COLOR.white) } }, "userEnteredFormat(backgroundColor,textFormat)"));
829
+ reqs.push(fmtReq(sheetId, h0, h0 + 1, 2, 3, white, "userEnteredFormat.backgroundColor"));
830
+ reqs.push(fmtReq(sheetId, h0, h0 + 1, 6, 7, white, "userEnteredFormat.backgroundColor"));
831
+ reqs.push(fmtReq(sheetId, h0, v.bottom, 0, 1, { horizontalAlignment: "LEFT" }, "userEnteredFormat.horizontalAlignment"));
832
+ reqs.push(fmtReq(sheetId, h0, v.bottom, 7, 8, { horizontalAlignment: "LEFT" }, "userEnteredFormat.horizontalAlignment"));
833
+ reqs.push(fmtReq(sheetId, h0, h0 + 1, v.totalC, v.totalC + 1, { horizontalAlignment: "LEFT" }, "userEnteredFormat.horizontalAlignment"));
834
+ const sage = { backgroundColor: hexToColor(COLOR.sage), textFormat: arial(true) };
835
+ reqs.push(fmtReq(sheetId, p0, p1, 4, 6, sage, "userEnteredFormat(backgroundColor,textFormat)"));
836
+ reqs.push(fmtReq(sheetId, p0, p1, v.totalC, v.totalC + 1, sage, "userEnteredFormat(backgroundColor,textFormat)"));
837
+ reqs.push(fmtReq(sheetId, tot0, tot0 + 1, 0, W, { backgroundColor: hexToColor(COLOR.totalBg), textFormat: arial(true) }, "userEnteredFormat(backgroundColor,textFormat)"));
838
+ reqs.push(fmtReq(sheetId, tot0, tot0 + 1, 2, 3, white, "userEnteredFormat.backgroundColor"));
839
+ reqs.push(fmtReq(sheetId, tot0, tot0 + 1, 6, 7, white, "userEnteredFormat.backgroundColor"));
840
+ reqs.push(fmtReq(sheetId, tot0 + 1, tot0 + 2, 7, W, { backgroundColor: hexToColor(COLOR.totalDaysBg), textFormat: arial(true) }, "userEnteredFormat(backgroundColor,textFormat)"));
841
+ return reqs;
842
+ }
843
+
844
+ /** Write every phase grid on the one Team Mix tab: clear-then-paint, GRID_GAP blank rows between grids. */
845
+ async function writeTeamMix(api, plan, rosters) {
768
846
  const grid = await api.grid(TAB_TEAM);
769
847
  const sheetId = grid.properties.sheetId;
770
- const v = teamMixValues(plan, roster);
771
- const clearRows = Math.max(60, rowCount(grid) + 2);
848
+ const grids = [];
849
+ let top = 0;
850
+ for (const r of rosters) {
851
+ const v = teamMixValues(plan, r.roster, rosters.length > 1 || r.phase ? r.phase : "", top);
852
+ grids.push({ phase: r.phase, v });
853
+ top = v.bottom + GRID_GAP;
854
+ }
855
+ const lastRow = grids[grids.length - 1].v.bottom;
856
+ const clearRows = Math.max(60, rowCount(grid) + 2, lastRow + 10);
772
857
  await api.clearValues(TAB_TEAM, `A1:Z${clearRows}`);
773
858
  await api.batch([
774
859
  { unmergeCells: { range: gridRange(sheetId, 0, clearRows, 0, 26) } },
775
860
  fmtReq(sheetId, 0, clearRows, 0, 26, {}, "userEnteredFormat"),
776
861
  ]);
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
862
  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]));
863
+ let maxN = 0;
864
+ for (const g of grids) {
865
+ await api.putValues(TAB_TEAM, `A${g.v.titleR}:${colLetter(g.v.width - 1)}${g.v.bottom}`, g.v.rows);
866
+ reqs.push(...teamMixFormatReqs(sheetId, g.v));
867
+ maxN = Math.max(maxN, g.v.totalC - g.v.firstMonthC);
868
+ }
869
+ reqs.push(...widthReqs(sheetId, [...TEAM_WIDTHS_FIXED, ...Array(maxN).fill(TEAM_MONTH_WIDTH), TEAM_TOTAL_WIDTH]));
801
870
  await api.batch(reqs);
802
- return v;
871
+ return { grids: grids.map((g) => ({ phase: g.phase, titleR: g.v.titleR, bottom: g.v.bottom, rows: g.v.rows.length })), lastRow };
803
872
  }
804
873
 
805
874
  // ───────────────────────── write: Technology Stack (spec §3) ─────────────────────────
@@ -865,7 +934,23 @@ function auditTshirt(grid) {
865
934
  check(out, "T-Shirt: section rows are styled (bg #1C4F8B)", !badSectionFmt.length, badSectionFmt.join(", "));
866
935
  check(out, "T-Shirt: 'Total (Days)' row exists below the items", totalRow0 > lastItem0 && lastItem0 >= 0, "not found");
867
936
  let totalDaysCell = NaN;
937
+ // Team Mix reconciles to the MIDPOINT of each phase's Low Hrs (L) and High Hrs (N), in days
938
+ const phaseDays = {};
939
+ for (const rr of layout.rollupRows) {
940
+ const low = numAt(grid, rr, 11), high = numAt(grid, rr, 13);
941
+ phaseDays[textAt(grid, rr, 9).trim()] = Number.isNaN(low) || Number.isNaN(high) ? NaN : round2((low + high) / 2 / 8);
942
+ }
868
943
  if (totalRow0 > lastItem0 && lastItem0 >= 0) {
944
+ check(out, "T-Shirt: totals row sits directly under the last item (no blank row)", totalRow0 === lastItem0 + 1, `last item row ${lastItem0 + 1}, totals row ${totalRow0 + 1}`);
945
+ const totFont = fontAt(grid, totalRow0, 0);
946
+ check(out, "T-Shirt: totals row is the template band (bg #D8DDE8, bold) across A:L", [0, 4, 9, 11].every((c) => bgAt(grid, totalRow0, c) === COLOR.tshirtTotalBg) && totFont.bold, `A bg ${bgAt(grid, totalRow0, 0)}, L bg ${bgAt(grid, totalRow0, 11)}, bold ${totFont.bold}`);
947
+ const sumRows = [totalRow0 + 1, totalRow0 + 2, totalRow0 + 3];
948
+ const labels = sumRows.map((r) => textAt(grid, r, 9));
949
+ check(out, "T-Shirt: summary block (Total Days / Total Hrs / Total Cost) sits directly under the totals row", labels[0] === "Total Days" && labels[1] === "Total Hrs" && labels[2] === "Total Cost", `J${sumRows[0] + 1}..J${sumRows[2] + 1} = [${labels.join(", ")}]`);
950
+ check(out, "T-Shirt: summary labels are bold", sumRows.every((r) => fontAt(grid, r, 9).bold), "");
951
+ const costCell = cellAt(grid, sumRows[2], 10);
952
+ const costFmt = costCell && costCell.userEnteredFormat && costCell.userEnteredFormat.numberFormat;
953
+ check(out, "T-Shirt: Total Cost is formatted as dollars", !!costFmt && costFmt.type === "CURRENCY", `K${sumRows[2] + 1} numberFormat ${JSON.stringify(costFmt)}`);
869
954
  const want = `=SUM(J${first0 + 1}:J${lastItem0 + 1})`;
870
955
  check(out, "T-Shirt: totals row sums the full item range", formulaAt(grid, totalRow0, 9) === want, `J${totalRow0 + 1} is '${formulaAt(grid, totalRow0, 9)}', want '${want}'`);
871
956
  totalDaysCell = numAt(grid, totalRow0, 9);
@@ -876,7 +961,7 @@ function auditTshirt(grid) {
876
961
  check(out, "T-Shirt: phase rollups (K4:N7) reference the full item range", !badRoll.length, badRoll.join(", "));
877
962
  let summaryAbove = false;
878
963
  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");
964
+ check(out, "T-Shirt: no summary label inside the summed range", !summaryAbove, "a Total Days/Hrs/Cost label sits inside the summed range");
880
965
  let raw = 0;
881
966
  for (let r = first0; r <= lastItem0; r++) {
882
967
  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 +969,7 @@ function auditTshirt(grid) {
884
969
  const recomputed = round2(raw * (1 + layout.mfTotal));
885
970
  check(out, "T-Shirt: Σ raw sizes × (1+MF) == Total Days cell", Math.abs(recomputed - totalDaysCell) < 0.02, `recomputed ${recomputed}, cell ${totalDaysCell}`);
886
971
  }
887
- return { checks: out, layout, totalDays: totalDaysCell, lastItem1: lastItem0 + 1 };
972
+ return { checks: out, layout, totalDays: totalDaysCell, staffDays: Number.isNaN(totalDaysCell) ? NaN : midDays(totalDaysCell, layout.highFactor), lastItem1: lastItem0 + 1, phaseDays };
888
973
  }
889
974
 
890
975
  function disciplineOfLabel(label) {
@@ -899,95 +984,132 @@ function disciplineOfLabel(label) {
899
984
  return "";
900
985
  }
901
986
 
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) {
987
+ /**
988
+ * Team Mix audit — one grid per phase with hours (spec §2.6). mfList (from the T-Shirt
989
+ * tab) is REQUIRED. phaseDays = { phase: days } read from the T-Shirt rollups (the
990
+ * independent figure each grid must reconcile to); tshirtTotalDays for the sum.
991
+ */
992
+ function auditTeamMix(grid, mfList, tshirtTotalDays, phaseDays) {
904
993
  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)`);
994
+ // locate grids: every row whose A == 'Skill set' is a header; its title is the row above
995
+ const headers = [];
996
+ for (let r = 0; r < rowCount(grid); r++) if (textAt(grid, r, 0) === "Skill set") headers.push(r);
997
+ check(out, "Team Mix: at least one grid (a 'Skill set' header row)", headers.length > 0, "no header row found");
998
+ const expectedPhases = phaseDays ? Object.keys(phaseDays).filter((ph) => phaseDays[ph] > 0) : [];
999
+ 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(", ")}]`);
1000
+ let sumDaysAll = 0;
1001
+ let tolAll = 0;
1002
+ let prevBottom = -1;
1003
+ headers.forEach((h, gi) => {
1004
+ const label = headers.length > 1 ? `Team Mix grid ${gi + 1}` : "Team Mix";
1005
+ const title = textAt(grid, h - 1, 0);
1006
+ check(out, `${label}: title row directly above the header`, h >= 1 && title !== "" && title !== "Skill set", `row ${h} above header is '${title}'`);
1007
+ if (gi > 0) {
1008
+ const gap = h - 1 - (prevBottom + 1);
1009
+ check(out, `${label}: exactly ${GRID_GAP} blank rows before it`, gap === GRID_GAP, `${gap} row(s) between the previous grid and this title`);
1010
+ let strayGap = [];
1011
+ 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}`);
1012
+ check(out, `${label}: the gap rows are empty and unformatted`, !strayGap.length, strayGap.slice(0, 6).join(", "));
1013
+ }
1014
+ const hdr = [];
1015
+ for (let c = 0; c < 30; c++) hdr.push(textAt(grid, h, c));
1016
+ 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)}'`);
1017
+ const monthCols = [];
1018
+ hdr.forEach((x, c) => { if (/^Mon \d+$/.test(x)) monthCols.push(c); });
1019
+ const totalC = hdr.indexOf("Total Hrs");
1020
+ const lastM = monthCols.length ? monthCols[monthCols.length - 1] : -1;
1021
+ 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}`);
1022
+ 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("|"));
1023
+ const people = [];
1024
+ let totalR = -1;
1025
+ for (let r = h + 1; r < rowCount(grid); r++) {
1026
+ const a = textAt(grid, r, 0);
1027
+ if (/^Total$/i.test(a)) { totalR = r; break; }
1028
+ if (a === "") break;
1029
+ people.push({ r, label: a, count: numAt(grid, r, 1), discipline: disciplineOfLabel(a) });
1030
+ }
1031
+ check(out, `${label}: a 'Total' row follows the people`, totalR > h, "no Total row");
1032
+ 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(", "));
1033
+ 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(", "));
1034
+ const groups = {};
1035
+ people.forEach((p) => { const k = p.label.replace(/\s+\d+$/, ""); (groups[k] = groups[k] ? groups[k] : []).push(p); });
1036
+ const smeared = [];
1037
+ 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`); });
1038
+ check(out, `${label}: per discipline, all rows but the last are 1.00 (saturate then spill)`, !smeared.length, smeared.join("; "));
1039
+ const cov = mfCoverageViolations(mfList, people.filter((p) => p.discipline));
1040
+ check(out, `${label}: every non-zero MF factor has a person`, !cov.length, cov.join("; "));
1041
+ const badF = [], negative = [], over = [];
1042
+ if (monthCols.length && totalC > 0) {
1043
+ const firstM = monthCols[0];
1044
+ for (const p of people) {
1045
+ const r1 = p.r + 1;
1046
+ if (formulaAt(grid, p.r, 4) !== `=D${r1}*20*B${r1}`) badF.push(`E${r1}`);
1047
+ if (formulaAt(grid, p.r, 5) !== `=E${r1}*8`) badF.push(`F${r1}`);
1048
+ if (monthCols.length > 1 && formulaAt(grid, p.r, lastM) !== remainderFormula(r1, firstM, monthCols.length)) badF.push(`${colLetter(lastM)}${r1} (remainder)`);
1049
+ if (!formulaAt(grid, p.r, totalC).startsWith("=")) badF.push(`${colLetter(totalC)}${r1}`);
1050
+ for (const c of monthCols) {
1051
+ const hv = numAt(grid, p.r, c);
1052
+ if (Number.isNaN(hv)) negative.push(`${colLetter(c)}${r1}=not a number`);
1053
+ else if (hv < -1e-9) negative.push(`${colLetter(c)}${r1}=${hv}`);
1054
+ else if (hv > SOFT_CEILING + 1e-9) over.push(`${colLetter(c)}${r1}=${hv}`);
1055
+ }
943
1056
  }
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}`);
1057
+ }
1058
+ check(out, `${label}: Days/Hrs/remainder/Total Hrs are formulas`, !badF.length, badF.join(", "));
1059
+ check(out, `${label}: no negative or non-numeric month`, !negative.length, negative.join(", "));
1060
+ check(out, `${label}: no month cell above ${SOFT_CEILING} hrs`, !over.length, over.join(", "));
1061
+ if (monthCols.length > 1 && people.length) {
1062
+ const flat = people.filter((p) => p.discipline !== "pm").every((p) => {
1063
+ const vals = monthCols.map((c) => round1(numAt(grid, p.r, c)));
1064
+ return vals.every((x) => Math.abs(x - vals[0]) < 0.05);
1065
+ });
1066
+ check(out, `${label}: ramp applied (roles are not all flat)`, !flat, "every non-PM role has identical hours each month");
1067
+ }
1068
+ const badFont = [], badSage = [], whiteMissing = [];
1069
+ for (const p of people) {
1070
+ for (let c = 0; c <= totalC; c++) {
1071
+ const f = fontAt(grid, p.r, c);
1072
+ if (f.family && f.family !== "Arial") badFont.push(`${colLetter(c)}${p.r + 1}=${f.family}`);
1073
+ const bg = bgAt(grid, p.r, c);
1074
+ const shouldSage = c === 4 || c === 5 || c === totalC;
1075
+ if (shouldSage && bg !== COLOR.sage) badSage.push(`${colLetter(c)}${p.r + 1} bg ${bg}`);
1076
+ if (!shouldSage && bg !== COLOR.white) whiteMissing.push(`${colLetter(c)}${p.r + 1} bg ${bg}`);
950
1077
  }
951
1078
  }
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}`);
1079
+ check(out, `${label}: body font is Arial (never Calibri)`, !badFont.length, badFont.slice(0, 8).join(", "));
1080
+ check(out, `${label}: sage on exactly Days, Hrs, Total Hrs of role rows`, !badSage.length && !whiteMissing.length, [...badSage, ...whiteMissing].slice(0, 8).join(", "));
1081
+ if (totalR > 0 && totalC > 0) {
1082
+ const bandBad = [];
1083
+ for (const c of [...monthCols, totalC, 0, 1]) if (bgAt(grid, totalR, c) !== COLOR.totalBg) bandBad.push(`${colLetter(c)}${totalR + 1}`);
1084
+ for (const c of [...monthCols, totalC, 7]) if (bgAt(grid, totalR + 1, c) !== COLOR.totalDaysBg) bandBad.push(`${colLetter(c)}${totalR + 2}`);
1085
+ check(out, `${label}: Total (#E5E5E5) and Total Days (#9EC1EF) bands span every month + Total Hrs`, !bandBad.length, bandBad.join(", "));
1086
+ const strayRight = [];
1087
+ 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}`);
1088
+ check(out, `${label}: nothing formatted right of the grid`, !strayRight.length, strayRight.slice(0, 8).join(", "));
1089
+ const sumDays = numAt(grid, totalR, 4);
1090
+ const sumCount = numAt(grid, totalR, 1);
1091
+ sumDaysAll += Number.isNaN(sumDays) ? 0 : sumDays;
1092
+ // Mths is written to 2 decimals (the sheet convention), so Σ Days can drift by up to
1093
+ // ΣCount × 20 × 0.005 from the exact figure — the tolerance follows the roster size.
1094
+ const tol = 0.05 + 0.1 * (Number.isNaN(sumCount) ? 1 : sumCount);
1095
+ tolAll += tol;
1096
+ const phase = expectedPhases.find((ph) => title.endsWith(ph));
1097
+ if (expectedPhases.length) {
1098
+ check(out, `${label}: title names a phase with hours`, !!phase, `title '${title}' ends with none of [${expectedPhases.join(", ")}]`);
1099
+ if (phase) check(out, `${label} (${phase}): Σ Days == midpoint of that phase's Low/High days`, Math.abs(sumDays - phaseDays[phase]) < tol, `Team Mix ${sumDays}, T-Shirt ${phaseDays[phase]} (tolerance ${round2(tol)})`);
1100
+ }
1101
+ prevBottom = totalR + 1;
972
1102
  }
1103
+ });
1104
+ if (prevBottom >= 0) {
1105
+ const strayBelow = [];
1106
+ 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}`);
1107
+ check(out, "Team Mix: nothing formatted below the last grid", !strayBelow.length, strayBelow.slice(0, 8).join(", "));
973
1108
  }
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
- }
1109
+ if (!Number.isNaN(tshirtTotalDays) && headers.length) {
1110
+ check(out, "Team Mix: Σ Days over all grids == midpoint of T-Shirt Low/High days", Math.abs(sumDaysAll - tshirtTotalDays) < Math.max(0.15, tolAll), `Team Mix ${round2(sumDaysAll)}, T-Shirt midpoint ${tshirtTotalDays} (tolerance ${round2(Math.max(0.15, tolAll))})`);
989
1111
  }
990
- return { checks: out, people: people.length, months: monthCols.length };
1112
+ return { checks: out, grids: headers.length };
991
1113
  }
992
1114
 
993
1115
  function auditTechStack(grid) {
@@ -1019,7 +1141,7 @@ async function runAudit(api) {
1019
1141
  const checks = [];
1020
1142
  const tshirt = auditTshirt(await api.grid(TAB_TSHIRT));
1021
1143
  checks.push(...tshirt.checks);
1022
- checks.push(...auditTeamMix(await api.grid(TAB_TEAM), tshirt.layout.mf, tshirt.totalDays).checks);
1144
+ checks.push(...auditTeamMix(await api.grid(TAB_TEAM), tshirt.layout.mf, tshirt.staffDays, tshirt.phaseDays).checks);
1023
1145
  checks.push(...auditTechStack(await api.grid(TAB_TECH)).checks);
1024
1146
  if (tabs.has(TAB_OVERVIEW)) checks.push(...auditOverview(await api.grid(TAB_OVERVIEW)).checks);
1025
1147
  else check(checks, "Overview tab exists (the estimates index imports its cells)", false, "missing");
@@ -1075,21 +1197,25 @@ async function verbRead(api, tab) {
1075
1197
  return out;
1076
1198
  }
1077
1199
 
1200
+ function rostersTable(rosters) {
1201
+ return rosters.map((r) => `── ${r.phase}: low ${r.totalDays} d · high ${r.highDays} d → staffing the midpoint ${r.staffDays} d ──\n${rosterTable(r.roster)}`).join("\n");
1202
+ }
1203
+
1078
1204
  async function verbPlanCheck(api, plan) {
1079
1205
  const layout = locateTshirt(await api.grid(TAB_TSHIRT));
1080
1206
  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) };
1207
+ const rosters = buildRosters(plan, layout);
1208
+ return { totals, mf: layout.mf, mfTotal: round2(layout.mfTotal), highFactor: layout.highFactor, rate: layout.rate, rosters, table: rostersTable(rosters) };
1083
1209
  }
1084
1210
 
1085
1211
  async function verbWrite(api, plan, opts) {
1086
1212
  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);
1213
+ const rosters = buildRosters(plan, ts.layout);
1214
+ const tm = await writeTeamMix(api, plan, rosters);
1089
1215
  const tech = await writeTechStack(api, plan);
1090
1216
  const result = {
1091
1217
  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 },
1218
+ teamMix: { grids: tm.grids, phases: rosters.map((r) => ({ phase: r.phase, lowDays: r.totalDays, highDays: r.highDays, staffDays: r.staffDays, people: r.roster.people.length, months: r.roster.months, columns: r.roster.n, sumDays: r.roster.sumDays })), table: rostersTable(rosters), lastRow: tm.lastRow },
1093
1219
  techStack: tech,
1094
1220
  };
1095
1221
  if (!opts.noAudit) result.audit = await runAudit(api);
@@ -1152,7 +1278,7 @@ async function main(args) {
1152
1278
  if (json) console.log(JSON.stringify({ ok, exitCode: ok ? 0 : 4, ...r }, null, 2));
1153
1279
  else {
1154
1280
  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}`);
1281
+ for (const ph of r.teamMix.phases) console.log(`Team Mix — ${ph.phase}: ${ph.people} people, ${ph.months} months, ${ph.columns} columns, Σ Days ${ph.sumDays} (midpoint of ${ph.lowDays}–${ph.highDays})`);
1156
1282
  console.log(r.teamMix.table);
1157
1283
  console.log(`Technology Stack: ${r.techStack.rows} rows`);
1158
1284
  if (r.audit) printChecks(r.audit);
@@ -1172,7 +1298,7 @@ function haltAndExit(e, json) {
1172
1298
 
1173
1299
  module.exports = {
1174
1300
  validatePlan, splitRoster, rosterViolations, mfCoverageViolations, monthPlan, resampleWeights, rampHours, buildRoster,
1175
- tshirtTotals, itemFormulas, rollupFormulas, tshirtRows, teamMixValues, locateTshirt, findPhaseSource,
1301
+ tshirtTotals, phaseTotals, buildRosters, midDays, itemFormulas, rollupFormulas, tshirtRows, teamMixValues, teamMixFormatReqs, remainderFormula, locateTshirt, findPhaseSource,
1176
1302
  auditTshirt, auditTeamMix, auditTechStack, auditOverview, colLetter, hexToColor, colorToHex, sheetIdFromArg,
1177
1303
  constants: { SIZE_CODES, PHASES, COLOR, RAMP, ROLE_LABEL, SOFT_CEILING, FOLD_THRESHOLD, TAB_TSHIRT, TAB_TEAM, TAB_TECH, PLAN_SCHEMA },
1178
1304
  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.13",
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 |
@@ -129,11 +130,16 @@ The old `Month / Days / Tot Days / Hrs` layout is retired: `Days` IS the per-per
129
130
 
130
131
  ### 2.2 The math
131
132
 
132
- - `months = totalDays / (Σ Count × 20)` — solve so `Σ Days` equals the T-Shirt `Total Days`.
133
+ - **The Team Mix staffs the MIDPOINT of the Low and High figures** (David, 2026-09-21): `staffDays = Low Total Days × (1 + high factor) / 2` — per phase, from that phase's `Low Hrs` and `High Hrs` rollups. Never the Low figure alone.
134
+ - `months = staffDays / (Σ Count × 20)` — solve so `Σ Days` equals that midpoint.
133
135
  - Month columns `N = ceil(months)`, EXCEPT when the fractional tail is under ~0.1 month: fold it into the last full month (soft ceiling ~172 hrs) instead of opening a near-empty column.
134
136
  - **Recompute `months` and `N` after ANY roster change.** Adding a role raises Σ Count, shortens the duration, and turns the remainder column negative (−12.80 hrs was the symptom).
135
137
  - The remainder formula makes each row sum exactly. Never write a fractional last month as a value.
136
138
 
139
+ ### 2.6 One grid per phase
140
+
141
+ Every phase (`MVP` / `Phase 1` / `Phase 2` / `Phase 3`) whose T-Shirt items have hours > 0 gets its own Team Mix grid, in phase order, on the same tab, with 2 blank unformatted rows between grids. Each grid's `Σ Days` reconciles to **the midpoint of that phase's Low and High days** (`(Low Hrs + High Hrs) ÷ 2 ÷ 8` from the phase rollups), and the grids together reconcile to the midpoint of `Total Days` and `Total Days × high factor`. Months and column count are computed per grid. The team mix is the same for every phase unless the plan gives `teamMix.phases.<phase>.fte`.
142
+
137
143
  ### 2.3 Roster shape — one row per PERSON
138
144
 
139
145
  1. **No row over Count 1.00.** `Backend Engineer 1.40` is two people → two rows.
@@ -207,10 +213,11 @@ T-SHIRT
207
213
  [ ] every item row's H:L are the §1.2 formulas (no values)
208
214
  [ ] section heading rows are merged A:L, bg #1C4F8B, and hold no sizes
209
215
  [ ] totals row + phase rollups (K4:N7) + summary block reference <last item row>
210
- [ ] summary block is BELOW the totals row
216
+ [ ] 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
217
  [ ] Σ raw sizes × (1+MF) == Total Days cell (recomputed independently)
212
- TEAM MIX
213
- [ ] header is row 2 only; row 3 is a person
218
+ TEAM MIX (each grid)
219
+ [ ] one grid per phase with hours; the title ends with the phase name; exactly 2 empty, unformatted rows between grids
220
+ [ ] the header is the row under the title; the row under the header is a person
214
221
  [ ] header months read Mon 1..Mon N with N == month column count; last header is Total Hrs
215
222
  [ ] no Count > 1.00; per discipline, all rows but the last are 1.00
216
223
  [ ] every non-zero MF factor has a matching person row
@@ -220,7 +227,7 @@ TEAM MIX
220
227
  [ ] font Arial 10 everywhere; sage on exactly E, F, Total Hrs of role rows; monthly cells white
221
228
  [ ] Total row (#E5E5E5) and Total Days row (#9EC1EF) span every month column + Total Hrs
222
229
  [ ] nothing formatted outside the data area
223
- [ ] Σ Days == T-Shirt Total Days
230
+ [ ] each grid's Σ Days == midpoint of that phase's Low/High days; all grids together == midpoint of Total Days and Total Days × high factor
224
231
  TECH STACK
225
232
  [ ] at least the categories that apply are filled; none says TBD
226
233
  OVERVIEW
@@ -257,5 +264,5 @@ The plan (judgment only):
257
264
  ```
258
265
 
259
266
  - `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.
267
+ - `teamMix.fte` is per-discipline FTE (`backend` `frontend` `qa` `pm` `ba` `devops` `techlead`). The tool splits it into people (saturate then spill), computes months and the column count, ramps by discipline, writes the remainder formula, and refuses a roster that leaves a weighted MF factor unstaffed. It writes **one grid per phase with hours** (§2.6); `teamMix.phases: { "Phase 1": { "fte": {…} } }` overrides the mix for one phase.
261
268
  - The MF list, legend, rate and high factor are READ from the sheet; the plan never carries them.