@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 +41 -0
- package/README.md +1 -1
- package/bin/gsd-t-estimate-sheet.cjs +269 -143
- package/commands/gsd-t-estimate.md +1 -1
- package/package.json +1 -1
- package/templates/estimate-sheet-spec.md +28 -21
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.
|
|
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
|
-
|
|
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 +
|
|
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
|
-
|
|
601
|
-
out.push({ kind: "summary", row1: totalRow1 +
|
|
602
|
-
out.push({ kind: "summary", row1: totalRow1 +
|
|
603
|
-
out.push({ kind: "summary", row1: 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
|
-
|
|
663
|
-
reqs.push(fmtReq(sheetId, first0,
|
|
664
|
-
reqs.push(fmtReq(sheetId, first0,
|
|
665
|
-
reqs.push(fmtReq(sheetId, first0,
|
|
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
|
-
|
|
675
|
-
|
|
676
|
-
reqs.push(fmtReq(sheetId, r0, r0 + 1,
|
|
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
|
-
|
|
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 =
|
|
749
|
-
const row = [p.label, p.count, "", roster.
|
|
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(
|
|
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
|
|
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,
|
|
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
|
-
|
|
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
|
|
771
|
-
|
|
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
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
reqs.push(
|
|
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
|
|
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
|
-
/**
|
|
903
|
-
|
|
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
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
check(out, "Team Mix:
|
|
909
|
-
const
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
for (
|
|
937
|
-
const
|
|
938
|
-
if (
|
|
939
|
-
if (
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
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
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
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
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
const
|
|
970
|
-
if (
|
|
971
|
-
|
|
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
|
-
|
|
975
|
-
|
|
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,
|
|
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.
|
|
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
|
|
1082
|
-
return { totals, mf: layout.mf, mfTotal: round2(layout.mfTotal), highFactor: layout.highFactor, rate: layout.rate,
|
|
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
|
|
1088
|
-
const tm = await writeTeamMix(api, plan,
|
|
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:
|
|
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: ${
|
|
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.
|
|
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 (
|
|
79
|
+
### 1.3 Totals and summary (directly under the last item — NO blank rows)
|
|
80
80
|
|
|
81
81
|
```
|
|
82
82
|
<last item row>
|
|
83
|
-
(
|
|
84
|
-
Total
|
|
85
|
-
|
|
86
|
-
J: Total
|
|
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
|
-
|
|
92
|
-
|
|
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
|
|
102
|
-
Row
|
|
103
|
-
Row
|
|
104
|
-
Row T Total (T =
|
|
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
|
-
- `
|
|
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
|
|
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
|
-
[ ]
|
|
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 ==
|
|
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.
|