@tekyzinc/gsd-t 5.20.14 → 5.21.10

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,53 @@
2
2
 
3
3
  All notable changes to GSD-T are documented here. Updated with each release.
4
4
 
5
+ ## [5.21.10] - 2026-09-22
6
+
7
+ ### Changed — the top tier now points at Opus 5.5
8
+
9
+ The `opus` tier alias resolved to `claude-opus-5`. It now resolves to
10
+ `claude-opus-5-5`, so every high-stakes stage (solution-space probe, partition
11
+ probe, competition producers and judge, pre-mortem, Red Team, both debug cycles)
12
+ runs Opus 5.5. Only the concrete model id changed: the three-tier shape, the
13
+ stage-to-tier map, and the relaxed fresh-context judge-blindness invariant are
14
+ untouched.
15
+
16
+ - `bin/gsd-t-model-tier-policy.cjs`: `MODEL_IDS.opus` → `claude-opus-5-5`; the
17
+ thinking-omission predicate still matches no current tier model
18
+ - `.gsd-t/contracts/model-tier-policy-contract.md`: → v2.1.0, alias table and
19
+ Updated line; the v2.0.0 note kept under `## Previously`
20
+ - `templates/workflows/gsd-t-{phase,verify,debug}.workflow.js`,
21
+ `templates/prompts/blind-adversary-subagent.md`: model id in the stage comments
22
+ - `templates/CLAUDE-global.md`, `README.md`, `commands/gsd-t-{help,status}.md`:
23
+ the documented `opus` = model id
24
+ - `test/m85-model-tier-policy.test.js`, `test/m86-policy-profiles.test.js`,
25
+ `test/m90-tier-policy-lint.test.js`: expected id; the drifted-literal negative
26
+ test still fails on a mismatch
27
+
28
+ No migration. A project picks the new id on its next `gsd-t update-all`.
29
+
30
+ ## [5.20.15] - 2026-09-21
31
+
32
+ ### Changed — phases must be contiguous; the Team Mix title row is the phase name only
33
+
34
+ David's review of the 19 Hilo estimates: one had items in MVP and Phase 2 with nothing in
35
+ Phase 1, and every Team Mix title row carried the estimate name.
36
+
37
+ - Rule (spec §2.6): phases are contiguous — `MVP`, `Phase 1`, `Phase 2`, … with no empty phase
38
+ between used ones. The audit fails a gap. New `phases` verb renumbers the item Phase cells to
39
+ close the gap and rebuilds the Team Mix grids.
40
+ - Rule (spec §2.1): each grid's title row is EXACTLY the phase name. The writer, `teammix` and
41
+ the audit follow it; the plan's `title` is no longer used. New `titles` verb sets existing
42
+ grids' title rows (the nearest non-empty row above the header — older grids keep a blank row
43
+ between title and header).
44
+ - Applied: Acron Academy renumbered (Phase 2→1, Phase 3→2, 20 cells) and its grids rebuilt;
45
+ all 19 estimates' Team Mix titles renamed.
46
+ - Rule (spec §0 / §1.2): Functionality and Low Level Requirements wrap on every item row, and
47
+ every cell on every tab is top-aligned. Writer, `format` verb (which now top-aligns all tabs)
48
+ and audit follow it; applied to all 19 estimates.
49
+ - The 14 single-phase estimates' Team Mixes rebuilt on the Low/High midpoint (`teammix`), so all
50
+ 19 now staff the midpoint; the command's Step 4 states the rule.
51
+
5
52
  ## [5.20.14] - 2026-09-21
6
53
 
7
54
  ### 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.21.10** - 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.
@@ -17,7 +17,7 @@
17
17
  **Real-Time Agent Dashboard** — `gsd-t-stream-feed-server.js` serves a streaming UI at `127.0.0.1:7842` that renders all workers' stream-json output as a continuous feed with task/wave banners, duration + usage chips, token corner bar, localStorage filters, and replay via `WS /feed?from=N`. Dashboard auto-starts idempotently on each spawn (`scripts/gsd-t-dashboard-autostart.cjs`). Port is project-scoped via `projectScopedDefaultPort(projectDir)` so multi-project workflows do not clobber each other.
