@tekyzinc/gsd-t 5.20.11 → 5.20.12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +28 -0
- package/README.md +1 -1
- package/bin/gsd-t-estimate-sheet.cjs +262 -143
- package/commands/gsd-t-estimate.md +1 -1
- package/package.json +1 -1
- package/templates/estimate-sheet-spec.md +26 -20
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,34 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to GSD-T are documented here. Updated with each release.
|
|
4
4
|
|
|
5
|
+
## [5.20.12] - 2026-09-21
|
|
6
|
+
|
|
7
|
+
### Fixed — estimate writer proven end-to-end; two-month remainder formula
|
|
8
|
+
|
|
9
|
+
First live run of `gsd-t estimate-sheet write` on a copy of the Tekyz template (David's
|
|
10
|
+
"E2E test of the estimator" sheet): 11 items in 4 sections, a 6-person / 2-month Team Mix,
|
|
11
|
+
and a 4-row Technology Stack, then all 33 read-back audit checks pass — 8 seconds, no hand edits.
|
|
12
|
+
|
|
13
|
+
- `bin/gsd-t-estimate-sheet.cjs`: with exactly two month columns the remainder was written
|
|
14
|
+
as `=F3-SUM(I3:I3)`; Google canonicalises a one-cell range to `SUM(I3)`, so the audit's
|
|
15
|
+
read-back never matched. New `remainderFormula()` emits `=F3-I3` for two months, the
|
|
16
|
+
`SUM(first:second-to-last)` form for three or more; writer and audit share it.
|
|
17
|
+
- `test/estimate-sheet-writer.test.js`: the two-month case, including the writer's own
|
|
18
|
+
output passing its own audit.
|
|
19
|
+
|
|
20
|
+
### Changed — David's review of the first live sheet (2026-09-21)
|
|
21
|
+
|
|
22
|
+
- T-Shirt: no blank row above or below the totals row; the totals row is the template's grey
|
|
23
|
+
band (`#D8DDE8`, bold) across A:L; the summary labels (Total Days / Hrs / Cost) are bold and
|
|
24
|
+
Total Cost is formatted as dollars. Audit checks added for each.
|
|
25
|
+
- Team Mix: **one grid per phase with hours** (MVP, Phase 1, …) stacked on the one tab with
|
|
26
|
+
exactly 2 blank rows between grids; each grid reconciles to its phase's T-Shirt days (read
|
|
27
|
+
from the phase rollups) and the grids together to Total Days. `teamMix.phases.<phase>.fte`
|
|
28
|
+
overrides the mix for one phase. `Mths` is written at full precision (displayed 0.00) so
|
|
29
|
+
Σ Days reconciles exactly instead of drifting with the roster size.
|
|
30
|
+
- Section heading blue stays `#1C4F8B` (confirmed); template copies stay a manual step (the
|
|
31
|
+
service account has no Drive quota; domain-wide delegation rejected as a master-key risk).
|
|
32
|
+
|
|
5
33
|
## [5.20.11] - 2026-09-17
|
|
6
34
|
|
|
7
35
|
### Fixed — `update-all` treated a downgrade as an upgrade and rolled 34 projects back
|
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# GSD-T: Contract-Driven Development for Claude Code
|
|
2
2
|
|
|
3
|
-
**v5.20.
|
|
3
|
+
**v5.20.12** - A methodology for reliable, parallelizable development using Claude Code with optional Agent Teams support.
|
|
4
4
|
|
|
5
5
|
**Eliminates context rot** — task-level fresh dispatch (one subagent per task, ~10-20% context each) means compaction never triggers.
|
|
6
6
|
**Compaction-proof debug loops** — `gsd-t headless --debug-loop` runs test-fix-retest cycles as separate `claude -p` sessions. A JSONL debug ledger persists all hypothesis/fix/learning history across fresh sessions. Anti-repetition preamble injection prevents retrying failed hypotheses. Escalation tiers (sonnet → opus → human) and a hard iteration ceiling enforced externally.
|
|
@@ -52,6 +52,7 @@ const COLOR = {
|
|
|
52
52
|
sage: "#D6E2DD",
|
|
53
53
|
totalBg: "#E5E5E5",
|
|
54
54
|
totalDaysBg: "#9EC1EF",
|
|
55
|
+
tshirtTotalBg: "#D8DDE8",
|
|
55
56
|
white: "#FFFFFF",
|
|
56
57
|
};
|
|
57
58
|
|
|
@@ -472,7 +473,9 @@ function monthPlan(totalDays, people) {
|
|
|
472
473
|
const sumCount = people.reduce((s, p) => s + p.count, 0);
|
|
473
474
|
if (!(sumCount > 0)) throw new Halt("Team Mix: total Count is 0");
|
|
474
475
|
if (!(totalDays > 0)) throw new Halt(`Team Mix: total days is ${totalDays}`);
|
|
475
|
-
|
|
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,42 @@ async function writeTshirtSizes(api, grid, sheetId, layout, plan, totals, phaseS
|
|
|
732
740
|
|
|
733
741
|
// ───────────────────────── write: Team Mix (spec §2) ─────────────────────────
|
|
734
742
|
|
|
735
|
-
|
|
743
|
+
/** Per-phase T-Shirt totals in PHASES order — only phases with hours > 0 (spec §2.6). */
|
|
744
|
+
function phaseTotals(plan, layout) {
|
|
745
|
+
const raw = {};
|
|
746
|
+
for (const s of plan.tshirt.sections) for (const it of s.items) {
|
|
747
|
+
raw[it.phase] = (raw[it.phase] ? raw[it.phase] : 0) + sizeDays(it.fe, layout.legend) + sizeDays(it.be, layout.legend);
|
|
748
|
+
}
|
|
749
|
+
return PHASES.filter((ph) => raw[ph] > 0).map((ph) => ({ phase: ph, rawDays: round2(raw[ph]), totalDays: round2(raw[ph] * (1 + layout.mfTotal)) }));
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
/** One roster per phase with hours. FTE comes from teamMix.phases[<phase>].fte when given, else teamMix.fte. */
|
|
753
|
+
function buildRosters(plan, layout) {
|
|
754
|
+
const phases = phaseTotals(plan, layout);
|
|
755
|
+
if (!phases.length) throw new Halt("no phase has any hours — nothing to staff");
|
|
756
|
+
return phases.map((ph) => {
|
|
757
|
+
const override = plan.teamMix.phases && plan.teamMix.phases[ph.phase] && plan.teamMix.phases[ph.phase].fte;
|
|
758
|
+
const sub = override ? { ...plan, teamMix: { ...plan.teamMix, fte: override } } : plan;
|
|
759
|
+
return { ...ph, roster: buildRoster(sub, ph.totalDays, layout.mf) };
|
|
760
|
+
});
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
/**
|
|
764
|
+
* The last month's REMAINDER formula (spec §2.1). n=1: the whole total; n=2: `=F-I`
|
|
765
|
+
* (Google rewrites a one-cell range `SUM(I3:I3)` to `SUM(I3)`, so the read-back
|
|
766
|
+
* would never match — write the canonical form); n>2: `=F-SUM(first:second-to-last)`.
|
|
767
|
+
*/
|
|
768
|
+
function remainderFormula(r1, firstMonthC, n) {
|
|
769
|
+
if (n === 1) return `=F${r1}`;
|
|
770
|
+
if (n === 2) return `=F${r1}-${colLetter(firstMonthC)}${r1}`;
|
|
771
|
+
return `=F${r1}-SUM(${colLetter(firstMonthC)}${r1}:${colLetter(firstMonthC + n - 2)}${r1})`;
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
/**
|
|
775
|
+
* The cell values of ONE Team Mix grid starting at 0-based row `top` (spec §2.1):
|
|
776
|
+
* title, header, one row per person, Total, Total Days. Formulas use absolute rows.
|
|
777
|
+
*/
|
|
778
|
+
function teamMixValues(plan, roster, phase, top) {
|
|
736
779
|
const n = roster.n;
|
|
737
780
|
const firstMonthC = 8; // I
|
|
738
781
|
const totalC = firstMonthC + n; // Total Hrs column index — derived, never remembered
|
|
@@ -742,18 +785,18 @@ function teamMixValues(plan, roster) {
|
|
|
742
785
|
for (let m = 1; m <= n; m++) header.push(`Mon ${m}`);
|
|
743
786
|
header.push("Total Hrs");
|
|
744
787
|
const rows = [];
|
|
745
|
-
rows.push([plan.title, ...Array(width - 1).fill("")]);
|
|
788
|
+
rows.push([phase ? `${plan.title} — ${phase}` : plan.title, ...Array(width - 1).fill("")]);
|
|
746
789
|
rows.push(header);
|
|
790
|
+
const firstP = top + 3; // 1-based row of the first person
|
|
747
791
|
roster.people.forEach((p, i) => {
|
|
748
|
-
const r1 =
|
|
749
|
-
const row = [p.label, p.count, "", roster.
|
|
792
|
+
const r1 = firstP + i;
|
|
793
|
+
const row = [p.label, p.count, "", roster.monthsExact, `=D${r1}*20*B${r1}`, `=E${r1}*8`, "", p.label];
|
|
750
794
|
for (let m = 0; m < n - 1; m++) row.push(p.monthHours[m]);
|
|
751
|
-
row.push(
|
|
795
|
+
row.push(remainderFormula(r1, firstMonthC, n));
|
|
752
796
|
row.push(`=SUM(${L(firstMonthC)}${r1}:${L(totalC - 1)}${r1})`);
|
|
753
797
|
rows.push(row);
|
|
754
798
|
});
|
|
755
|
-
const
|
|
756
|
-
const lastP = 2 + roster.people.length;
|
|
799
|
+
const lastP = firstP + roster.people.length - 1;
|
|
757
800
|
const totalR = lastP + 1;
|
|
758
801
|
const total = ["Total", `=SUM(B${firstP}:B${lastP})`, "", "", `=SUM(E${firstP}:E${lastP})`, `=SUM(F${firstP}:F${lastP})`, "", "Total Hours"];
|
|
759
802
|
for (let c = firstMonthC; c <= totalC; c++) total.push(`=SUM(${L(c)}${firstP}:${L(c)}${lastP})`);
|
|
@@ -761,45 +804,65 @@ function teamMixValues(plan, roster) {
|
|
|
761
804
|
const totalDays = ["", "", "", "", "", "", "", "Total Days"];
|
|
762
805
|
for (let c = firstMonthC; c <= totalC; c++) totalDays.push(`=${L(c)}${totalR}/8`);
|
|
763
806
|
rows.push(totalDays);
|
|
764
|
-
return { rows, width, totalC, firstMonthC,
|
|
807
|
+
return { rows, width, totalC, firstMonthC, top, titleR: top + 1, headerR: top + 2, firstP, lastP, totalR, totalDaysR: totalR + 1, bottom: totalR + 1 };
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
const GRID_GAP = 2; // blank rows between phase grids (spec §2.6)
|
|
811
|
+
|
|
812
|
+
/** Format requests for one grid (spec §2.1 styling), computed from the grid's own shape. */
|
|
813
|
+
function teamMixFormatReqs(sheetId, v) {
|
|
814
|
+
const W = v.width;
|
|
815
|
+
const arial = (bold) => ({ fontFamily: "Arial", fontSize: 10, bold });
|
|
816
|
+
const white = { backgroundColor: hexToColor(COLOR.white) };
|
|
817
|
+
const reqs = [];
|
|
818
|
+
const t0 = v.titleR - 1, h0 = v.headerR - 1, p0 = v.firstP - 1, p1 = v.lastP, tot0 = v.totalR - 1;
|
|
819
|
+
reqs.push({ mergeCells: { range: gridRange(sheetId, t0, t0 + 1, 0, W), mergeType: "MERGE_ALL" } });
|
|
820
|
+
reqs.push(fmtReq(sheetId, t0, t0 + 1, 0, W, { backgroundColor: hexToColor(COLOR.teamTitleBg), textFormat: { fontFamily: "Montserrat", fontSize: 20, bold: true, foregroundColor: hexToColor(COLOR.white) }, horizontalAlignment: "CENTER" }, "userEnteredFormat(backgroundColor,textFormat,horizontalAlignment)"));
|
|
821
|
+
reqs.push(fmtReq(sheetId, h0, v.bottom, 0, W, { textFormat: arial(false), horizontalAlignment: "RIGHT", numberFormat: { type: "NUMBER", pattern: "0.00" } }, FMT_FIELDS_ALL));
|
|
822
|
+
reqs.push(fmtReq(sheetId, h0, h0 + 1, 0, W, { backgroundColor: hexToColor(COLOR.teamHeaderBg), textFormat: { ...arial(true), foregroundColor: hexToColor(COLOR.white) } }, "userEnteredFormat(backgroundColor,textFormat)"));
|
|
823
|
+
reqs.push(fmtReq(sheetId, h0, h0 + 1, 2, 3, white, "userEnteredFormat.backgroundColor"));
|
|
824
|
+
reqs.push(fmtReq(sheetId, h0, h0 + 1, 6, 7, white, "userEnteredFormat.backgroundColor"));
|
|
825
|
+
reqs.push(fmtReq(sheetId, h0, v.bottom, 0, 1, { horizontalAlignment: "LEFT" }, "userEnteredFormat.horizontalAlignment"));
|
|
826
|
+
reqs.push(fmtReq(sheetId, h0, v.bottom, 7, 8, { horizontalAlignment: "LEFT" }, "userEnteredFormat.horizontalAlignment"));
|
|
827
|
+
reqs.push(fmtReq(sheetId, h0, h0 + 1, v.totalC, v.totalC + 1, { horizontalAlignment: "LEFT" }, "userEnteredFormat.horizontalAlignment"));
|
|
828
|
+
const sage = { backgroundColor: hexToColor(COLOR.sage), textFormat: arial(true) };
|
|
829
|
+
reqs.push(fmtReq(sheetId, p0, p1, 4, 6, sage, "userEnteredFormat(backgroundColor,textFormat)"));
|
|
830
|
+
reqs.push(fmtReq(sheetId, p0, p1, v.totalC, v.totalC + 1, sage, "userEnteredFormat(backgroundColor,textFormat)"));
|
|
831
|
+
reqs.push(fmtReq(sheetId, tot0, tot0 + 1, 0, W, { backgroundColor: hexToColor(COLOR.totalBg), textFormat: arial(true) }, "userEnteredFormat(backgroundColor,textFormat)"));
|
|
832
|
+
reqs.push(fmtReq(sheetId, tot0, tot0 + 1, 2, 3, white, "userEnteredFormat.backgroundColor"));
|
|
833
|
+
reqs.push(fmtReq(sheetId, tot0, tot0 + 1, 6, 7, white, "userEnteredFormat.backgroundColor"));
|
|
834
|
+
reqs.push(fmtReq(sheetId, tot0 + 1, tot0 + 2, 7, W, { backgroundColor: hexToColor(COLOR.totalDaysBg), textFormat: arial(true) }, "userEnteredFormat(backgroundColor,textFormat)"));
|
|
835
|
+
return reqs;
|
|
765
836
|
}
|
|
766
837
|
|
|
767
|
-
|
|
838
|
+
/** Write every phase grid on the one Team Mix tab: clear-then-paint, GRID_GAP blank rows between grids. */
|
|
839
|
+
async function writeTeamMix(api, plan, rosters) {
|
|
768
840
|
const grid = await api.grid(TAB_TEAM);
|
|
769
841
|
const sheetId = grid.properties.sheetId;
|
|
770
|
-
const
|
|
771
|
-
|
|
842
|
+
const grids = [];
|
|
843
|
+
let top = 0;
|
|
844
|
+
for (const r of rosters) {
|
|
845
|
+
const v = teamMixValues(plan, r.roster, rosters.length > 1 || r.phase ? r.phase : "", top);
|
|
846
|
+
grids.push({ phase: r.phase, v });
|
|
847
|
+
top = v.bottom + GRID_GAP;
|
|
848
|
+
}
|
|
849
|
+
const lastRow = grids[grids.length - 1].v.bottom;
|
|
850
|
+
const clearRows = Math.max(60, rowCount(grid) + 2, lastRow + 10);
|
|
772
851
|
await api.clearValues(TAB_TEAM, `A1:Z${clearRows}`);
|
|
773
852
|
await api.batch([
|
|
774
853
|
{ unmergeCells: { range: gridRange(sheetId, 0, clearRows, 0, 26) } },
|
|
775
854
|
fmtReq(sheetId, 0, clearRows, 0, 26, {}, "userEnteredFormat"),
|
|
776
855
|
]);
|
|
777
|
-
await api.putValues(TAB_TEAM, `A1:${colLetter(v.width - 1)}${v.rows.length}`, v.rows);
|
|
778
|
-
const W = v.width;
|
|
779
|
-
const arial = (bold) => ({ fontFamily: "Arial", fontSize: 10, bold });
|
|
780
|
-
const white = { backgroundColor: hexToColor(COLOR.white) };
|
|
781
856
|
const reqs = [];
|
|
782
|
-
|
|
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]));
|
|
857
|
+
let maxN = 0;
|
|
858
|
+
for (const g of grids) {
|
|
859
|
+
await api.putValues(TAB_TEAM, `A${g.v.titleR}:${colLetter(g.v.width - 1)}${g.v.bottom}`, g.v.rows);
|
|
860
|
+
reqs.push(...teamMixFormatReqs(sheetId, g.v));
|
|
861
|
+
maxN = Math.max(maxN, g.v.totalC - g.v.firstMonthC);
|
|
862
|
+
}
|
|
863
|
+
reqs.push(...widthReqs(sheetId, [...TEAM_WIDTHS_FIXED, ...Array(maxN).fill(TEAM_MONTH_WIDTH), TEAM_TOTAL_WIDTH]));
|
|
801
864
|
await api.batch(reqs);
|
|
802
|
-
return v;
|
|
865
|
+
return { grids: grids.map((g) => ({ phase: g.phase, titleR: g.v.titleR, bottom: g.v.bottom, rows: g.v.rows.length })), lastRow };
|
|
803
866
|
}
|
|
804
867
|
|
|
805
868
|
// ───────────────────────── write: Technology Stack (spec §3) ─────────────────────────
|
|
@@ -865,7 +928,22 @@ function auditTshirt(grid) {
|
|
|
865
928
|
check(out, "T-Shirt: section rows are styled (bg #1C4F8B)", !badSectionFmt.length, badSectionFmt.join(", "));
|
|
866
929
|
check(out, "T-Shirt: 'Total (Days)' row exists below the items", totalRow0 > lastItem0 && lastItem0 >= 0, "not found");
|
|
867
930
|
let totalDaysCell = NaN;
|
|
931
|
+
const phaseDays = {};
|
|
932
|
+
for (const rr of layout.rollupRows) {
|
|
933
|
+
const hrs = numAt(grid, rr, 11); // L = Low Hrs for that phase
|
|
934
|
+
phaseDays[textAt(grid, rr, 9).trim()] = Number.isNaN(hrs) ? NaN : round2(hrs / 8);
|
|
935
|
+
}
|
|
868
936
|
if (totalRow0 > lastItem0 && lastItem0 >= 0) {
|
|
937
|
+
check(out, "T-Shirt: totals row sits directly under the last item (no blank row)", totalRow0 === lastItem0 + 1, `last item row ${lastItem0 + 1}, totals row ${totalRow0 + 1}`);
|
|
938
|
+
const totFont = fontAt(grid, totalRow0, 0);
|
|
939
|
+
check(out, "T-Shirt: totals row is the template band (bg #D8DDE8, bold) across A:L", [0, 4, 9, 11].every((c) => bgAt(grid, totalRow0, c) === COLOR.tshirtTotalBg) && totFont.bold, `A bg ${bgAt(grid, totalRow0, 0)}, L bg ${bgAt(grid, totalRow0, 11)}, bold ${totFont.bold}`);
|
|
940
|
+
const sumRows = [totalRow0 + 1, totalRow0 + 2, totalRow0 + 3];
|
|
941
|
+
const labels = sumRows.map((r) => textAt(grid, r, 9));
|
|
942
|
+
check(out, "T-Shirt: summary block (Total Days / Total Hrs / Total Cost) sits directly under the totals row", labels[0] === "Total Days" && labels[1] === "Total Hrs" && labels[2] === "Total Cost", `J${sumRows[0] + 1}..J${sumRows[2] + 1} = [${labels.join(", ")}]`);
|
|
943
|
+
check(out, "T-Shirt: summary labels are bold", sumRows.every((r) => fontAt(grid, r, 9).bold), "");
|
|
944
|
+
const costCell = cellAt(grid, sumRows[2], 10);
|
|
945
|
+
const costFmt = costCell && costCell.userEnteredFormat && costCell.userEnteredFormat.numberFormat;
|
|
946
|
+
check(out, "T-Shirt: Total Cost is formatted as dollars", !!costFmt && costFmt.type === "CURRENCY", `K${sumRows[2] + 1} numberFormat ${JSON.stringify(costFmt)}`);
|
|
869
947
|
const want = `=SUM(J${first0 + 1}:J${lastItem0 + 1})`;
|
|
870
948
|
check(out, "T-Shirt: totals row sums the full item range", formulaAt(grid, totalRow0, 9) === want, `J${totalRow0 + 1} is '${formulaAt(grid, totalRow0, 9)}', want '${want}'`);
|
|
871
949
|
totalDaysCell = numAt(grid, totalRow0, 9);
|
|
@@ -876,7 +954,7 @@ function auditTshirt(grid) {
|
|
|
876
954
|
check(out, "T-Shirt: phase rollups (K4:N7) reference the full item range", !badRoll.length, badRoll.join(", "));
|
|
877
955
|
let summaryAbove = false;
|
|
878
956
|
for (let r = first0; r < totalRow0; r++) if (/^Total (Days|Hrs|Cost)$/i.test(textAt(grid, r, 9))) summaryAbove = true;
|
|
879
|
-
check(out, "T-Shirt: summary
|
|
957
|
+
check(out, "T-Shirt: no summary label inside the summed range", !summaryAbove, "a Total Days/Hrs/Cost label sits inside the summed range");
|
|
880
958
|
let raw = 0;
|
|
881
959
|
for (let r = first0; r <= lastItem0; r++) {
|
|
882
960
|
for (const c of [5, 6]) { const v = textAt(grid, r, c).trim(); if (v && layout.legend[v] != null) raw += layout.legend[v]; }
|
|
@@ -884,7 +962,7 @@ function auditTshirt(grid) {
|
|
|
884
962
|
const recomputed = round2(raw * (1 + layout.mfTotal));
|
|
885
963
|
check(out, "T-Shirt: Σ raw sizes × (1+MF) == Total Days cell", Math.abs(recomputed - totalDaysCell) < 0.02, `recomputed ${recomputed}, cell ${totalDaysCell}`);
|
|
886
964
|
}
|
|
887
|
-
return { checks: out, layout, totalDays: totalDaysCell, lastItem1: lastItem0 + 1 };
|
|
965
|
+
return { checks: out, layout, totalDays: totalDaysCell, lastItem1: lastItem0 + 1, phaseDays };
|
|
888
966
|
}
|
|
889
967
|
|
|
890
968
|
function disciplineOfLabel(label) {
|
|
@@ -899,95 +977,132 @@ function disciplineOfLabel(label) {
|
|
|
899
977
|
return "";
|
|
900
978
|
}
|
|
901
979
|
|
|
902
|
-
/**
|
|
903
|
-
|
|
980
|
+
/**
|
|
981
|
+
* Team Mix audit — one grid per phase with hours (spec §2.6). mfList (from the T-Shirt
|
|
982
|
+
* tab) is REQUIRED. phaseDays = { phase: days } read from the T-Shirt rollups (the
|
|
983
|
+
* independent figure each grid must reconcile to); tshirtTotalDays for the sum.
|
|
984
|
+
*/
|
|
985
|
+
function auditTeamMix(grid, mfList, tshirtTotalDays, phaseDays) {
|
|
904
986
|
const out = [];
|
|
905
|
-
|
|
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
|
-
|
|
987
|
+
// locate grids: every row whose A == 'Skill set' is a header; its title is the row above
|
|
988
|
+
const headers = [];
|
|
989
|
+
for (let r = 0; r < rowCount(grid); r++) if (textAt(grid, r, 0) === "Skill set") headers.push(r);
|
|
990
|
+
check(out, "Team Mix: at least one grid (a 'Skill set' header row)", headers.length > 0, "no header row found");
|
|
991
|
+
const expectedPhases = phaseDays ? Object.keys(phaseDays).filter((ph) => phaseDays[ph] > 0) : [];
|
|
992
|
+
if (expectedPhases.length) check(out, "Team Mix: one grid per phase with hours", headers.length === expectedPhases.length, `${headers.length} grid(s) for phases [${expectedPhases.join(", ")}]`);
|
|
993
|
+
let sumDaysAll = 0;
|
|
994
|
+
let tolAll = 0;
|
|
995
|
+
let prevBottom = -1;
|
|
996
|
+
headers.forEach((h, gi) => {
|
|
997
|
+
const label = headers.length > 1 ? `Team Mix grid ${gi + 1}` : "Team Mix";
|
|
998
|
+
const title = textAt(grid, h - 1, 0);
|
|
999
|
+
check(out, `${label}: title row directly above the header`, h >= 1 && title !== "" && title !== "Skill set", `row ${h} above header is '${title}'`);
|
|
1000
|
+
if (gi > 0) {
|
|
1001
|
+
const gap = h - 1 - (prevBottom + 1);
|
|
1002
|
+
check(out, `${label}: exactly ${GRID_GAP} blank rows before it`, gap === GRID_GAP, `${gap} row(s) between the previous grid and this title`);
|
|
1003
|
+
let strayGap = [];
|
|
1004
|
+
for (let r = prevBottom + 1; r < h - 1; r++) for (let c = 0; c < 26; c++) if (textAt(grid, r, c) !== "" || bgAt(grid, r, c) !== COLOR.white) strayGap.push(`${colLetter(c)}${r + 1}`);
|
|
1005
|
+
check(out, `${label}: the gap rows are empty and unformatted`, !strayGap.length, strayGap.slice(0, 6).join(", "));
|
|
1006
|
+
}
|
|
1007
|
+
const hdr = [];
|
|
1008
|
+
for (let c = 0; c < 30; c++) hdr.push(textAt(grid, h, c));
|
|
1009
|
+
check(out, `${label}: row after the header is a person, not a second header`, textAt(grid, h + 1, 0) !== "Skill set" && textAt(grid, h + 1, 0) !== "", `A${h + 2}='${textAt(grid, h + 1, 0)}'`);
|
|
1010
|
+
const monthCols = [];
|
|
1011
|
+
hdr.forEach((x, c) => { if (/^Mon \d+$/.test(x)) monthCols.push(c); });
|
|
1012
|
+
const totalC = hdr.indexOf("Total Hrs");
|
|
1013
|
+
const lastM = monthCols.length ? monthCols[monthCols.length - 1] : -1;
|
|
1014
|
+
check(out, `${label}: month headers are Mon 1..N, contiguous, then Total Hrs`, monthCols.length > 0 && totalC === lastM + 1 && monthCols.every((c, i) => hdr[c] === `Mon ${i + 1}` && c === monthCols[0] + i), `months [${monthCols.map((c) => hdr[c]).join(",")}], Total Hrs at column ${totalC}`);
|
|
1015
|
+
check(out, `${label}: header columns are Skill set/Count/Mths/Days/Hrs/Resource`, hdr[1] === "Count" && hdr[3] === "Mths" && hdr[4] === "Days" && hdr[5] === "Hrs" && hdr[7] === "Resource", hdr.slice(0, 8).join("|"));
|
|
1016
|
+
const people = [];
|
|
1017
|
+
let totalR = -1;
|
|
1018
|
+
for (let r = h + 1; r < rowCount(grid); r++) {
|
|
1019
|
+
const a = textAt(grid, r, 0);
|
|
1020
|
+
if (/^Total$/i.test(a)) { totalR = r; break; }
|
|
1021
|
+
if (a === "") break;
|
|
1022
|
+
people.push({ r, label: a, count: numAt(grid, r, 1), discipline: disciplineOfLabel(a) });
|
|
1023
|
+
}
|
|
1024
|
+
check(out, `${label}: a 'Total' row follows the people`, totalR > h, "no Total row");
|
|
1025
|
+
check(out, `${label}: every person has a numeric Count`, people.every((p) => !Number.isNaN(p.count)), people.filter((p) => Number.isNaN(p.count)).map((p) => p.label).join(", "));
|
|
1026
|
+
check(out, `${label}: no Count > 1.00`, people.every((p) => p.count <= 1 + 1e-9), people.filter((p) => p.count > 1).map((p) => `${p.label}=${p.count}`).join(", "));
|
|
1027
|
+
const groups = {};
|
|
1028
|
+
people.forEach((p) => { const k = p.label.replace(/\s+\d+$/, ""); (groups[k] = groups[k] ? groups[k] : []).push(p); });
|
|
1029
|
+
const smeared = [];
|
|
1030
|
+
for (const [k, rows] of Object.entries(groups)) rows.slice(0, -1).forEach((p) => { if (Math.abs(p.count - 1) > 1e-9) smeared.push(`${k}: ${p.count} before a later row`); });
|
|
1031
|
+
check(out, `${label}: per discipline, all rows but the last are 1.00 (saturate then spill)`, !smeared.length, smeared.join("; "));
|
|
1032
|
+
const cov = mfCoverageViolations(mfList, people.filter((p) => p.discipline));
|
|
1033
|
+
check(out, `${label}: every non-zero MF factor has a person`, !cov.length, cov.join("; "));
|
|
1034
|
+
const badF = [], negative = [], over = [];
|
|
1035
|
+
if (monthCols.length && totalC > 0) {
|
|
1036
|
+
const firstM = monthCols[0];
|
|
1037
|
+
for (const p of people) {
|
|
1038
|
+
const r1 = p.r + 1;
|
|
1039
|
+
if (formulaAt(grid, p.r, 4) !== `=D${r1}*20*B${r1}`) badF.push(`E${r1}`);
|
|
1040
|
+
if (formulaAt(grid, p.r, 5) !== `=E${r1}*8`) badF.push(`F${r1}`);
|
|
1041
|
+
if (monthCols.length > 1 && formulaAt(grid, p.r, lastM) !== remainderFormula(r1, firstM, monthCols.length)) badF.push(`${colLetter(lastM)}${r1} (remainder)`);
|
|
1042
|
+
if (!formulaAt(grid, p.r, totalC).startsWith("=")) badF.push(`${colLetter(totalC)}${r1}`);
|
|
1043
|
+
for (const c of monthCols) {
|
|
1044
|
+
const hv = numAt(grid, p.r, c);
|
|
1045
|
+
if (Number.isNaN(hv)) negative.push(`${colLetter(c)}${r1}=not a number`);
|
|
1046
|
+
else if (hv < -1e-9) negative.push(`${colLetter(c)}${r1}=${hv}`);
|
|
1047
|
+
else if (hv > SOFT_CEILING + 1e-9) over.push(`${colLetter(c)}${r1}=${hv}`);
|
|
1048
|
+
}
|
|
943
1049
|
}
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
1050
|
+
}
|
|
1051
|
+
check(out, `${label}: Days/Hrs/remainder/Total Hrs are formulas`, !badF.length, badF.join(", "));
|
|
1052
|
+
check(out, `${label}: no negative or non-numeric month`, !negative.length, negative.join(", "));
|
|
1053
|
+
check(out, `${label}: no month cell above ${SOFT_CEILING} hrs`, !over.length, over.join(", "));
|
|
1054
|
+
if (monthCols.length > 1 && people.length) {
|
|
1055
|
+
const flat = people.filter((p) => p.discipline !== "pm").every((p) => {
|
|
1056
|
+
const vals = monthCols.map((c) => round1(numAt(grid, p.r, c)));
|
|
1057
|
+
return vals.every((x) => Math.abs(x - vals[0]) < 0.05);
|
|
1058
|
+
});
|
|
1059
|
+
check(out, `${label}: ramp applied (roles are not all flat)`, !flat, "every non-PM role has identical hours each month");
|
|
1060
|
+
}
|
|
1061
|
+
const badFont = [], badSage = [], whiteMissing = [];
|
|
1062
|
+
for (const p of people) {
|
|
1063
|
+
for (let c = 0; c <= totalC; c++) {
|
|
1064
|
+
const f = fontAt(grid, p.r, c);
|
|
1065
|
+
if (f.family && f.family !== "Arial") badFont.push(`${colLetter(c)}${p.r + 1}=${f.family}`);
|
|
1066
|
+
const bg = bgAt(grid, p.r, c);
|
|
1067
|
+
const shouldSage = c === 4 || c === 5 || c === totalC;
|
|
1068
|
+
if (shouldSage && bg !== COLOR.sage) badSage.push(`${colLetter(c)}${p.r + 1} bg ${bg}`);
|
|
1069
|
+
if (!shouldSage && bg !== COLOR.white) whiteMissing.push(`${colLetter(c)}${p.r + 1} bg ${bg}`);
|
|
950
1070
|
}
|
|
951
1071
|
}
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
const
|
|
970
|
-
if (
|
|
971
|
-
|
|
1072
|
+
check(out, `${label}: body font is Arial (never Calibri)`, !badFont.length, badFont.slice(0, 8).join(", "));
|
|
1073
|
+
check(out, `${label}: sage on exactly Days, Hrs, Total Hrs of role rows`, !badSage.length && !whiteMissing.length, [...badSage, ...whiteMissing].slice(0, 8).join(", "));
|
|
1074
|
+
if (totalR > 0 && totalC > 0) {
|
|
1075
|
+
const bandBad = [];
|
|
1076
|
+
for (const c of [...monthCols, totalC, 0, 1]) if (bgAt(grid, totalR, c) !== COLOR.totalBg) bandBad.push(`${colLetter(c)}${totalR + 1}`);
|
|
1077
|
+
for (const c of [...monthCols, totalC, 7]) if (bgAt(grid, totalR + 1, c) !== COLOR.totalDaysBg) bandBad.push(`${colLetter(c)}${totalR + 2}`);
|
|
1078
|
+
check(out, `${label}: Total (#E5E5E5) and Total Days (#9EC1EF) bands span every month + Total Hrs`, !bandBad.length, bandBad.join(", "));
|
|
1079
|
+
const strayRight = [];
|
|
1080
|
+
for (let r = h - 1; r <= totalR + 1; r++) for (let c = totalC + 1; c < 26; c++) if (bgAt(grid, r, c) !== COLOR.white) strayRight.push(`${colLetter(c)}${r + 1}`);
|
|
1081
|
+
check(out, `${label}: nothing formatted right of the grid`, !strayRight.length, strayRight.slice(0, 8).join(", "));
|
|
1082
|
+
const sumDays = numAt(grid, totalR, 4);
|
|
1083
|
+
const sumCount = numAt(grid, totalR, 1);
|
|
1084
|
+
sumDaysAll += Number.isNaN(sumDays) ? 0 : sumDays;
|
|
1085
|
+
// Mths is written to 2 decimals (the sheet convention), so Σ Days can drift by up to
|
|
1086
|
+
// ΣCount × 20 × 0.005 from the exact figure — the tolerance follows the roster size.
|
|
1087
|
+
const tol = 0.05 + 0.1 * (Number.isNaN(sumCount) ? 1 : sumCount);
|
|
1088
|
+
tolAll += tol;
|
|
1089
|
+
const phase = expectedPhases.find((ph) => title.endsWith(ph));
|
|
1090
|
+
if (expectedPhases.length) {
|
|
1091
|
+
check(out, `${label}: title names a phase with hours`, !!phase, `title '${title}' ends with none of [${expectedPhases.join(", ")}]`);
|
|
1092
|
+
if (phase) check(out, `${label} (${phase}): Σ Days == that phase's T-Shirt days`, Math.abs(sumDays - phaseDays[phase]) < tol, `Team Mix ${sumDays}, T-Shirt ${phaseDays[phase]} (tolerance ${round2(tol)})`);
|
|
1093
|
+
}
|
|
1094
|
+
prevBottom = totalR + 1;
|
|
972
1095
|
}
|
|
1096
|
+
});
|
|
1097
|
+
if (prevBottom >= 0) {
|
|
1098
|
+
const strayBelow = [];
|
|
1099
|
+
for (let r = prevBottom + 1; r < Math.min(rowCount(grid), prevBottom + 40); r++) for (let c = 0; c < 26; c++) if (bgAt(grid, r, c) !== COLOR.white) strayBelow.push(`${colLetter(c)}${r + 1}`);
|
|
1100
|
+
check(out, "Team Mix: nothing formatted below the last grid", !strayBelow.length, strayBelow.slice(0, 8).join(", "));
|
|
973
1101
|
}
|
|
974
|
-
|
|
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
|
-
}
|
|
1102
|
+
if (!Number.isNaN(tshirtTotalDays) && headers.length) {
|
|
1103
|
+
check(out, "Team Mix: Σ Days over all grids == T-Shirt Total Days", Math.abs(sumDaysAll - tshirtTotalDays) < Math.max(0.15, tolAll), `Team Mix ${round2(sumDaysAll)}, T-Shirt ${tshirtTotalDays} (tolerance ${round2(Math.max(0.15, tolAll))})`);
|
|
989
1104
|
}
|
|
990
|
-
return { checks: out,
|
|
1105
|
+
return { checks: out, grids: headers.length };
|
|
991
1106
|
}
|
|
992
1107
|
|
|
993
1108
|
function auditTechStack(grid) {
|
|
@@ -1019,7 +1134,7 @@ async function runAudit(api) {
|
|
|
1019
1134
|
const checks = [];
|
|
1020
1135
|
const tshirt = auditTshirt(await api.grid(TAB_TSHIRT));
|
|
1021
1136
|
checks.push(...tshirt.checks);
|
|
1022
|
-
checks.push(...auditTeamMix(await api.grid(TAB_TEAM), tshirt.layout.mf, tshirt.totalDays).checks);
|
|
1137
|
+
checks.push(...auditTeamMix(await api.grid(TAB_TEAM), tshirt.layout.mf, tshirt.totalDays, tshirt.phaseDays).checks);
|
|
1023
1138
|
checks.push(...auditTechStack(await api.grid(TAB_TECH)).checks);
|
|
1024
1139
|
if (tabs.has(TAB_OVERVIEW)) checks.push(...auditOverview(await api.grid(TAB_OVERVIEW)).checks);
|
|
1025
1140
|
else check(checks, "Overview tab exists (the estimates index imports its cells)", false, "missing");
|
|
@@ -1075,21 +1190,25 @@ async function verbRead(api, tab) {
|
|
|
1075
1190
|
return out;
|
|
1076
1191
|
}
|
|
1077
1192
|
|
|
1193
|
+
function rostersTable(rosters) {
|
|
1194
|
+
return rosters.map((r) => `── ${r.phase}: ${r.totalDays} d ──\n${rosterTable(r.roster)}`).join("\n");
|
|
1195
|
+
}
|
|
1196
|
+
|
|
1078
1197
|
async function verbPlanCheck(api, plan) {
|
|
1079
1198
|
const layout = locateTshirt(await api.grid(TAB_TSHIRT));
|
|
1080
1199
|
const totals = tshirtTotals(plan, layout);
|
|
1081
|
-
const
|
|
1082
|
-
return { totals, mf: layout.mf, mfTotal: round2(layout.mfTotal), highFactor: layout.highFactor, rate: layout.rate,
|
|
1200
|
+
const rosters = buildRosters(plan, layout);
|
|
1201
|
+
return { totals, mf: layout.mf, mfTotal: round2(layout.mfTotal), highFactor: layout.highFactor, rate: layout.rate, rosters, table: rostersTable(rosters) };
|
|
1083
1202
|
}
|
|
1084
1203
|
|
|
1085
1204
|
async function verbWrite(api, plan, opts) {
|
|
1086
1205
|
const ts = await writeTshirt(api, plan, opts);
|
|
1087
|
-
const
|
|
1088
|
-
const tm = await writeTeamMix(api, plan,
|
|
1206
|
+
const rosters = buildRosters(plan, ts.layout);
|
|
1207
|
+
const tm = await writeTeamMix(api, plan, rosters);
|
|
1089
1208
|
const tech = await writeTechStack(api, plan);
|
|
1090
1209
|
const result = {
|
|
1091
1210
|
tshirt: { mode: plan.tshirt.mode, itemsWritten: ts.itemsWritten, firstItem1: ts.firstItem1, lastItem1: ts.lastItem1, totalRow1: ts.totalRow1, totals: ts.totals },
|
|
1092
|
-
teamMix: { people: roster.people.length, months: roster.months, columns: roster.n, sumDays: roster.sumDays, table:
|
|
1211
|
+
teamMix: { grids: tm.grids, phases: rosters.map((r) => ({ phase: r.phase, totalDays: r.totalDays, people: r.roster.people.length, months: r.roster.months, columns: r.roster.n, sumDays: r.roster.sumDays })), table: rostersTable(rosters), lastRow: tm.lastRow },
|
|
1093
1212
|
techStack: tech,
|
|
1094
1213
|
};
|
|
1095
1214
|
if (!opts.noAudit) result.audit = await runAudit(api);
|
|
@@ -1152,7 +1271,7 @@ async function main(args) {
|
|
|
1152
1271
|
if (json) console.log(JSON.stringify({ ok, exitCode: ok ? 0 : 4, ...r }, null, 2));
|
|
1153
1272
|
else {
|
|
1154
1273
|
console.log(`T-Shirt (${r.tshirt.mode}): ${r.tshirt.itemsWritten} items, rows ${r.tshirt.firstItem1}–${r.tshirt.lastItem1}, totals row ${r.tshirt.totalRow1}; total ${r.tshirt.totals.totalDays} d, $${r.tshirt.totals.lowDollars} – $${r.tshirt.totals.highDollars}`);
|
|
1155
|
-
console.log(`Team Mix: ${
|
|
1274
|
+
for (const ph of r.teamMix.phases) console.log(`Team Mix — ${ph.phase}: ${ph.people} people, ${ph.months} months, ${ph.columns} columns, Σ Days ${ph.sumDays}`);
|
|
1156
1275
|
console.log(r.teamMix.table);
|
|
1157
1276
|
console.log(`Technology Stack: ${r.techStack.rows} rows`);
|
|
1158
1277
|
if (r.audit) printChecks(r.audit);
|
|
@@ -1172,7 +1291,7 @@ function haltAndExit(e, json) {
|
|
|
1172
1291
|
|
|
1173
1292
|
module.exports = {
|
|
1174
1293
|
validatePlan, splitRoster, rosterViolations, mfCoverageViolations, monthPlan, resampleWeights, rampHours, buildRoster,
|
|
1175
|
-
tshirtTotals, itemFormulas, rollupFormulas, tshirtRows, teamMixValues, locateTshirt, findPhaseSource,
|
|
1294
|
+
tshirtTotals, phaseTotals, buildRosters, itemFormulas, rollupFormulas, tshirtRows, teamMixValues, teamMixFormatReqs, remainderFormula, locateTshirt, findPhaseSource,
|
|
1176
1295
|
auditTshirt, auditTeamMix, auditTechStack, auditOverview, colLetter, hexToColor, colorToHex, sheetIdFromArg,
|
|
1177
1296
|
constants: { SIZE_CODES, PHASES, COLOR, RAMP, ROLE_LABEL, SOFT_CEILING, FOLD_THRESHOLD, TAB_TSHIRT, TAB_TEAM, TAB_TECH, PLAN_SCHEMA },
|
|
1178
1297
|
Halt, SheetsApi, getToken, runAudit, main,
|
|
@@ -85,7 +85,7 @@ Reorder items into domains. Insert a **section-heading row** before each group p
|
|
|
85
85
|
|
|
86
86
|
## Step 4: Team Mix — JUDGMENT · PAUSE FOR REVIEW
|
|
87
87
|
|
|
88
|
-
Your judgment is ONE thing: the FTE per discipline (`teamMix.fte` in the plan — `backend` `frontend` `qa` `pm` `ba`, optionally `techlead` `devops`). Everything else on the tab is computed by the tool per spec §2: the split into people (saturate at 1.00, then spill), months, the column count, the ramp by discipline, the remainder formula.
|
|
88
|
+
Your judgment is ONE thing: the FTE per discipline (`teamMix.fte` in the plan — `backend` `frontend` `qa` `pm` `ba`, optionally `techlead` `devops`; per-phase override via `teamMix.phases.<phase>.fte`). Everything else on the tab is computed by the tool per spec §2: the split into people (saturate at 1.00, then spill), months, the column count, the ramp by discipline, the remainder formula — and **one grid per phase with hours** (MVP, Phase 1, …), stacked on the one tab with 2 blank rows between.
|
|
89
89
|
|
|
90
90
|
1. **Staff every weighted MF factor** — QA → `qa`, PM → `pm`, Analysis → `ba`. Deployment / standups / buffer are absorbed by the engineers and lead. Typical fractions: PM 0.20–0.25 · BA 0.10 · Tech Lead 0.25 · QA 0.40–0.50. The tool HALTS on a roster that leaves a factor unstaffed — do not argue with it; add the person.
|
|
91
91
|
2. **Run `gsd-t estimate-sheet plan-check --sheet <url> --plan <plan.json>`.** It prints the MF list it read, the T-Shirt total, and the roster table it WOULD write (person · Count · Mths · Days · Hrs · Mon 1..N with the remainder) — or halts with the violation.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tekyzinc/gsd-t",
|
|
3
|
-
"version": "5.20.
|
|
3
|
+
"version": "5.20.12",
|
|
4
4
|
"description": "GSD-T: Contract-Driven Development for Claude Code — 54 slash commands with headless-by-default workflow spawning, unattended supervisor relay with event stream, graph-powered code analysis, real-time agent dashboard, task telemetry, doc-ripple enforcement, backlog management, impact analysis, test sync, milestone archival, and PRD generation",
|
|
5
5
|
"author": "Tekyz, Inc.",
|
|
6
6
|
"license": "MIT",
|
|
@@ -76,35 +76,36 @@ Column widths: `[150, 120, 300, 430, 122, 90, 90, 61, 53, 76, 81, 81]`.
|
|
|
76
76
|
| `K` | `=J{r}*8*$H$4` | nf `$#,##0.00` |
|
|
77
77
|
| `L` | `=K{r}*$G$4` | nf `$#,##0.00` |
|
|
78
78
|
|
|
79
|
-
### 1.3 Totals and summary (
|
|
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 |
|
|
@@ -134,6 +135,10 @@ The old `Month / Days / Tot Days / Hrs` layout is retired: `Days` IS the per-per
|
|
|
134
135
|
- **Recompute `months` and `N` after ANY roster change.** Adding a role raises Σ Count, shortens the duration, and turns the remainder column negative (−12.80 hrs was the symptom).
|
|
135
136
|
- The remainder formula makes each row sum exactly. Never write a fractional last month as a value.
|
|
136
137
|
|
|
138
|
+
### 2.6 One grid per phase
|
|
139
|
+
|
|
140
|
+
Every phase (`MVP` / `Phase 1` / `Phase 2` / `Phase 3`) whose T-Shirt items have hours > 0 gets its own Team Mix grid, in phase order, on the same tab, with 2 blank unformatted rows between grids. Each grid's `Σ Days` reconciles to **that phase's** T-Shirt days (the phase rollup `Low Hrs ÷ 8`), and the grids together reconcile to `Total Days`. Months and column count are computed per grid. The team mix is the same for every phase unless the plan gives `teamMix.phases.<phase>.fte`.
|
|
141
|
+
|
|
137
142
|
### 2.3 Roster shape — one row per PERSON
|
|
138
143
|
|
|
139
144
|
1. **No row over Count 1.00.** `Backend Engineer 1.40` is two people → two rows.
|
|
@@ -207,10 +212,11 @@ T-SHIRT
|
|
|
207
212
|
[ ] every item row's H:L are the §1.2 formulas (no values)
|
|
208
213
|
[ ] section heading rows are merged A:L, bg #1C4F8B, and hold no sizes
|
|
209
214
|
[ ] totals row + phase rollups (K4:N7) + summary block reference <last item row>
|
|
210
|
-
[ ] summary block
|
|
215
|
+
[ ] totals row directly under the last item, grey band #D8DDE8 bold across A:L; summary block directly under it, labels bold, Total Cost in dollars
|
|
211
216
|
[ ] Σ raw sizes × (1+MF) == Total Days cell (recomputed independently)
|
|
212
|
-
TEAM MIX
|
|
213
|
-
[ ]
|
|
217
|
+
TEAM MIX (each grid)
|
|
218
|
+
[ ] one grid per phase with hours; the title ends with the phase name; exactly 2 empty, unformatted rows between grids
|
|
219
|
+
[ ] the header is the row under the title; the row under the header is a person
|
|
214
220
|
[ ] header months read Mon 1..Mon N with N == month column count; last header is Total Hrs
|
|
215
221
|
[ ] no Count > 1.00; per discipline, all rows but the last are 1.00
|
|
216
222
|
[ ] every non-zero MF factor has a matching person row
|
|
@@ -220,7 +226,7 @@ TEAM MIX
|
|
|
220
226
|
[ ] font Arial 10 everywhere; sage on exactly E, F, Total Hrs of role rows; monthly cells white
|
|
221
227
|
[ ] Total row (#E5E5E5) and Total Days row (#9EC1EF) span every month column + Total Hrs
|
|
222
228
|
[ ] nothing formatted outside the data area
|
|
223
|
-
[ ] Σ Days == T-Shirt Total Days
|
|
229
|
+
[ ] each grid's Σ Days == that phase's T-Shirt days; all grids together == Total Days
|
|
224
230
|
TECH STACK
|
|
225
231
|
[ ] at least the categories that apply are filled; none says TBD
|
|
226
232
|
OVERVIEW
|
|
@@ -257,5 +263,5 @@ The plan (judgment only):
|
|
|
257
263
|
```
|
|
258
264
|
|
|
259
265
|
- `tshirt.mode` `items` writes whole rows below the header (halts if rows exist unless `--replace`); `sizes` fills `E:L` on rows that already exist (a gap-analysis sheet), matched by the `(id)` suffix in column C — never by position.
|
|
260
|
-
- `teamMix.fte` is per-discipline FTE (`backend` `frontend` `qa` `pm` `ba` `devops` `techlead`). The tool splits it into people (saturate then spill), computes months and the column count, ramps by discipline, writes the remainder formula, and refuses a roster that leaves a weighted MF factor unstaffed.
|
|
266
|
+
- `teamMix.fte` is per-discipline FTE (`backend` `frontend` `qa` `pm` `ba` `devops` `techlead`). The tool splits it into people (saturate then spill), computes months and the column count, ramps by discipline, writes the remainder formula, and refuses a roster that leaves a weighted MF factor unstaffed. It writes **one grid per phase with hours** (§2.6); `teamMix.phases: { "Phase 1": { "fte": {…} } }` overrides the mix for one phase.
|
|
261
267
|
- The MF list, legend, rate and high factor are READ from the sheet; the plan never carries them.
|