@tekyzinc/gsd-t 5.19.11 → 5.20.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.
@@ -0,0 +1,1184 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * gsd-t-estimate-sheet — the deterministic writer for Tekyz estimate sheets.
4
+ *
5
+ * Spec: templates/estimate-sheet-spec.md (installed at ~/.claude/templates/).
6
+ *
7
+ * WHY THIS EXISTS
8
+ * ---------------
9
+ * Two shipped Hilo estimates (Sep 2026) each needed ~a dozen hand-corrections,
10
+ * every one on sheet structure or formatting the model had re-derived from prose
11
+ * each run: a second header row, stale month labels, the sage highlight cleared
12
+ * as a "stray fill", Calibri on Team Mix, a roster with no PM/BA, Count 1.40 on
13
+ * one row, 0.42/0.56/0.23 smeared across three part-timers, flat ramps, a
14
+ * negative remainder month, legend text in size cells, Phase dropdowns lost on a
15
+ * values-write, an off-by-one Total Hrs column. Prose rules failed ~25 times.
16
+ * This tool makes them code: the model supplies JUDGMENT ONLY (sizes, team FTE,
17
+ * tech-stack lines) as a JSON plan; everything derived — formulas, roster split,
18
+ * months, ramp, styling, coordinates — is computed here, written, then AUDITED
19
+ * BY READING BACK. Any violation HALTS (exit 4). Nothing continues past a failure:
20
+ * every catch in this file either re-throws a Halt or exits the process.
21
+ *
22
+ * Verbs:
23
+ * read --sheet <id|url> [--tab <name>] dump layout (read-before-write)
24
+ * plan-check --sheet <id|url> --plan <plan.json> validate plan, print the roster it WOULD write
25
+ * write --sheet <id|url> --plan <plan.json> [--replace] write T-Shirt + Team Mix + Tech Stack, then audit
26
+ * audit --sheet <id|url> the spec §5 checklist, by read-back
27
+ * plan-schema print the plan shape
28
+ * Flags: --json (envelope only) --key <path> --no-audit (write only; for debugging)
29
+ * Exit: 0 ok · 4 violations / audit failures · 64 bad input, auth, or API error
30
+ */
31
+ "use strict";
32
+
33
+ const fs = require("fs");
34
+ const os = require("os");
35
+ const path = require("path");
36
+ const crypto = require("crypto");
37
+
38
+ // ───────────────────────── constants (spec §1–§3) ─────────────────────────
39
+
40
+ const TAB_TSHIRT = "T-Shirt Size Estimate";
41
+ const TAB_TEAM = "Team Mix";
42
+ const TAB_TECH = "Technology Stack";
43
+ const TAB_OVERVIEW = "Overview";
44
+
45
+ const SIZE_CODES = ["XS", "S", "M", "L", "XL", "XXL"];
46
+ const PHASES = ["MVP", "Phase 1", "Phase 2", "Phase 3"];
47
+
48
+ const COLOR = {
49
+ sectionBg: "#1C4F8B",
50
+ teamTitleBg: "#3C78D8",
51
+ teamHeaderBg: "#1C4F8C",
52
+ sage: "#D6E2DD",
53
+ totalBg: "#E5E5E5",
54
+ totalDaysBg: "#9EC1EF",
55
+ white: "#FFFFFF",
56
+ };
57
+
58
+ const TSHIRT_WIDTHS = [150, 120, 300, 430, 122, 90, 90, 61, 53, 76, 81, 81];
59
+ const TEAM_WIDTHS_FIXED = [170, 60, 14, 55, 55, 55, 60, 159]; // A..H
60
+ const TEAM_MONTH_WIDTH = 48;
61
+ const TEAM_TOTAL_WIDTH = 66;
62
+ const TECH_WIDTHS = [210, 760];
63
+
64
+ const SOFT_CEILING = 172; // spec §2.2 — a small tail folds into the prior month
65
+ const FOLD_THRESHOLD = 0.1; // month fraction under this folds instead of opening a column
66
+
67
+ // Ramp profiles (spec §2.5): 3-point weights, resampled to N months.
68
+ const RAMP = {
69
+ frontend: [1.35, 1.05, 0.6],
70
+ backend: [0.9, 1.15, 0.95],
71
+ qa: [0.4, 0.9, 1.7],
72
+ ba: [1.75, 0.8, 0.45],
73
+ devops: [0.45, 0.85, 1.7],
74
+ pm: [1, 1, 1],
75
+ techlead: [1.3, 0.8, 0.9],
76
+ };
77
+
78
+ const ROLE_LABEL = {
79
+ backend: "Backend / API Engineer",
80
+ frontend: "Frontend Engineer",
81
+ qa: "QA Engineer",
82
+ pm: "Project Manager",
83
+ ba: "Business Analyst",
84
+ devops: "DevOps Engineer",
85
+ techlead: "Tech Lead / Architect",
86
+ };
87
+
88
+ // MF factor label → the discipline that must staff it (spec §2.4). Factors with
89
+ // no entry (deployment, standups, buffer) are absorbed across the engineers and
90
+ // the lead and need no row of their own — David's rule, confirmed against the
91
+ // hand-corrected ATP sheet, which has no DevOps row and is correct.
92
+ const MF_COVERAGE = [
93
+ { match: /qa|test/i, disciplines: ["qa"] },
94
+ { match: /^pm$|project/i, disciplines: ["pm"] },
95
+ { match: /analy/i, disciplines: ["ba"] },
96
+ ];
97
+
98
+ const DEFAULT_KEY = path.join(os.homedir(), ".claude", "gsd-t-secrets", "gsd-t-sheets-writer-key.json");
99
+
100
+ // ───────────────────────── small helpers ─────────────────────────
101
+
102
+ class Halt extends Error {
103
+ constructor(message, exitCode = 4, details = undefined) {
104
+ super(message);
105
+ this.exitCode = exitCode;
106
+ this.details = details;
107
+ }
108
+ }
109
+
110
+ function colLetter(idx0) {
111
+ let s = "";
112
+ let n = idx0 + 1;
113
+ while (n > 0) {
114
+ const r = (n - 1) % 26;
115
+ s = String.fromCharCode(65 + r) + s;
116
+ n = Math.floor((n - 1) / 26);
117
+ }
118
+ return s;
119
+ }
120
+
121
+ function hexToColor(hex) {
122
+ const h = hex.replace("#", "");
123
+ return {
124
+ red: parseInt(h.slice(0, 2), 16) / 255,
125
+ green: parseInt(h.slice(2, 4), 16) / 255,
126
+ blue: parseInt(h.slice(4, 6), 16) / 255,
127
+ };
128
+ }
129
+
130
+ /** Sheets omits a channel that is 0; a cell with NO backgroundColor at all is unset = white. */
131
+ function colorToHex(c) {
132
+ if (!c) return COLOR.white;
133
+ const f = (v) => Math.round((v == null ? 0 : v) * 255).toString(16).padStart(2, "0").toUpperCase();
134
+ return `#${f(c.red)}${f(c.green)}${f(c.blue)}`;
135
+ }
136
+
137
+ function round1(n) { return Math.round(n * 10) / 10; }
138
+ function round2(n) { return Math.round(n * 100) / 100; }
139
+
140
+ function sheetIdFromArg(arg) {
141
+ if (!arg) throw new Halt("--sheet <id|url> is required", 64);
142
+ const m = String(arg).match(/\/d\/([A-Za-z0-9_-]+)/);
143
+ return m ? m[1] : String(arg).trim();
144
+ }
145
+
146
+ function gridRange(sheetId, r0, r1, c0, c1) {
147
+ return { sheetId, startRowIndex: r0, endRowIndex: r1, startColumnIndex: c0, endColumnIndex: c1 };
148
+ }
149
+
150
+ // ───────────────────────── auth + API (spec §0) ─────────────────────────
151
+
152
+ function b64url(buf) {
153
+ return Buffer.from(buf).toString("base64").replace(/=+$/, "").replace(/\+/g, "-").replace(/\//g, "_");
154
+ }
155
+
156
+ async function getToken(keyPath, scope = "https://www.googleapis.com/auth/spreadsheets") {
157
+ if (typeof fetch !== "function") throw new Halt("Node 18+ is required (global fetch)", 64);
158
+ const kp = keyPath ? keyPath : DEFAULT_KEY;
159
+ if (!fs.existsSync(kp)) {
160
+ throw new Halt(`service-account key not found at ${kp} — see commands/gsd-t-estimate.md Step 6 to provision it once`, 64);
161
+ }
162
+ const key = JSON.parse(fs.readFileSync(kp, "utf8"));
163
+ const now = Math.floor(Date.now() / 1000);
164
+ const header = b64url(JSON.stringify({ alg: "RS256", typ: "JWT" }));
165
+ const claim = b64url(JSON.stringify({
166
+ iss: key.client_email, scope, aud: "https://oauth2.googleapis.com/token", iat: now, exp: now + 3600,
167
+ }));
168
+ const signer = crypto.createSign("RSA-SHA256");
169
+ signer.update(`${header}.${claim}`);
170
+ const sig = b64url(signer.sign(key.private_key));
171
+ const body = new URLSearchParams({
172
+ grant_type: "urn:ietf:params:oauth:grant-type:jwt-bearer",
173
+ assertion: `${header}.${claim}.${sig}`,
174
+ });
175
+ const res = await fetch("https://oauth2.googleapis.com/token", { method: "POST", body });
176
+ const json = await res.json();
177
+ if (!res.ok || !json.access_token) throw new Halt(`token exchange failed: ${res.status} ${JSON.stringify(json)}`, 64);
178
+ return json.access_token;
179
+ }
180
+
181
+ class SheetsApi {
182
+ constructor(token, sheetId) {
183
+ this.token = token;
184
+ this.id = sheetId;
185
+ this.base = `https://sheets.googleapis.com/v4/spreadsheets/${sheetId}`;
186
+ }
187
+
188
+ async call(method, url, body) {
189
+ // Spec §0: NEVER send X-Goog-User-Project — on these sheets it CAUSES a 403.
190
+ const res = await fetch(url, {
191
+ method,
192
+ headers: { Authorization: `Bearer ${this.token}`, "Content-Type": "application/json" },
193
+ body: body == null ? undefined : JSON.stringify(body),
194
+ });
195
+ const text = await res.text();
196
+ if (!res.ok) {
197
+ const short = text.slice(0, 400);
198
+ if (res.status === 403) {
199
+ throw new Halt(`403 from Sheets API: ${short}. The sheet is probably not shared with the service account — share it as Editor and re-run.`, 64);
200
+ }
201
+ throw new Halt(`${method} ${url.replace(this.base, "")} → ${res.status}: ${short}`, 64);
202
+ }
203
+ if (!text) return {};
204
+ let json;
205
+ try {
206
+ json = JSON.parse(text);
207
+ } catch (e) {
208
+ throw new Halt(`${method} ${url.replace(this.base, "")} → ${res.status} but the body is not JSON: ${text.slice(0, 200)}`, 64);
209
+ }
210
+ return json;
211
+ }
212
+
213
+ async meta() {
214
+ return this.call("GET", `${this.base}?fields=properties.title,sheets.properties`);
215
+ }
216
+
217
+ async grid(tab) {
218
+ const rng = encodeURIComponent(`'${tab}'`);
219
+ const fields = "sheets(merges,properties,data(rowData(values(formattedValue,userEnteredValue,effectiveValue,userEnteredFormat(backgroundColor,textFormat,horizontalAlignment,numberFormat),dataValidation)),columnMetadata(pixelSize)))";
220
+ const r = await this.call("GET", `${this.base}?ranges=${rng}&includeGridData=true&fields=${fields}`);
221
+ if (!r.sheets || !r.sheets[0]) throw new Halt(`tab '${tab}' not found`, 64);
222
+ return r.sheets[0];
223
+ }
224
+
225
+ async putValues(tab, a1, values) {
226
+ const rng = encodeURIComponent(`'${tab}'!${a1}`);
227
+ return this.call("PUT", `${this.base}/values/${rng}?valueInputOption=USER_ENTERED`, {
228
+ range: `'${tab}'!${a1}`, majorDimension: "ROWS", values,
229
+ });
230
+ }
231
+
232
+ async clearValues(tab, a1) {
233
+ const rng = encodeURIComponent(`'${tab}'!${a1}`);
234
+ return this.call("POST", `${this.base}/values/${rng}:clear`, {});
235
+ }
236
+
237
+ // Spec §0: small batches. The FIRST failing batch halts — later batches are
238
+ // not sent, and the audit shows exactly what landed. Never continue past a failure.
239
+ async batch(requests, size = 50) {
240
+ let sent = 0;
241
+ for (let i = 0; i < requests.length; i += size) {
242
+ const chunk = requests.slice(i, i + size);
243
+ try {
244
+ await this.call("POST", `${this.base}:batchUpdate`, { requests: chunk });
245
+ } catch (e) {
246
+ throw new Halt(`batchUpdate failed on batch ${i / size + 1} (after ${sent} requests applied; the sheet is now PARTIAL — run 'audit'): ${e.message}`, 64);
247
+ }
248
+ sent += chunk.length;
249
+ }
250
+ return sent;
251
+ }
252
+ }
253
+
254
+ // ───────────────────────── grid reading ─────────────────────────
255
+
256
+ function rowsOf(grid) {
257
+ return (grid.data && grid.data[0] && grid.data[0].rowData) ? grid.data[0].rowData : [];
258
+ }
259
+ function cellAt(grid, r, c) {
260
+ const row = rowsOf(grid)[r];
261
+ return row && row.values ? row.values[c] : undefined;
262
+ }
263
+ function textAt(grid, r, c) {
264
+ const cell = cellAt(grid, r, c);
265
+ if (!cell) return "";
266
+ const uv = cell.userEnteredValue;
267
+ if (uv) {
268
+ if (uv.stringValue != null) return String(uv.stringValue);
269
+ if (uv.numberValue != null) return String(uv.numberValue);
270
+ if (uv.formulaValue != null) return uv.formulaValue;
271
+ if (uv.boolValue != null) return String(uv.boolValue);
272
+ }
273
+ return cell.formattedValue == null ? "" : String(cell.formattedValue);
274
+ }
275
+ /** NaN when the cell holds no number — callers decide what that means. */
276
+ function numAt(grid, r, c) {
277
+ const cell = cellAt(grid, r, c);
278
+ if (!cell) return NaN;
279
+ if (cell.effectiveValue && cell.effectiveValue.numberValue != null) return cell.effectiveValue.numberValue;
280
+ if (cell.userEnteredValue && cell.userEnteredValue.numberValue != null) return cell.userEnteredValue.numberValue;
281
+ return NaN;
282
+ }
283
+ function formulaAt(grid, r, c) {
284
+ const cell = cellAt(grid, r, c);
285
+ return cell && cell.userEnteredValue && cell.userEnteredValue.formulaValue != null ? cell.userEnteredValue.formulaValue : "";
286
+ }
287
+ function bgAt(grid, r, c) {
288
+ const cell = cellAt(grid, r, c);
289
+ return colorToHex(cell && cell.userEnteredFormat ? cell.userEnteredFormat.backgroundColor : undefined);
290
+ }
291
+ function fontAt(grid, r, c) {
292
+ const cell = cellAt(grid, r, c);
293
+ const tf = cell && cell.userEnteredFormat && cell.userEnteredFormat.textFormat ? cell.userEnteredFormat.textFormat : {};
294
+ return { family: tf.fontFamily ? tf.fontFamily : "", size: tf.fontSize ? tf.fontSize : 0, bold: !!tf.bold };
295
+ }
296
+ function rowCount(grid) { return rowsOf(grid).length; }
297
+ function rowIsEmpty(grid, r, cols = 12) {
298
+ for (let c = 0; c < cols; c++) if (textAt(grid, r, c) !== "") return false;
299
+ return true;
300
+ }
301
+
302
+ /** Locate the T-Shirt tab's fixed header block (spec §1.1). HALTS if it is not the template. */
303
+ function locateTshirt(grid) {
304
+ let headerRow = -1;
305
+ for (let r = 0; r < Math.min(rowCount(grid), 40); r++) {
306
+ if (/^Module\/Functionality$/i.test(textAt(grid, r, 0).trim())) { headerRow = r; break; }
307
+ }
308
+ if (headerRow < 0) throw new Halt(`${TAB_TSHIRT}: column header row ('Module/Functionality' in A) not found — is this the Tekyz template?`);
309
+ const legend = {};
310
+ for (let r = 0; r < 20; r++) {
311
+ const m = textAt(grid, r, 0).trim().match(/^(XS|S|M|L|XL|XXL)\s*-/);
312
+ if (m) legend[m[1]] = numAt(grid, r, 1);
313
+ }
314
+ for (const code of SIZE_CODES) {
315
+ if (!(code in legend) || Number.isNaN(legend[code])) throw new Halt(`${TAB_TSHIRT}: size legend missing '${code}' in A4:B9`);
316
+ }
317
+ const mf = [];
318
+ let mfTotalCell = null;
319
+ for (let r = 0; r < 20; r++) {
320
+ const label = textAt(grid, r, 4).trim();
321
+ if (!label) continue;
322
+ if (/^Total MF$/i.test(label)) { mfTotalCell = { r, c: 5, value: numAt(grid, r, 5) }; continue; }
323
+ if (/^Multiplication Factor$/i.test(label)) continue;
324
+ const v = numAt(grid, r, 5);
325
+ if (!Number.isNaN(v)) mf.push({ label, value: v, row: r + 1 });
326
+ }
327
+ if (!mf.length) throw new Halt(`${TAB_TSHIRT}: multiplication-factor list (E4:F9) not found`);
328
+ if (!mfTotalCell) throw new Halt(`${TAB_TSHIRT}: 'Total MF' cell not found`);
329
+ const mfTotal = mf.reduce((s, f) => s + f.value, 0);
330
+ const highFactor = numAt(grid, 3, 6);
331
+ const rate = numAt(grid, 3, 7);
332
+ if (Number.isNaN(highFactor)) throw new Halt(`${TAB_TSHIRT}: High $ Factor (G4) is not a number`);
333
+ if (Number.isNaN(rate)) throw new Halt(`${TAB_TSHIRT}: Avg. Hrly Rate (H4) is not a number`);
334
+ const rollupRows = [];
335
+ for (let r = 0; r < 12; r++) if (PHASES.includes(textAt(grid, r, 9).trim())) rollupRows.push(r);
336
+ if (rollupRows.length !== 4) throw new Halt(`${TAB_TSHIRT}: expected 4 phase rollup rows in column J (MVP, Phase 1–3), found ${rollupRows.length}`);
337
+ return { headerRow, firstItemRow: headerRow + 1, legend, mf, mfTotal, mfTotalCell, highFactor, rate, rollupRows };
338
+ }
339
+
340
+ /** Row index of a cell in column E with a ONE_OF_LIST validation — the Phase dropdown source; -1 if none. */
341
+ function findPhaseSource(grid, fromRow) {
342
+ for (let r = fromRow; r < rowCount(grid); r++) {
343
+ const cell = cellAt(grid, r, 4);
344
+ const dv = cell && cell.dataValidation;
345
+ if (dv && dv.condition && dv.condition.type === "ONE_OF_LIST") return r;
346
+ }
347
+ return -1;
348
+ }
349
+
350
+ // ───────────────────────── plan validation (pure) ─────────────────────────
351
+
352
+ const PLAN_SCHEMA = {
353
+ title: "string — Team Mix title, e.g. 'Hilo ATOS — ATP Gap Closure'",
354
+ tshirt: {
355
+ mode: "'items' (write whole rows from row 14) | 'sizes' (rows already exist; fill E:L, matched by the id in column C)",
356
+ sections: [{
357
+ heading: "string — 'A. AUTH & TRANSPORT CONTRACT' (items mode only)",
358
+ items: [{
359
+ id: "string — 'GA-1' / 'TD-12' / 'FR-3' (goes into column C in parentheses; used to match rows in sizes mode)",
360
+ module: "string", userType: "string", functionality: "string", requirement: "string",
361
+ phase: "'MVP' | 'Phase 1' | 'Phase 2' | 'Phase 3'",
362
+ fe: "'' | 'XS' | 'S' | 'M' | 'L' | 'XL' | 'XXL'", be: "same",
363
+ }],
364
+ }],
365
+ },
366
+ teamMix: {
367
+ fte: "{ backend: 1.5, frontend: 0.4, qa: 0.4, pm: 0.25, ba: 0.1, techlead?: 0.25, devops?: 0.1 } — the tool splits into people (1.00 + remainder), computes months and the ramp",
368
+ labels: "optional { backend: 'Backend / API Engineer', ... } overrides",
369
+ },
370
+ techStack: [{ category: "Frontend", description: "Next.js 16 / React …" }],
371
+ };
372
+
373
+ function sizeOf(v) { return v == null ? "" : String(v).trim(); }
374
+
375
+ function validatePlan(plan) {
376
+ const errors = [];
377
+ if (!plan || typeof plan !== "object") return ["plan is not an object"];
378
+ if (!plan.title || typeof plan.title !== "string") errors.push("title: required string");
379
+ const t = plan.tshirt && typeof plan.tshirt === "object" ? plan.tshirt : {};
380
+ if (!["items", "sizes"].includes(t.mode)) errors.push("tshirt.mode: must be 'items' or 'sizes'");
381
+ const sections = Array.isArray(t.sections) ? t.sections : [];
382
+ if (!sections.length) errors.push("tshirt.sections: non-empty array required");
383
+ const ids = new Set();
384
+ sections.forEach((s, si) => {
385
+ if (t.mode === "items" && (!s.heading || typeof s.heading !== "string")) errors.push(`tshirt.sections[${si}].heading: required in items mode`);
386
+ if (!Array.isArray(s.items) || !s.items.length) { errors.push(`tshirt.sections[${si}].items: non-empty array required`); return; }
387
+ s.items.forEach((it, ii) => {
388
+ const where = `tshirt.sections[${si}].items[${ii}]`;
389
+ if (!it.id) errors.push(`${where}.id: required`);
390
+ else if (ids.has(it.id)) errors.push(`${where}.id: duplicate '${it.id}'`);
391
+ else ids.add(it.id);
392
+ if (t.mode === "items") {
393
+ for (const k of ["module", "userType", "functionality", "requirement"]) {
394
+ if (typeof it[k] !== "string" || !it[k].trim()) errors.push(`${where}.${k}: required string`);
395
+ }
396
+ }
397
+ if (!PHASES.includes(it.phase)) errors.push(`${where}.phase: must be one of ${PHASES.join(" | ")}`);
398
+ for (const k of ["fe", "be"]) {
399
+ const v = sizeOf(it[k]);
400
+ if (v !== "" && !SIZE_CODES.includes(v)) {
401
+ errors.push(`${where}.${k}: '${it[k]}' is not a bare size code (${SIZE_CODES.join(" ")}) — legend text like 'XS - Extra Small' is not allowed`);
402
+ }
403
+ }
404
+ if (sizeOf(it.fe) === "" && sizeOf(it.be) === "") errors.push(`${where}: both fe and be are blank — an item with no size is a defect`);
405
+ });
406
+ });
407
+ const tm = plan.teamMix && typeof plan.teamMix === "object" ? plan.teamMix : {};
408
+ if (!tm.fte || typeof tm.fte !== "object" || !Object.keys(tm.fte).length) errors.push("teamMix.fte: object of discipline → FTE required");
409
+ else {
410
+ for (const [d, v] of Object.entries(tm.fte)) {
411
+ if (!RAMP[d]) errors.push(`teamMix.fte.${d}: unknown discipline (known: ${Object.keys(RAMP).join(", ")})`);
412
+ if (typeof v !== "number" || !(v > 0)) errors.push(`teamMix.fte.${d}: must be a positive number`);
413
+ }
414
+ }
415
+ if (!Array.isArray(plan.techStack) || !plan.techStack.length) errors.push("techStack: non-empty array required — an empty Technology Stack tab is a defect");
416
+ else plan.techStack.forEach((row, i) => {
417
+ if (!row || !row.category || !row.description) errors.push(`techStack[${i}]: category and description required`);
418
+ else if (/\bTBD\b/i.test(String(row.description))) errors.push(`techStack[${i}]: 'TBD' is not a description — read it from the repo or say 'not determined from the repo'`);
419
+ });
420
+ return errors;
421
+ }
422
+
423
+ // ───────────────────────── Team Mix math (pure, spec §2) ─────────────────────────
424
+
425
+ /** Split discipline FTE into people: saturate at 1.00, spill the remainder. */
426
+ function splitRoster(fte, labels) {
427
+ const people = [];
428
+ for (const [discipline, total] of Object.entries(fte)) {
429
+ if (!ROLE_LABEL[discipline]) throw new Halt(`teamMix.fte.${discipline}: unknown discipline`);
430
+ const n = Math.ceil(total - 1e-9);
431
+ const label = labels && labels[discipline] ? labels[discipline] : ROLE_LABEL[discipline];
432
+ let left = total;
433
+ for (let i = 1; i <= n; i++) {
434
+ const count = round2(Math.min(1, left));
435
+ left = round2(left - count);
436
+ people.push({ discipline, label: n > 1 ? `${label} ${i}` : label, count });
437
+ }
438
+ }
439
+ return people;
440
+ }
441
+
442
+ /** Roster invariants (spec §2.3): every row ≤ 1.00; per discipline all but the last row are exactly 1.00. */
443
+ function rosterViolations(people) {
444
+ const v = [];
445
+ const byD = {};
446
+ people.forEach((p) => { (byD[p.discipline] = byD[p.discipline] ? byD[p.discipline] : []).push(p); });
447
+ for (const p of people) if (p.count > 1 + 1e-9) v.push(`${p.label}: Count ${p.count} > 1.00 — one row per person`);
448
+ for (const [d, rows] of Object.entries(byD)) {
449
+ rows.slice(0, -1).forEach((p) => { if (Math.abs(p.count - 1) > 1e-9) v.push(`${p.label}: ${d} has a later row but this one is ${p.count}, not 1.00 — saturate then spill`); });
450
+ }
451
+ return v;
452
+ }
453
+
454
+ /** Every non-zero MF factor must have a person (spec §2.4). mfList is REQUIRED — no list, no verdict. */
455
+ function mfCoverageViolations(mfList, people) {
456
+ if (!Array.isArray(mfList) || !mfList.length) throw new Halt("MF coverage check needs the sheet's multiplication-factor list");
457
+ const have = new Set(people.map((p) => p.discipline));
458
+ const v = [];
459
+ for (const f of mfList) {
460
+ if (!(f.value > 0)) continue;
461
+ const rule = MF_COVERAGE.find((r) => r.match.test(f.label));
462
+ if (!rule) continue; // standups / buffer — absorbed, needs no row
463
+ if (!rule.disciplines.some((d) => have.has(d))) {
464
+ v.push(`MF factor '${f.label}' (${f.value}) has no person — add ${rule.disciplines.map((d) => ROLE_LABEL[d]).join(" or ")} to teamMix.fte`);
465
+ }
466
+ }
467
+ return v;
468
+ }
469
+
470
+ /** months = totalDays / (ΣCount × 20); N columns with the fold rule (spec §2.2). */
471
+ function monthPlan(totalDays, people) {
472
+ const sumCount = people.reduce((s, p) => s + p.count, 0);
473
+ if (!(sumCount > 0)) throw new Halt("Team Mix: total Count is 0");
474
+ if (!(totalDays > 0)) throw new Halt(`Team Mix: total days is ${totalDays}`);
475
+ const months = round2(totalDays / (sumCount * 20));
476
+ const whole = Math.floor(months);
477
+ const frac = months - whole;
478
+ const n = whole >= 1 && frac < FOLD_THRESHOLD ? whole : Math.ceil(months);
479
+ return { months, sumCount, n: Math.max(1, n) };
480
+ }
481
+
482
+ /** Resample a 3-point profile to n points (linear). */
483
+ function resampleWeights(profile, n) {
484
+ if (n === 1) return [1];
485
+ const out = [];
486
+ for (let i = 0; i < n; i++) {
487
+ const pos = (i / (n - 1)) * (profile.length - 1);
488
+ const lo = Math.floor(pos);
489
+ const hi = Math.min(profile.length - 1, lo + 1);
490
+ out.push(profile[lo] + (profile[hi] - profile[lo]) * (pos - lo));
491
+ }
492
+ return out;
493
+ }
494
+
495
+ /**
496
+ * Ramp one person's total hours across n months (spec §2.5). Returns n values;
497
+ * the caller writes 1..n-1 as values and month n as the remainder FORMULA.
498
+ * Soft ceiling: no month above SOFT_CEILING; excess moves to the lowest month
499
+ * (deterministic). HALTS if the person cannot fit at all or the discipline is unknown.
500
+ */
501
+ function rampHours(totalHours, discipline, n) {
502
+ if (!RAMP[discipline]) throw new Halt(`ramp: unknown discipline '${discipline}'`);
503
+ if (totalHours > n * SOFT_CEILING + 1e-9) {
504
+ throw new Halt(`${discipline}: ${round1(totalHours)} hrs cannot fit in ${n} months at ${SOFT_CEILING} hrs/month — add a person`);
505
+ }
506
+ const w = resampleWeights(RAMP[discipline], n);
507
+ const sum = w.reduce((a, b) => a + b, 0);
508
+ const hours = w.map((x) => (totalHours * x) / sum);
509
+ for (let guard = 0; guard < 50; guard++) {
510
+ const over = hours.findIndex((h) => h > SOFT_CEILING + 1e-9);
511
+ if (over < 0) break;
512
+ const excess = hours[over] - SOFT_CEILING;
513
+ hours[over] = SOFT_CEILING;
514
+ let best = -1;
515
+ hours.forEach((h, i) => { if (i !== over && (best < 0 || h < hours[best])) best = i; });
516
+ hours[best] += excess;
517
+ }
518
+ return hours.map(round1);
519
+ }
520
+
521
+ function buildRoster(plan, totalDays, mfList) {
522
+ const people = splitRoster(plan.teamMix.fte, plan.teamMix.labels);
523
+ const violations = [...rosterViolations(people), ...mfCoverageViolations(mfList, people)];
524
+ if (violations.length) throw new Halt(`Team Mix roster violations:\n - ${violations.join("\n - ")}`, 4, violations);
525
+ const { months, sumCount, n } = monthPlan(totalDays, people);
526
+ const rows = people.map((p) => {
527
+ const days = round2(months * 20 * p.count);
528
+ const hours = round2(days * 8);
529
+ return { ...p, months, days, hours, monthHours: rampHours(hours, p.discipline, n) };
530
+ });
531
+ const sumDays = round2(rows.reduce((s, r) => s + r.days, 0));
532
+ return { people: rows, months, sumCount, n, sumDays };
533
+ }
534
+
535
+ // ───────────────────────── T-Shirt totals (pure) ─────────────────────────
536
+
537
+ function sizeDays(code, legend) {
538
+ const v = sizeOf(code);
539
+ if (v === "") return 0;
540
+ if (legend[v] == null) throw new Halt(`size '${v}' is not in the sheet's legend`);
541
+ return legend[v];
542
+ }
543
+
544
+ function tshirtTotals(plan, layout) {
545
+ let raw = 0;
546
+ for (const s of plan.tshirt.sections) for (const it of s.items) raw += sizeDays(it.fe, layout.legend) + sizeDays(it.be, layout.legend);
547
+ const total = raw * (1 + layout.mfTotal);
548
+ const lowDollars = total * 8 * layout.rate;
549
+ return { rawDays: round2(raw), totalDays: round2(total), lowDollars: round2(lowDollars), highDollars: round2(lowDollars * layout.highFactor) };
550
+ }
551
+
552
+ // ───────────────────────── T-Shirt formulas (pure, spec §1.2–1.3) ─────────────────────────
553
+
554
+ function itemFormulas(r) {
555
+ return {
556
+ 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)))`,
557
+ I: `=H${r}*$F$10`,
558
+ J: `=H${r}+I${r}`,
559
+ K: `=J${r}*8*$H$4`,
560
+ L: `=K${r}*$G$4`,
561
+ };
562
+ }
563
+
564
+ function rollupFormulas(rollupRow1, firstRow1, lastRow1) {
565
+ const e = `$E$${firstRow1}:$E$${lastRow1}`;
566
+ return [
567
+ `=SUMIF(${e},$J${rollupRow1},$K$${firstRow1}:$K$${lastRow1})`,
568
+ `=SUMIF(${e},$J${rollupRow1},$J$${firstRow1}:$J$${lastRow1})*8`,
569
+ `=SUMIF(${e},$J${rollupRow1},$L$${firstRow1}:$L$${lastRow1})`,
570
+ `=IF($H$4=0,0,M${rollupRow1}/$H$4)`,
571
+ ];
572
+ }
573
+
574
+ /** Rows for items mode, starting at firstRow0 (0-based). */
575
+ function tshirtRows(plan, firstRow0) {
576
+ const out = [];
577
+ let r1 = firstRow0 + 1;
578
+ for (const s of plan.tshirt.sections) {
579
+ out.push({ kind: "section", row1: r1, values: [s.heading, "", "", "", "", "", "", "", "", "", "", ""] });
580
+ r1++;
581
+ for (const it of s.items) {
582
+ const f = itemFormulas(r1);
583
+ out.push({
584
+ kind: "item", row1: r1, id: it.id,
585
+ values: [it.module, it.userType, `${it.functionality} (${it.id})`, it.requirement, it.phase,
586
+ sizeOf(it.fe), sizeOf(it.be), f.H, f.I, f.J, f.K, f.L],
587
+ });
588
+ r1++;
589
+ }
590
+ }
591
+ const lastItem1 = r1 - 1;
592
+ const totalRow1 = lastItem1 + 2;
593
+ const f1 = firstRow0 + 1;
594
+ out.push({ kind: "blank", row1: lastItem1 + 1, values: Array(12).fill("") });
595
+ out.push({
596
+ kind: "total", row1: totalRow1,
597
+ values: ["Total (Days)", "", "", "", "", "", "",
598
+ `=SUM(H${f1}:H${lastItem1})`, `=SUM(I${f1}:I${lastItem1})`, `=SUM(J${f1}:J${lastItem1})`, `=SUM(K${f1}:K${lastItem1})`, `=SUM(L${f1}:L${lastItem1})`],
599
+ });
600
+ out.push({ kind: "blank", row1: totalRow1 + 1, values: Array(12).fill("") });
601
+ out.push({ kind: "summary", row1: totalRow1 + 2, values: ["", "", "", "", "", "", "", "", "", "Total Days", `=J${totalRow1}`, `=J${totalRow1}*$G$4`] });
602
+ out.push({ kind: "summary", row1: totalRow1 + 3, values: ["", "", "", "", "", "", "", "", "", "Total Hrs", `=J${totalRow1}*8`, `=J${totalRow1}*$G$4*8`] });
603
+ out.push({ kind: "summary", row1: totalRow1 + 4, values: ["", "", "", "", "", "", "", "", "", "Total Cost", `=K${totalRow1}`, `=L${totalRow1}`] });
604
+ return { rows: out, firstItem1: f1, lastItem1, totalRow1 };
605
+ }
606
+
607
+ // ───────────────────────── write: T-Shirt ─────────────────────────
608
+
609
+ function fmtReq(sheetId, r0, r1, c0, c1, format, fields) {
610
+ return { repeatCell: { range: gridRange(sheetId, r0, r1, c0, c1), cell: { userEnteredFormat: format }, fields } };
611
+ }
612
+ function widthReqs(sheetId, widths) {
613
+ return widths.map((w, i) => ({ updateDimensionProperties: { range: { sheetId, dimension: "COLUMNS", startIndex: i, endIndex: i + 1 }, properties: { pixelSize: w }, fields: "pixelSize" } }));
614
+ }
615
+ function copyPhaseReq(sheetId, srcRow0, dstRow0) {
616
+ return { copyPaste: {
617
+ source: gridRange(sheetId, srcRow0, srcRow0 + 1, 4, 5),
618
+ destination: gridRange(sheetId, dstRow0, dstRow0 + 1, 4, 5),
619
+ pasteType: "PASTE_NORMAL", pasteOrientation: "NORMAL",
620
+ } };
621
+ }
622
+ const FMT_ITEM_TEXT = { textFormat: { fontFamily: "Calibri", fontSize: 10, bold: false }, horizontalAlignment: "LEFT" };
623
+ const FMT_ITEM_SIZE = { horizontalAlignment: "CENTER", textFormat: { fontFamily: "Calibri", fontSize: 10 } };
624
+ const FMT_ITEM_NUM = { horizontalAlignment: "RIGHT", numberFormat: { type: "NUMBER", pattern: "0.00" }, textFormat: { fontFamily: "Calibri", fontSize: 10 } };
625
+ const FMT_ITEM_CUR = { horizontalAlignment: "RIGHT", numberFormat: { type: "CURRENCY", pattern: "$#,##0.00" }, textFormat: { fontFamily: "Calibri", fontSize: 10 } };
626
+ const FMT_FIELDS_ALL = "userEnteredFormat(textFormat,horizontalAlignment,numberFormat)";
627
+
628
+ async function writeTshirt(api, plan, opts) {
629
+ const grid = await api.grid(TAB_TSHIRT);
630
+ const sheetId = grid.properties.sheetId;
631
+ const layout = locateTshirt(grid);
632
+ const totals = tshirtTotals(plan, layout);
633
+ const phaseSrc = findPhaseSource(grid, 0);
634
+ 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`);
635
+
636
+ if (plan.tshirt.mode === "sizes") return writeTshirtSizes(api, grid, sheetId, layout, plan, totals, phaseSrc);
637
+
638
+ const first0 = layout.firstItemRow;
639
+ let occupied = 0;
640
+ for (let r = first0; r < rowCount(grid); r++) if (!rowIsEmpty(grid, r)) occupied++;
641
+ if (occupied && !opts.replace) {
642
+ throw new Halt(`${TAB_TSHIRT}: ${occupied} non-empty row(s) below the header. Pass --replace to overwrite them, or use tshirt.mode 'sizes' to fill existing rows.`);
643
+ }
644
+ const built = tshirtRows(plan, first0);
645
+ const lastWritten1 = built.rows[built.rows.length - 1].row1;
646
+
647
+ // 1. clear values + formats + merges in the item area (clear-then-paint)
648
+ const clearTo = Math.max(lastWritten1 + 5, rowCount(grid) + 1);
649
+ await api.clearValues(TAB_TSHIRT, `A${first0 + 1}:L${clearTo}`);
650
+ await api.batch([
651
+ { unmergeCells: { range: gridRange(sheetId, first0, clearTo, 0, 12) } },
652
+ fmtReq(sheetId, first0, clearTo, 0, 12, {}, "userEnteredFormat"),
653
+ ]);
654
+ // 2. values + formulas
655
+ await api.putValues(TAB_TSHIRT, `A${first0 + 1}:L${lastWritten1}`, built.rows.map((r) => r.values));
656
+ // 3. rollups re-pointed to the real item range
657
+ for (const rr of layout.rollupRows) {
658
+ await api.putValues(TAB_TSHIRT, `K${rr + 1}:N${rr + 1}`, [rollupFormulas(rr + 1, built.firstItem1, built.lastItem1)]);
659
+ }
660
+ // 4. formats — item area defaults, then per-row kinds
661
+ const reqs = [];
662
+ reqs.push(fmtReq(sheetId, first0, lastWritten1, 0, 12, FMT_ITEM_TEXT, "userEnteredFormat(textFormat,horizontalAlignment)"));
663
+ reqs.push(fmtReq(sheetId, first0, lastWritten1, 4, 7, FMT_ITEM_SIZE, "userEnteredFormat(horizontalAlignment,textFormat)"));
664
+ reqs.push(fmtReq(sheetId, first0, lastWritten1, 7, 10, FMT_ITEM_NUM, FMT_FIELDS_ALL));
665
+ reqs.push(fmtReq(sheetId, first0, lastWritten1, 10, 12, FMT_ITEM_CUR, FMT_FIELDS_ALL));
666
+ for (const row of built.rows) {
667
+ const r0 = row.row1 - 1;
668
+ if (row.kind === "section") {
669
+ reqs.push({ mergeCells: { range: gridRange(sheetId, r0, r0 + 1, 0, 12), mergeType: "MERGE_ALL" } });
670
+ reqs.push(fmtReq(sheetId, r0, r0 + 1, 0, 12,
671
+ { backgroundColor: hexToColor(COLOR.sectionBg), textFormat: { fontFamily: "Arial", fontSize: 10, bold: true, foregroundColor: hexToColor(COLOR.white) }, horizontalAlignment: "LEFT" },
672
+ "userEnteredFormat(backgroundColor,textFormat,horizontalAlignment)"));
673
+ } else if (row.kind === "total") {
674
+ reqs.push(fmtReq(sheetId, r0, r0 + 1, 0, 12, { textFormat: { fontFamily: "Calibri", fontSize: 10, bold: true } }, "userEnteredFormat.textFormat"));
675
+ } else if (row.kind === "summary") {
676
+ reqs.push(fmtReq(sheetId, r0, r0 + 1, 9, 12, { textFormat: { fontFamily: "Calibri", fontSize: 10, bold: true } }, "userEnteredFormat.textFormat"));
677
+ } else if (row.kind === "item") {
678
+ reqs.push(copyPhaseReq(sheetId, phaseSrc, r0)); // carries validation + chip format; value re-put below
679
+ }
680
+ }
681
+ reqs.push(...widthReqs(sheetId, TSHIRT_WIDTHS));
682
+ await api.batch(reqs);
683
+ // 5. the Phase VALUES again — copyPaste overwrote them with the source cell's value
684
+ const phaseVals = [];
685
+ for (let r1 = built.firstItem1; r1 <= built.lastItem1; r1++) {
686
+ const row = built.rows.find((x) => x.row1 === r1);
687
+ phaseVals.push([row.kind === "item" ? row.values[4] : ""]);
688
+ }
689
+ await api.putValues(TAB_TSHIRT, `E${built.firstItem1}:E${built.lastItem1}`, phaseVals);
690
+
691
+ return { layout, totals, firstItem1: built.firstItem1, lastItem1: built.lastItem1, totalRow1: built.totalRow1, itemsWritten: built.rows.filter((r) => r.kind === "item").length };
692
+ }
693
+
694
+ /** sizes mode — rows exist (gap-analysis sheet); fill E:L on rows matched by "(id)" in column C. */
695
+ async function writeTshirtSizes(api, grid, sheetId, layout, plan, totals, phaseSrc) {
696
+ const rowsById = new Map();
697
+ for (let r = layout.firstItemRow; r < rowCount(grid); r++) {
698
+ const m = textAt(grid, r, 2).match(/\(([A-Za-z]+-\d+(?:\.\d+)*)\)\s*$/);
699
+ if (m) rowsById.set(m[1], r);
700
+ }
701
+ const missing = [];
702
+ const writes = [];
703
+ let lastItem0 = layout.firstItemRow;
704
+ for (const s of plan.tshirt.sections) for (const it of s.items) {
705
+ const r0 = rowsById.get(it.id);
706
+ if (r0 == null) { missing.push(it.id); continue; }
707
+ lastItem0 = Math.max(lastItem0, r0);
708
+ const f = itemFormulas(r0 + 1);
709
+ writes.push({ r0, phase: it.phase, values: [it.phase, sizeOf(it.fe), sizeOf(it.be), f.H, f.I, f.J, f.K, f.L] });
710
+ }
711
+ if (missing.length) throw new Halt(`${TAB_TSHIRT}: no row carries these ids in column C — ${missing.join(", ")}. Rows are matched BY NAME (the "(id)" suffix), never by position.`, 4, missing);
712
+ let totalRow0 = -1;
713
+ for (let r = lastItem0 + 1; r < rowCount(grid); r++) if (/^Total \(Days\)/i.test(textAt(grid, r, 0))) { totalRow0 = r; break; }
714
+ if (totalRow0 < 0) throw new Halt(`${TAB_TSHIRT}: no 'Total (Days)' row found below the items — add it (spec §1.3) and re-run`);
715
+
716
+ for (const w of writes) await api.putValues(TAB_TSHIRT, `E${w.r0 + 1}:L${w.r0 + 1}`, [w.values]);
717
+ const reqs = [];
718
+ for (const w of writes) {
719
+ if (w.r0 !== phaseSrc) reqs.push(copyPhaseReq(sheetId, phaseSrc, w.r0));
720
+ reqs.push(fmtReq(sheetId, w.r0, w.r0 + 1, 4, 7, FMT_ITEM_SIZE, "userEnteredFormat(horizontalAlignment,textFormat)"));
721
+ reqs.push(fmtReq(sheetId, w.r0, w.r0 + 1, 7, 10, FMT_ITEM_NUM, FMT_FIELDS_ALL));
722
+ reqs.push(fmtReq(sheetId, w.r0, w.r0 + 1, 10, 12, FMT_ITEM_CUR, FMT_FIELDS_ALL));
723
+ }
724
+ await api.batch(reqs);
725
+ for (const w of writes) await api.putValues(TAB_TSHIRT, `E${w.r0 + 1}`, [[w.phase]]);
726
+ const first1 = layout.firstItemRow + 1;
727
+ const last1 = lastItem0 + 1;
728
+ await api.putValues(TAB_TSHIRT, `H${totalRow0 + 1}:L${totalRow0 + 1}`, [[`=SUM(H${first1}:H${last1})`, `=SUM(I${first1}:I${last1})`, `=SUM(J${first1}:J${last1})`, `=SUM(K${first1}:K${last1})`, `=SUM(L${first1}:L${last1})`]]);
729
+ for (const rr of layout.rollupRows) await api.putValues(TAB_TSHIRT, `K${rr + 1}:N${rr + 1}`, [rollupFormulas(rr + 1, first1, last1)]);
730
+ return { layout, totals, firstItem1: first1, lastItem1: last1, totalRow1: totalRow0 + 1, itemsWritten: writes.length };
731
+ }
732
+
733
+ // ───────────────────────── write: Team Mix (spec §2) ─────────────────────────
734
+
735
+ function teamMixValues(plan, roster) {
736
+ const n = roster.n;
737
+ const firstMonthC = 8; // I
738
+ const totalC = firstMonthC + n; // Total Hrs column index — derived, never remembered
739
+ const width = totalC + 1;
740
+ const L = colLetter;
741
+ const header = ["Skill set", "Count", "", "Mths", "Days", "Hrs", "", "Resource"];
742
+ for (let m = 1; m <= n; m++) header.push(`Mon ${m}`);
743
+ header.push("Total Hrs");
744
+ const rows = [];
745
+ rows.push([plan.title, ...Array(width - 1).fill("")]);
746
+ rows.push(header);
747
+ roster.people.forEach((p, i) => {
748
+ const r1 = 3 + i;
749
+ const row = [p.label, p.count, "", roster.months, `=D${r1}*20*B${r1}`, `=E${r1}*8`, "", p.label];
750
+ for (let m = 0; m < n - 1; m++) row.push(p.monthHours[m]);
751
+ row.push(n === 1 ? `=F${r1}` : `=F${r1}-SUM(${L(firstMonthC)}${r1}:${L(firstMonthC + n - 2)}${r1})`);
752
+ row.push(`=SUM(${L(firstMonthC)}${r1}:${L(totalC - 1)}${r1})`);
753
+ rows.push(row);
754
+ });
755
+ const firstP = 3;
756
+ const lastP = 2 + roster.people.length;
757
+ const totalR = lastP + 1;
758
+ const total = ["Total", `=SUM(B${firstP}:B${lastP})`, "", "", `=SUM(E${firstP}:E${lastP})`, `=SUM(F${firstP}:F${lastP})`, "", "Total Hours"];
759
+ for (let c = firstMonthC; c <= totalC; c++) total.push(`=SUM(${L(c)}${firstP}:${L(c)}${lastP})`);
760
+ rows.push(total);
761
+ const totalDays = ["", "", "", "", "", "", "", "Total Days"];
762
+ for (let c = firstMonthC; c <= totalC; c++) totalDays.push(`=${L(c)}${totalR}/8`);
763
+ rows.push(totalDays);
764
+ return { rows, width, totalC, firstMonthC, totalR, firstP, lastP };
765
+ }
766
+
767
+ async function writeTeamMix(api, plan, roster) {
768
+ const grid = await api.grid(TAB_TEAM);
769
+ const sheetId = grid.properties.sheetId;
770
+ const v = teamMixValues(plan, roster);
771
+ const clearRows = Math.max(60, rowCount(grid) + 2);
772
+ await api.clearValues(TAB_TEAM, `A1:Z${clearRows}`);
773
+ await api.batch([
774
+ { unmergeCells: { range: gridRange(sheetId, 0, clearRows, 0, 26) } },
775
+ fmtReq(sheetId, 0, clearRows, 0, 26, {}, "userEnteredFormat"),
776
+ ]);
777
+ await api.putValues(TAB_TEAM, `A1:${colLetter(v.width - 1)}${v.rows.length}`, v.rows);
778
+ const W = v.width;
779
+ const arial = (bold) => ({ fontFamily: "Arial", fontSize: 10, bold });
780
+ const white = { backgroundColor: hexToColor(COLOR.white) };
781
+ const reqs = [];
782
+ reqs.push({ mergeCells: { range: gridRange(sheetId, 0, 1, 0, W), mergeType: "MERGE_ALL" } });
783
+ reqs.push(fmtReq(sheetId, 0, 1, 0, W, { backgroundColor: hexToColor(COLOR.teamTitleBg), textFormat: { fontFamily: "Montserrat", fontSize: 20, bold: true, foregroundColor: hexToColor(COLOR.white) }, horizontalAlignment: "CENTER" }, "userEnteredFormat(backgroundColor,textFormat,horizontalAlignment)"));
784
+ reqs.push(fmtReq(sheetId, 1, v.rows.length, 0, W, { textFormat: arial(false), horizontalAlignment: "RIGHT", numberFormat: { type: "NUMBER", pattern: "0.00" } }, FMT_FIELDS_ALL));
785
+ reqs.push(fmtReq(sheetId, 1, 2, 0, W, { backgroundColor: hexToColor(COLOR.teamHeaderBg), textFormat: { ...arial(true), foregroundColor: hexToColor(COLOR.white) } }, "userEnteredFormat(backgroundColor,textFormat)"));
786
+ reqs.push(fmtReq(sheetId, 1, 2, 2, 3, white, "userEnteredFormat.backgroundColor"));
787
+ reqs.push(fmtReq(sheetId, 1, 2, 6, 7, white, "userEnteredFormat.backgroundColor"));
788
+ reqs.push(fmtReq(sheetId, 1, v.rows.length, 0, 1, { horizontalAlignment: "LEFT" }, "userEnteredFormat.horizontalAlignment"));
789
+ reqs.push(fmtReq(sheetId, 1, v.rows.length, 7, 8, { horizontalAlignment: "LEFT" }, "userEnteredFormat.horizontalAlignment"));
790
+ reqs.push(fmtReq(sheetId, 1, 2, v.totalC, v.totalC + 1, { horizontalAlignment: "LEFT" }, "userEnteredFormat.horizontalAlignment"));
791
+ const p0 = v.firstP - 1, p1 = v.lastP;
792
+ const sage = { backgroundColor: hexToColor(COLOR.sage), textFormat: arial(true) };
793
+ reqs.push(fmtReq(sheetId, p0, p1, 4, 6, sage, "userEnteredFormat(backgroundColor,textFormat)"));
794
+ reqs.push(fmtReq(sheetId, p0, p1, v.totalC, v.totalC + 1, sage, "userEnteredFormat(backgroundColor,textFormat)"));
795
+ const t0 = v.totalR - 1;
796
+ reqs.push(fmtReq(sheetId, t0, t0 + 1, 0, W, { backgroundColor: hexToColor(COLOR.totalBg), textFormat: arial(true) }, "userEnteredFormat(backgroundColor,textFormat)"));
797
+ reqs.push(fmtReq(sheetId, t0, t0 + 1, 2, 3, white, "userEnteredFormat.backgroundColor"));
798
+ reqs.push(fmtReq(sheetId, t0, t0 + 1, 6, 7, white, "userEnteredFormat.backgroundColor"));
799
+ reqs.push(fmtReq(sheetId, t0 + 1, t0 + 2, 7, W, { backgroundColor: hexToColor(COLOR.totalDaysBg), textFormat: arial(true) }, "userEnteredFormat(backgroundColor,textFormat)"));
800
+ reqs.push(...widthReqs(sheetId, [...TEAM_WIDTHS_FIXED, ...Array(roster.n).fill(TEAM_MONTH_WIDTH), TEAM_TOTAL_WIDTH]));
801
+ await api.batch(reqs);
802
+ return v;
803
+ }
804
+
805
+ // ───────────────────────── write: Technology Stack (spec §3) ─────────────────────────
806
+
807
+ async function writeTechStack(api, plan) {
808
+ const grid = await api.grid(TAB_TECH);
809
+ const sheetId = grid.properties.sheetId;
810
+ const rows = [["Technology Stack", ""], ...plan.techStack.map((r) => [r.category, r.description])];
811
+ const clearRows = Math.max(40, rowCount(grid) + 2);
812
+ await api.clearValues(TAB_TECH, `A1:B${clearRows}`);
813
+ await api.batch([
814
+ { unmergeCells: { range: gridRange(sheetId, 0, clearRows, 0, 2) } },
815
+ fmtReq(sheetId, 0, clearRows, 0, 2, {}, "userEnteredFormat"),
816
+ ]);
817
+ await api.putValues(TAB_TECH, `A1:B${rows.length}`, rows);
818
+ await api.batch([
819
+ { mergeCells: { range: gridRange(sheetId, 0, 1, 0, 2), mergeType: "MERGE_ALL" } },
820
+ fmtReq(sheetId, 0, 1, 0, 2, { backgroundColor: hexToColor(COLOR.teamHeaderBg), textFormat: { fontSize: 12, bold: true, foregroundColor: hexToColor(COLOR.white) } }, "userEnteredFormat(backgroundColor,textFormat)"),
821
+ fmtReq(sheetId, 1, rows.length, 0, 2, { textFormat: { fontFamily: "Arial", fontSize: 10 }, wrapStrategy: "WRAP" }, "userEnteredFormat(textFormat,wrapStrategy)"),
822
+ ...widthReqs(sheetId, TECH_WIDTHS),
823
+ ]);
824
+ return { rows: rows.length - 1 };
825
+ }
826
+
827
+ // ───────────────────────── audit (spec §5, by read-back) ─────────────────────────
828
+
829
+ function check(list, name, ok, detail) { list.push({ check: name, ok: !!ok, detail: ok ? "" : String(detail == null ? "" : detail) }); }
830
+
831
+ /** T-Shirt audit. The template header block is REQUIRED — locateTshirt halts if it is missing. */
832
+ function auditTshirt(grid) {
833
+ const out = [];
834
+ const layout = locateTshirt(grid);
835
+ const first0 = layout.firstItemRow;
836
+ const badSizes = [], noDv = [], badFormula = [], sectionsWithSizes = [], badSectionFmt = [];
837
+ let lastItem0 = -1, totalRow0 = -1;
838
+ for (let r = first0; r < rowCount(grid); r++) {
839
+ const a = textAt(grid, r, 0);
840
+ if (/^Total \(Days\)/i.test(a)) { totalRow0 = r; break; }
841
+ if (rowIsEmpty(grid, r)) continue;
842
+ const isSection = a !== "" && textAt(grid, r, 2) === "" && textAt(grid, r, 7) === "";
843
+ if (isSection) {
844
+ const feTxt = textAt(grid, r, 5);
845
+ const beTxt = textAt(grid, r, 6);
846
+ if (feTxt !== "" || beTxt !== "") sectionsWithSizes.push(r + 1);
847
+ if (bgAt(grid, r, 0) !== COLOR.sectionBg) badSectionFmt.push(`row ${r + 1} bg ${bgAt(grid, r, 0)}`);
848
+ continue;
849
+ }
850
+ lastItem0 = r;
851
+ for (const c of [5, 6]) {
852
+ const v = textAt(grid, r, c).trim();
853
+ if (v && !SIZE_CODES.includes(v)) badSizes.push(`${colLetter(c)}${r + 1}='${v}'`);
854
+ }
855
+ const cell = cellAt(grid, r, 4);
856
+ if (!(cell && cell.dataValidation && cell.dataValidation.condition && cell.dataValidation.condition.type === "ONE_OF_LIST")) noDv.push(`E${r + 1}`);
857
+ const f = itemFormulas(r + 1);
858
+ [["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}`); });
859
+ }
860
+ check(out, "T-Shirt: at least one item row", lastItem0 >= 0, "no item rows below the header");
861
+ check(out, "T-Shirt: size cells are bare codes (no legend text)", !badSizes.length, badSizes.join(", "));
862
+ check(out, "T-Shirt: every item row has the Phase dropdown", !noDv.length, noDv.join(", "));
863
+ check(out, "T-Shirt: H:L are the spec formulas on every item row", !badFormula.length, badFormula.slice(0, 12).join(", "));
864
+ check(out, "T-Shirt: section rows carry no sizes", !sectionsWithSizes.length, `rows ${sectionsWithSizes.join(", ")}`);
865
+ check(out, "T-Shirt: section rows are styled (bg #1C4F8B)", !badSectionFmt.length, badSectionFmt.join(", "));
866
+ check(out, "T-Shirt: 'Total (Days)' row exists below the items", totalRow0 > lastItem0 && lastItem0 >= 0, "not found");
867
+ let totalDaysCell = NaN;
868
+ if (totalRow0 > lastItem0 && lastItem0 >= 0) {
869
+ const want = `=SUM(J${first0 + 1}:J${lastItem0 + 1})`;
870
+ check(out, "T-Shirt: totals row sums the full item range", formulaAt(grid, totalRow0, 9) === want, `J${totalRow0 + 1} is '${formulaAt(grid, totalRow0, 9)}', want '${want}'`);
871
+ totalDaysCell = numAt(grid, totalRow0, 9);
872
+ const badRoll = [];
873
+ for (const rr of layout.rollupRows) {
874
+ if (formulaAt(grid, rr, 10) !== rollupFormulas(rr + 1, first0 + 1, lastItem0 + 1)[0]) badRoll.push(`K${rr + 1}`);
875
+ }
876
+ check(out, "T-Shirt: phase rollups (K4:N7) reference the full item range", !badRoll.length, badRoll.join(", "));
877
+ let summaryAbove = false;
878
+ for (let r = first0; r < totalRow0; r++) if (/^Total (Days|Hrs|Cost)$/i.test(textAt(grid, r, 9))) summaryAbove = true;
879
+ check(out, "T-Shirt: summary block is below the totals row", !summaryAbove, "a Total Days/Hrs/Cost label sits inside the summed range");
880
+ let raw = 0;
881
+ for (let r = first0; r <= lastItem0; r++) {
882
+ for (const c of [5, 6]) { const v = textAt(grid, r, c).trim(); if (v && layout.legend[v] != null) raw += layout.legend[v]; }
883
+ }
884
+ const recomputed = round2(raw * (1 + layout.mfTotal));
885
+ check(out, "T-Shirt: Σ raw sizes × (1+MF) == Total Days cell", Math.abs(recomputed - totalDaysCell) < 0.02, `recomputed ${recomputed}, cell ${totalDaysCell}`);
886
+ }
887
+ return { checks: out, layout, totalDays: totalDaysCell, lastItem1: lastItem0 + 1 };
888
+ }
889
+
890
+ function disciplineOfLabel(label) {
891
+ const l = label.toLowerCase();
892
+ if (/qa|test/.test(l)) return "qa";
893
+ if (/project manager|\bpm\b/.test(l)) return "pm";
894
+ if (/analyst/.test(l)) return "ba";
895
+ if (/devops/.test(l)) return "devops";
896
+ if (/lead|architect/.test(l)) return "techlead";
897
+ if (/front/.test(l)) return "frontend";
898
+ if (/back|api/.test(l)) return "backend";
899
+ return "";
900
+ }
901
+
902
+ /** Team Mix audit. mfList (from the T-Shirt tab) is REQUIRED; tshirtTotalDays may be NaN only when the T-Shirt has no totals row. */
903
+ function auditTeamMix(grid, mfList, tshirtTotalDays) {
904
+ const out = [];
905
+ const hdr = [];
906
+ for (let c = 0; c < 30; c++) hdr.push(textAt(grid, 1, c));
907
+ check(out, "Team Mix: row 2 is the header (A2='Skill set')", hdr[0] === "Skill set", `A2='${hdr[0]}'`);
908
+ check(out, "Team Mix: row 3 is a person, not a second header", textAt(grid, 2, 0) !== "Skill set" && textAt(grid, 2, 0) !== "", `A3='${textAt(grid, 2, 0)}'`);
909
+ const monthCols = [];
910
+ hdr.forEach((h, c) => { if (/^Mon \d+$/.test(h)) monthCols.push(c); });
911
+ const totalC = hdr.indexOf("Total Hrs");
912
+ const lastM = monthCols.length ? monthCols[monthCols.length - 1] : -1;
913
+ check(out, "Team Mix: month headers are Mon 1..N, contiguous, then Total Hrs", monthCols.length > 0 && totalC === lastM + 1 && monthCols.every((c, i) => hdr[c] === `Mon ${i + 1}` && c === monthCols[0] + i), `months [${monthCols.map((c) => hdr[c]).join(",")}], Total Hrs at column ${totalC}`);
914
+ check(out, "Team Mix: header columns are Skill set/Count/Mths/Days/Hrs/Resource", hdr[1] === "Count" && hdr[3] === "Mths" && hdr[4] === "Days" && hdr[5] === "Hrs" && hdr[7] === "Resource", hdr.slice(0, 8).join("|"));
915
+ const people = [];
916
+ let totalR = -1;
917
+ for (let r = 2; r < rowCount(grid); r++) {
918
+ const a = textAt(grid, r, 0);
919
+ if (/^Total$/i.test(a)) { totalR = r; break; }
920
+ if (a === "") break;
921
+ people.push({ r, label: a, count: numAt(grid, r, 1), discipline: disciplineOfLabel(a) });
922
+ }
923
+ check(out, "Team Mix: a 'Total' row follows the people", totalR > 2, "no Total row");
924
+ check(out, "Team Mix: every person has a numeric Count", people.every((p) => !Number.isNaN(p.count)), people.filter((p) => Number.isNaN(p.count)).map((p) => p.label).join(", "));
925
+ check(out, "Team Mix: no Count > 1.00", people.every((p) => p.count <= 1 + 1e-9), people.filter((p) => p.count > 1).map((p) => `${p.label}=${p.count}`).join(", "));
926
+ const groups = {};
927
+ people.forEach((p) => { const k = p.label.replace(/\s+\d+$/, ""); (groups[k] = groups[k] ? groups[k] : []).push(p); });
928
+ const smeared = [];
929
+ for (const [k, rows] of Object.entries(groups)) rows.slice(0, -1).forEach((p) => { if (Math.abs(p.count - 1) > 1e-9) smeared.push(`${k}: ${p.count} before a later row`); });
930
+ check(out, "Team Mix: per discipline, all rows but the last are 1.00 (saturate then spill)", !smeared.length, smeared.join("; "));
931
+ const cov = mfCoverageViolations(mfList, people.filter((p) => p.discipline));
932
+ check(out, "Team Mix: every non-zero MF factor has a person", !cov.length, cov.join("; "));
933
+ const badF = [], negative = [], over = [];
934
+ if (monthCols.length && totalC > 0) {
935
+ const firstM = monthCols[0];
936
+ for (const p of people) {
937
+ const r1 = p.r + 1;
938
+ if (formulaAt(grid, p.r, 4) !== `=D${r1}*20*B${r1}`) badF.push(`E${r1}`);
939
+ if (formulaAt(grid, p.r, 5) !== `=E${r1}*8`) badF.push(`F${r1}`);
940
+ if (monthCols.length > 1) {
941
+ const want = `=F${r1}-SUM(${colLetter(firstM)}${r1}:${colLetter(lastM - 1)}${r1})`;
942
+ if (formulaAt(grid, p.r, lastM) !== want) badF.push(`${colLetter(lastM)}${r1} (remainder)`);
943
+ }
944
+ if (!formulaAt(grid, p.r, totalC).startsWith("=")) badF.push(`${colLetter(totalC)}${r1}`);
945
+ for (const c of monthCols) {
946
+ const h = numAt(grid, p.r, c);
947
+ if (Number.isNaN(h)) negative.push(`${colLetter(c)}${r1}=not a number`);
948
+ else if (h < -1e-9) negative.push(`${colLetter(c)}${r1}=${h}`);
949
+ else if (h > SOFT_CEILING + 1e-9) over.push(`${colLetter(c)}${r1}=${h}`);
950
+ }
951
+ }
952
+ }
953
+ check(out, "Team Mix: Days/Hrs/remainder/Total Hrs are formulas", !badF.length, badF.join(", "));
954
+ check(out, "Team Mix: no negative or non-numeric month", !negative.length, negative.join(", "));
955
+ check(out, `Team Mix: no month cell above ${SOFT_CEILING} hrs`, !over.length, over.join(", "));
956
+ if (monthCols.length > 1 && people.length) {
957
+ const flat = people.filter((p) => p.discipline !== "pm").every((p) => {
958
+ const vals = monthCols.map((c) => round1(numAt(grid, p.r, c)));
959
+ return vals.every((v) => Math.abs(v - vals[0]) < 0.05);
960
+ });
961
+ check(out, "Team Mix: ramp applied (roles are not all flat)", !flat, "every non-PM role has identical hours each month");
962
+ }
963
+ const badFont = [], badSage = [], whiteMissing = [];
964
+ for (const p of people) {
965
+ for (let c = 0; c <= totalC; c++) {
966
+ const f = fontAt(grid, p.r, c);
967
+ if (f.family && f.family !== "Arial") badFont.push(`${colLetter(c)}${p.r + 1}=${f.family}`);
968
+ const bg = bgAt(grid, p.r, c);
969
+ const shouldSage = c === 4 || c === 5 || c === totalC;
970
+ if (shouldSage && bg !== COLOR.sage) badSage.push(`${colLetter(c)}${p.r + 1} bg ${bg}`);
971
+ if (!shouldSage && bg !== COLOR.white) whiteMissing.push(`${colLetter(c)}${p.r + 1} bg ${bg}`);
972
+ }
973
+ }
974
+ check(out, "Team Mix: body font is Arial (never Calibri)", !badFont.length, badFont.slice(0, 8).join(", "));
975
+ check(out, "Team Mix: sage on exactly Days, Hrs, Total Hrs of role rows", !badSage.length && !whiteMissing.length, [...badSage, ...whiteMissing].slice(0, 8).join(", "));
976
+ if (totalR > 0 && totalC > 0) {
977
+ const bandBad = [];
978
+ for (const c of [...monthCols, totalC, 0, 1]) if (bgAt(grid, totalR, c) !== COLOR.totalBg) bandBad.push(`${colLetter(c)}${totalR + 1}`);
979
+ for (const c of [...monthCols, totalC, 7]) if (bgAt(grid, totalR + 1, c) !== COLOR.totalDaysBg) bandBad.push(`${colLetter(c)}${totalR + 2}`);
980
+ check(out, "Team Mix: Total (#E5E5E5) and Total Days (#9EC1EF) bands span every month + Total Hrs", !bandBad.length, bandBad.join(", "));
981
+ const stray = [];
982
+ for (let r = totalR + 2; r < Math.min(rowCount(grid), totalR + 40); r++) for (let c = 0; c < 26; c++) if (bgAt(grid, r, c) !== COLOR.white) stray.push(`${colLetter(c)}${r + 1}`);
983
+ for (let r = 0; r <= totalR + 1; r++) for (let c = totalC + 1; c < 26; c++) if (bgAt(grid, r, c) !== COLOR.white) stray.push(`${colLetter(c)}${r + 1}`);
984
+ check(out, "Team Mix: nothing formatted outside the data area", !stray.length, stray.slice(0, 8).join(", "));
985
+ const sumDays = numAt(grid, totalR, 4);
986
+ if (!Number.isNaN(tshirtTotalDays)) {
987
+ check(out, "Team Mix: Σ Days == T-Shirt Total Days", Math.abs(sumDays - tshirtTotalDays) < 0.15, `Team Mix ${sumDays}, T-Shirt ${tshirtTotalDays}`);
988
+ }
989
+ }
990
+ return { checks: out, people: people.length, months: monthCols.length };
991
+ }
992
+
993
+ function auditTechStack(grid) {
994
+ const out = [];
995
+ let filled = 0;
996
+ const tbd = [];
997
+ for (let r = 1; r < rowCount(grid); r++) {
998
+ const a = textAt(grid, r, 0), b = textAt(grid, r, 1);
999
+ if (a && b) { filled++; if (/\bTBD\b/i.test(b)) tbd.push(a); }
1000
+ }
1001
+ check(out, "Technology Stack: A1 header present", /^Technology Stack$/i.test(textAt(grid, 0, 0)), `A1='${textAt(grid, 0, 0)}'`);
1002
+ check(out, "Technology Stack: at least 3 category rows filled", filled >= 3, `${filled} filled`);
1003
+ check(out, "Technology Stack: no row says TBD", !tbd.length, tbd.join(", "));
1004
+ return { checks: out, rows: filled };
1005
+ }
1006
+
1007
+ function auditOverview(grid) {
1008
+ const out = [];
1009
+ const cells = [[1, 2], [1, 4], [2, 2], [2, 4]];
1010
+ const bad = cells.filter(([r, c]) => Number.isNaN(numAt(grid, r, c)) || !formulaAt(grid, r, c).includes(TAB_TSHIRT));
1011
+ check(out, "Overview: C2/E2/C3/E3 reference the T-Shirt tab and resolve to numbers", !bad.length, bad.map(([r, c]) => `${colLetter(c)}${r + 1}='${formulaAt(grid, r, c) ? formulaAt(grid, r, c) : textAt(grid, r, c)}'`).join(", "));
1012
+ return { checks: out };
1013
+ }
1014
+
1015
+ async function runAudit(api) {
1016
+ const meta = await api.meta();
1017
+ const tabs = new Set(meta.sheets.map((s) => s.properties.title));
1018
+ for (const t of [TAB_TSHIRT, TAB_TEAM, TAB_TECH]) if (!tabs.has(t)) throw new Halt(`tab '${t}' is missing — this is not a Tekyz estimate sheet`);
1019
+ const checks = [];
1020
+ const tshirt = auditTshirt(await api.grid(TAB_TSHIRT));
1021
+ checks.push(...tshirt.checks);
1022
+ checks.push(...auditTeamMix(await api.grid(TAB_TEAM), tshirt.layout.mf, tshirt.totalDays).checks);
1023
+ checks.push(...auditTechStack(await api.grid(TAB_TECH)).checks);
1024
+ if (tabs.has(TAB_OVERVIEW)) checks.push(...auditOverview(await api.grid(TAB_OVERVIEW)).checks);
1025
+ else check(checks, "Overview tab exists (the estimates index imports its cells)", false, "missing");
1026
+ const failed = checks.filter((c) => !c.ok);
1027
+ return { title: meta.properties.title, checks, failed: failed.length, ok: failed.length === 0 };
1028
+ }
1029
+
1030
+ // ───────────────────────── verbs ─────────────────────────
1031
+
1032
+ function loadPlan(p) {
1033
+ if (!p) throw new Halt("--plan <plan.json> is required", 64);
1034
+ if (!fs.existsSync(p)) throw new Halt(`plan file not found: ${p}`, 64);
1035
+ let plan;
1036
+ try {
1037
+ plan = JSON.parse(fs.readFileSync(p, "utf8"));
1038
+ } catch (e) {
1039
+ throw new Halt(`plan ${p} is not valid JSON: ${e.message}`, 64);
1040
+ }
1041
+ const errors = validatePlan(plan);
1042
+ if (errors.length) throw new Halt(`plan invalid:\n - ${errors.join("\n - ")}`, 4, errors);
1043
+ return plan;
1044
+ }
1045
+
1046
+ function rosterTable(roster) {
1047
+ const lines = [];
1048
+ lines.push(["Skill set", "Count", "Mths", "Days", "Hrs", ...Array.from({ length: roster.n }, (_, i) => `Mon ${i + 1}`)].join(" | "));
1049
+ for (const p of roster.people) {
1050
+ const months = p.monthHours.map((h, i) => (i === roster.n - 1 ? `${round1(p.hours - p.monthHours.slice(0, -1).reduce((a, b) => a + b, 0))} (rem)` : String(h)));
1051
+ lines.push([p.label, p.count.toFixed(2), p.months.toFixed(2), p.days.toFixed(2), p.hours.toFixed(2), ...months].join(" | "));
1052
+ }
1053
+ lines.push(`Σ Count ${roster.sumCount.toFixed(2)} · months ${roster.months} · columns ${roster.n} · Σ Days ${roster.sumDays}`);
1054
+ return lines.join("\n");
1055
+ }
1056
+
1057
+ async function verbRead(api, tab) {
1058
+ const meta = await api.meta();
1059
+ const out = { title: meta.properties.title, tabs: meta.sheets.map((s) => ({ title: s.properties.title, sheetId: s.properties.sheetId })) };
1060
+ if (tab) {
1061
+ const grid = await api.grid(tab);
1062
+ const rows = [];
1063
+ for (let r = 0; r < rowCount(grid); r++) {
1064
+ if (rowIsEmpty(grid, r, 20)) continue;
1065
+ const cells = [];
1066
+ for (let c = 0; c < 20; c++) {
1067
+ const t = textAt(grid, r, c);
1068
+ if (t !== "") cells.push(`${colLetter(c)}${r + 1}=${JSON.stringify(t)}${bgAt(grid, r, c) !== COLOR.white ? `[${bgAt(grid, r, c)}]` : ""}`);
1069
+ }
1070
+ rows.push(cells.join(" | "));
1071
+ }
1072
+ out.tab = tab;
1073
+ out.rows = rows;
1074
+ }
1075
+ return out;
1076
+ }
1077
+
1078
+ async function verbPlanCheck(api, plan) {
1079
+ const layout = locateTshirt(await api.grid(TAB_TSHIRT));
1080
+ const totals = tshirtTotals(plan, layout);
1081
+ const roster = buildRoster(plan, totals.totalDays, layout.mf);
1082
+ return { totals, mf: layout.mf, mfTotal: round2(layout.mfTotal), highFactor: layout.highFactor, rate: layout.rate, roster, table: rosterTable(roster) };
1083
+ }
1084
+
1085
+ async function verbWrite(api, plan, opts) {
1086
+ const ts = await writeTshirt(api, plan, opts);
1087
+ const roster = buildRoster(plan, ts.totals.totalDays, ts.layout.mf);
1088
+ const tm = await writeTeamMix(api, plan, roster);
1089
+ const tech = await writeTechStack(api, plan);
1090
+ const result = {
1091
+ tshirt: { mode: plan.tshirt.mode, itemsWritten: ts.itemsWritten, firstItem1: ts.firstItem1, lastItem1: ts.lastItem1, totalRow1: ts.totalRow1, totals: ts.totals },
1092
+ teamMix: { people: roster.people.length, months: roster.months, columns: roster.n, sumDays: roster.sumDays, table: rosterTable(roster), rowsWritten: tm.rows.length },
1093
+ techStack: tech,
1094
+ };
1095
+ if (!opts.noAudit) result.audit = await runAudit(api);
1096
+ return result;
1097
+ }
1098
+
1099
+ // ───────────────────────── CLI ─────────────────────────
1100
+
1101
+ function parseArgs(argv) {
1102
+ const out = { _: [] };
1103
+ for (let i = 0; i < argv.length; i++) {
1104
+ const a = argv[i];
1105
+ if (a === "--json" || a === "--replace" || a === "--no-audit") out[a.slice(2)] = true;
1106
+ else if (a.startsWith("--")) { out[a.slice(2)] = argv[i + 1]; i++; }
1107
+ else out._.push(a);
1108
+ }
1109
+ return out;
1110
+ }
1111
+
1112
+ function printChecks(audit) {
1113
+ for (const c of audit.checks) console.log(` ${c.ok ? "✓" : "✗"} ${c.check}${c.ok ? "" : ` — ${c.detail}`}`);
1114
+ console.log(`${audit.ok ? "AUDIT PASS" : `AUDIT FAIL (${audit.failed})`} — ${audit.title}`);
1115
+ }
1116
+
1117
+ 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]";
1118
+
1119
+ /** Runs a verb; returns the exit code. Throws Halt (or any error) — the runner below turns that into exit 4/64. */
1120
+ async function main(args) {
1121
+ const verb = args._[0];
1122
+ const json = !!args.json;
1123
+ if (!verb || verb === "help") { console.log(USAGE); return 0; }
1124
+ if (verb === "plan-schema") { console.log(JSON.stringify(PLAN_SCHEMA, null, 2)); return 0; }
1125
+ const sheetId = sheetIdFromArg(args.sheet);
1126
+ const api = new SheetsApi(await getToken(args.key), sheetId);
1127
+ if (verb === "read") {
1128
+ const r = await verbRead(api, args.tab);
1129
+ if (json) console.log(JSON.stringify(r, null, 2));
1130
+ 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)); }
1131
+ return 0;
1132
+ }
1133
+ if (verb === "audit") {
1134
+ const a = await runAudit(api);
1135
+ if (json) console.log(JSON.stringify({ ok: a.ok, exitCode: a.ok ? 0 : 4, ...a }, null, 2)); else printChecks(a);
1136
+ return a.ok ? 0 : 4;
1137
+ }
1138
+ const plan = loadPlan(args.plan);
1139
+ if (verb === "plan-check") {
1140
+ const r = await verbPlanCheck(api, plan);
1141
+ if (json) console.log(JSON.stringify({ ok: true, exitCode: 0, ...r }, null, 2));
1142
+ else {
1143
+ console.log(`MF ${r.mf.map((f) => `${f.label} ${f.value}`).join(" · ")} = ${r.mfTotal} · high ×${r.highFactor} · rate $${r.rate}`);
1144
+ console.log(`T-Shirt: raw ${r.totals.rawDays} d → total ${r.totals.totalDays} d → $${r.totals.lowDollars} – $${r.totals.highDollars}`);
1145
+ console.log(r.table);
1146
+ }
1147
+ return 0;
1148
+ }
1149
+ if (verb === "write") {
1150
+ const r = await verbWrite(api, plan, { replace: !!args.replace, noAudit: !!args["no-audit"] });
1151
+ const ok = !r.audit || r.audit.ok;
1152
+ if (json) console.log(JSON.stringify({ ok, exitCode: ok ? 0 : 4, ...r }, null, 2));
1153
+ else {
1154
+ console.log(`T-Shirt (${r.tshirt.mode}): ${r.tshirt.itemsWritten} items, rows ${r.tshirt.firstItem1}–${r.tshirt.lastItem1}, totals row ${r.tshirt.totalRow1}; total ${r.tshirt.totals.totalDays} d, $${r.tshirt.totals.lowDollars} – $${r.tshirt.totals.highDollars}`);
1155
+ console.log(`Team Mix: ${r.teamMix.people} people, ${r.teamMix.months} months, ${r.teamMix.columns} columns, Σ Days ${r.teamMix.sumDays}`);
1156
+ console.log(r.teamMix.table);
1157
+ console.log(`Technology Stack: ${r.techStack.rows} rows`);
1158
+ if (r.audit) printChecks(r.audit);
1159
+ }
1160
+ return ok ? 0 : 4;
1161
+ }
1162
+ throw new Halt(`unknown verb '${verb}'\n${USAGE}`, 64);
1163
+ }
1164
+
1165
+ /** The frozen halt shape: a rejected main() is printed and exits non-zero. Never returns. */
1166
+ function haltAndExit(e, json) {
1167
+ const code = e instanceof Halt ? e.exitCode : 64;
1168
+ if (json) console.log(JSON.stringify({ ok: false, exitCode: code, error: e.message, details: e instanceof Halt ? e.details : undefined }, null, 2));
1169
+ else console.error(`HALT (${code}): ${e.message}`);
1170
+ process.exit(code);
1171
+ }
1172
+
1173
+ module.exports = {
1174
+ validatePlan, splitRoster, rosterViolations, mfCoverageViolations, monthPlan, resampleWeights, rampHours, buildRoster,
1175
+ tshirtTotals, itemFormulas, rollupFormulas, tshirtRows, teamMixValues, locateTshirt, findPhaseSource,
1176
+ auditTshirt, auditTeamMix, auditTechStack, auditOverview, colLetter, hexToColor, colorToHex, sheetIdFromArg,
1177
+ constants: { SIZE_CODES, PHASES, COLOR, RAMP, ROLE_LABEL, SOFT_CEILING, FOLD_THRESHOLD, TAB_TSHIRT, TAB_TEAM, TAB_TECH, PLAN_SCHEMA },
1178
+ Halt, SheetsApi, getToken, runAudit, main,
1179
+ };
1180
+
1181
+ if (require.main === module) {
1182
+ const args = parseArgs(process.argv.slice(2));
1183
+ main(args).then((code) => process.exit(code), (e) => haltAndExit(e, !!args.json));
1184
+ }