@tekyzinc/gsd-t 5.20.14 → 5.20.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,28 @@
2
2
 
3
3
  All notable changes to GSD-T are documented here. Updated with each release.
4
4
 
5
+ ## [5.20.15] - 2026-09-21
6
+
7
+ ### Changed — phases must be contiguous; the Team Mix title row is the phase name only
8
+
9
+ David's review of the 19 Hilo estimates: one had items in MVP and Phase 2 with nothing in
10
+ Phase 1, and every Team Mix title row carried the estimate name.
11
+
12
+ - Rule (spec §2.6): phases are contiguous — `MVP`, `Phase 1`, `Phase 2`, … with no empty phase
13
+ between used ones. The audit fails a gap. New `phases` verb renumbers the item Phase cells to
14
+ close the gap and rebuilds the Team Mix grids.
15
+ - Rule (spec §2.1): each grid's title row is EXACTLY the phase name. The writer, `teammix` and
16
+ the audit follow it; the plan's `title` is no longer used. New `titles` verb sets existing
17
+ grids' title rows (the nearest non-empty row above the header — older grids keep a blank row
18
+ between title and header).
19
+ - Applied: Acron Academy renumbered (Phase 2→1, Phase 3→2, 20 cells) and its grids rebuilt;
20
+ all 19 estimates' Team Mix titles renamed.
21
+ - Rule (spec §0 / §1.2): Functionality and Low Level Requirements wrap on every item row, and
22
+ every cell on every tab is top-aligned. Writer, `format` verb (which now top-aligns all tabs)
23
+ and audit follow it; applied to all 19 estimates.
24
+ - The 14 single-phase estimates' Team Mixes rebuilt on the Low/High midpoint (`teammix`), so all
25
+ 19 now staff the midpoint; the command's Step 4 states the rule.
26
+
5
27
  ## [5.20.14] - 2026-09-21
6
28
 
7
29
  ### Added — `gsd-t estimate-sheet teammix` rebuilds any estimate's Team Mix; the reader handles both template layouts
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # GSD-T: Contract-Driven Development for Claude Code
2
2
 
3
- **v5.20.14** - A methodology for reliable, parallelizable development using Claude Code with optional Agent Teams support.
3
+ **v5.20.15** - 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.
@@ -25,6 +25,8 @@
25
25
  * write --sheet <id|url> --plan <plan.json> [--replace] write T-Shirt + Team Mix + Tech Stack, then audit
26
26
  * teammix --sheet <id|url> [--fte <json>] [--dry-run] rebuild the Team Mix (one grid per phase) from the sheet's own roster + rollups
27
27
  * format --sheet <id|url> [--dry-run] normalise T-Shirt formatting only (section rows, totals band, summary block); values untouched
28
+ * phases --sheet <id|url> [--dry-run] close phase gaps (MVP, Phase 2 → MVP, Phase 1) on the T-Shirt tab, then rebuild the Team Mix
29
+ * titles --sheet <id|url> [--dry-run] set each Team Mix grid's title row to exactly its phase name
28
30
  * audit --sheet <id|url> the spec §5 checklist, by read-back
29
31
  * plan-schema print the plan shape
30
32
  * Flags: --json (envelope only) --key <path> --no-audit (write only; for debugging)