18
18
  **Rigorous User-Journey Coverage + Anti-Drift Test Quality** — `bin/journey-coverage.cjs` regex listener detector + `gsd-t check-coverage` CLI + `scripts/hooks/pre-commit-journey-coverage` commit gate blocks viewer-source commits when uncovered listeners exist. Journey specs in `e2e/journeys/` use functional assertions (zero `toBeVisible`-only tests) per the E2E Test Quality Standard in CLAUDE.md.
19
19
  **Universal Playwright Bootstrap + Deterministic UI Enforcement (M50)** — three executable enforcement layers: (1) `bin/playwright-bootstrap.cjs` + `bin/ui-detection.cjs` - idempotent installer detects package manager, installs `@playwright/test` + chromium, scaffolds `e2e/`; (2) Workflow runtime runs `playwright-bootstrap.cjs::installPlaywright()` before any E2E stage when `hasUI && !hasPlaywright`; install failure halts with `blocked-needs-human`; (3) `scripts/hooks/pre-commit-playwright-gate` (opt-in via `gsd-t doctor --install-hooks`) blocks viewer-source commits when staged files are newer than `.gsd-t/.last-playwright-pass`. The `gsd-t setup-playwright [path]` subcommand handles manual install.
20
- **Surgical model selection** — models are assigned haiku/sonnet/opus per phase (**Fable removed 2026-07-24**; `opus` = **claude-opus-5**). **Single-source tier policy:** `bin/gsd-t-model-tier-policy.cjs` is the SINGLE source of truth; every high-stakes stage (solution-space probe, partition probe, competition judge, pre-mortem, Red Team, competition producers, debug both cycles) runs Opus 5. Opus 5 shipped at the same price as Opus 4.8 but >2× its coding score, so Fable's cost premium is no longer justified. The M82 judge-blindness invariant is relaxed to "fresh independent context" — producers and judge both run opus. Drift is mechanically enforced by the M71-family lint (`test/m85-workflow-tier-policy-lint.test.js`). **M86 model profiles:** `bin/gsd-t-model-profile.cjs` adds a per-project SECOND dimension — three named profiles (`standard` / `pro` / `premium`) that dial which stages run on Opus vs. Sonnet.
20
+ **Surgical model selection** — models are assigned haiku/sonnet/opus per phase (**Fable removed 2026-07-24**; `opus` = **claude-opus-5-5**). **Single-source tier policy:** `bin/gsd-t-model-tier-policy.cjs` is the SINGLE source of truth; every high-stakes stage (solution-space probe, partition probe, competition judge, pre-mortem, Red Team, competition producers, debug both cycles) runs Opus 5. Opus 5 shipped at the same price as Opus 4.8 but >2× its coding score, so Fable's cost premium is no longer justified. The M82 judge-blindness invariant is relaxed to "fresh independent context" — producers and judge both run opus. Drift is mechanically enforced by the M71-family lint (`test/m85-workflow-tier-policy-lint.test.js`). **M86 model profiles:** `bin/gsd-t-model-profile.cjs` adds a per-project SECOND dimension — three named profiles (`standard` / `pro` / `premium`) that dial which stages run on Opus vs. Sonnet.
21
21
  **Token Telemetry** — `gsd-t-calibration-hook.js` records token usage per spawn to `.gsd-t/token-metrics.jsonl` (18-field rows). `gsd-t-token-aggregator.js` aggregates across tasks for the `/gsd-t-metrics` view. Use the native Claude Code `/context` command for live in-session context percentage.
22
22
  **Quality North Star** — projects define a `## Quality North Star` section in CLAUDE.md (1–3 sentences, e.g., "This is a published npm library. Every public API must be intuitive and backward-compatible."). `gsd-t-init` auto-detects preset (library/web-app/cli) from package.json signals; `gsd-t-setup` configures it for existing projects. Subagents read it as a quality lens; absent = silent skip (backward compatible).
23
23
  **Design Brief Artifact** — during partition, UI/frontend projects (React, Vue, Svelte, Flutter, Tailwind) automatically get `.gsd-t/contracts/design-brief.md` with color palette, typography, spacing system, component patterns, and tone/voice. Non-UI projects skip silently. User-customized briefs are preserved. Referenced in plan phase for visual consistency.
@@ -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,
@@ -18,16 +18,16 @@
18
18
  * Frozen map: tier alias → concrete model id.
19
19
  * Consumers MUST import from here — never re-hardcode these strings.
