@tekyzinc/gsd-t 5.20.13 → 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,57 @@
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
+
27
+ ## [5.20.14] - 2026-09-21
28
+
29
+ ### Added — `gsd-t estimate-sheet teammix` rebuilds any estimate's Team Mix; the reader handles both template layouts
30
+
31
+ Reviewing the 19 estimates linked from the Hilo index found ten on an older T-Shirt layout
32
+ (Project/Client rows on top, legend from row 8, up to four size columns, header row 17) and an
33
+ older Team Mix model (Count = peak-month hours ÷ 160). Five estimates have more than one phase
34
+ and needed the per-phase Team Mix.
35
+
36
+ - `bin/gsd-t-estimate-sheet.cjs`: `locateTshirt` finds every header cell BY LABEL and returns a
37
+ column map; item/rollup formulas and the audit are generated from the map. New `teammix` verb:
38
+ derives the roster from the sheet's existing grid (entered Counts summed per discipline; a
39
+ peak-utilisation roster splits the sheet's total FTE by each role's hours), reads phase
40
+ Low/High hours from the rollups, and rewrites one grid per phase; `--fte` overrides, `--dry-run`
41
+ previews, unmappable roles halt. Disciplines `design` and `mobile` added. `write` refuses the
42
+ older layout by name. Fold rule now checks a full-timer still fits under 172 hrs. 429 rate
43
+ limits back off and retry the identical request (3×), then halt.
44
+ - Applied to the five multi-phase Hilo estimates (Acron, Role-Based Permissions, Part 141 & 61,
45
+ Part 121, Aeroplanned); all Team Mix checks pass on each. Aeroplanned had no PM/BA row — added
46
+ at 0.20 / 0.10.
47
+ - `test/estimate-sheet-writer.test.js`: older-layout reader, roster derivation (both models),
48
+ fold-vs-ceiling.
49
+ - `format` verb: normalises a T-Shirt tab's formatting only — section rows to the spec blue
50
+ (unmerging each sheet's own merge range first), blank rows before the totals row deleted, the
51
+ grey bold totals band, and the standard summary block (bold labels, Total Cost in dollars)
52
+ rewritten directly under it; recognises the four old summary variants and inserts rows when
53
+ another table sits close below. Applied to all 19 Hilo estimates; every totals/summary check
54
+ now passes on all 19.
55
+
5
56
  ## [5.20.13] - 2026-09-21
6
57
 
7
58
  ### Fixed — Team Mix staffs the midpoint of Low and High, not Low
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # GSD-T: Contract-Driven Development for Claude Code
2
2
 
3
- **v5.20.13** - 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.
@@ -23,6 +23,10 @@
23
23
  * read --sheet <id|url> [--tab <name>] dump layout (read-before-write)
24
24
  * plan-check --sheet <id|url> --plan <plan.json> validate plan, print the roster it WOULD write
25
25
  * write --sheet <id|url> --plan <plan.json> [--replace] write T-Shirt + Team Mix + Tech Stack, then audit
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
+ * 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
26
30
  * audit --sheet <id|url> the spec §5 checklist, by read-back
27
31
  * plan-schema print the plan shape
28
32
  * Flags: --json (envelope only) --key <path> --no-audit (write only; for debugging)
@@ -74,6 +78,8 @@ const RAMP = {
74
78
  devops: [0.45, 0.85, 1.7],
75
79
  pm: [1, 1, 1],
76
80
  techlead: [1.3, 0.8, 0.9],
81
+ design: [1.6, 0.9, 0.5], // Design / UX: heaviest at the start, a tail for revisions
82
+ mobile: [1.35, 1.05, 0.6], // same shape as frontend
77
83
  };
78
84
 
79
85
  const ROLE_LABEL = {
@@ -84,6 +90,8 @@ const ROLE_LABEL = {
84
90
  ba: "Business Analyst",
85
91
  devops: "DevOps Engineer",
86
92
  techlead: "Tech Lead / Architect",
93
+ design: "Design / UX",
94
+ mobile: "Mobile Engineer",
87
95
  };
88
96
 
89
97
  // MF factor label → the discipline that must staff it (spec §2.4). Factors with
@@ -186,7 +194,7 @@ class SheetsApi {
186
194
  this.base = `https://sheets.googleapis.com/v4/spreadsheets/${sheetId}`;
187
195
  }
188
196
 
189
- async call(method, url, body) {
197
+ async call(method, url, body, attempt = 1) {
190
198
  // Spec §0: NEVER send X-Goog-User-Project — on these sheets it CAUSES a 403.
191
199
  const res = await fetch(url, {
192
200
  method,
@@ -194,6 +202,15 @@ class SheetsApi {
194
202
  body: body == null ? undefined : JSON.stringify(body),
195
203
  });
196
204
  const text = await res.text();
205
+ if (res.status === 429 && attempt <= 3) {
206
+ // Rate limit (60 reads/min/user — hit 2026-09-21 auditing 19 sheets in a row). This is
207
+ // flow control, not a masked failure: the same request is re-sent unchanged after the
208
+ // documented wait, and the 4th 429 halts.
209
+ const wait = 20000 * attempt;
210
+ console.error(` … Sheets API 429 (rate limit) — waiting ${wait / 1000}s, retry ${attempt}/3`);
211
+ await new Promise((r) => setTimeout(r, wait));
212
+ return this.call(method, url, body, attempt + 1);
213
+ }
197
214
  if (!res.ok) {
198
215
  const short = text.slice(0, 400);
199
216
  if (res.status === 403) {
@@ -217,7 +234,7 @@ class SheetsApi {
217
234
 
218
235
  async grid(tab) {
219
236
  const rng = encodeURIComponent(`'${tab}'`);
220
- 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)))";
221
238
  const r = await this.call("GET", `${this.base}?ranges=${rng}&includeGridData=true&fields=${fields}`);
222
239
  if (!r.sheets || !r.sheets[0]) throw new Halt(`tab '${tab}' not found`, 64);
223
240
  return r.sheets[0];
@@ -300,58 +317,104 @@ function rowIsEmpty(grid, r, cols = 12) {
300
317
  return true;
301
318
  }
302
319
 
303
- /** Locate the T-Shirt tab's fixed header block (spec §1.1). HALTS if it is not the template. */
304
- function locateTshirt(grid) {
305
- let headerRow = -1;
306
- for (let r = 0; r < Math.min(rowCount(grid), 40); r++) {
307
- if (/^Module\/Functionality$/i.test(textAt(grid, r, 0).trim())) { headerRow = r; break; }
320
+ /** Find the first cell whose text equals `label` (case-insensitive, trimmed) in the top `maxRows` rows. */
321
+ function findCell(grid, label, maxRows = 25, maxCols = 20) {
322
+ const want = label.toLowerCase();
323
+ for (let r = 0; r < Math.min(rowCount(grid), maxRows); r++) {
324
+ for (let c = 0; c < maxCols; c++) if (textAt(grid, r, c).trim().toLowerCase() === want) return { r, c };
308
325
  }
309
- if (headerRow < 0) throw new Halt(`${TAB_TSHIRT}: column header row ('Module/Functionality' in A) not found — is this the Tekyz template?`);
326
+ return null;
327
+ }
328
+
329
+ /**
330
+ * Locate the T-Shirt tab's header block BY LABEL, not by fixed row (spec §1.1). Two layouts
331
+ * are in the wild: the current template (Date row 1, legend A4:B9, header row 13, two size
332
+ * columns) and an older one (Project/Client/Date rows 3–5, legend A8:B13, header row 17, up
333
+ * to four size columns). Everything the writer and audit need is returned as a column map.
334
+ * HALTS on anything it cannot find — never guesses a coordinate.
335
+ */
336
+ function locateTshirt(grid) {
337
+ const hdr = findCell(grid, "Module/Functionality", 40, 3);
338
+ if (!hdr || hdr.c !== 0) throw new Halt(`${TAB_TSHIRT}: column header row ('Module/Functionality' in A) not found — is this the Tekyz template?`);
339
+ const headerRow = hdr.r;
340
+ const headerText = [];
341
+ for (let c = 0; c < 20; c++) headerText.push(textAt(grid, headerRow, c).trim());
342
+ const colOf = (label) => { const c = headerText.findIndex((x) => x.toLowerCase() === label.toLowerCase()); if (c < 0) throw new Halt(`${TAB_TSHIRT}: header row ${headerRow + 1} has no '${label}' column`); return c; };
343
+ const cols = { phase: colOf("Phase"), days: colOf("Days"), mfactor: colOf("MFactor Days"), total: colOf("Total Days"), low: colOf("LOW $"), high: colOf("HIGH $") };
344
+ cols.sizes = [];
345
+ for (let c = cols.phase + 1; c < cols.days; c++) cols.sizes.push(c);
346
+ if (!cols.sizes.length) throw new Halt(`${TAB_TSHIRT}: no size columns between 'Phase' and 'Days'`);
347
+ cols.sizeLabels = cols.sizes.map((c) => headerText[c]);
348
+ // legend: the XS..XXL rows in column A
310
349
  const legend = {};
311
- for (let r = 0; r < 20; r++) {
350
+ let legendFirst = -1, legendLast = -1;
351
+ for (let r = 0; r < headerRow; r++) {
312
352
  const m = textAt(grid, r, 0).trim().match(/^(XS|S|M|L|XL|XXL)\s*-/);
313
- if (m) legend[m[1]] = numAt(grid, r, 1);
353
+ if (m) { legend[m[1]] = numAt(grid, r, 1); if (legendFirst < 0) legendFirst = r; legendLast = r; }
314
354
  }
315
355
  for (const code of SIZE_CODES) {
316
- if (!(code in legend) || Number.isNaN(legend[code])) throw new Halt(`${TAB_TSHIRT}: size legend missing '${code}' in A4:B9`);
356
+ if (!(code in legend) || Number.isNaN(legend[code])) throw new Halt(`${TAB_TSHIRT}: size legend missing '${code}' (looked for 'XS - …' labels in column A above the header row)`);
317
357
  }
358
+ // MF list: under 'Multiplication Factor' down to 'Total MF'
359
+ const mfHdr = findCell(grid, "Multiplication Factor", headerRow, 12);
360
+ if (!mfHdr) throw new Halt(`${TAB_TSHIRT}: 'Multiplication Factor' header not found`);
318
361
  const mf = [];
319
362
  let mfTotalCell = null;
320
- for (let r = 0; r < 20; r++) {
321
- const label = textAt(grid, r, 4).trim();
363
+ for (let r = mfHdr.r + 1; r < headerRow; r++) {
364
+ const label = textAt(grid, r, mfHdr.c).trim();
322
365
  if (!label) continue;
323
- if (/^Total MF$/i.test(label)) { mfTotalCell = { r, c: 5, value: numAt(grid, r, 5) }; continue; }
324
- if (/^Multiplication Factor$/i.test(label)) continue;
325
- const v = numAt(grid, r, 5);
366
+ if (/^Total MF$/i.test(label)) { mfTotalCell = { r, c: mfHdr.c + 1, value: numAt(grid, r, mfHdr.c + 1) }; break; }
367
+ const v = numAt(grid, r, mfHdr.c + 1);
326
368
  if (!Number.isNaN(v)) mf.push({ label, value: v, row: r + 1 });
327
369
  }
328
- if (!mf.length) throw new Halt(`${TAB_TSHIRT}: multiplication-factor list (E4:F9) not found`);
329
- if (!mfTotalCell) throw new Halt(`${TAB_TSHIRT}: 'Total MF' cell not found`);
370
+ if (!mf.length) throw new Halt(`${TAB_TSHIRT}: multiplication-factor list under '${colLetter(mfHdr.c)}${mfHdr.r + 1}' is empty`);
371
+ if (!mfTotalCell) throw new Halt(`${TAB_TSHIRT}: 'Total MF' cell not found under the MF list`);
330
372
  const mfTotal = mf.reduce((s, f) => s + f.value, 0);
331
- const highFactor = numAt(grid, 3, 6);
332
- const rate = numAt(grid, 3, 7);
333
- if (Number.isNaN(highFactor)) throw new Halt(`${TAB_TSHIRT}: High $ Factor (G4) is not a number`);
334
- if (Number.isNaN(rate)) throw new Halt(`${TAB_TSHIRT}: Avg. Hrly Rate (H4) is not a number`);
373
+ const hfHdr = findCell(grid, "High $ Factor", headerRow, 14);
374
+ if (!hfHdr) throw new Halt(`${TAB_TSHIRT}: 'High $ Factor' header not found`);
375
+ const highFactor = numAt(grid, hfHdr.r + 1, hfHdr.c);
376
+ if (Number.isNaN(highFactor)) throw new Halt(`${TAB_TSHIRT}: High $ Factor (${colLetter(hfHdr.c)}${hfHdr.r + 2}) is not a number`);
377
+ const rateHdr = findCell(grid, "Avg. Hrly Rate", headerRow, 14);
378
+ if (!rateHdr) throw new Halt(`${TAB_TSHIRT}: 'Avg. Hrly Rate' header not found`);
379
+ const rate = numAt(grid, rateHdr.r + 1, rateHdr.c);
380
+ if (Number.isNaN(rate)) throw new Halt(`${TAB_TSHIRT}: Avg. Hrly Rate (${colLetter(rateHdr.c)}${rateHdr.r + 2}) is not a number`);
381
+ cols.highFactor = { r: hfHdr.r + 1, c: hfHdr.c };
382
+ cols.rate = { r: rateHdr.r + 1, c: rateHdr.c };
383
+ cols.mfTotal = { r: mfTotalCell.r, c: mfTotalCell.c };
384
+ // phase rollups: the column holding 'MVP' above the header; Low/High Hrs columns from their headers
385
+ const mvp = findCell(grid, "MVP", headerRow, 16);
386
+ if (!mvp) throw new Halt(`${TAB_TSHIRT}: phase rollup block ('MVP' label above the header row) not found`);
335
387
  const rollupRows = [];
336
- for (let r = 0; r < 12; r++) if (PHASES.includes(textAt(grid, r, 9).trim())) rollupRows.push(r);
337
- if (rollupRows.length !== 4) throw new Halt(`${TAB_TSHIRT}: expected 4 phase rollup rows in column J (MVP, Phase 1–3), found ${rollupRows.length}`);
338
- return { headerRow, firstItemRow: headerRow + 1, legend, mf, mfTotal, mfTotalCell, highFactor, rate, rollupRows };
388
+ for (let r = 0; r < headerRow; r++) if (PHASES.includes(textAt(grid, r, mvp.c).trim())) rollupRows.push(r);
389
+ if (rollupRows.length !== 4) throw new Halt(`${TAB_TSHIRT}: expected 4 phase rollup rows in column ${colLetter(mvp.c)} (MVP, Phase 1–3), found ${rollupRows.length}`);
390
+ const lowHrs = findCell(grid, "Low Hrs", headerRow, 20), highHrs = findCell(grid, "High Hrs", headerRow, 20);
391
+ const lowDol = findCell(grid, "Low ($)", headerRow, 20), highDol = findCell(grid, "High ($)", headerRow, 20);
392
+ if (!lowHrs || !highHrs || !lowDol || !highDol) throw new Halt(`${TAB_TSHIRT}: rollup headers 'Low ($)' / 'Low Hrs' / 'High ($)' / 'High Hrs' not all found`);
393
+ cols.rollupPhase = mvp.c; cols.lowHrs = lowHrs.c; cols.highHrs = highHrs.c; cols.lowDol = lowDol.c; cols.highDol = highDol.c;
394
+ return { headerRow, firstItemRow: headerRow + 1, legend, legendFirst1: legendFirst + 1, legendLast1: legendLast + 1, mf, mfTotal, mfTotalCell, highFactor, rate, rollupRows, cols };
339
395
  }
340
396
 
341
- /** Row index of a cell in column E with a ONE_OF_LIST validation — the Phase dropdown source; -1 if none. */
342
- function findPhaseSource(grid, fromRow) {
397
+ /** The standard template's column map (what `write` produces). */
398
+ const STANDARD_COLS = { phase: 4, sizes: [5, 6], days: 7, mfactor: 8, total: 9, low: 10, high: 11, rollupPhase: 9, lowHrs: 11, highHrs: 13, lowDol: 10, highDol: 12, highFactor: { r: 3, c: 6 }, rate: { r: 3, c: 7 }, mfTotal: { r: 9, c: 5 } };
399
+ const STANDARD_LEGEND = { first1: 4, last1: 9 };
400
+
401
+ /** Row index of a cell in the Phase column with a ONE_OF_LIST validation — the Phase dropdown source; -1 if none. */
402
+ function findPhaseSourceIn(grid, fromRow, phaseCol) {
343
403
  for (let r = fromRow; r < rowCount(grid); r++) {
344
- const cell = cellAt(grid, r, 4);
404
+ const cell = cellAt(grid, r, phaseCol);
345
405
  const dv = cell && cell.dataValidation;
346
406
  if (dv && dv.condition && dv.condition.type === "ONE_OF_LIST") return r;
347
407
  }
348
408
  return -1;
349
409
  }
350
410
 
411
+ /** Row index of a cell in column E with a ONE_OF_LIST validation — the Phase dropdown source; -1 if none. */
412
+ function findPhaseSource(grid, fromRow) { return findPhaseSourceIn(grid, fromRow, 4); }
413
+
351
414
  // ───────────────────────── plan validation (pure) ─────────────────────────
352
415
 
353
416
  const PLAN_SCHEMA = {
354
- 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)",
355
418
  tshirt: {
356
419
  mode: "'items' (write whole rows from row 14) | 'sizes' (rows already exist; fill E:L, matched by the id in column C)",
357
420
  sections: [{
@@ -376,7 +439,7 @@ function sizeOf(v) { return v == null ? "" : String(v).trim(); }
376
439
  function validatePlan(plan) {
377
440
  const errors = [];
378
441
  if (!plan || typeof plan !== "object") return ["plan is not an object"];
379
- 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)");
380
443
  const t = plan.tshirt && typeof plan.tshirt === "object" ? plan.tshirt : {};
381
444
  if (!["items", "sizes"].includes(t.mode)) errors.push("tshirt.mode: must be 'items' or 'sizes'");
382
445
  const sections = Array.isArray(t.sections) ? t.sections : [];
@@ -478,7 +541,11 @@ function monthPlan(totalDays, people) {
478
541
  const months = totalDays / (sumCount * 20);
479
542
  const whole = Math.floor(months);
480
543
  const frac = months - whole;
481
- const n = whole >= 1 && frac < FOLD_THRESHOLD ? whole : Math.ceil(months);
544
+ // Fold a small tail into the last full month ONLY if a full-time person still fits under the
545
+ // soft ceiling: months × 160 ≤ whole × 172. (Acron, 2026-09-21: 1.098 months folded to one
546
+ // column put Backend 1 at 175.7 hrs — over the ceiling — so the fold must check the hours.)
547
+ const fits = months * 160 <= whole * SOFT_CEILING + 1e-9;
548
+ const n = whole >= 1 && frac < FOLD_THRESHOLD && fits ? whole : Math.ceil(months);
482
549
  return { months, sumCount, n: Math.max(1, n) };
483
550
  }
484
551
 
@@ -554,25 +621,39 @@ function tshirtTotals(plan, layout) {
554
621
 
555
622
  // ───────────────────────── T-Shirt formulas (pure, spec §1.2–1.3) ─────────────────────────
556
623
 
557
- function itemFormulas(r) {
558
- return {
559
- H: `=(IF(F${r}="",0,SUMIF($A$4:$A$9,LEFT(F${r},2)&"*",$B$4:$B$9))+IF(G${r}="",0,SUMIF($A$4:$A$9,LEFT(G${r},2)&"*",$B$4:$B$9)))`,
560
- I: `=H${r}*$F$10`,
561
- J: `=H${r}+I${r}`,
562
- K: `=J${r}*8*$H$4`,
563
- L: `=K${r}*$G$4`,
624
+ /** Item-row formulas for any column map (spec §1.2). Keys are the STANDARD letters H..L; `cells` carries the real columns. */
625
+ function itemFormulasFor(r, cols, legend) {
626
+ const L = colLetter;
627
+ const lg = `$A$${legend.first1}:$A$${legend.last1}`, lv = `$B$${legend.first1}:$B$${legend.last1}`;
628
+ const days = "=(" + cols.sizes.map((c) => `IF(${L(c)}${r}="",0,SUMIF(${lg},LEFT(${L(c)}${r},2)&"*",${lv}))`).join("+") + ")";
629
+ const abs = (cell) => `$${L(cell.c)}$${cell.r + 1}`;
630
+ const f = {
631
+ H: days,
632
+ I: `=${L(cols.days)}${r}*${abs(cols.mfTotal)}`,
633
+ J: `=${L(cols.days)}${r}+${L(cols.mfactor)}${r}`,
634
+ K: `=${L(cols.total)}${r}*8*${abs(cols.rate)}`,
635
+ L: `=${L(cols.low)}${r}*${abs(cols.highFactor)}`,
564
636
  };
637
+ f.cells = [[cols.days, f.H], [cols.mfactor, f.I], [cols.total, f.J], [cols.low, f.K], [cols.high, f.L]];
638
+ return f;
565
639
  }
566
640
 
567
- function rollupFormulas(rollupRow1, firstRow1, lastRow1) {
568
- const e = `$E$${firstRow1}:$E$${lastRow1}`;
641
+ /** The standard template's item formulas (what `write` produces). */
642
+ function itemFormulas(r) { return itemFormulasFor(r, STANDARD_COLS, STANDARD_LEGEND); }
643
+
644
+ function rollupFormulasFor(rollupRow1, firstRow1, lastRow1, cols) {
645
+ const L = colLetter;
646
+ const e = `$${L(cols.phase)}$${firstRow1}:$${L(cols.phase)}$${lastRow1}`;
647
+ const ph = `$${L(cols.rollupPhase)}${rollupRow1}`;
648
+ const rate = `$${L(cols.rate.c)}$${cols.rate.r + 1}`;
569
649
  return [
570
- `=SUMIF(${e},$J${rollupRow1},$K$${firstRow1}:$K$${lastRow1})`,
571
- `=SUMIF(${e},$J${rollupRow1},$J$${firstRow1}:$J$${lastRow1})*8`,
572
- `=SUMIF(${e},$J${rollupRow1},$L$${firstRow1}:$L$${lastRow1})`,
573
- `=IF($H$4=0,0,M${rollupRow1}/$H$4)`,
650
+ `=SUMIF(${e},${ph},$${L(cols.low)}$${firstRow1}:$${L(cols.low)}$${lastRow1})`,
651
+ `=SUMIF(${e},${ph},$${L(cols.total)}$${firstRow1}:$${L(cols.total)}$${lastRow1})*8`,
652
+ `=SUMIF(${e},${ph},$${L(cols.high)}$${firstRow1}:$${L(cols.high)}$${lastRow1})`,
653
+ `=IF(${rate}=0,0,${L(cols.highDol)}${rollupRow1}/${rate})`,
574
654
  ];
575
655
  }
656
+ function rollupFormulas(rollupRow1, firstRow1, lastRow1) { return rollupFormulasFor(rollupRow1, firstRow1, lastRow1, STANDARD_COLS); }
576
657
 
577
658
  /** Rows for items mode, starting at firstRow0 (0-based). */
578
659
  function tshirtRows(plan, firstRow0) {
@@ -611,6 +692,13 @@ function tshirtRows(plan, firstRow0) {
611
692
  function fmtReq(sheetId, r0, r1, c0, c1, format, fields) {
612
693
  return { repeatCell: { range: gridRange(sheetId, r0, r1, c0, c1), cell: { userEnteredFormat: format }, fields } };
613
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
+ }
614
702
  function widthReqs(sheetId, widths) {
615
703
  return widths.map((w, i) => ({ updateDimensionProperties: { range: { sheetId, dimension: "COLUMNS", startIndex: i, endIndex: i + 1 }, properties: { pixelSize: w }, fields: "pixelSize" } }));
616
704
  }
@@ -631,6 +719,8 @@ async function writeTshirt(api, plan, opts) {
631
719
  const grid = await api.grid(TAB_TSHIRT);
632
720
  const sheetId = grid.properties.sheetId;
633
721
  const layout = locateTshirt(grid);
722
+ const std = ["phase", "days", "mfactor", "total", "low", "high"].every((k) => layout.cols[k] === STANDARD_COLS[k]) && layout.cols.sizes.length === 2 && layout.legendFirst1 === 4 && layout.headerRow === 12;
723
+ if (!std) throw new Halt(`${TAB_TSHIRT}: this sheet is not the current template layout (header row ${layout.headerRow + 1}, size columns ${layout.cols.sizeLabels.join("/")}) — 'write' supports the current template only; 'teammix' and 'audit' work on both`);
634
724
  const totals = tshirtTotals(plan, layout);
635
725
  const phaseSrc = findPhaseSource(grid, 0);
636
726
  if (phaseSrc < 0) throw new Halt(`${TAB_TSHIRT}: no Phase dropdown source cell found in column E — add a ONE_OF_LIST validation (MVP / Phase 1 / Phase 2 / Phase 3) to E${layout.firstItemRow + 1} and re-run`);
@@ -686,6 +776,8 @@ async function writeTshirt(api, plan, opts) {
686
776
  reqs.push(copyPhaseReq(sheetId, phaseSrc, r0)); // carries validation + chip format; value re-put below
687
777
  }
688
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
689
781
  reqs.push(...widthReqs(sheetId, TSHIRT_WIDTHS));
690
782
  await api.batch(reqs);
691
783
  // 5. the Phase VALUES again — copyPaste overwrote them with the source cell's value
@@ -728,7 +820,9 @@ async function writeTshirtSizes(api, grid, sheetId, layout, plan, totals, phaseS
728
820
  reqs.push(fmtReq(sheetId, w.r0, w.r0 + 1, 4, 7, FMT_ITEM_SIZE, "userEnteredFormat(horizontalAlignment,textFormat)"));
729
821
  reqs.push(fmtReq(sheetId, w.r0, w.r0 + 1, 7, 10, FMT_ITEM_NUM, FMT_FIELDS_ALL));
730
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));
731
824
  }
825
+ reqs.push(topAlignReq(sheetId, rowCount(grid) + 2));
732
826
  await api.batch(reqs);
733
827
  for (const w of writes) await api.putValues(TAB_TSHIRT, `E${w.r0 + 1}`, [[w.phase]]);
734
828
  const first1 = layout.firstItemRow + 1;
@@ -791,7 +885,7 @@ function teamMixValues(plan, roster, phase, top) {
791
885
  for (let m = 1; m <= n; m++) header.push(`Mon ${m}`);
792
886
  header.push("Total Hrs");
793
887
  const rows = [];
794
- 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)
795
889
  rows.push(header);
796
890
  const firstP = top + 3; // 1-based row of the first person
797
891
  roster.people.forEach((p, i) => {
@@ -848,7 +942,7 @@ async function writeTeamMix(api, plan, rosters) {
848
942
  const grids = [];
849
943
  let top = 0;
850
944
  for (const r of rosters) {
851
- const v = teamMixValues(plan, r.roster, rosters.length > 1 || r.phase ? r.phase : "", top);
945
+ const v = teamMixValues(plan, r.roster, r.phase, top);
852
946
  grids.push({ phase: r.phase, v });
853
947
  top = v.bottom + GRID_GAP;
854
948
  }
@@ -867,6 +961,7 @@ async function writeTeamMix(api, plan, rosters) {
867
961
  maxN = Math.max(maxN, g.v.totalC - g.v.firstMonthC);
868
962
  }
869
963
  reqs.push(...widthReqs(sheetId, [...TEAM_WIDTHS_FIXED, ...Array(maxN).fill(TEAM_MONTH_WIDTH), TEAM_TOTAL_WIDTH]));
964
+ reqs.push(topAlignReq(sheetId, lastRow + 5));
870
965
  await api.batch(reqs);
871
966
  return { grids: grids.map((g) => ({ phase: g.phase, titleR: g.v.titleR, bottom: g.v.bottom, rows: g.v.rows.length })), lastRow };
872
967
  }
@@ -888,6 +983,7 @@ async function writeTechStack(api, plan) {
888
983
  { mergeCells: { range: gridRange(sheetId, 0, 1, 0, 2), mergeType: "MERGE_ALL" } },
889
984
  fmtReq(sheetId, 0, 1, 0, 2, { backgroundColor: hexToColor(COLOR.teamHeaderBg), textFormat: { fontSize: 12, bold: true, foregroundColor: hexToColor(COLOR.white) } }, "userEnteredFormat(backgroundColor,textFormat)"),
890
985
  fmtReq(sheetId, 1, rows.length, 0, 2, { textFormat: { fontFamily: "Arial", fontSize: 10 }, wrapStrategy: "WRAP" }, "userEnteredFormat(textFormat,wrapStrategy)"),
986
+ topAlignReq(sheetId, rows.length + 5),
891
987
  ...widthReqs(sheetId, TECH_WIDTHS),
892
988
  ]);
893
989
  return { rows: rows.length - 1 };
@@ -897,74 +993,85 @@ async function writeTechStack(api, plan) {
897
993
 
898
994
  function check(list, name, ok, detail) { list.push({ check: name, ok: !!ok, detail: ok ? "" : String(detail == null ? "" : detail) }); }
899
995
 
900
- /** T-Shirt audit. The template header block is REQUIRED — locateTshirt halts if it is missing. */
996
+ /** T-Shirt audit — layout-aware via locateTshirt's column map. HALTS if the header block is missing. */
901
997
  function auditTshirt(grid) {
902
998
  const out = [];
903
999
  const layout = locateTshirt(grid);
1000
+ const { cols } = layout;
1001
+ const legendRows = { first1: layout.legendFirst1, last1: layout.legendLast1 };
904
1002
  const first0 = layout.firstItemRow;
905
- const badSizes = [], noDv = [], badFormula = [], sectionsWithSizes = [], badSectionFmt = [];
1003
+ const badSizes = [], noDv = [], badFormula = [], sectionsWithSizes = [], badSectionFmt = [], noWrap = [], notTop = [];
906
1004
  let lastItem0 = -1, totalRow0 = -1;
907
1005
  for (let r = first0; r < rowCount(grid); r++) {
908
1006
  const a = textAt(grid, r, 0);
909
1007
  if (/^Total \(Days\)/i.test(a)) { totalRow0 = r; break; }
910
- if (rowIsEmpty(grid, r)) continue;
911
- const isSection = a !== "" && textAt(grid, r, 2) === "" && textAt(grid, r, 7) === "";
1008
+ if (rowIsEmpty(grid, r, cols.high + 1)) continue;
1009
+ const isSection = a !== "" && textAt(grid, r, 2) === "" && textAt(grid, r, cols.days) === "";
912
1010
  if (isSection) {
913
- const feTxt = textAt(grid, r, 5);
914
- const beTxt = textAt(grid, r, 6);
915
- if (feTxt !== "" || beTxt !== "") sectionsWithSizes.push(r + 1);
1011
+ if (cols.sizes.some((c) => textAt(grid, r, c) !== "")) sectionsWithSizes.push(r + 1);
916
1012
  if (bgAt(grid, r, 0) !== COLOR.sectionBg) badSectionFmt.push(`row ${r + 1} bg ${bgAt(grid, r, 0)}`);
917
1013
  continue;
918
1014
  }
919
1015
  lastItem0 = r;
920
- for (const c of [5, 6]) {
1016
+ for (const c of cols.sizes) {
921
1017
  const v = textAt(grid, r, c).trim();
922
1018
  if (v && !SIZE_CODES.includes(v)) badSizes.push(`${colLetter(c)}${r + 1}='${v}'`);
923
1019
  }
924
- const cell = cellAt(grid, r, 4);
925
- if (!(cell && cell.dataValidation && cell.dataValidation.condition && cell.dataValidation.condition.type === "ONE_OF_LIST")) noDv.push(`E${r + 1}`);
926
- const f = itemFormulas(r + 1);
927
- [["H", 7], ["I", 8], ["J", 9], ["K", 10], ["L", 11]].forEach(([k, c]) => { if (formulaAt(grid, r, c) !== f[k]) badFormula.push(`${k}${r + 1}`); });
1020
+ const cell = cellAt(grid, r, cols.phase);
1021
+ if (!(cell && cell.dataValidation && cell.dataValidation.condition && cell.dataValidation.condition.type === "ONE_OF_LIST")) noDv.push(`${colLetter(cols.phase)}${r + 1}`);
1022
+ const f = itemFormulasFor(r + 1, cols, legendRows);
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}`); }
928
1026
  }
929
1027
  check(out, "T-Shirt: at least one item row", lastItem0 >= 0, "no item rows below the header");
930
- check(out, "T-Shirt: size cells are bare codes (no legend text)", !badSizes.length, badSizes.join(", "));
931
- check(out, "T-Shirt: every item row has the Phase dropdown", !noDv.length, noDv.join(", "));
932
- check(out, "T-Shirt: H:L are the spec formulas on every item row", !badFormula.length, badFormula.slice(0, 12).join(", "));
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(", "));
1030
+ check(out, "T-Shirt: size cells are bare codes (no legend text, no '-')", !badSizes.length, badSizes.slice(0, 10).join(", "));
1031
+ check(out, "T-Shirt: every item row has the Phase dropdown", !noDv.length, noDv.slice(0, 10).join(", "));
1032
+ check(out, "T-Shirt: Days..HIGH $ are the spec formulas on every item row", !badFormula.length, badFormula.slice(0, 12).join(", "));
933
1033
  check(out, "T-Shirt: section rows carry no sizes", !sectionsWithSizes.length, `rows ${sectionsWithSizes.join(", ")}`);
934
- check(out, "T-Shirt: section rows are styled (bg #1C4F8B)", !badSectionFmt.length, badSectionFmt.join(", "));
1034
+ check(out, "T-Shirt: section rows are styled (bg #1C4F8B)", !badSectionFmt.length, badSectionFmt.slice(0, 6).join(", "));
935
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(", ")}`);
936
1040
  let totalDaysCell = NaN;
937
- // Team Mix reconciles to the MIDPOINT of each phase's Low Hrs (L) and High Hrs (N), in days
938
1041
  const phaseDays = {};
939
1042
  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);
1043
+ const low = numAt(grid, rr, cols.lowHrs), high = numAt(grid, rr, cols.highHrs);
1044
+ phaseDays[textAt(grid, rr, cols.rollupPhase).trim()] = Number.isNaN(low) || Number.isNaN(high) ? NaN : round2((low + high) / 2 / 8);
942
1045
  }
943
1046
  if (totalRow0 > lastItem0 && lastItem0 >= 0) {
944
1047
  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
1048
  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}`);
1049
+ check(out, "T-Shirt: totals row is the template band (bg #D8DDE8, bold) across the table", [0, cols.phase, cols.total, cols.high].every((c) => bgAt(grid, totalRow0, c) === COLOR.tshirtTotalBg) && totFont.bold, `A bg ${bgAt(grid, totalRow0, 0)}, ${colLetter(cols.high)} bg ${bgAt(grid, totalRow0, cols.high)}, bold ${totFont.bold}`);
1050
+ const want = `=SUM(${colLetter(cols.total)}${first0 + 1}:${colLetter(cols.total)}${lastItem0 + 1})`;
1051
+ check(out, "T-Shirt: totals row sums the full item range", formulaAt(grid, totalRow0, cols.total) === want, `${colLetter(cols.total)}${totalRow0 + 1} is '${formulaAt(grid, totalRow0, cols.total)}', want '${want}'`);
1052
+ totalDaysCell = numAt(grid, totalRow0, cols.total);
1053
+ // summary block: the three labels directly under the totals row, anywhere in the row
1054
+ const findLabel = (r) => { for (let c = 0; c < cols.high + 1; c++) { const v = textAt(grid, r, c).trim(); if (/^Total (Days|Hrs|Cost)$/i.test(v)) return { c, v }; } return null; };
947
1055
  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)}`);
954
- const want = `=SUM(J${first0 + 1}:J${lastItem0 + 1})`;
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}'`);
956
- totalDaysCell = numAt(grid, totalRow0, 9);
1056
+ const labels = sumRows.map(findLabel);
1057
+ check(out, "T-Shirt: summary block (Total Days / Total Hrs / Total Cost) sits directly under the totals row", labels[0] && labels[0].v === "Total Days" && labels[1] && labels[1].v === "Total Hrs" && labels[2] && labels[2].v === "Total Cost", `rows ${sumRows[0] + 1}–${sumRows[2] + 1} = [${labels.map((l) => (l ? l.v : "")).join(", ")}]`);
1058
+ check(out, "T-Shirt: summary labels are bold", labels.every((l) => l && fontAt(grid, sumRows[labels.indexOf(l)], l.c).bold), "");
1059
+ if (labels[2]) {
1060
+ const costCell = cellAt(grid, sumRows[2], labels[2].c + 1);
1061
+ const costFmt = costCell && costCell.userEnteredFormat && costCell.userEnteredFormat.numberFormat;
1062
+ check(out, "T-Shirt: Total Cost is formatted as dollars", !!costFmt && costFmt.type === "CURRENCY", `${colLetter(labels[2].c + 1)}${sumRows[2] + 1} numberFormat ${JSON.stringify(costFmt)}`);
1063
+ } else check(out, "T-Shirt: Total Cost is formatted as dollars", false, "no Total Cost row");
957
1064
  const badRoll = [];
958
1065
  for (const rr of layout.rollupRows) {
959
- if (formulaAt(grid, rr, 10) !== rollupFormulas(rr + 1, first0 + 1, lastItem0 + 1)[0]) badRoll.push(`K${rr + 1}`);
1066
+ if (formulaAt(grid, rr, cols.lowDol) !== rollupFormulasFor(rr + 1, first0 + 1, lastItem0 + 1, cols)[0]) badRoll.push(`${colLetter(cols.lowDol)}${rr + 1}`);
960
1067
  }
961
- check(out, "T-Shirt: phase rollups (K4:N7) reference the full item range", !badRoll.length, badRoll.join(", "));
1068
+ check(out, "T-Shirt: phase rollups reference the full item range", !badRoll.length, badRoll.join(", "));
962
1069
  let summaryAbove = false;
963
- for (let r = first0; r < totalRow0; r++) if (/^Total (Days|Hrs|Cost)$/i.test(textAt(grid, r, 9))) summaryAbove = true;
1070
+ for (let r = first0; r < totalRow0; r++) if (findLabel(r)) summaryAbove = true;
964
1071
  check(out, "T-Shirt: no summary label inside the summed range", !summaryAbove, "a Total Days/Hrs/Cost label sits inside the summed range");
965
1072
  let raw = 0;
966
1073
  for (let r = first0; r <= lastItem0; r++) {
967
- for (const c of [5, 6]) { const v = textAt(grid, r, c).trim(); if (v && layout.legend[v] != null) raw += layout.legend[v]; }
1074
+ for (const c of cols.sizes) { const v = textAt(grid, r, c).trim(); if (v && layout.legend[v] != null) raw += layout.legend[v]; }
968
1075
  }
969
1076
  const recomputed = round2(raw * (1 + layout.mfTotal));
970
1077
  check(out, "T-Shirt: Σ raw sizes × (1+MF) == Total Days cell", Math.abs(recomputed - totalDaysCell) < 0.02, `recomputed ${recomputed}, cell ${totalDaysCell}`);
@@ -975,12 +1082,14 @@ function auditTshirt(grid) {
975
1082
  function disciplineOfLabel(label) {
976
1083
  const l = label.toLowerCase();
977
1084
  if (/qa|test/.test(l)) return "qa";
978
- if (/project manager|\bpm\b/.test(l)) return "pm";
979
- if (/analyst/.test(l)) return "ba";
1085
+ if (/project m|\bpm\b/.test(l)) return "pm";
1086
+ if (/analys/.test(l)) return "ba";
980
1087
  if (/devops/.test(l)) return "devops";
981
1088
  if (/lead|architect/.test(l)) return "techlead";
1089
+ if (/design|\bux\b/.test(l)) return "design";
1090
+ if (/mobile|ios|android/.test(l)) return "mobile";
982
1091
  if (/front/.test(l)) return "frontend";
983
- if (/back|api/.test(l)) return "backend";
1092
+ if (/back|api|platform/.test(l)) return "backend";
984
1093
  return "";
985
1094
  }
986
1095
 
@@ -1077,6 +1186,8 @@ function auditTeamMix(grid, mfList, tshirtTotalDays, phaseDays) {
1077
1186
  }
1078
1187
  }
1079
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(", "));
1080
1191
  check(out, `${label}: sage on exactly Days, Hrs, Total Hrs of role rows`, !badSage.length && !whiteMissing.length, [...badSage, ...whiteMissing].slice(0, 8).join(", "));
1081
1192
  if (totalR > 0 && totalC > 0) {
1082
1193
  const bandBad = [];
@@ -1093,9 +1204,9 @@ function auditTeamMix(grid, mfList, tshirtTotalDays, phaseDays) {
1093
1204
  // ΣCount × 20 × 0.005 from the exact figure — the tolerance follows the roster size.
1094
1205
  const tol = 0.05 + 0.1 * (Number.isNaN(sumCount) ? 1 : sumCount);
1095
1206
  tolAll += tol;
1096
- const phase = expectedPhases.find((ph) => title.endsWith(ph));
1207
+ const phase = expectedPhases.find((ph) => title.trim() === ph);
1097
1208
  if (expectedPhases.length) {
1098
- 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(", ")}]`);
1099
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)})`);
1100
1211
  }
1101
1212
  prevBottom = totalR + 1;
@@ -1208,6 +1319,264 @@ async function verbPlanCheck(api, plan) {
1208
1319
  return { totals, mf: layout.mf, mfTotal: round2(layout.mfTotal), highFactor: layout.highFactor, rate: layout.rate, rosters, table: rostersTable(rosters) };
1209
1320
  }
1210
1321
 
1322
+ /**
1323
+ * Derive the per-discipline FTE from a sheet's EXISTING Team Mix (first grid): each person
1324
+ * row's label → discipline, Count summed per discipline. HALTS on a label it cannot classify
1325
+ * or a grid it cannot find — a rebuild must never guess a roster.
1326
+ */
1327
+ function deriveFteFromTeamMix(grid) {
1328
+ let header = -1;
1329
+ for (let r = 0; r < rowCount(grid); r++) if (textAt(grid, r, 0) === "Skill set") { header = r; break; }
1330
+ if (header < 0) throw new Halt(`${TAB_TEAM}: no 'Skill set' header row — cannot derive the roster`);
1331
+ // Two roster models exist. Current: Count is an ENTERED FTE → sum per discipline. Older:
1332
+ // Count is a FORMULA `=ROUND(MAX(months)/160,2)` (peak-month utilisation), so summing it
1333
+ // overstates the team; there, keep the sheet's total FTE and split it by each role's share
1334
+ // of Total Hrs. The model is read from the first Count cell, never assumed.
1335
+ const firstCount = formulaAt(grid, header + 1, 1);
1336
+ const peakModel = /MAX\(/i.test(firstCount);
1337
+ let totalHrsCol = -1;
1338
+ for (let c = 0; c < 30; c++) if (textAt(grid, header, c).trim() === "Total Hrs") totalHrsCol = c;
1339
+ if (peakModel && totalHrsCol < 0) throw new Halt(`${TAB_TEAM}: peak-utilisation roster but no 'Total Hrs' column to split by`);
1340
+ const people = [];
1341
+ const unknown = [];
1342
+ let totalFte = NaN;
1343
+ for (let r = header + 1; r < rowCount(grid); r++) {
1344
+ const a = textAt(grid, r, 0);
1345
+ if (a === "" || /^Total/i.test(a)) break;
1346
+ if (/^Tot\b/i.test(a)) { totalFte = numAt(grid, r, 1); continue; } // 'Tot FTE' / 'Tot Dys/Hrs' summary line inside the roster
1347
+ const d = disciplineOfLabel(a);
1348
+ if (!d) { unknown.push(a); continue; }
1349
+ const count = numAt(grid, r, 1);
1350
+ const hours = peakModel ? numAt(grid, r, totalHrsCol) : NaN;
1351
+ if (Number.isNaN(count)) throw new Halt(`${TAB_TEAM}: '${a}' has no numeric Count in column B`);
1352
+ if (peakModel && Number.isNaN(hours)) throw new Halt(`${TAB_TEAM}: '${a}' has no numeric Total Hrs`);
1353
+ people.push({ d, count, hours });
1354
+ }
1355
+ if (unknown.length) throw new Halt(`${TAB_TEAM}: cannot map these roles to a discipline — ${unknown.join(", ")}. Known: ${Object.keys(ROLE_LABEL).join(", ")}`);
1356
+ if (!people.length) throw new Halt(`${TAB_TEAM}: no person rows under the header`);
1357
+ const fte = {};
1358
+ if (peakModel) {
1359
+ const sumFte = Number.isNaN(totalFte) ? people.reduce((s, p) => s + p.count, 0) : totalFte;
1360
+ const sumHrs = people.reduce((s, p) => s + p.hours, 0);
1361
+ if (!(sumHrs > 0)) throw new Halt(`${TAB_TEAM}: Total Hrs sum to 0 — cannot split the roster`);
1362
+ for (const p of people) fte[p.d] = round2((fte[p.d] ? fte[p.d] : 0) + sumFte * p.hours / sumHrs);
1363
+ } else {
1364
+ for (const p of people) fte[p.d] = round2((fte[p.d] ? fte[p.d] : 0) + p.count);
1365
+ }
1366
+ for (const d of Object.keys(fte)) if (!(fte[d] > 0)) delete fte[d];
1367
+ return fte;
1368
+ }
1369
+
1370
+ /** Per-phase Low/High days read from the T-Shirt rollups (phases with hours > 0), in PHASES order. */
1371
+ function phaseTotalsFromSheet(grid, layout) {
1372
+ const out = [];
1373
+ for (const rr of layout.rollupRows) {
1374
+ const ph = textAt(grid, rr, layout.cols.rollupPhase).trim();
1375
+ const low = numAt(grid, rr, layout.cols.lowHrs), high = numAt(grid, rr, layout.cols.highHrs);
1376
+ if (Number.isNaN(low) || Number.isNaN(high)) throw new Halt(`${TAB_TSHIRT}: rollup row ${rr + 1} (${ph}) has no Low Hrs / High Hrs values`);
1377
+ if (low > 0) out.push({ phase: ph, totalDays: round2(low / 8), highDays: round2(high / 8), staffDays: round2((low + high) / 2 / 8) });
1378
+ }
1379
+ if (!out.length) throw new Halt(`${TAB_TSHIRT}: no phase has hours — nothing to staff`);
1380
+ return PHASES.filter((p) => out.some((o) => o.phase === p)).map((p) => out.find((o) => o.phase === p));
1381
+ }
1382
+
1383
+ /** Rebuild the Team Mix tab (one grid per phase) from the sheet's own roster + rollups. No plan needed. */
1384
+ async function verbTeamMix(api, opts) {
1385
+ const tshirt = await api.grid(TAB_TSHIRT);
1386
+ const layout = locateTshirt(tshirt);
1387
+ const team = await api.grid(TAB_TEAM);
1388
+ const fte = opts.fte ? opts.fte : deriveFteFromTeamMix(team);
1389
+ const title = "";
1390
+ const plan = { teamMix: { fte } };
1391
+ const rosters = phaseTotalsFromSheet(tshirt, layout).map((ph) => ({ ...ph, roster: buildRoster(plan, ph.staffDays, layout.mf) }));
1392
+ if (opts.dryRun) return { title, fte, rosters, table: rostersTable(rosters) };
1393
+ const tm = await writeTeamMix(api, plan, rosters);
1394
+ const result = { title, fte, 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), grids: tm.grids };
1395
+ if (!opts.noAudit) result.audit = await runAudit(api);
1396
+ return result;
1397
+ }
1398
+
1399
+ /**
1400
+ * Normalise a T-Shirt tab's FORMATTING to the spec without touching any size, phase, item text
1401
+ * or formula (spec §1.2 section rows, §1.3 totals row + summary block). Layout-aware via the
1402
+ * column map, so it works on both template generations. What it does, in order:
1403
+ * 1. section heading rows → merged A:<HIGH $>, bg #1C4F8B, white bold Arial 10
1404
+ * 2. blank rows between the last item and the totals row → deleted
1405
+ * 3. totals row → bg #D8DDE8, bold 10, across the table
1406
+ * 4. summary block (Total Days / Total Hrs / Total Cost) → rewritten directly under the totals
1407
+ * row in the standard shape; blank rows in between deleted; labels bold; Cost in dollars
1408
+ * HALTS when the rows under the totals row hold anything other than the old summary block or
1409
+ * blanks — it never overwrites content it did not expect.
1410
+ */
1411
+ async function verbFormat(api, opts) {
1412
+ const grid = await api.grid(TAB_TSHIRT);
1413
+ const sheetId = grid.properties.sheetId;
1414
+ const layout = locateTshirt(grid);
1415
+ const { cols } = layout;
1416
+ const width = cols.high + 1;
1417
+ const L = colLetter;
1418
+ const first0 = layout.firstItemRow;
1419
+ const sections = [];
1420
+ let lastItem0 = -1, totalRow0 = -1;
1421
+ for (let r = first0; r < rowCount(grid); r++) {
1422
+ const a = textAt(grid, r, 0);
1423
+ if (/^Total \(Days\)/i.test(a)) { totalRow0 = r; break; }
1424
+ if (rowIsEmpty(grid, r, width)) continue;
1425
+ if (a !== "" && textAt(grid, r, 2) === "" && textAt(grid, r, cols.days) === "") sections.push(r);
1426
+ else lastItem0 = r;
1427
+ }
1428
+ if (lastItem0 < 0) throw new Halt(`${TAB_TSHIRT}: no item rows`);
1429
+ if (totalRow0 < 0) throw new Halt(`${TAB_TSHIRT}: no 'Total (Days)' row below the items — add it first`);
1430
+ // the rows under the totals row must be the old summary block or blank
1431
+ // Old summary variants seen across the 19 Hilo sheets (2026-09-21): the standard 3 rows;
1432
+ // a 'Low | High' header row above them; 'Total Cost (Low)' / '(High)' split rows; nothing at
1433
+ // all. Anything else under the totals row (a second table, notes) ends the block and is
1434
+ // NEVER touched — the new block goes in the free rows above it, inserting rows if needed.
1435
+ const summaryRe = /^(Total (Days|Hrs|Cost)( \((Low|High)\))?|Low|High)$/i;
1436
+ const totRef = new RegExp(`\\b[A-Z]{1,2}\\$?${totalRow0 + 1}\\b`);
1437
+ let summaryEnd0 = totalRow0; // last row of the old summary block (or the totals row if none)
1438
+ let nextContent0 = -1; // first row of foreign content below the block, if any
1439
+ for (let r = totalRow0 + 1; r < Math.min(rowCount(grid), totalRow0 + 10); r++) {
1440
+ let rowHas = false, foreign = false;
1441
+ for (let c = 0; c < width; c++) {
1442
+ const v = textAt(grid, r, c).trim();
1443
+ if (v === "") continue;
1444
+ rowHas = true;
1445
+ const ok = summaryRe.test(v) || (v.startsWith("=") && (totRef.test(v) || new RegExp(`\\b[A-Z]{1,2}(${totalRow0 + 2}|${totalRow0 + 3}|${totalRow0 + 4})\\b`).test(v)));
1446
+ if (!ok) foreign = true;
1447
+ }
1448
+ if (foreign) { nextContent0 = r; break; }
1449
+ if (rowHas) summaryEnd0 = r;
1450
+ }
1451
+ const blankBefore = totalRow0 - lastItem0 - 1;
1452
+ const freeAfter = (nextContent0 < 0 ? Infinity : nextContent0 - 1) - totalRow0; // rows available under the totals row
1453
+ const plan = { sections: sections.map((r) => r + 1), lastItem1: lastItem0 + 1, totalRow1: totalRow0 + 1, blankRowsBeforeTotals: blankBefore, oldSummaryRows: summaryEnd0 > totalRow0 ? `${totalRow0 + 2}–${summaryEnd0 + 1}` : "none", foreignContentAtRow: nextContent0 < 0 ? null : nextContent0 + 1, rowsToInsertForSummary: Math.max(0, 3 - Math.min(freeAfter, 3)) };
1454
+ if (opts.dryRun) return { layout: { headerRow1: layout.headerRow + 1, sizeColumns: cols.sizeLabels, totalCol: L(cols.total) }, plan };
1455
+
1456
+ // 1. section rows (before any row deletion — indices are current)
1457
+ const reqs = [];
1458
+ const merges = Array.isArray(grid.merges) ? grid.merges : [];
1459
+ for (const r0 of sections) {
1460
+ // Google only unmerges an EXACT existing range — use the sheet's own merge list (two Hilo
1461
+ // sheets had section rows merged wider than the table, and A:<HIGH $> 400'd).
1462
+ for (const m of merges) if (m.startRowIndex <= r0 && m.endRowIndex > r0) reqs.push({ unmergeCells: { range: { sheetId, startRowIndex: m.startRowIndex, endRowIndex: m.endRowIndex, startColumnIndex: m.startColumnIndex, endColumnIndex: m.endColumnIndex } } });
1463
+ reqs.push({ mergeCells: { range: gridRange(sheetId, r0, r0 + 1, 0, width), mergeType: "MERGE_ALL" } });
1464
+ reqs.push(fmtReq(sheetId, r0, r0 + 1, 0, width,
1465
+ { backgroundColor: hexToColor(COLOR.sectionBg), textFormat: { fontFamily: "Arial", fontSize: 10, bold: true, foregroundColor: hexToColor(COLOR.white) }, horizontalAlignment: "LEFT" },
1466
+ "userEnteredFormat(backgroundColor,textFormat,horizontalAlignment)"));
1467
+ }
1468
+ if (reqs.length) await api.batch(reqs);
1469
+ // 2. clear the old summary block (values + formats), then delete the blank rows before the totals row
1470
+ if (summaryEnd0 > totalRow0) {
1471
+ await api.clearValues(TAB_TSHIRT, `A${totalRow0 + 2}:${L(width - 1)}${summaryEnd0 + 1}`);
1472
+ await api.batch([fmtReq(sheetId, totalRow0 + 1, summaryEnd0 + 1, 0, width, {}, "userEnteredFormat")]);
1473
+ }
1474
+ let tot0 = totalRow0;
1475
+ if (blankBefore > 0) {
1476
+ await api.batch([{ deleteDimension: { range: { sheetId, dimension: "ROWS", startIndex: lastItem0 + 1, endIndex: totalRow0 } } }]);
1477
+ tot0 = lastItem0 + 1;
1478
+ }
1479
+ // make room for the 3 summary rows when other content sits close under the totals row
1480
+ if (plan.rowsToInsertForSummary > 0) {
1481
+ const at = tot0 + 1 + (freeAfter === Infinity ? 3 : Math.max(0, freeAfter)); // insert right before the foreign content
1482
+ await api.batch([{ insertDimension: { range: { sheetId, dimension: "ROWS", startIndex: at, endIndex: at + plan.rowsToInsertForSummary }, inheritFromBefore: false } }]);
1483
+ }
1484
+ // 3. totals row band
1485
+ await api.batch([
1486
+ fmtReq(sheetId, tot0, tot0 + 1, 0, width, { backgroundColor: hexToColor(COLOR.tshirtTotalBg), textFormat: { fontSize: 10, bold: true }, horizontalAlignment: "LEFT" }, "userEnteredFormat(backgroundColor,textFormat,horizontalAlignment)"),
1487
+ fmtReq(sheetId, tot0, tot0 + 1, cols.days, width, { horizontalAlignment: "RIGHT" }, "userEnteredFormat.horizontalAlignment"),
1488
+ ]);
1489
+ // 4. summary block directly under the totals row
1490
+ const t1 = tot0 + 1;
1491
+ const hf = `$${L(cols.highFactor.c)}$${cols.highFactor.r + 1}`;
1492
+ const rows = [
1493
+ ["Total Days", `=${L(cols.total)}${t1}`, `=${L(cols.total)}${t1}*${hf}`],
1494
+ ["Total Hrs", `=${L(cols.total)}${t1}*8`, `=${L(cols.total)}${t1}*${hf}*8`],
1495
+ ["Total Cost", `=${L(cols.low)}${t1}`, `=${L(cols.high)}${t1}`],
1496
+ ];
1497
+ await api.putValues(TAB_TSHIRT, `${L(cols.total)}${t1 + 1}:${L(cols.total + 2)}${t1 + 3}`, rows);
1498
+ await api.batch([
1499
+ fmtReq(sheetId, t1, t1 + 3, cols.total, cols.total + 3, { textFormat: { fontSize: 10, bold: true } }, "userEnteredFormat.textFormat"),
1500
+ fmtReq(sheetId, t1, t1 + 2, cols.total + 1, cols.total + 3, { horizontalAlignment: "RIGHT", numberFormat: { type: "NUMBER", pattern: "0.00" } }, "userEnteredFormat(horizontalAlignment,numberFormat)"),
1501
+ fmtReq(sheetId, t1 + 2, t1 + 3, cols.total + 1, cols.total + 3, { horizontalAlignment: "RIGHT", numberFormat: { type: "CURRENCY", pattern: "$#,##0.00" } }, "userEnteredFormat(horizontalAlignment,numberFormat)"),
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
+ }
1511
+ const result = { plan, totalRow1: t1 };
1512
+ if (!opts.noAudit) result.audit = await runAudit(api);
1513
+ return result;
1514
+ }
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
+
1211
1580
  async function verbWrite(api, plan, opts) {
1212
1581
  const ts = await writeTshirt(api, plan, opts);
1213
1582
  const rosters = buildRosters(plan, ts.layout);
@@ -1228,7 +1597,7 @@ function parseArgs(argv) {
1228
1597
  const out = { _: [] };
1229
1598
  for (let i = 0; i < argv.length; i++) {
1230
1599
  const a = argv[i];
1231
- if (a === "--json" || a === "--replace" || a === "--no-audit") out[a.slice(2)] = true;
1600
+ if (a === "--json" || a === "--replace" || a === "--no-audit" || a === "--dry-run") out[a.slice(2)] = true;
1232
1601
  else if (a.startsWith("--")) { out[a.slice(2)] = argv[i + 1]; i++; }
1233
1602
  else out._.push(a);
1234
1603
  }
@@ -1240,7 +1609,7 @@ function printChecks(audit) {
1240
1609
  console.log(`${audit.ok ? "AUDIT PASS" : `AUDIT FAIL (${audit.failed})`} — ${audit.title}`);
1241
1610
  }
1242
1611
 
1243
- const USAGE = "usage: gsd-t estimate-sheet <read|plan-check|write|audit|plan-schema> --sheet <id|url> [--tab <name>] [--plan <plan.json>] [--replace] [--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]";
1244
1613
 
1245
1614
  /** Runs a verb; returns the exit code. Throws Halt (or any error) — the runner below turns that into exit 4/64. */
1246
1615
  async function main(args) {
@@ -1256,6 +1625,41 @@ async function main(args) {
1256
1625
  else { console.log(r.title); r.tabs.forEach((t) => console.log(` tab ${JSON.stringify(t.title)} id=${t.sheetId}`)); (r.rows ? r.rows : []).forEach((l) => console.log(l)); }
1257
1626
  return 0;
1258
1627
  }
1628
+ if (verb === "teammix") {
1629
+ // rebuild the Team Mix from the sheet's own roster + phase rollups (no plan file)
1630
+ let fte;
1631
+ if (args.fte) { try { fte = JSON.parse(args.fte); } catch (e) { throw new Halt(`--fte must be JSON like {"backend":1.5,"qa":0.4}: ${e.message}`, 64); } }
1632
+ const r = await verbTeamMix(api, { fte, title: args.title, dryRun: !!args["dry-run"], noAudit: !!args["no-audit"] });
1633
+ const ok = !r.audit || r.audit.ok;
1634
+ if (json) console.log(JSON.stringify({ ok, exitCode: ok ? 0 : 4, ...r }, null, 2));
1635
+ else {
1636
+ console.log(`title: ${r.title}`); console.log(`fte: ${JSON.stringify(r.fte)}`); console.log(r.table);
1637
+ if (r.audit) printChecks(r.audit); else if (args["dry-run"]) console.log("(dry run — nothing written)");
1638
+ }
1639
+ return ok ? 0 : 4;
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
+ }
1656
+ if (verb === "format") {
1657
+ const r = await verbFormat(api, { dryRun: !!args["dry-run"], noAudit: !!args["no-audit"] });
1658
+ const ok = !r.audit || r.audit.ok;
1659
+ if (json) console.log(JSON.stringify({ ok, exitCode: ok ? 0 : 4, ...r }, null, 2));
1660
+ else { console.log(JSON.stringify(r.plan)); if (r.audit) printChecks(r.audit); else if (args["dry-run"]) console.log("(dry run — nothing written)"); }
1661
+ return ok ? 0 : 4;
1662
+ }
1259
1663
  if (verb === "audit") {
1260
1664
  const a = await runAudit(api);
1261
1665
  if (json) console.log(JSON.stringify({ ok: a.ok, exitCode: a.ok ? 0 : 4, ...a }, null, 2)); else printChecks(a);
@@ -1298,7 +1702,7 @@ function haltAndExit(e, json) {
1298
1702
 
1299
1703
  module.exports = {
1300
1704
  validatePlan, splitRoster, rosterViolations, mfCoverageViolations, monthPlan, resampleWeights, rampHours, buildRoster,
1301
- tshirtTotals, phaseTotals, buildRosters, midDays, 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,
1302
1706
  auditTshirt, auditTeamMix, auditTechStack, auditOverview, colLetter, hexToColor, colorToHex, sheetIdFromArg,
1303
1707
  constants: { SIZE_CODES, PHASES, COLOR, RAMP, ROLE_LABEL, SOFT_CEILING, FOLD_THRESHOLD, TAB_TSHIRT, TAB_TEAM, TAB_TECH, PLAN_SCHEMA },
1304
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.13",
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)
@@ -132,12 +134,16 @@ The old `Month / Days / Tot Days / Hrs` layout is retired: `Days` IS the per-per
132
134
 
133
135
  - **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
136
  - `months = staffDays / (Σ Count × 20)` — solve so `Σ Days` equals that midpoint.
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.
137
+ - Month columns `N = ceil(months)`, EXCEPT when the fractional tail is under ~0.1 month AND a full-time person still fits under the soft ceiling (`months × 160 ≤ N × 172`): then fold it into the last full month instead of opening a near-empty column.
136
138
  - **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).
137
139
  - The remainder formula makes each row sum exactly. Never write a fractional last month as a value.
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
@@ -172,6 +178,8 @@ Monthly hours follow when the work happens. Normalise the weights, multiply by t
172
178
  | DevOps | 0.45 / 0.85 / 1.70 | back-loaded — mirror of BA |
173
179
  | Project Manager | 1.00 / 1.00 / 1.00 | flat |
174
180
  | Tech Lead / Architect | 1.30 / 0.80 / 0.90 | design-heavy, uptick at integration |
181
+ | Design / UX | 1.60 / 0.90 / 0.50 | heaviest at the start, a tail for revisions |
182
+ | Mobile | 1.35 / 1.05 / 0.60 | same shape as frontend |
175
183
 
176
184
  - A flat `160 / 160 / 147` for every role is the tell that no ramping was applied.
177
185
  - **The only ceiling is 160 hrs per person per month, soft to ~172.** There is NO `Count × 160` per-row ceiling — a 0.40 QA can work 106 hrs in their heavy month; that is what ramping means.
@@ -210,13 +218,15 @@ Every item is a read-back check against the live sheet. Any ✗ blocks delivery.
210
218
  T-SHIRT
211
219
  [ ] every size cell in F:G is one of XS S M L XL XXL or blank (no legend text)
212
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
213
222
  [ ] every item row's H:L are the §1.2 formulas (no values)
214
223
  [ ] section heading rows are merged A:L, bg #1C4F8B, and hold no sizes
215
224
  [ ] totals row + phase rollups (K4:N7) + summary block reference <last item row>
216
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
217
226
  [ ] Σ raw sizes × (1+MF) == Total Days cell (recomputed independently)
227
+ [ ] phases are contiguous — no empty phase between used ones
218
228
  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
229
+ [ ] one grid per phase with hours; the title row is EXACTLY the phase name; exactly 2 empty, unformatted rows between grids
220
230
  [ ] the header is the row under the title; the row under the header is a person
221
231
  [ ] header months read Mon 1..Mon N with N == month column count; last header is Total Hrs
222
232
  [ ] no Count > 1.00; per discipline, all rows but the last are 1.00
@@ -246,8 +256,19 @@ gsd-t estimate-sheet read --sheet <id|url> [--tab <name>] # read-before-
246
256
  gsd-t estimate-sheet plan-check --sheet <id|url> --plan plan.json # validate + the roster it WOULD write (the Step 4 pause)
247
257
  gsd-t estimate-sheet write --sheet <id|url> --plan plan.json [--replace] # T-Shirt + Team Mix + Tech Stack, then audit
248
258
  gsd-t estimate-sheet audit --sheet <id|url> # §5 checklist by read-back
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
260
+ # the last item, standard summary block directly under it (§1.3); no size, phase, text or item formula is touched;
261
+ # rows under the totals row that are not the old summary block are never overwritten (rows are inserted above them)
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]
265
+ # rebuild the Team Mix (one grid per phase) from the sheet's OWN roster and phase rollups — no plan needed;
266
+ # the roster is derived from the existing grid (entered Counts summed per discipline; older peak-utilisation
267
+ # rosters split the sheet's total FTE by each role's hours); --fte overrides it; halts on a role it cannot map
249
268
  ```
250
269
 
270
+ Both the current template and the older layout (Project/Client rows on top, legend from row 8, up to four size columns, header row 17) are READ by `audit` and `teammix` — every coordinate comes from the labels, never a fixed row. `write` produces the current template only.
271
+
251
272
  The plan (judgment only):
252
273
 
253
274
  ```json
@@ -264,5 +285,5 @@ The plan (judgment only):
264
285
  ```
265
286
 
266
287
  - `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.
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.
288
+ - `teamMix.fte` is per-discipline FTE (`backend` `frontend` `qa` `pm` `ba` `devops` `techlead` `design` `mobile`). 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.
268
289
  - The MF list, legend, rate and high factor are READ from the sheet; the plan never carries them.