@@ -232,7 +234,7 @@ class SheetsApi {
232
234
 
233
235
  async grid(tab) {
234
236
  const rng = encodeURIComponent(`'${tab}'`);
235
- const fields = "sheets(merges,properties,data(rowData(values(formattedValue,userEnteredValue,effectiveValue,userEnteredFormat(backgroundColor,textFormat,horizontalAlignment,numberFormat),dataValidation)),columnMetadata(pixelSize)))";
237
+ const fields = "sheets(merges,properties,data(rowData(values(formattedValue,userEnteredValue,effectiveValue,userEnteredFormat(backgroundColor,textFormat,horizontalAlignment,verticalAlignment,wrapStrategy,numberFormat),dataValidation)),columnMetadata(pixelSize)))";
236
238
  const r = await this.call("GET", `${this.base}?ranges=${rng}&includeGridData=true&fields=${fields}`);
237
239
  if (!r.sheets || !r.sheets[0]) throw new Halt(`tab '${tab}' not found`, 64);
238
240
  return r.sheets[0];
@@ -412,7 +414,7 @@ function findPhaseSource(grid, fromRow) { return findPhaseSourceIn(grid, fromRow
412
414
  // ───────────────────────── plan validation (pure) ─────────────────────────
413
415
 
414
416
  const PLAN_SCHEMA = {
415
- title: "string — Team Mix title, e.g. 'Hilo ATOS — ATP Gap Closure'",
417
+ title: "optional string — no longer written anywhere (the Team Mix title row is the phase name only)",
416
418
  tshirt: {
417
419
  mode: "'items' (write whole rows from row 14) | 'sizes' (rows already exist; fill E:L, matched by the id in column C)",
418
420
  sections: [{
@@ -437,7 +439,7 @@ function sizeOf(v) { return v == null ? "" : String(v).trim(); }
437
439
  function validatePlan(plan) {
438
440
  const errors = [];
439
441
  if (!plan || typeof plan !== "object") return ["plan is not an object"];
440
- if (!plan.title || typeof plan.title !== "string") errors.push("title: required string");
442
+ if (plan.title != null && typeof plan.title !== "string") errors.push("title: must be a string when given (unused since v5.20.15 — the Team Mix title row is the phase name)");
441
443
  const t = plan.tshirt && typeof plan.tshirt === "object" ? plan.tshirt : {};
442
444
  if (!["items", "sizes"].includes(t.mode)) errors.push("tshirt.mode: must be 'items' or 'sizes'");
443
445
  const sections = Array.isArray(t.sections) ? t.sections : [];
@@ -690,6 +692,13 @@ function tshirtRows(plan, firstRow0) {
690
692
  function fmtReq(sheetId, r0, r1, c0, c1, format, fields) {
691
693
  return { repeatCell: { range: gridRange(sheetId, r0, r1, c0, c1), cell: { userEnteredFormat: format }, fields } };
692
694
  }
695
+ /** Rule (spec §0): every cell on a tab the tool writes is TOP-aligned; Functionality + Low Level Requirements wrap. */
696
+ function topAlignReq(sheetId, rows) {
697
+ return fmtReq(sheetId, 0, rows, 0, 26, { verticalAlignment: "TOP" }, "userEnteredFormat.verticalAlignment");
698
+ }
699
+ function wrapReq(sheetId, r0, r1, c0, c1) {
700
+ return fmtReq(sheetId, r0, r1, c0, c1, { wrapStrategy: "WRAP" }, "userEnteredFormat.wrapStrategy");
701
+ }
693
702
  function widthReqs(sheetId, widths) {
694
703
  return widths.map((w, i) => ({ updateDimensionProperties: { range: { sheetId, dimension: "COLUMNS", startIndex: i, endIndex: i + 1 }, properties: { pixelSize: w }, fields: "pixelSize" } }));
695
704
  }
@@ -767,6 +776,8 @@ async function writeTshirt(api, plan, opts) {
767
776
  reqs.push(copyPhaseReq(sheetId, phaseSrc, r0)); // carries validation + chip format; value re-put below
768
777
  }
769
778
  }
779
+ reqs.push(wrapReq(sheetId, first0, lastItem0, 2, 4)); // Functionality + Low Level Requirements wrap
780
+ reqs.push(topAlignReq(sheetId, lastWritten1 + 5)); // every cell top-aligned
770
781
  reqs.push(...widthReqs(sheetId, TSHIRT_WIDTHS));
771
782
  await api.batch(reqs);
772
783
  // 5. the Phase VALUES again — copyPaste overwrote them with the source cell's value
@@ -809,7 +820,9 @@ async function writeTshirtSizes(api, grid, sheetId, layout, plan, totals, phaseS
809
820
  reqs.push(fmtReq(sheetId, w.r0, w.r0 + 1, 4, 7, FMT_ITEM_SIZE, "userEnteredFormat(horizontalAlignment,textFormat)"));
810
821
  reqs.push(fmtReq(sheetId, w.r0, w.r0 + 1, 7, 10, FMT_ITEM_NUM, FMT_FIELDS_ALL));
811
822
  reqs.push(fmtReq(sheetId, w.r0, w.r0 + 1, 10, 12, FMT_ITEM_CUR, FMT_FIELDS_ALL));
823
+ reqs.push(wrapReq(sheetId, w.r0, w.r0 + 1, 2, 4));
812
824
  }
825
+ reqs.push(topAlignReq(sheetId, rowCount(grid) + 2));
813
826
  await api.batch(reqs);
814
827
  for (const w of writes) await api.putValues(TAB_TSHIRT, `E${w.r0 + 1}`, [[w.phase]]);
815
828
  const first1 = layout.firstItemRow + 1;
@@ -872,7 +885,7 @@ function teamMixValues(plan, roster, phase, top) {
872
885
  for (let m = 1; m <= n; m++) header.push(`Mon ${m}`);
873
886
  header.push("Total Hrs");
874
887
  const rows = [];
875
- rows.push([phase ? `${plan.title} — ${phase}` : plan.title, ...Array(width - 1).fill("")]);
888
+ rows.push([phase ? phase : "MVP", ...Array(width - 1).fill("")]); // the title row holds ONLY the phase name (David, 2026-09-21)
876
889
  rows.push(header);
877
890
  const firstP = top + 3; // 1-based row of the first person
878
891
  roster.people.forEach((p, i) => {
@@ -929,7 +942,7 @@ async function writeTeamMix(api, plan, rosters) {
929
942
  const grids = [];
930
943
  let top = 0;
931
944
  for (const r of rosters) {
932
- const v = teamMixValues(plan, r.roster, rosters.length > 1 || r.phase ? r.phase : "", top);
945
+ const v = teamMixValues(plan, r.roster, r.phase, top);
933
946
  grids.push({ phase: r.phase, v });
934
947
  top = v.bottom + GRID_GAP;
935
948
  }
@@ -948,6 +961,7 @@ async function writeTeamMix(api, plan, rosters) {
948
961
  maxN = Math.max(maxN, g.v.totalC - g.v.firstMonthC);
949
962
  }
950
963
  reqs.push(...widthReqs(sheetId, [...TEAM_WIDTHS_FIXED, ...Array(maxN).fill(TEAM_MONTH_WIDTH), TEAM_TOTAL_WIDTH]));
964
+ reqs.push(topAlignReq(sheetId, lastRow + 5));
951
965
  await api.batch(reqs);
952
966
  return { grids: grids.map((g) => ({ phase: g.phase, titleR: g.v.titleR, bottom: g.v.bottom, rows: g.v.rows.length })), lastRow };
953
967
  }
@@ -969,6 +983,7 @@ async function writeTechStack(api, plan) {
969
983
  { mergeCells: { range: gridRange(sheetId, 0, 1, 0, 2), mergeType: "MERGE_ALL" } },
970
984
  fmtReq(sheetId, 0, 1, 0, 2, { backgroundColor: hexToColor(COLOR.teamHeaderBg), textFormat: { fontSize: 12, bold: true, foregroundColor: hexToColor(COLOR.white) } }, "userEnteredFormat(backgroundColor,textFormat)"),
971
985
  fmtReq(sheetId, 1, rows.length, 0, 2, { textFormat: { fontFamily: "Arial", fontSize: 10 }, wrapStrategy: "WRAP" }, "userEnteredFormat(textFormat,wrapStrategy)"),
986
+ topAlignReq(sheetId, rows.length + 5),
972
987
  ...widthReqs(sheetId, TECH_WIDTHS),
973
988
  ]);
974
989
  return { rows: rows.length - 1 };
@@ -985,7 +1000,7 @@ function auditTshirt(grid) {
985
1000
  const { cols } = layout;
986
1001
  const legendRows = { first1: layout.legendFirst1, last1: layout.legendLast1 };
987
1002
  const first0 = layout.firstItemRow;
988
- const badSizes = [], noDv = [], badFormula = [], sectionsWithSizes = [], badSectionFmt = [];
1003
+ const badSizes = [], noDv = [], badFormula = [], sectionsWithSizes = [], badSectionFmt = [], noWrap = [], notTop = [];
989
1004
  let lastItem0 = -1, totalRow0 = -1;
990
1005
  for (let r = first0; r < rowCount(grid); r++) {
991
1006
  const a = textAt(grid, r, 0);
@@ -1006,14 +1021,22 @@ function auditTshirt(grid) {
1006
1021
  if (!(cell && cell.dataValidation && cell.dataValidation.condition && cell.dataValidation.condition.type === "ONE_OF_LIST")) noDv.push(`${colLetter(cols.phase)}${r + 1}`);
1007
1022
  const f = itemFormulasFor(r + 1, cols, legendRows);
1008
1023
  for (const [c, want] of f.cells) if (formulaAt(grid, r, c) !== want) badFormula.push(`${colLetter(c)}${r + 1}`);
1024
+ for (const c of [2, 3]) { const fm = cellAt(grid, r, c) && cellAt(grid, r, c).userEnteredFormat; if (!fm || fm.wrapStrategy !== "WRAP") noWrap.push(`${colLetter(c)}${r + 1}`); }
1025
+ for (const c of [0, 2, cols.days]) { const fm = cellAt(grid, r, c) && cellAt(grid, r, c).userEnteredFormat; if (!fm || fm.verticalAlignment !== "TOP") notTop.push(`${colLetter(c)}${r + 1}`); }
1009
1026
  }
1010
1027
  check(out, "T-Shirt: at least one item row", lastItem0 >= 0, "no item rows below the header");
1028
+ check(out, "T-Shirt: Functionality + Low Level Requirements wrap on every item row", !noWrap.length, noWrap.slice(0, 8).join(", "));
1029
+ check(out, "T-Shirt: item cells are top-aligned", !notTop.length, notTop.slice(0, 8).join(", "));
1011
1030
  check(out, "T-Shirt: size cells are bare codes (no legend text, no '-')", !badSizes.length, badSizes.slice(0, 10).join(", "));
1012
1031
  check(out, "T-Shirt: every item row has the Phase dropdown", !noDv.length, noDv.slice(0, 10).join(", "));
1013
1032
  check(out, "T-Shirt: Days..HIGH $ are the spec formulas on every item row", !badFormula.length, badFormula.slice(0, 12).join(", "));
1014
1033
  check(out, "T-Shirt: section rows carry no sizes", !sectionsWithSizes.length, `rows ${sectionsWithSizes.join(", ")}`);
1015
1034
  check(out, "T-Shirt: section rows are styled (bg #1C4F8B)", !badSectionFmt.length, badSectionFmt.slice(0, 6).join(", "));
1016
1035
  check(out, "T-Shirt: 'Total (Days)' row exists below the items", totalRow0 > lastItem0 && lastItem0 >= 0, "not found");
1036
+ const usedPhases = new Set();
1037
+ for (let r = first0; r <= lastItem0; r++) { const v = textAt(grid, r, cols.phase).trim(); if (PHASES.includes(v)) usedPhases.add(v); }
1038
+ const gapMap = phaseGapMap([...usedPhases]);
1039
+ check(out, "T-Shirt: phases are contiguous (no empty phase between used ones)", !Object.keys(gapMap).length, `used [${PHASES.filter((p) => usedPhases.has(p)).join(", ")}] — rename ${Object.entries(gapMap).map(([a, b]) => `${a}→${b}`).join(", ")}`);
1017
1040
  let totalDaysCell = NaN;
1018
1041
  const phaseDays = {};
1019
1042
  for (const rr of layout.rollupRows) {
@@ -1163,6 +1186,8 @@ function auditTeamMix(grid, mfList, tshirtTotalDays, phaseDays) {
1163
1186
  }
1164
1187
  }
1165
1188
  check(out, `${label}: body font is Arial (never Calibri)`, !badFont.length, badFont.slice(0, 8).join(", "));
1189
+ const notTopTm = people.filter((p) => { const fm = cellAt(grid, p.r, 0) && cellAt(grid, p.r, 0).userEnteredFormat; return !fm || fm.verticalAlignment !== "TOP"; }).map((p) => `A${p.r + 1}`);
1190
+ check(out, `${label}: cells are top-aligned`, !notTopTm.length, notTopTm.slice(0, 6).join(", "));
1166
1191
  check(out, `${label}: sage on exactly Days, Hrs, Total Hrs of role rows`, !badSage.length && !whiteMissing.length, [...badSage, ...whiteMissing].slice(0, 8).join(", "));
1167
1192
  if (totalR > 0 && totalC > 0) {
1168
1193
  const bandBad = [];
@@ -1179,9 +1204,9 @@ function auditTeamMix(grid, mfList, tshirtTotalDays, phaseDays) {
1179
1204
  // ΣCount × 20 × 0.005 from the exact figure — the tolerance follows the roster size.
1180
1205
  const tol = 0.05 + 0.1 * (Number.isNaN(sumCount) ? 1 : sumCount);
1181
1206
  tolAll += tol;
1182
- const phase = expectedPhases.find((ph) => title.endsWith(ph));
1207
+ const phase = expectedPhases.find((ph) => title.trim() === ph);
1183
1208
  if (expectedPhases.length) {
1184
- check(out, `${label}: title names a phase with hours`, !!phase, `title '${title}' ends with none of [${expectedPhases.join(", ")}]`);
1209
+ check(out, `${label}: title row is exactly the phase name`, !!phase, `title '${title}' is not one of [${expectedPhases.join(", ")}]`);
1185
1210
  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)})`);
1186
1211
  }
1187
1212
  prevBottom = totalR + 1;
@@ -1361,9 +1386,8 @@ async function verbTeamMix(api, opts) {
1361
1386
  const layout = locateTshirt(tshirt);
1362
1387
  const team = await api.grid(TAB_TEAM);
1363
1388
  const fte = opts.fte ? opts.fte : deriveFteFromTeamMix(team);
1364
- const title = opts.title ? opts.title : textAt(team, 0, 0).replace(/\s+—\s+(MVP|Phase \d)$/, "");
1365
- if (!title) throw new Halt(`${TAB_TEAM}: A1 has no title to carry over — pass --title`);
1366
- const plan = { title, teamMix: { fte } };
1389
+ const title = "";
1390
+ const plan = { teamMix: { fte } };
1367
1391
  const rosters = phaseTotalsFromSheet(tshirt, layout).map((ph) => ({ ...ph, roster: buildRoster(plan, ph.staffDays, layout.mf) }));
1368
1392
  if (opts.dryRun) return { title, fte, rosters, table: rostersTable(rosters) };
1369
1393
  const tm = await writeTeamMix(api, plan, rosters);
@@ -1476,11 +1500,83 @@ async function verbFormat(api, opts) {
1476
1500
  fmtReq(sheetId, t1, t1 + 2, cols.total + 1, cols.total + 3, { horizontalAlignment: "RIGHT", numberFormat: { type: "NUMBER", pattern: "0.00" } }, "userEnteredFormat(horizontalAlignment,numberFormat)"),
1477
1501
  fmtReq(sheetId, t1 + 2, t1 + 3, cols.total + 1, cols.total + 3, { horizontalAlignment: "RIGHT", numberFormat: { type: "CURRENCY", pattern: "$#,##0.00" } }, "userEnteredFormat(horizontalAlignment,numberFormat)"),
1478
1502
  ]);
1503
+ // 5. wrap Functionality + Low Level Requirements on item rows; top-align every cell on EVERY tab
1504
+ await api.batch([wrapReq(sheetId, first0, tot0, 2, 4), topAlignReq(sheetId, Math.max(rowCount(grid), t1 + 6))]);
1505
+ const meta = await api.meta();
1506
+ for (const s of meta.sheets) {
1507
+ if (s.properties.title === TAB_TSHIRT) continue;
1508
+ const rows = s.properties.gridProperties && s.properties.gridProperties.rowCount ? Math.min(s.properties.gridProperties.rowCount, 200) : 100;
1509
+ await api.batch([topAlignReq(s.properties.sheetId, rows)]);
1510
+ }
1479
1511
  const result = { plan, totalRow1: t1 };
1480
1512
  if (!opts.noAudit) result.audit = await runAudit(api);
1481
1513
  return result;
1482
1514
  }
1483
1515
 
1516
+ /**
1517
+ * Phases must be contiguous: MVP, then Phase 1, Phase 2, … with no empty phase between used
1518
+ * ones (David, 2026-09-21: "MVP then nothing in Phase 1 but items in Phase 2"). Given the set
1519
+ * of phases that carry items, return the rename map that closes the gaps ({} when none).
1520
+ */
1521
+ function phaseGapMap(usedPhases) {
1522
+ const numbered = PHASES.filter((p) => p !== "MVP" && usedPhases.includes(p));
1523
+ const map = {};
1524
+ numbered.forEach((p, i) => { const want = `Phase ${i + 1}`; if (p !== want) map[p] = want; });
1525
+ return map;
1526
+ }
1527
+
1528
+ /** Renumber phases on the T-Shirt tab to close gaps, then rebuild the Team Mix grids to match. */
1529
+ async function verbPhases(api, opts) {
1530
+ const grid = await api.grid(TAB_TSHIRT);
1531
+ const layout = locateTshirt(grid);
1532
+ const { cols } = layout;
1533
+ const used = new Set();
1534
+ const cells = [];
1535
+ for (let r = layout.firstItemRow; r < rowCount(grid); r++) {
1536
+ if (/^Total \(Days\)/i.test(textAt(grid, r, 0))) break;
1537
+ const v = textAt(grid, r, cols.phase).trim();
1538
+ if (v === "") continue;
1539
+ if (!PHASES.includes(v)) throw new Halt(`${TAB_TSHIRT}: ${colLetter(cols.phase)}${r + 1} holds '${v}', not one of ${PHASES.join(" | ")}`);
1540
+ used.add(v);
1541
+ cells.push({ r, v });
1542
+ }
1543
+ const map = phaseGapMap([...used]);
1544
+ const plan = { phasesUsed: PHASES.filter((p) => used.has(p)), renames: map, cellsToChange: cells.filter((c) => map[c.v]).length };
1545
+ if (opts.dryRun || !Object.keys(map).length) return { plan, changed: false };
1546
+ for (const c of cells) if (map[c.v]) await api.putValues(TAB_TSHIRT, `${colLetter(cols.phase)}${c.r + 1}`, [[map[c.v]]]);
1547
+ const tm = await verbTeamMix(api, { fte: opts.fte, noAudit: true });
1548
+ const result = { plan, changed: true, teamMix: tm.phases };
1549
+ if (!opts.noAudit) result.audit = await runAudit(api);
1550
+ return result;
1551
+ }
1552
+
1553
+ /** Set every Team Mix grid's title row to exactly its phase name (rule §2.1). */
1554
+ async function verbTitles(api, opts) {
1555
+ const tshirt = await api.grid(TAB_TSHIRT);
1556
+ const layout = locateTshirt(tshirt);
1557
+ const withHours = phaseTotalsFromSheet(tshirt, layout).map((p) => p.phase);
1558
+ const team = await api.grid(TAB_TEAM);
1559
+ const headers = [];
1560
+ for (let r = 0; r < rowCount(team); r++) if (textAt(team, r, 0) === "Skill set") headers.push(r);
1561
+ if (!headers.length) throw new Halt(`${TAB_TEAM}: no grid found`);
1562
+ if (headers.length !== withHours.length) throw new Halt(`${TAB_TEAM}: ${headers.length} grid(s) but ${withHours.length} phase(s) with hours [${withHours.join(", ")}] — run 'teammix' first`);
1563
+ const changes = [];
1564
+ headers.forEach((h, i) => {
1565
+ if (h < 1) throw new Halt(`${TAB_TEAM}: header at row 1 has no title row above it`);
1566
+ // the title is the nearest non-empty row above the header (older grids keep a blank row 2
1567
+ // between title and header — writing into the blank row left the old title in place)
1568
+ const titleR = textAt(team, h - 1, 0).trim() === "" && h >= 2 && textAt(team, h - 2, 0).trim() !== "" ? h - 2 : h - 1;
1569
+ const cur = textAt(team, titleR, 0).trim();
1570
+ const fromTitle = PHASES.find((p) => cur === p || cur.endsWith(` — ${p}`));
1571
+ const want = fromTitle ? fromTitle : withHours[i];
1572
+ if (fromTitle && fromTitle !== withHours[i]) throw new Halt(`${TAB_TEAM}: grid ${i + 1} is titled for ${fromTitle} but the ${i + 1}${["st", "nd", "rd"][i] || "th"} phase with hours is ${withHours[i]} — run 'teammix' first`);
1573
+ if (cur !== want) changes.push({ row1: titleR + 1, from: cur, to: want });
1574
+ });
1575
+ if (opts.dryRun) return { changes, changed: false };
1576
+ for (const c of changes) await api.putValues(TAB_TEAM, `A${c.row1}`, [[c.to]]);
1577
+ return { changes, changed: changes.length > 0 };
1578
+ }
1579
+
1484
1580
  async function verbWrite(api, plan, opts) {
1485
1581
  const ts = await writeTshirt(api, plan, opts);
1486
1582
  const rosters = buildRosters(plan, ts.layout);
@@ -1513,7 +1609,7 @@ function printChecks(audit) {
1513
1609
  console.log(`${audit.ok ? "AUDIT PASS" : `AUDIT FAIL (${audit.failed})`} — ${audit.title}`);
1514
1610
  }
1515
1611
 
1516
- const USAGE = "usage: gsd-t estimate-sheet <read|plan-check|write|teammix|format|audit|plan-schema> --sheet <id|url> [--tab <name>] [--plan <plan.json>] [--replace] [--fte '{\"backend\":1.5}'] [--title <t>] [--dry-run] [--no-audit] [--key <path>] [--json]";
1612
+ const USAGE = "usage: gsd-t estimate-sheet <read|plan-check|write|teammix|format|phases|titles|audit|plan-schema> --sheet <id|url> [--tab <name>] [--plan <plan.json>] [--replace] [--fte '{\"backend\":1.5}'] [--title <t>] [--dry-run] [--no-audit] [--key <path>] [--json]";
1517
1613
 
1518
1614
  /** Runs a verb; returns the exit code. Throws Halt (or any error) — the runner below turns that into exit 4/64. */
1519
1615
  async function main(args) {
@@ -1542,6 +1638,21 @@ async function main(args) {
1542
1638
  }
1543
1639
  return ok ? 0 : 4;
1544
1640
  }
1641
+ if (verb === "phases") {
1642
+ let fte;
1643
+ if (args.fte) { try { fte = JSON.parse(args.fte); } catch (e) { throw new Halt(`--fte must be JSON: ${e.message}`, 64); } }
1644
+ const r = await verbPhases(api, { dryRun: !!args["dry-run"], noAudit: !!args["no-audit"], fte });
1645
+ const ok = !r.audit || r.audit.ok;
1646
+ if (json) console.log(JSON.stringify({ ok, exitCode: ok ? 0 : 4, ...r }, null, 2));
1647
+ else { console.log(JSON.stringify(r.plan)); console.log(r.changed ? "phases renumbered + Team Mix rebuilt" : (Object.keys(r.plan.renames).length ? "(dry run — nothing written)" : "no gap — nothing to do")); if (r.audit) printChecks(r.audit); }
1648
+ return ok ? 0 : 4;
1649
+ }
1650
+ if (verb === "titles") {
1651
+ const r = await verbTitles(api, { dryRun: !!args["dry-run"] });
1652
+ if (json) console.log(JSON.stringify({ ok: true, exitCode: 0, ...r }, null, 2));
1653
+ else { console.log(r.changes.length ? r.changes.map((c) => `A${c.row1}: '${c.from}' → '${c.to}'`).join("\n") : "titles already correct"); if (args["dry-run"] && r.changes.length) console.log("(dry run — nothing written)"); }
1654
+ return 0;
1655
+ }
1545
1656
  if (verb === "format") {
1546
1657
  const r = await verbFormat(api, { dryRun: !!args["dry-run"], noAudit: !!args["no-audit"] });
1547
1658
  const ok = !r.audit || r.audit.ok;
@@ -1591,7 +1702,7 @@ function haltAndExit(e, json) {
1591
1702
 
1592
1703
  module.exports = {
1593
1704
  validatePlan, splitRoster, rosterViolations, mfCoverageViolations, monthPlan, resampleWeights, rampHours, buildRoster,
1594
- tshirtTotals, phaseTotals, buildRosters, midDays, deriveFteFromTeamMix, phaseTotalsFromSheet, verbTeamMix, itemFormulasFor, rollupFormulasFor, findCell, itemFormulas, rollupFormulas, tshirtRows, teamMixValues, teamMixFormatReqs, remainderFormula, locateTshirt, findPhaseSource,
1705
+ tshirtTotals, phaseTotals, buildRosters, midDays, deriveFteFromTeamMix, phaseTotalsFromSheet, verbTeamMix, phaseGapMap, verbPhases, verbTitles, itemFormulasFor, rollupFormulasFor, findCell, itemFormulas, rollupFormulas, tshirtRows, teamMixValues, teamMixFormatReqs, remainderFormula, locateTshirt, findPhaseSource,
1595
1706
  auditTshirt, auditTeamMix, auditTechStack, auditOverview, colLetter, hexToColor, colorToHex, sheetIdFromArg,
1596
1707
  constants: { SIZE_CODES, PHASES, COLOR, RAMP, ROLE_LABEL, SOFT_CEILING, FOLD_THRESHOLD, TAB_TSHIRT, TAB_TEAM, TAB_TECH, PLAN_SCHEMA },
1597
1708
  Halt, SheetsApi, getToken, runAudit, main,
@@ -87,6 +87,7 @@ Reorder items into domains. Insert a **section-heading row** before each group p
87
87
 
88
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
+ 0. **The Team Mix staffs the MIDPOINT of the Low and High estimates** — per phase, `(Low Hrs + High Hrs) ÷ 2 ÷ 8` days from that phase's rollups — never the Low figure. The tool computes it; you do not choose it.
90
91
  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
92
  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.
92
93
  3. **PAUSE:** present that table verbatim. Wait for `continue` or corrections (a resize or a different FTE → edit the plan, re-run plan-check, present again).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tekyzinc/gsd-t",
3
- "version": "5.20.14",
3
+ "version": "5.20.15",
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",
@@ -27,6 +27,8 @@ Reference implementation (read it, don't guess): "ATP SOW Gap Analysis and Estim
27
27
  | **Map sheet rows to items BY NAME, not by counting.** | Section header rows shift positional alignment and put a whole column on the wrong tasks. |
28
28
  | **Recompute the total from raw sizes independently** (`Σ(FE,BE days) × (1 + MF)`) before reporting a number. | A total was reported from a cell just repaired; the true figure differed by 13 days. |
29
29
  | **`IMPORTRANGE` `#REF!` on the index sheet needs a human "Allow access" click.** Report it; do not "fix" it. | The service account cannot grant it. |
30
+ | **Every cell on every tab the tool writes is TOP-aligned.** | David, 2026-09-21. |
31
+ | **Functionality and Low Level Requirements wrap** (T-Shirt columns C and D, every item row); Technology Stack descriptions wrap. | Long requirement text was running off the cell. |
30
32
  | Access token expires in 1 h — mint fresh per run; regenerate on a sudden 401. | |
31
33
 
32
34
  ---
@@ -65,8 +67,8 @@ Column widths: `[150, 120, 300, 430, 122, 90, 90, 61, 53, 76, 81, 81]`.
65
67
  |---|---|---|
66
68
  | `A` | Module | |
67
69
  | `B` | User type | |
68
- | `C` | Functionality — **include the item id** `(GA-n)` / `(TD-n)` / `(FR-n)` | |
69
- | `D` | Low-level requirement (one or two sentences) | |
70
+ | `C` | Functionality — **include the item id** `(GA-n)` / `(TD-n)` / `(FR-n)` | **wrap** |
71
+ | `D` | Low-level requirement (one or two sentences) | **wrap** |
70
72
  | `E` | Phase — `MVP` / `Phase 1` / `Phase 2` / `Phase 3` | **Carries a `ONE_OF_LIST` validation + chip format. Populate by `copyPaste` (`PASTE_NORMAL`) from an existing MVP cell — a values-write drops the dropdown.** |
71
73
  | `F` | Web Portal (frontend) size | **Bare code only: `XS` `S` `M` `L` `XL` `XXL`. Never the legend text `"XS - Extra Small"`.** Blank = 0. |
72
74
  | `G` | Backend/API size | same |
@@ -97,7 +99,7 @@ Total (Days) | … | H =SUM(H14:H<last>) | I =SUM(I…) | J =SUM(J…) | K =SUM(
97
99
  ### 2.1 Layout of ONE grid
98
100
 
99
101
  ```
100
- Row t Title "<estimate title> — <phase>" (merged A:<Total Hrs col>)
102
+ Row t Title — EXACTLY the phase name: "MVP" / "Phase 1" / … (merged A:<Total Hrs col>; nothing else in it)
101
103
  Row t+1 Header (the ONLY header row in the grid)
102
104
  Row t+2.. one row PER PERSON
103
105
  Row T Total (T = t + 2 + nroles)
@@ -138,6 +140,10 @@ The old `Month / Days / Tot Days / Hrs` layout is retired: `Days` IS the per-per
138
140
 
139
141
  ### 2.6 One grid per phase
140
142
 
143
+ **Phases are contiguous.** Items use `MVP`, then `Phase 1`, `Phase 2`, `Phase 3` with no empty phase between used ones — `MVP` + `Phase 2` with nothing in `Phase 1` is a defect; renumber (`Phase 2` → `Phase 1`, `Phase 3` → `Phase 2`) so the numbering has no gap (`gsd-t estimate-sheet phases` does this and rebuilds the grids). The audit fails a gap.
144
+
145
+ **The title row of each grid is the phase name and nothing else** — `MVP`, `Phase 1`, … Never the estimate title, never "<title> — Phase 1" (David, 2026-09-21).
146
+
141
147
  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
148
 
143
149
  ### 2.3 Roster shape — one row per PERSON
@@ -212,13 +218,15 @@ Every item is a read-back check against the live sheet. Any ✗ blocks delivery.
212
218
  T-SHIRT
213
219
  [ ] every size cell in F:G is one of XS S M L XL XXL or blank (no legend text)
214
220
  [ ] every item row's E cell has ONE_OF_LIST validation
221
+ [ ] C and D wrap on every item row; every cell top-aligned
215
222
  [ ] every item row's H:L are the §1.2 formulas (no values)
216
223
  [ ] section heading rows are merged A:L, bg #1C4F8B, and hold no sizes
217
224
  [ ] totals row + phase rollups (K4:N7) + summary block reference <last item row>
218
225
  [ ] 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
219
226
  [ ] Σ raw sizes × (1+MF) == Total Days cell (recomputed independently)
227
+ [ ] phases are contiguous — no empty phase between used ones
220
228
  TEAM MIX (each grid)
221
- [ ] one grid per phase with hours; the title ends with the phase name; exactly 2 empty, unformatted rows between grids
229
+ [ ] one grid per phase with hours; the title row is EXACTLY the phase name; exactly 2 empty, unformatted rows between grids
222
230
  [ ] the header is the row under the title; the row under the header is a person
223
231
  [ ] header months read Mon 1..Mon N with N == month column count; last header is Total Hrs
224
232
  [ ] no Count > 1.00; per discipline, all rows but the last are 1.00
@@ -251,7 +259,9 @@ gsd-t estimate-sheet audit --sheet <id|url> # §5 checklis
251
259
  gsd-t estimate-sheet format --sheet <id|url> [--dry-run] # normalise T-Shirt FORMATTING only: section rows (§1.2), grey totals band directly under
252
260
  # the last item, standard summary block directly under it (§1.3); no size, phase, text or item formula is touched;
253
261
  # rows under the totals row that are not the old summary block are never overwritten (rows are inserted above them)
254
- gsd-t estimate-sheet teammix --sheet <id|url> [--fte '{"backend":1.5,…}'] [--title <t>] [--dry-run]
262
+ gsd-t estimate-sheet phases --sheet <id|url> [--dry-run] # close phase gaps on the T-Shirt tab (Phase 2→1, 3→2 …), then rebuild the Team Mix grids
263
+ gsd-t estimate-sheet titles --sheet <id|url> [--dry-run] # set each Team Mix grid's title row to exactly its phase name
264
+ gsd-t estimate-sheet teammix --sheet <id|url> [--fte '{"backend":1.5,…}'] [--dry-run]
255
265
  # rebuild the Team Mix (one grid per phase) from the sheet's OWN roster and phase rollups — no plan needed;
256
266
  # the roster is derived from the existing grid (entered Counts summed per discipline; older peak-utilisation
257
267
  # rosters split the sheet's total FTE by each role's hours); --fte overrides it; halts on a role it cannot map