20
20
  *
21
- * THREE tiers (Fable removed 2026-07-24): `opus` is now `claude-opus-5` — the
21
+ * THREE tiers (Fable removed 2026-07-24): `opus` is now `claude-opus-5-5` — the
22
22
  * default top tier. Opus 5 shipped at the SAME price as Opus 4.8 ($5/$25 per M
23
23
  * tokens) but >2× its coding score and within 0.5% of Fable 5 at max effort, so
24
24
  * the Fable cost premium ($10/$50 — double Opus 5) is no longer justified. Every
25
- * stage formerly on `fable` OR `opus` (4.8) now runs `opus` = claude-opus-5.
25
+ * stage formerly on `fable` OR `opus` (4.8) now runs `opus` = claude-opus-5-5.
26
26
  *
27
27
  * @type {Readonly<{opus: string, sonnet: string, haiku: string}>}
28
28
  */
29
29
  const MODEL_IDS = Object.freeze({
30
- opus: 'claude-opus-5',
30
+ opus: 'claude-opus-5-5',
31
31
  sonnet: 'claude-sonnet-4-6',
32
32
  haiku: 'claude-haiku-4-5-20251001',
33
33
  });
@@ -38,7 +38,7 @@ const MODEL_IDS = Object.freeze({
38
38
 
39
39
  /**
40
40
  * Frozen map: stage key → tier alias.
41
- * Fable removed 2026-07-24: all 7 stages resolve to `opus` (= claude-opus-5).
41
+ * Fable removed 2026-07-24: all 7 stages resolve to `opus` (= claude-opus-5-5).
42
42
  * The M82 competition judge-blindness invariant is RELAXED from "different model"
43
43
  * to "fresh independent context" — producers AND judge both run Opus 5 (fresh
44
44
  * contexts remove memory-bias; the modest residual taste/blind-spot bias is
@@ -67,7 +67,7 @@ const STAGE_TIERS = Object.freeze({
67
67
  *
68
68
  * This predicate existed for `claude-fable-5`, which returned HTTP 400 when the
69
69
  * explicit thinking-disabled parameter was sent. Fable was removed 2026-07-24;
70
- * NO current tier model (opus=claude-opus-5, sonnet, haiku) is known to require
70
+ * NO current tier model (opus=claude-opus-5-5, sonnet, haiku) is known to require
71
71
  * omission — Opus 5 and Sonnet 5 default `effort:high` on the API and accept the
72
72
  * thinking params normally. Kept as a single-home predicate (callers still import
73
73
  * it) so a future model that needs omission is added HERE, never re-hardcoded.
@@ -116,7 +116,7 @@ function resolve(stageKey) {
116
116
  * standard — cost-leanest: the high-stakes reasoning stages run sonnet,
117
117
  * only the probes stay opus.
118
118
  * pro — mid: red-team + pre-mortem + debug-cycle-2 → opus; the rest sonnet.
119
- * premium — full opus posture: all 6 designated stages → opus (= claude-opus-5).
119
+ * premium — full opus posture: all 6 designated stages → opus (= claude-opus-5-5).
120
120
  *
121
121
  * competition-producers is held at opus in ALL profiles (always opus-5). The
122
122
  * former judge≠producers blindness clamp is REMOVED — the invariant is now
@@ -161,8 +161,8 @@ const INJECTABLE_STAGES = Object.freeze([
161
161
  'debug-cycle-2',
162
162
  ]);
163
163
 
164
- /** The HELD producers model id (always opus = claude-opus-5). */
165
- const PRODUCERS_MODEL_ID = MODEL_IDS.opus; // claude-opus-5
164
+ /** The HELD producers model id (always opus = claude-opus-5-5). */
165
+ const PRODUCERS_MODEL_ID = MODEL_IDS.opus; // claude-opus-5-5
166
166
 
167
167
  /**
168
168
  * Resolves the concrete model id for a given stage key under a profile,
@@ -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).
@@ -536,7 +536,7 @@ Use these when user asks for help on a specific command:
536
536
  - **Contract**: `.gsd-t/contracts/plan-hardening-contract.md` v1.0.0 STABLE.
537
537
 
538
538
  ### model-tier-policy (M85)
539
- - **Summary**: SINGLE source of truth for GSD-T model-tier assignments. Publishes the authoritative tier set (haiku/sonnet/opus — **Fable removed 2026-07-24**; `opus` = claude-opus-5) and the 7 designated stage→tier mappings (all → opus). Opus 5 shipped at the same price as Opus 4.8 but >2× its coding score, so every stage formerly on Fable OR Opus 4.8 now runs Opus 5. The M82 competition judge-blindness invariant is RELAXED to "fresh independent context" — producers AND judge both run opus. A M71-family lint (`test/m85-workflow-tier-policy-lint.test.js`) proves every workflow `model:` literal matches the policy — a drifted literal FAILS the lint (mandatory negative test).
539
+ - **Summary**: SINGLE source of truth for GSD-T model-tier assignments. Publishes the authoritative tier set (haiku/sonnet/opus — **Fable removed 2026-07-24**; `opus` = claude-opus-5-5) and the 7 designated stage→tier mappings (all → opus). Opus 5 shipped at the same price as Opus 4.8 but >2× its coding score, so every stage formerly on Fable OR Opus 4.8 now runs Opus 5. The M82 competition judge-blindness invariant is RELAXED to "fresh independent context" — producers AND judge both run opus. A M71-family lint (`test/m85-workflow-tier-policy-lint.test.js`) proves every workflow `model:` literal matches the policy — a drifted literal FAILS the lint (mandatory negative test).
540
540
  - **Files**: `bin/gsd-t-model-tier-policy.cjs` (zero external deps — installer invariant).
541
541
  - **Use when**: Any phase that needs to resolve a concrete model id from a stage key at invoke time (M69 pattern). Workflows NEVER `require` this module (sandbox ban) — they use hard-coded tier alias literals the lint proves match the policy.
542
542
  - **CLI**: `gsd-t model-tier-policy resolve <stageKey> [--json]`. Emits `{ok, stageKey, tier, model, requiresThinkingOmitted}`. Exit 0 resolved · 1 unknown stage key.
@@ -88,7 +88,7 @@ The **Model Profile** line MUST always name the active profile — never blank,
88
88
  - If the file is absent, display the global default by name with the `(default)` marker: `Model Profile: premium (default)`.
89
89
  - If the file is present but malformed or contains an unknown profile, display: `Model Profile: premium (default, config-error)` — never silently promote to the most expensive posture.
90
90
 
91
- Profiles control which workflow stages run on Opus vs. Sonnet (Fable removed 2026-07-24 — `opus` = claude-opus-5):
91
+ Profiles control which workflow stages run on Opus vs. Sonnet (Fable removed 2026-07-24 — `opus` = claude-opus-5-5):
92
92
  - `standard` — probes opus; high-stakes stages (judge/pre-mortem/red-team/debug-cycle-2) sonnet (cost-leanest)
93
93
  - `pro` — probes + pre-mortem + red-team + debug-cycle-2 opus; judge sonnet
94
94
  - `premium` — all 6 designated stages opus (full posture, global default)
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@tekyzinc/gsd-t",
3
- "version": "5.20.14",
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",
3
+ "version": "5.21.10",
4
+ "description": "GSD-T: Contract-Driven Development for Claude Code \u2014 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",
7
7
  "repository": {
@@ -263,7 +263,7 @@ Every GSD-T project gets TWO logging streams scaffolded by default at `gsd-t-ini
263
263
  Every code-producing phase ends with `gsd-t-verify.workflow.js`, which runs three orthogonal validators as `parallel()` `agent()` stages with schema-validated output. Per `.gsd-t/contracts/orthogonal-validation-contract.md` v1.0.0 STABLE, they are declared orthogonal objective functions — no collapse, no substitution, no transitive trust.
264
264
 
265
265
  - **`/code-review ultra`** — cooperative correctness + cleanup. Severity: `important` / `nit` / `pre-existing`. Skippable via `args.skipUltra=true` + `args.skipUltraReason`. `skipUltra=true` is INELIGIBLE for `VERIFIED`.
266
- - **Red Team** — adversarial / security / boundaries. Non-skippable. Protocol: `templates/prompts/red-team-subagent.md`. Verdict: `FAIL` (any CRITICAL or HIGH bug — blocks completion) or `GRUDGING-PASS` (exhaustive search, nothing found). CRITICAL/HIGH bugs get up to 2 fix cycles before deferral. Runs on `model: "opus"` (= claude-opus-5; Fable removed 2026-07-24).
266
+ - **Red Team** — adversarial / security / boundaries. Non-skippable. Protocol: `templates/prompts/red-team-subagent.md`. Verdict: `FAIL` (any CRITICAL or HIGH bug — blocks completion) or `GRUDGING-PASS` (exhaustive search, nothing found). CRITICAL/HIGH bugs get up to 2 fix cycles before deferral. Runs on `model: "opus"` (= claude-opus-5-5; Fable removed 2026-07-24).
267
267
  - **QA** — test execution + shallow-test detection + contract compliance. Non-skippable. Protocol: `templates/prompts/qa-subagent.md`. Writes ZERO feature code. Any shallow E2E test blocks phase completion. Runs on `model: "sonnet"`.
268
268
 
269
269
  When `.gsd-t/contracts/design-contract.md` or `.gsd-t/contracts/design/` exists, a fourth stage runs Design Verification (protocol: `templates/prompts/design-verify-subagent.md`) — opens a browser, compares the build against the design, returns a structured element-by-element MATCH/DEVIATION schema. Deviations block completion.
@@ -272,13 +272,13 @@ Synthesis stage merges results without category collapse. Verdict: `VERIFIED` /
272
272
 
273
273
  ## Model Display (MANDATORY)
274
274
 
275
- **Each Workflow `agent()` call declares its model explicitly** via the `model:` option (`"haiku"` / `"sonnet"` / `"opus"` — **Fable removed 2026-07-24**; `opus` = claude-opus-5). The Workflow runtime emits a `⚙ [{model}] {label}` line per stage in `/workflows`, giving the user real-time visibility into which model handles each operation.
275
+ **Each Workflow `agent()` call declares its model explicitly** via the `model:` option (`"haiku"` / `"sonnet"` / `"opus"` — **Fable removed 2026-07-24**; `opus` = claude-opus-5-5). The Workflow runtime emits a `⚙ [{model}] {label}` line per stage in `/workflows`, giving the user real-time visibility into which model handles each operation.
276
276
 
277
277
  **Model assignments:**
278
278
  - `model: "haiku"` — strictly mechanical tasks: run test suites and report counts, check file existence, validate JSON structure, branch guard checks
279
279
  - `model: "sonnet"` — mid-tier reasoning: routine code changes, standard refactors, test writing, QA evaluation, straightforward synthesis
280
280
  - `model: "opus"` — high-stakes reasoning: architecture decisions, security analysis, complex debugging, cross-module refactors, quality judgment on critical paths
281
- - **Fable removed 2026-07-24** — `opus` (= claude-opus-5) is the top tier and the default for every high-stakes stage: solution-space probe, partition probe, competition judge, pre-mortem, Red Team, competition producers, debug both cycles. Opus 5 shipped at the same price as Opus 4.8 but >2× its coding score, so every stage formerly on Fable OR Opus 4.8 now runs Opus 5. The M82 competition judge-blindness invariant is RELAXED to "fresh independent context" — producers AND judge both run `opus` (fresh contexts remove memory-bias; the modest residual taste/blind-spot bias is accepted for a stronger judge). **Single source of truth for tier assignments:** `bin/gsd-t-model-tier-policy.cjs` + `.gsd-t/contracts/model-tier-policy-contract.md`. The M71-family lint (`test/m85-workflow-tier-policy-lint.test.js`) proves every workflow `model:` literal matches the policy and a drifted literal FAILS the lint (mandatory negative test).
281
+ - **Fable removed 2026-07-24** — `opus` (= claude-opus-5-5) is the top tier and the default for every high-stakes stage: solution-space probe, partition probe, competition judge, pre-mortem, Red Team, competition producers, debug both cycles. Opus 5 shipped at the same price as Opus 4.8 but >2× its coding score, so every stage formerly on Fable OR Opus 4.8 now runs Opus 5. The M82 competition judge-blindness invariant is RELAXED to "fresh independent context" — producers AND judge both run `opus` (fresh contexts remove memory-bias; the modest residual taste/blind-spot bias is accepted for a stronger judge). **Single source of truth for tier assignments:** `bin/gsd-t-model-tier-policy.cjs` + `.gsd-t/contracts/model-tier-policy-contract.md`. The M71-family lint (`test/m85-workflow-tier-policy-lint.test.js`) proves every workflow `model:` literal matches the policy and a drifted literal FAILS the lint (mandatory negative test).
282
282
 
283
283
  **Context budget:** Workflow scripts receive a `budget` global (`budget.total`, `budget.spent()`, `budget.remaining()`) tied to the user's per-turn token target. Use it for dynamic loops (`while (budget.total && budget.remaining() > 50_000) { ... }`) or to scale fleet size. Opus 4.7/4.8 ship 1M context windows; the legacy meter at `bin/token-budget.cjs` was retired in M61 — use native `/context` for live in-session usage.
284
284
 
@@ -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
@@ -4,7 +4,7 @@
4
4
  **Report concisely:** verdict/answer first, no preamble. Gloss every code/jargon term (e.g. `M93-D2` = milestone 93, domain 2) in plain words on first use. Bullets over paragraphs. Expand only if asked.
5
5
  <!-- /reader-contract -->
6
6
 
7
- **Model:** `opus` (= claude-opus-5; Fable removed 2026-07-24 — highest-leverage judgment; separate context from the proposing agent)
7
+ **Model:** `opus` (= claude-opus-5-5; Fable removed 2026-07-24 — highest-leverage judgment; separate context from the proposing agent)
8
8
 
9
9
  **Framing:** You are reviewing someone ELSE's architectural design — you did NOT propose it and have no attachment to it. Your goal is to find the **fatal flaw** in the premise being challenged, before a single line of code is committed to that premise. This framing (independent reviewer, not the author) is essential for escaping self-preference bias: the proposing agent's prior context makes it systematically less able to see its own premise's failures (source: https://arxiv.org/abs/2310.08118 — LLM self-evaluation is biased toward confirming prior outputs; https://arxiv.org/abs/2404.13076 — blind adversarial framing surfaces failures that self-critique misses).
10
10
 
@@ -57,7 +57,7 @@ const _args = (typeof args === "string") ? (() => { try { return JSON.parse(args
57
57
  // Default to {} so the premium fallback literals apply when no invoker injects overrides.
58
58
  // overrides values are CONCRETE model ids (resolver envelope); the bare literals below
59
59
  // are tier ALIASES. The sandbox runtime accepts BOTH forms in model: — proven live for
60
- // the tier alias resolves to claude-opus-5 (Fable removed 2026-07-24).
60
+ // the tier alias resolves to claude-opus-5-5 (Fable removed 2026-07-24).
61
61
  const overrides = (_args.overrides && typeof _args.overrides === "object") ? _args.overrides : {};
62
62
  const _CLI_ENVELOPE_SCHEMA = {
63
63
  type: "object", required: ["ok", "exitCode"], additionalProperties: true,
@@ -83,7 +83,7 @@ const _args = (typeof args === "string") ? (() => { try { return JSON.parse(args
83
83
  // (preserves byte-identical M85 behavior for callers that have not been updated yet).
84
84
  // overrides values are CONCRETE model ids (resolver envelope); the bare literals below
85
85
  // are tier ALIASES. The sandbox runtime accepts BOTH forms in model: — proven live for
86
- // the tier alias resolves to claude-opus-5 (Fable removed 2026-07-24).
86
+ // the tier alias resolves to claude-opus-5-5 (Fable removed 2026-07-24).
87
87
  const overrides = (_args.overrides && typeof _args.overrides === "object") ? _args.overrides : {};
88
88
  // `envelope` is typed as an OBJECT (or null), not "any".
89
89
  //
@@ -57,7 +57,7 @@ const _args = (typeof args === "string") ? (() => { try { return JSON.parse(args
57
57
  // Default to {} so the premium fallback literals apply when no invoker injects overrides.
58
58
  // overrides values are CONCRETE model ids (resolver envelope); the bare literals below
59
59
  // are tier ALIASES. The sandbox runtime accepts BOTH forms in model: — proven live for
60
- // the tier alias resolves to claude-opus-5 (Fable removed 2026-07-24).
60
+ // the tier alias resolves to claude-opus-5-5 (Fable removed 2026-07-24).
61
61
  const overrides = (_args.overrides && typeof _args.overrides === "object") ? _args.overrides : {};
62
62
  const _CLI_ENVELOPE_SCHEMA = {
63
63
  type: "object", required: ["ok", "exitCode"], additionalProperties: true,