@tekyzinc/gsd-t 5.20.12 → 5.20.14
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 +42 -0
- package/README.md +1 -1
- package/bin/gsd-t-estimate-sheet.cjs +388 -88
- package/package.json +1 -1
- package/templates/estimate-sheet-spec.md +17 -5
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,48 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to GSD-T are documented here. Updated with each release.
|
|
4
4
|
|
|
5
|
+
## [5.20.14] - 2026-09-21
|
|
6
|
+
|
|
7
|
+
### Added — `gsd-t estimate-sheet teammix` rebuilds any estimate's Team Mix; the reader handles both template layouts
|
|
8
|
+
|
|
9
|
+
Reviewing the 19 estimates linked from the Hilo index found ten on an older T-Shirt layout
|
|
10
|
+
(Project/Client rows on top, legend from row 8, up to four size columns, header row 17) and an
|
|
11
|
+
older Team Mix model (Count = peak-month hours ÷ 160). Five estimates have more than one phase
|
|
12
|
+
and needed the per-phase Team Mix.
|
|
13
|
+
|
|
14
|
+
- `bin/gsd-t-estimate-sheet.cjs`: `locateTshirt` finds every header cell BY LABEL and returns a
|
|
15
|
+
column map; item/rollup formulas and the audit are generated from the map. New `teammix` verb:
|
|
16
|
+
derives the roster from the sheet's existing grid (entered Counts summed per discipline; a
|
|
17
|
+
peak-utilisation roster splits the sheet's total FTE by each role's hours), reads phase
|
|
18
|
+
Low/High hours from the rollups, and rewrites one grid per phase; `--fte` overrides, `--dry-run`
|
|
19
|
+
previews, unmappable roles halt. Disciplines `design` and `mobile` added. `write` refuses the
|
|
20
|
+
older layout by name. Fold rule now checks a full-timer still fits under 172 hrs. 429 rate
|
|
21
|
+
limits back off and retry the identical request (3×), then halt.
|
|
22
|
+
- Applied to the five multi-phase Hilo estimates (Acron, Role-Based Permissions, Part 141 & 61,
|
|
23
|
+
Part 121, Aeroplanned); all Team Mix checks pass on each. Aeroplanned had no PM/BA row — added
|
|
24
|
+
at 0.20 / 0.10.
|
|
25
|
+
- `test/estimate-sheet-writer.test.js`: older-layout reader, roster derivation (both models),
|
|
26
|
+
fold-vs-ceiling.
|
|
27
|
+
- `format` verb: normalises a T-Shirt tab's formatting only — section rows to the spec blue
|
|
28
|
+
(unmerging each sheet's own merge range first), blank rows before the totals row deleted, the
|
|
29
|
+
grey bold totals band, and the standard summary block (bold labels, Total Cost in dollars)
|
|
30
|
+
rewritten directly under it; recognises the four old summary variants and inserts rows when
|
|
31
|
+
another table sits close below. Applied to all 19 Hilo estimates; every totals/summary check
|
|
32
|
+
now passes on all 19.
|
|
33
|
+
|
|
34
|
+
## [5.20.13] - 2026-09-21
|
|
35
|
+
|
|
36
|
+
### Fixed — Team Mix staffs the midpoint of Low and High, not Low
|
|
37
|
+
|
|
38
|
+
David's correction on the first live sheet. The Team Mix was sized to the Low figure;
|
|
39
|
+
Tekyz staffs the midpoint of the Low and High estimates.
|
|
40
|
+
|
|
41
|
+
- `bin/gsd-t-estimate-sheet.cjs`: `midDays = low × (1 + high factor) / 2`; each phase grid is
|
|
42
|
+
built and audited against `(Low Hrs + High Hrs) ÷ 2 ÷ 8` from that phase's rollups; the
|
|
43
|
+
grids together against the midpoint of `Total Days` and `Total Days × high factor`.
|
|
44
|
+
- `templates/estimate-sheet-spec.md` §2.2 / §2.6 / §5; tests updated; live write on the E2E
|
|
45
|
+
copy: 3 phase grids, all audit checks pass.
|
|
46
|
+
|
|
5
47
|
## [5.20.12] - 2026-09-21
|
|
6
48
|
|
|
7
49
|
### Fixed — estimate writer proven end-to-end; two-month remainder formula
|
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# GSD-T: Contract-Driven Development for Claude Code
|
|
2
2
|
|
|
3
|
-
**v5.20.
|
|
3
|
+
**v5.20.14** - A methodology for reliable, parallelizable development using Claude Code with optional Agent Teams support.
|
|
4
4
|
|
|
5
5
|
**Eliminates context rot** — task-level fresh dispatch (one subagent per task, ~10-20% context each) means compaction never triggers.
|
|
6
6
|
**Compaction-proof debug loops** — `gsd-t headless --debug-loop` runs test-fix-retest cycles as separate `claude -p` sessions. A JSONL debug ledger persists all hypothesis/fix/learning history across fresh sessions. Anti-repetition preamble injection prevents retrying failed hypotheses. Escalation tiers (sonnet → opus → human) and a hard iteration ceiling enforced externally.
|
|
@@ -23,6 +23,8 @@
|
|
|
23
23
|
* read --sheet <id|url> [--tab <name>] dump layout (read-before-write)
|
|
24
24
|
* plan-check --sheet <id|url> --plan <plan.json> validate plan, print the roster it WOULD write
|
|
25
25
|
* write --sheet <id|url> --plan <plan.json> [--replace] write T-Shirt + Team Mix + Tech Stack, then audit
|
|
26
|
+
* teammix --sheet <id|url> [--fte <json>] [--dry-run] rebuild the Team Mix (one grid per phase) from the sheet's own roster + rollups
|
|
27
|
+
* format --sheet <id|url> [--dry-run] normalise T-Shirt formatting only (section rows, totals band, summary block); values untouched
|
|
26
28
|
* audit --sheet <id|url> the spec §5 checklist, by read-back
|
|
27
29
|
* plan-schema print the plan shape
|
|
28
30
|
* Flags: --json (envelope only) --key <path> --no-audit (write only; for debugging)
|
|
@@ -74,6 +76,8 @@ const RAMP = {
|
|
|
74
76
|
devops: [0.45, 0.85, 1.7],
|
|
75
77
|
pm: [1, 1, 1],
|
|
76
78
|
techlead: [1.3, 0.8, 0.9],
|
|
79
|
+
design: [1.6, 0.9, 0.5], // Design / UX: heaviest at the start, a tail for revisions
|
|
80
|
+
mobile: [1.35, 1.05, 0.6], // same shape as frontend
|
|
77
81
|
};
|
|
78
82
|
|
|
79
83
|
const ROLE_LABEL = {
|
|
@@ -84,6 +88,8 @@ const ROLE_LABEL = {
|
|
|
84
88
|
ba: "Business Analyst",
|
|
85
89
|
devops: "DevOps Engineer",
|
|
86
90
|
techlead: "Tech Lead / Architect",
|
|
91
|
+
design: "Design / UX",
|
|
92
|
+
mobile: "Mobile Engineer",
|
|
87
93
|
};
|
|
88
94
|
|
|
89
95
|
// MF factor label → the discipline that must staff it (spec §2.4). Factors with
|
|
@@ -186,7 +192,7 @@ class SheetsApi {
|
|
|
186
192
|
this.base = `https://sheets.googleapis.com/v4/spreadsheets/${sheetId}`;
|
|
187
193
|
}
|
|
188
194
|
|
|
189
|
-
async call(method, url, body) {
|
|
195
|
+
async call(method, url, body, attempt = 1) {
|
|
190
196
|
// Spec §0: NEVER send X-Goog-User-Project — on these sheets it CAUSES a 403.
|
|
191
197
|
const res = await fetch(url, {
|
|
192
198
|
method,
|
|
@@ -194,6 +200,15 @@ class SheetsApi {
|
|
|
194
200
|
body: body == null ? undefined : JSON.stringify(body),
|
|
195
201
|
});
|
|
196
202
|
const text = await res.text();
|
|
203
|
+
if (res.status === 429 && attempt <= 3) {
|
|
204
|
+
// Rate limit (60 reads/min/user — hit 2026-09-21 auditing 19 sheets in a row). This is
|
|
205
|
+
// flow control, not a masked failure: the same request is re-sent unchanged after the
|
|
206
|
+
// documented wait, and the 4th 429 halts.
|
|
207
|
+
const wait = 20000 * attempt;
|
|
208
|
+
console.error(` … Sheets API 429 (rate limit) — waiting ${wait / 1000}s, retry ${attempt}/3`);
|
|
209
|
+
await new Promise((r) => setTimeout(r, wait));
|
|
210
|
+
return this.call(method, url, body, attempt + 1);
|
|
211
|
+
}
|
|
197
212
|
if (!res.ok) {
|
|
198
213
|
const short = text.slice(0, 400);
|
|
199
214
|
if (res.status === 403) {
|
|
@@ -300,54 +315,100 @@ function rowIsEmpty(grid, r, cols = 12) {
|
|
|
300
315
|
return true;
|
|
301
316
|
}
|
|
302
317
|
|
|
303
|
-
/**
|
|
304
|
-
function
|
|
305
|
-
|
|
306
|
-
for (let r = 0; r < Math.min(rowCount(grid),
|
|
307
|
-
if (
|
|
318
|
+
/** Find the first cell whose text equals `label` (case-insensitive, trimmed) in the top `maxRows` rows. */
|
|
319
|
+
function findCell(grid, label, maxRows = 25, maxCols = 20) {
|
|
320
|
+
const want = label.toLowerCase();
|
|
321
|
+
for (let r = 0; r < Math.min(rowCount(grid), maxRows); r++) {
|
|
322
|
+
for (let c = 0; c < maxCols; c++) if (textAt(grid, r, c).trim().toLowerCase() === want) return { r, c };
|
|
308
323
|
}
|
|
309
|
-
|
|
324
|
+
return null;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* Locate the T-Shirt tab's header block BY LABEL, not by fixed row (spec §1.1). Two layouts
|
|
329
|
+
* are in the wild: the current template (Date row 1, legend A4:B9, header row 13, two size
|
|
330
|
+
* columns) and an older one (Project/Client/Date rows 3–5, legend A8:B13, header row 17, up
|
|
331
|
+
* to four size columns). Everything the writer and audit need is returned as a column map.
|
|
332
|
+
* HALTS on anything it cannot find — never guesses a coordinate.
|
|
333
|
+
*/
|
|
334
|
+
function locateTshirt(grid) {
|
|
335
|
+
const hdr = findCell(grid, "Module/Functionality", 40, 3);
|
|
336
|
+
if (!hdr || hdr.c !== 0) throw new Halt(`${TAB_TSHIRT}: column header row ('Module/Functionality' in A) not found — is this the Tekyz template?`);
|
|
337
|
+
const headerRow = hdr.r;
|
|
338
|
+
const headerText = [];
|
|
339
|
+
for (let c = 0; c < 20; c++) headerText.push(textAt(grid, headerRow, c).trim());
|
|
340
|
+
const colOf = (label) => { const c = headerText.findIndex((x) => x.toLowerCase() === label.toLowerCase()); if (c < 0) throw new Halt(`${TAB_TSHIRT}: header row ${headerRow + 1} has no '${label}' column`); return c; };
|
|
341
|
+
const cols = { phase: colOf("Phase"), days: colOf("Days"), mfactor: colOf("MFactor Days"), total: colOf("Total Days"), low: colOf("LOW $"), high: colOf("HIGH $") };
|
|
342
|
+
cols.sizes = [];
|
|
343
|
+
for (let c = cols.phase + 1; c < cols.days; c++) cols.sizes.push(c);
|
|
344
|
+
if (!cols.sizes.length) throw new Halt(`${TAB_TSHIRT}: no size columns between 'Phase' and 'Days'`);
|
|
345
|
+
cols.sizeLabels = cols.sizes.map((c) => headerText[c]);
|
|
346
|
+
// legend: the XS..XXL rows in column A
|
|
310
347
|
const legend = {};
|
|
311
|
-
|
|
348
|
+
let legendFirst = -1, legendLast = -1;
|
|
349
|
+
for (let r = 0; r < headerRow; r++) {
|
|
312
350
|
const m = textAt(grid, r, 0).trim().match(/^(XS|S|M|L|XL|XXL)\s*-/);
|
|
313
|
-
if (m) legend[m[1]] = numAt(grid, r, 1);
|
|
351
|
+
if (m) { legend[m[1]] = numAt(grid, r, 1); if (legendFirst < 0) legendFirst = r; legendLast = r; }
|
|
314
352
|
}
|
|
315
353
|
for (const code of SIZE_CODES) {
|
|
316
|
-
if (!(code in legend) || Number.isNaN(legend[code])) throw new Halt(`${TAB_TSHIRT}: size legend missing '${code}' in
|
|
354
|
+
if (!(code in legend) || Number.isNaN(legend[code])) throw new Halt(`${TAB_TSHIRT}: size legend missing '${code}' (looked for 'XS - …' labels in column A above the header row)`);
|
|
317
355
|
}
|
|
356
|
+
// MF list: under 'Multiplication Factor' down to 'Total MF'
|
|
357
|
+
const mfHdr = findCell(grid, "Multiplication Factor", headerRow, 12);
|
|
358
|
+
if (!mfHdr) throw new Halt(`${TAB_TSHIRT}: 'Multiplication Factor' header not found`);
|
|
318
359
|
const mf = [];
|
|
319
360
|
let mfTotalCell = null;
|
|
320
|
-
for (let r =
|
|
321
|
-
const label = textAt(grid, r,
|
|
361
|
+
for (let r = mfHdr.r + 1; r < headerRow; r++) {
|
|
362
|
+
const label = textAt(grid, r, mfHdr.c).trim();
|
|
322
363
|
if (!label) continue;
|
|
323
|
-
if (/^Total MF$/i.test(label)) { mfTotalCell = { r, c:
|
|
324
|
-
|
|
325
|
-
const v = numAt(grid, r, 5);
|
|
364
|
+
if (/^Total MF$/i.test(label)) { mfTotalCell = { r, c: mfHdr.c + 1, value: numAt(grid, r, mfHdr.c + 1) }; break; }
|
|
365
|
+
const v = numAt(grid, r, mfHdr.c + 1);
|
|
326
366
|
if (!Number.isNaN(v)) mf.push({ label, value: v, row: r + 1 });
|
|
327
367
|
}
|
|
328
|
-
if (!mf.length) throw new Halt(`${TAB_TSHIRT}: multiplication-factor list (
|
|
329
|
-
if (!mfTotalCell) throw new Halt(`${TAB_TSHIRT}: 'Total MF' cell not found`);
|
|
368
|
+
if (!mf.length) throw new Halt(`${TAB_TSHIRT}: multiplication-factor list under '${colLetter(mfHdr.c)}${mfHdr.r + 1}' is empty`);
|
|
369
|
+
if (!mfTotalCell) throw new Halt(`${TAB_TSHIRT}: 'Total MF' cell not found under the MF list`);
|
|
330
370
|
const mfTotal = mf.reduce((s, f) => s + f.value, 0);
|
|
331
|
-
const
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
if (Number.isNaN(
|
|
371
|
+
const hfHdr = findCell(grid, "High $ Factor", headerRow, 14);
|
|
372
|
+
if (!hfHdr) throw new Halt(`${TAB_TSHIRT}: 'High $ Factor' header not found`);
|
|
373
|
+
const highFactor = numAt(grid, hfHdr.r + 1, hfHdr.c);
|
|
374
|
+
if (Number.isNaN(highFactor)) throw new Halt(`${TAB_TSHIRT}: High $ Factor (${colLetter(hfHdr.c)}${hfHdr.r + 2}) is not a number`);
|
|
375
|
+
const rateHdr = findCell(grid, "Avg. Hrly Rate", headerRow, 14);
|
|
376
|
+
if (!rateHdr) throw new Halt(`${TAB_TSHIRT}: 'Avg. Hrly Rate' header not found`);
|
|
377
|
+
const rate = numAt(grid, rateHdr.r + 1, rateHdr.c);
|
|
378
|
+
if (Number.isNaN(rate)) throw new Halt(`${TAB_TSHIRT}: Avg. Hrly Rate (${colLetter(rateHdr.c)}${rateHdr.r + 2}) is not a number`);
|
|
379
|
+
cols.highFactor = { r: hfHdr.r + 1, c: hfHdr.c };
|
|
380
|
+
cols.rate = { r: rateHdr.r + 1, c: rateHdr.c };
|
|
381
|
+
cols.mfTotal = { r: mfTotalCell.r, c: mfTotalCell.c };
|
|
382
|
+
// phase rollups: the column holding 'MVP' above the header; Low/High Hrs columns from their headers
|
|
383
|
+
const mvp = findCell(grid, "MVP", headerRow, 16);
|
|
384
|
+
if (!mvp) throw new Halt(`${TAB_TSHIRT}: phase rollup block ('MVP' label above the header row) not found`);
|
|
335
385
|
const rollupRows = [];
|
|
336
|
-
for (let r = 0; r <
|
|
337
|
-
if (rollupRows.length !== 4) throw new Halt(`${TAB_TSHIRT}: expected 4 phase rollup rows in column
|
|
338
|
-
|
|
386
|
+
for (let r = 0; r < headerRow; r++) if (PHASES.includes(textAt(grid, r, mvp.c).trim())) rollupRows.push(r);
|
|
387
|
+
if (rollupRows.length !== 4) throw new Halt(`${TAB_TSHIRT}: expected 4 phase rollup rows in column ${colLetter(mvp.c)} (MVP, Phase 1–3), found ${rollupRows.length}`);
|
|
388
|
+
const lowHrs = findCell(grid, "Low Hrs", headerRow, 20), highHrs = findCell(grid, "High Hrs", headerRow, 20);
|
|
389
|
+
const lowDol = findCell(grid, "Low ($)", headerRow, 20), highDol = findCell(grid, "High ($)", headerRow, 20);
|
|
390
|
+
if (!lowHrs || !highHrs || !lowDol || !highDol) throw new Halt(`${TAB_TSHIRT}: rollup headers 'Low ($)' / 'Low Hrs' / 'High ($)' / 'High Hrs' not all found`);
|
|
391
|
+
cols.rollupPhase = mvp.c; cols.lowHrs = lowHrs.c; cols.highHrs = highHrs.c; cols.lowDol = lowDol.c; cols.highDol = highDol.c;
|
|
392
|
+
return { headerRow, firstItemRow: headerRow + 1, legend, legendFirst1: legendFirst + 1, legendLast1: legendLast + 1, mf, mfTotal, mfTotalCell, highFactor, rate, rollupRows, cols };
|
|
339
393
|
}
|
|
340
394
|
|
|
341
|
-
/**
|
|
342
|
-
|
|
395
|
+
/** The standard template's column map (what `write` produces). */
|
|
396
|
+
const STANDARD_COLS = { phase: 4, sizes: [5, 6], days: 7, mfactor: 8, total: 9, low: 10, high: 11, rollupPhase: 9, lowHrs: 11, highHrs: 13, lowDol: 10, highDol: 12, highFactor: { r: 3, c: 6 }, rate: { r: 3, c: 7 }, mfTotal: { r: 9, c: 5 } };
|
|
397
|
+
const STANDARD_LEGEND = { first1: 4, last1: 9 };
|
|
398
|
+
|
|
399
|
+
/** Row index of a cell in the Phase column with a ONE_OF_LIST validation — the Phase dropdown source; -1 if none. */
|
|
400
|
+
function findPhaseSourceIn(grid, fromRow, phaseCol) {
|
|
343
401
|
for (let r = fromRow; r < rowCount(grid); r++) {
|
|
344
|
-
const cell = cellAt(grid, r,
|
|
402
|
+
const cell = cellAt(grid, r, phaseCol);
|
|
345
403
|
const dv = cell && cell.dataValidation;
|
|
346
404
|
if (dv && dv.condition && dv.condition.type === "ONE_OF_LIST") return r;
|
|
347
405
|
}
|
|
348
406
|
return -1;
|
|
349
407
|
}
|
|
350
408
|
|
|
409
|
+
/** Row index of a cell in column E with a ONE_OF_LIST validation — the Phase dropdown source; -1 if none. */
|
|
410
|
+
function findPhaseSource(grid, fromRow) { return findPhaseSourceIn(grid, fromRow, 4); }
|
|
411
|
+
|
|
351
412
|
// ───────────────────────── plan validation (pure) ─────────────────────────
|
|
352
413
|
|
|
353
414
|
const PLAN_SCHEMA = {
|
|
@@ -478,7 +539,11 @@ function monthPlan(totalDays, people) {
|
|
|
478
539
|
const months = totalDays / (sumCount * 20);
|
|
479
540
|
const whole = Math.floor(months);
|
|
480
541
|
const frac = months - whole;
|
|
481
|
-
|
|
542
|
+
// Fold a small tail into the last full month ONLY if a full-time person still fits under the
|
|
543
|
+
// soft ceiling: months × 160 ≤ whole × 172. (Acron, 2026-09-21: 1.098 months folded to one
|
|
544
|
+
// column put Backend 1 at 175.7 hrs — over the ceiling — so the fold must check the hours.)
|
|
545
|
+
const fits = months * 160 <= whole * SOFT_CEILING + 1e-9;
|
|
546
|
+
const n = whole >= 1 && frac < FOLD_THRESHOLD && fits ? whole : Math.ceil(months);
|
|
482
547
|
return { months, sumCount, n: Math.max(1, n) };
|
|
483
548
|
}
|
|
484
549
|
|
|
@@ -554,25 +619,39 @@ function tshirtTotals(plan, layout) {
|
|
|
554
619
|
|
|
555
620
|
// ───────────────────────── T-Shirt formulas (pure, spec §1.2–1.3) ─────────────────────────
|
|
556
621
|
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
622
|
+
/** Item-row formulas for any column map (spec §1.2). Keys are the STANDARD letters H..L; `cells` carries the real columns. */
|
|
623
|
+
function itemFormulasFor(r, cols, legend) {
|
|
624
|
+
const L = colLetter;
|
|
625
|
+
const lg = `$A$${legend.first1}:$A$${legend.last1}`, lv = `$B$${legend.first1}:$B$${legend.last1}`;
|
|
626
|
+
const days = "=(" + cols.sizes.map((c) => `IF(${L(c)}${r}="",0,SUMIF(${lg},LEFT(${L(c)}${r},2)&"*",${lv}))`).join("+") + ")";
|
|
627
|
+
const abs = (cell) => `$${L(cell.c)}$${cell.r + 1}`;
|
|
628
|
+
const f = {
|
|
629
|
+
H: days,
|
|
630
|
+
I: `=${L(cols.days)}${r}*${abs(cols.mfTotal)}`,
|
|
631
|
+
J: `=${L(cols.days)}${r}+${L(cols.mfactor)}${r}`,
|
|
632
|
+
K: `=${L(cols.total)}${r}*8*${abs(cols.rate)}`,
|
|
633
|
+
L: `=${L(cols.low)}${r}*${abs(cols.highFactor)}`,
|
|
564
634
|
};
|
|
635
|
+
f.cells = [[cols.days, f.H], [cols.mfactor, f.I], [cols.total, f.J], [cols.low, f.K], [cols.high, f.L]];
|
|
636
|
+
return f;
|
|
565
637
|
}
|
|
566
638
|
|
|
567
|
-
|
|
568
|
-
|
|
639
|
+
/** The standard template's item formulas (what `write` produces). */
|
|
640
|
+
function itemFormulas(r) { return itemFormulasFor(r, STANDARD_COLS, STANDARD_LEGEND); }
|
|
641
|
+
|
|
642
|
+
function rollupFormulasFor(rollupRow1, firstRow1, lastRow1, cols) {
|
|
643
|
+
const L = colLetter;
|
|
644
|
+
const e = `$${L(cols.phase)}$${firstRow1}:$${L(cols.phase)}$${lastRow1}`;
|
|
645
|
+
const ph = `$${L(cols.rollupPhase)}${rollupRow1}`;
|
|
646
|
+
const rate = `$${L(cols.rate.c)}$${cols.rate.r + 1}`;
|
|
569
647
|
return [
|
|
570
|
-
`=SUMIF(${e},$
|
|
571
|
-
`=SUMIF(${e},$
|
|
572
|
-
`=SUMIF(${e},$
|
|
573
|
-
`=IF($
|
|
648
|
+
`=SUMIF(${e},${ph},$${L(cols.low)}$${firstRow1}:$${L(cols.low)}$${lastRow1})`,
|
|
649
|
+
`=SUMIF(${e},${ph},$${L(cols.total)}$${firstRow1}:$${L(cols.total)}$${lastRow1})*8`,
|
|
650
|
+
`=SUMIF(${e},${ph},$${L(cols.high)}$${firstRow1}:$${L(cols.high)}$${lastRow1})`,
|
|
651
|
+
`=IF(${rate}=0,0,${L(cols.highDol)}${rollupRow1}/${rate})`,
|
|
574
652
|
];
|
|
575
653
|
}
|
|
654
|
+
function rollupFormulas(rollupRow1, firstRow1, lastRow1) { return rollupFormulasFor(rollupRow1, firstRow1, lastRow1, STANDARD_COLS); }
|
|
576
655
|
|
|
577
656
|
/** Rows for items mode, starting at firstRow0 (0-based). */
|
|
578
657
|
function tshirtRows(plan, firstRow0) {
|
|
@@ -631,6 +710,8 @@ async function writeTshirt(api, plan, opts) {
|
|
|
631
710
|
const grid = await api.grid(TAB_TSHIRT);
|
|
632
711
|
const sheetId = grid.properties.sheetId;
|
|
633
712
|
const layout = locateTshirt(grid);
|
|
713
|
+
const std = ["phase", "days", "mfactor", "total", "low", "high"].every((k) => layout.cols[k] === STANDARD_COLS[k]) && layout.cols.sizes.length === 2 && layout.legendFirst1 === 4 && layout.headerRow === 12;
|
|
714
|
+
if (!std) throw new Halt(`${TAB_TSHIRT}: this sheet is not the current template layout (header row ${layout.headerRow + 1}, size columns ${layout.cols.sizeLabels.join("/")}) — 'write' supports the current template only; 'teammix' and 'audit' work on both`);
|
|
634
715
|
const totals = tshirtTotals(plan, layout);
|
|
635
716
|
const phaseSrc = findPhaseSource(grid, 0);
|
|
636
717
|
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`);
|
|
@@ -740,13 +821,19 @@ async function writeTshirtSizes(api, grid, sheetId, layout, plan, totals, phaseS
|
|
|
740
821
|
|
|
741
822
|
// ───────────────────────── write: Team Mix (spec §2) ─────────────────────────
|
|
742
823
|
|
|
743
|
-
/**
|
|
824
|
+
/** Team Mix staffs the MIDPOINT of the Low and High figures (David, 2026-09-21): mid = low × (1 + highFactor) / 2. */
|
|
825
|
+
function midDays(lowDays, highFactor) { return round2(lowDays * (1 + highFactor) / 2); }
|
|
826
|
+
|
|
827
|
+
/** Per-phase T-Shirt totals in PHASES order — only phases with hours > 0 (spec §2.6). `totalDays` is the Low figure; `staffDays` the midpoint the Team Mix staffs. */
|
|
744
828
|
function phaseTotals(plan, layout) {
|
|
745
829
|
const raw = {};
|
|
746
830
|
for (const s of plan.tshirt.sections) for (const it of s.items) {
|
|
747
831
|
raw[it.phase] = (raw[it.phase] ? raw[it.phase] : 0) + sizeDays(it.fe, layout.legend) + sizeDays(it.be, layout.legend);
|
|
748
832
|
}
|
|
749
|
-
return PHASES.filter((ph) => raw[ph] > 0).map((ph) =>
|
|
833
|
+
return PHASES.filter((ph) => raw[ph] > 0).map((ph) => {
|
|
834
|
+
const low = round2(raw[ph] * (1 + layout.mfTotal));
|
|
835
|
+
return { phase: ph, rawDays: round2(raw[ph]), totalDays: low, highDays: round2(low * layout.highFactor), staffDays: midDays(low, layout.highFactor) };
|
|
836
|
+
});
|
|
750
837
|
}
|
|
751
838
|
|
|
752
839
|
/** One roster per phase with hours. FTE comes from teamMix.phases[<phase>].fte when given, else teamMix.fte. */
|
|
@@ -756,7 +843,7 @@ function buildRosters(plan, layout) {
|
|
|
756
843
|
return phases.map((ph) => {
|
|
757
844
|
const override = plan.teamMix.phases && plan.teamMix.phases[ph.phase] && plan.teamMix.phases[ph.phase].fte;
|
|
758
845
|
const sub = override ? { ...plan, teamMix: { ...plan.teamMix, fte: override } } : plan;
|
|
759
|
-
return { ...ph, roster: buildRoster(sub, ph.
|
|
846
|
+
return { ...ph, roster: buildRoster(sub, ph.staffDays, layout.mf) };
|
|
760
847
|
});
|
|
761
848
|
}
|
|
762
849
|
|
|
@@ -891,89 +978,95 @@ async function writeTechStack(api, plan) {
|
|
|
891
978
|
|
|
892
979
|
function check(list, name, ok, detail) { list.push({ check: name, ok: !!ok, detail: ok ? "" : String(detail == null ? "" : detail) }); }
|
|
893
980
|
|
|
894
|
-
/** T-Shirt audit
|
|
981
|
+
/** T-Shirt audit — layout-aware via locateTshirt's column map. HALTS if the header block is missing. */
|
|
895
982
|
function auditTshirt(grid) {
|
|
896
983
|
const out = [];
|
|
897
984
|
const layout = locateTshirt(grid);
|
|
985
|
+
const { cols } = layout;
|
|
986
|
+
const legendRows = { first1: layout.legendFirst1, last1: layout.legendLast1 };
|
|
898
987
|
const first0 = layout.firstItemRow;
|
|
899
988
|
const badSizes = [], noDv = [], badFormula = [], sectionsWithSizes = [], badSectionFmt = [];
|
|
900
989
|
let lastItem0 = -1, totalRow0 = -1;
|
|
901
990
|
for (let r = first0; r < rowCount(grid); r++) {
|
|
902
991
|
const a = textAt(grid, r, 0);
|
|
903
992
|
if (/^Total \(Days\)/i.test(a)) { totalRow0 = r; break; }
|
|
904
|
-
if (rowIsEmpty(grid, r)) continue;
|
|
905
|
-
const isSection = a !== "" && textAt(grid, r, 2) === "" && textAt(grid, r,
|
|
993
|
+
if (rowIsEmpty(grid, r, cols.high + 1)) continue;
|
|
994
|
+
const isSection = a !== "" && textAt(grid, r, 2) === "" && textAt(grid, r, cols.days) === "";
|
|
906
995
|
if (isSection) {
|
|
907
|
-
|
|
908
|
-
const beTxt = textAt(grid, r, 6);
|
|
909
|
-
if (feTxt !== "" || beTxt !== "") sectionsWithSizes.push(r + 1);
|
|
996
|
+
if (cols.sizes.some((c) => textAt(grid, r, c) !== "")) sectionsWithSizes.push(r + 1);
|
|
910
997
|
if (bgAt(grid, r, 0) !== COLOR.sectionBg) badSectionFmt.push(`row ${r + 1} bg ${bgAt(grid, r, 0)}`);
|
|
911
998
|
continue;
|
|
912
999
|
}
|
|
913
1000
|
lastItem0 = r;
|
|
914
|
-
for (const c of
|
|
1001
|
+
for (const c of cols.sizes) {
|
|
915
1002
|
const v = textAt(grid, r, c).trim();
|
|
916
1003
|
if (v && !SIZE_CODES.includes(v)) badSizes.push(`${colLetter(c)}${r + 1}='${v}'`);
|
|
917
1004
|
}
|
|
918
|
-
const cell = cellAt(grid, r,
|
|
919
|
-
if (!(cell && cell.dataValidation && cell.dataValidation.condition && cell.dataValidation.condition.type === "ONE_OF_LIST")) noDv.push(
|
|
920
|
-
const f =
|
|
921
|
-
|
|
1005
|
+
const cell = cellAt(grid, r, cols.phase);
|
|
1006
|
+
if (!(cell && cell.dataValidation && cell.dataValidation.condition && cell.dataValidation.condition.type === "ONE_OF_LIST")) noDv.push(`${colLetter(cols.phase)}${r + 1}`);
|
|
1007
|
+
const f = itemFormulasFor(r + 1, cols, legendRows);
|
|
1008
|
+
for (const [c, want] of f.cells) if (formulaAt(grid, r, c) !== want) badFormula.push(`${colLetter(c)}${r + 1}`);
|
|
922
1009
|
}
|
|
923
1010
|
check(out, "T-Shirt: at least one item row", lastItem0 >= 0, "no item rows below the header");
|
|
924
|
-
check(out, "T-Shirt: size cells are bare codes (no legend text)", !badSizes.length, badSizes.join(", "));
|
|
925
|
-
check(out, "T-Shirt: every item row has the Phase dropdown", !noDv.length, noDv.join(", "));
|
|
926
|
-
check(out, "T-Shirt:
|
|
1011
|
+
check(out, "T-Shirt: size cells are bare codes (no legend text, no '-')", !badSizes.length, badSizes.slice(0, 10).join(", "));
|
|
1012
|
+
check(out, "T-Shirt: every item row has the Phase dropdown", !noDv.length, noDv.slice(0, 10).join(", "));
|
|
1013
|
+
check(out, "T-Shirt: Days..HIGH $ are the spec formulas on every item row", !badFormula.length, badFormula.slice(0, 12).join(", "));
|
|
927
1014
|
check(out, "T-Shirt: section rows carry no sizes", !sectionsWithSizes.length, `rows ${sectionsWithSizes.join(", ")}`);
|
|
928
|
-
check(out, "T-Shirt: section rows are styled (bg #1C4F8B)", !badSectionFmt.length, badSectionFmt.join(", "));
|
|
1015
|
+
check(out, "T-Shirt: section rows are styled (bg #1C4F8B)", !badSectionFmt.length, badSectionFmt.slice(0, 6).join(", "));
|
|
929
1016
|
check(out, "T-Shirt: 'Total (Days)' row exists below the items", totalRow0 > lastItem0 && lastItem0 >= 0, "not found");
|
|
930
1017
|
let totalDaysCell = NaN;
|
|
931
1018
|
const phaseDays = {};
|
|
932
1019
|
for (const rr of layout.rollupRows) {
|
|
933
|
-
const
|
|
934
|
-
phaseDays[textAt(grid, rr,
|
|
1020
|
+
const low = numAt(grid, rr, cols.lowHrs), high = numAt(grid, rr, cols.highHrs);
|
|
1021
|
+
phaseDays[textAt(grid, rr, cols.rollupPhase).trim()] = Number.isNaN(low) || Number.isNaN(high) ? NaN : round2((low + high) / 2 / 8);
|
|
935
1022
|
}
|
|
936
1023
|
if (totalRow0 > lastItem0 && lastItem0 >= 0) {
|
|
937
1024
|
check(out, "T-Shirt: totals row sits directly under the last item (no blank row)", totalRow0 === lastItem0 + 1, `last item row ${lastItem0 + 1}, totals row ${totalRow0 + 1}`);
|
|
938
1025
|
const totFont = fontAt(grid, totalRow0, 0);
|
|
939
|
-
check(out, "T-Shirt: totals row is the template band (bg #D8DDE8, bold) across
|
|
1026
|
+
check(out, "T-Shirt: totals row is the template band (bg #D8DDE8, bold) across the table", [0, cols.phase, cols.total, cols.high].every((c) => bgAt(grid, totalRow0, c) === COLOR.tshirtTotalBg) && totFont.bold, `A bg ${bgAt(grid, totalRow0, 0)}, ${colLetter(cols.high)} bg ${bgAt(grid, totalRow0, cols.high)}, bold ${totFont.bold}`);
|
|
1027
|
+
const want = `=SUM(${colLetter(cols.total)}${first0 + 1}:${colLetter(cols.total)}${lastItem0 + 1})`;
|
|
1028
|
+
check(out, "T-Shirt: totals row sums the full item range", formulaAt(grid, totalRow0, cols.total) === want, `${colLetter(cols.total)}${totalRow0 + 1} is '${formulaAt(grid, totalRow0, cols.total)}', want '${want}'`);
|
|
1029
|
+
totalDaysCell = numAt(grid, totalRow0, cols.total);
|
|
1030
|
+
// summary block: the three labels directly under the totals row, anywhere in the row
|
|
1031
|
+
const findLabel = (r) => { for (let c = 0; c < cols.high + 1; c++) { const v = textAt(grid, r, c).trim(); if (/^Total (Days|Hrs|Cost)$/i.test(v)) return { c, v }; } return null; };
|
|
940
1032
|
const sumRows = [totalRow0 + 1, totalRow0 + 2, totalRow0 + 3];
|
|
941
|
-
const labels = sumRows.map(
|
|
942
|
-
check(out, "T-Shirt: summary block (Total Days / Total Hrs / Total Cost) sits directly under the totals row", labels[0] === "Total Days" && labels[1] === "Total Hrs" && labels[2] === "Total Cost", `
|
|
943
|
-
check(out, "T-Shirt: summary labels are bold",
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
check(out, "T-Shirt:
|
|
949
|
-
totalDaysCell = numAt(grid, totalRow0, 9);
|
|
1033
|
+
const labels = sumRows.map(findLabel);
|
|
1034
|
+
check(out, "T-Shirt: summary block (Total Days / Total Hrs / Total Cost) sits directly under the totals row", labels[0] && labels[0].v === "Total Days" && labels[1] && labels[1].v === "Total Hrs" && labels[2] && labels[2].v === "Total Cost", `rows ${sumRows[0] + 1}–${sumRows[2] + 1} = [${labels.map((l) => (l ? l.v : "")).join(", ")}]`);
|
|
1035
|
+
check(out, "T-Shirt: summary labels are bold", labels.every((l) => l && fontAt(grid, sumRows[labels.indexOf(l)], l.c).bold), "");
|
|
1036
|
+
if (labels[2]) {
|
|
1037
|
+
const costCell = cellAt(grid, sumRows[2], labels[2].c + 1);
|
|
1038
|
+
const costFmt = costCell && costCell.userEnteredFormat && costCell.userEnteredFormat.numberFormat;
|
|
1039
|
+
check(out, "T-Shirt: Total Cost is formatted as dollars", !!costFmt && costFmt.type === "CURRENCY", `${colLetter(labels[2].c + 1)}${sumRows[2] + 1} numberFormat ${JSON.stringify(costFmt)}`);
|
|
1040
|
+
} else check(out, "T-Shirt: Total Cost is formatted as dollars", false, "no Total Cost row");
|
|
950
1041
|
const badRoll = [];
|
|
951
1042
|
for (const rr of layout.rollupRows) {
|
|
952
|
-
if (formulaAt(grid, rr,
|
|
1043
|
+
if (formulaAt(grid, rr, cols.lowDol) !== rollupFormulasFor(rr + 1, first0 + 1, lastItem0 + 1, cols)[0]) badRoll.push(`${colLetter(cols.lowDol)}${rr + 1}`);
|
|
953
1044
|
}
|
|
954
|
-
check(out, "T-Shirt: phase rollups
|
|
1045
|
+
check(out, "T-Shirt: phase rollups reference the full item range", !badRoll.length, badRoll.join(", "));
|
|
955
1046
|
let summaryAbove = false;
|
|
956
|
-
for (let r = first0; r < totalRow0; r++) if (
|
|
1047
|
+
for (let r = first0; r < totalRow0; r++) if (findLabel(r)) summaryAbove = true;
|
|
957
1048
|
check(out, "T-Shirt: no summary label inside the summed range", !summaryAbove, "a Total Days/Hrs/Cost label sits inside the summed range");
|
|
958
1049
|
let raw = 0;
|
|
959
1050
|
for (let r = first0; r <= lastItem0; r++) {
|
|
960
|
-
for (const c of
|
|
1051
|
+
for (const c of cols.sizes) { const v = textAt(grid, r, c).trim(); if (v && layout.legend[v] != null) raw += layout.legend[v]; }
|
|
961
1052
|
}
|
|
962
1053
|
const recomputed = round2(raw * (1 + layout.mfTotal));
|
|
963
1054
|
check(out, "T-Shirt: Σ raw sizes × (1+MF) == Total Days cell", Math.abs(recomputed - totalDaysCell) < 0.02, `recomputed ${recomputed}, cell ${totalDaysCell}`);
|
|
964
1055
|
}
|
|
965
|
-
return { checks: out, layout, totalDays: totalDaysCell, lastItem1: lastItem0 + 1, phaseDays };
|
|
1056
|
+
return { checks: out, layout, totalDays: totalDaysCell, staffDays: Number.isNaN(totalDaysCell) ? NaN : midDays(totalDaysCell, layout.highFactor), lastItem1: lastItem0 + 1, phaseDays };
|
|
966
1057
|
}
|
|
967
1058
|
|
|
968
1059
|
function disciplineOfLabel(label) {
|
|
969
1060
|
const l = label.toLowerCase();
|
|
970
1061
|
if (/qa|test/.test(l)) return "qa";
|
|
971
|
-
if (/project
|
|
972
|
-
if (/
|
|
1062
|
+
if (/project m|\bpm\b/.test(l)) return "pm";
|
|
1063
|
+
if (/analys/.test(l)) return "ba";
|
|
973
1064
|
if (/devops/.test(l)) return "devops";
|
|
974
1065
|
if (/lead|architect/.test(l)) return "techlead";
|
|
1066
|
+
if (/design|\bux\b/.test(l)) return "design";
|
|
1067
|
+
if (/mobile|ios|android/.test(l)) return "mobile";
|
|
975
1068
|
if (/front/.test(l)) return "frontend";
|
|
976
|
-
if (/back|api/.test(l)) return "backend";
|
|
1069
|
+
if (/back|api|platform/.test(l)) return "backend";
|
|
977
1070
|
return "";
|
|
978
1071
|
}
|
|
979
1072
|
|
|
@@ -1089,7 +1182,7 @@ function auditTeamMix(grid, mfList, tshirtTotalDays, phaseDays) {
|
|
|
1089
1182
|
const phase = expectedPhases.find((ph) => title.endsWith(ph));
|
|
1090
1183
|
if (expectedPhases.length) {
|
|
1091
1184
|
check(out, `${label}: title names a phase with hours`, !!phase, `title '${title}' ends with none of [${expectedPhases.join(", ")}]`);
|
|
1092
|
-
if (phase) check(out, `${label} (${phase}): Σ Days == that phase's
|
|
1185
|
+
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)})`);
|
|
1093
1186
|
}
|
|
1094
1187
|
prevBottom = totalR + 1;
|
|
1095
1188
|
}
|
|
@@ -1100,7 +1193,7 @@ function auditTeamMix(grid, mfList, tshirtTotalDays, phaseDays) {
|
|
|
1100
1193
|
check(out, "Team Mix: nothing formatted below the last grid", !strayBelow.length, strayBelow.slice(0, 8).join(", "));
|
|
1101
1194
|
}
|
|
1102
1195
|
if (!Number.isNaN(tshirtTotalDays) && headers.length) {
|
|
1103
|
-
check(out, "Team Mix: Σ Days over all grids == T-Shirt
|
|
1196
|
+
check(out, "Team Mix: Σ Days over all grids == midpoint of T-Shirt Low/High days", Math.abs(sumDaysAll - tshirtTotalDays) < Math.max(0.15, tolAll), `Team Mix ${round2(sumDaysAll)}, T-Shirt midpoint ${tshirtTotalDays} (tolerance ${round2(Math.max(0.15, tolAll))})`);
|
|
1104
1197
|
}
|
|
1105
1198
|
return { checks: out, grids: headers.length };
|
|
1106
1199
|
}
|
|
@@ -1134,7 +1227,7 @@ async function runAudit(api) {
|
|
|
1134
1227
|
const checks = [];
|
|
1135
1228
|
const tshirt = auditTshirt(await api.grid(TAB_TSHIRT));
|
|
1136
1229
|
checks.push(...tshirt.checks);
|
|
1137
|
-
checks.push(...auditTeamMix(await api.grid(TAB_TEAM), tshirt.layout.mf, tshirt.
|
|
1230
|
+
checks.push(...auditTeamMix(await api.grid(TAB_TEAM), tshirt.layout.mf, tshirt.staffDays, tshirt.phaseDays).checks);
|
|
1138
1231
|
checks.push(...auditTechStack(await api.grid(TAB_TECH)).checks);
|
|
1139
1232
|
if (tabs.has(TAB_OVERVIEW)) checks.push(...auditOverview(await api.grid(TAB_OVERVIEW)).checks);
|
|
1140
1233
|
else check(checks, "Overview tab exists (the estimates index imports its cells)", false, "missing");
|
|
@@ -1191,7 +1284,7 @@ async function verbRead(api, tab) {
|
|
|
1191
1284
|
}
|
|
1192
1285
|
|
|
1193
1286
|
function rostersTable(rosters) {
|
|
1194
|
-
return rosters.map((r) => `── ${r.phase}: ${r.totalDays} d ──\n${rosterTable(r.roster)}`).join("\n");
|
|
1287
|
+
return rosters.map((r) => `── ${r.phase}: low ${r.totalDays} d · high ${r.highDays} d → staffing the midpoint ${r.staffDays} d ──\n${rosterTable(r.roster)}`).join("\n");
|
|
1195
1288
|
}
|
|
1196
1289
|
|
|
1197
1290
|
async function verbPlanCheck(api, plan) {
|
|
@@ -1201,6 +1294,193 @@ async function verbPlanCheck(api, plan) {
|
|
|
1201
1294
|
return { totals, mf: layout.mf, mfTotal: round2(layout.mfTotal), highFactor: layout.highFactor, rate: layout.rate, rosters, table: rostersTable(rosters) };
|
|
1202
1295
|
}
|
|
1203
1296
|
|
|
1297
|
+
/**
|
|
1298
|
+
* Derive the per-discipline FTE from a sheet's EXISTING Team Mix (first grid): each person
|
|
1299
|
+
* row's label → discipline, Count summed per discipline. HALTS on a label it cannot classify
|
|
1300
|
+
* or a grid it cannot find — a rebuild must never guess a roster.
|
|
1301
|
+
*/
|
|
1302
|
+
function deriveFteFromTeamMix(grid) {
|
|
1303
|
+
let header = -1;
|
|
1304
|
+
for (let r = 0; r < rowCount(grid); r++) if (textAt(grid, r, 0) === "Skill set") { header = r; break; }
|
|
1305
|
+
if (header < 0) throw new Halt(`${TAB_TEAM}: no 'Skill set' header row — cannot derive the roster`);
|
|
1306
|
+
// Two roster models exist. Current: Count is an ENTERED FTE → sum per discipline. Older:
|
|
1307
|
+
// Count is a FORMULA `=ROUND(MAX(months)/160,2)` (peak-month utilisation), so summing it
|
|
1308
|
+
// overstates the team; there, keep the sheet's total FTE and split it by each role's share
|
|
1309
|
+
// of Total Hrs. The model is read from the first Count cell, never assumed.
|
|
1310
|
+
const firstCount = formulaAt(grid, header + 1, 1);
|
|
1311
|
+
const peakModel = /MAX\(/i.test(firstCount);
|
|
1312
|
+
let totalHrsCol = -1;
|
|
1313
|
+
for (let c = 0; c < 30; c++) if (textAt(grid, header, c).trim() === "Total Hrs") totalHrsCol = c;
|
|
1314
|
+
if (peakModel && totalHrsCol < 0) throw new Halt(`${TAB_TEAM}: peak-utilisation roster but no 'Total Hrs' column to split by`);
|
|
1315
|
+
const people = [];
|
|
1316
|
+
const unknown = [];
|
|
1317
|
+
let totalFte = NaN;
|
|
1318
|
+
for (let r = header + 1; r < rowCount(grid); r++) {
|
|
1319
|
+
const a = textAt(grid, r, 0);
|
|
1320
|
+
if (a === "" || /^Total/i.test(a)) break;
|
|
1321
|
+
if (/^Tot\b/i.test(a)) { totalFte = numAt(grid, r, 1); continue; } // 'Tot FTE' / 'Tot Dys/Hrs' summary line inside the roster
|
|
1322
|
+
const d = disciplineOfLabel(a);
|
|
1323
|
+
if (!d) { unknown.push(a); continue; }
|
|
1324
|
+
const count = numAt(grid, r, 1);
|
|
1325
|
+
const hours = peakModel ? numAt(grid, r, totalHrsCol) : NaN;
|
|
1326
|
+
if (Number.isNaN(count)) throw new Halt(`${TAB_TEAM}: '${a}' has no numeric Count in column B`);
|
|
1327
|
+
if (peakModel && Number.isNaN(hours)) throw new Halt(`${TAB_TEAM}: '${a}' has no numeric Total Hrs`);
|
|
1328
|
+
people.push({ d, count, hours });
|
|
1329
|
+
}
|
|
1330
|
+
if (unknown.length) throw new Halt(`${TAB_TEAM}: cannot map these roles to a discipline — ${unknown.join(", ")}. Known: ${Object.keys(ROLE_LABEL).join(", ")}`);
|
|
1331
|
+
if (!people.length) throw new Halt(`${TAB_TEAM}: no person rows under the header`);
|
|
1332
|
+
const fte = {};
|
|
1333
|
+
if (peakModel) {
|
|
1334
|
+
const sumFte = Number.isNaN(totalFte) ? people.reduce((s, p) => s + p.count, 0) : totalFte;
|
|
1335
|
+
const sumHrs = people.reduce((s, p) => s + p.hours, 0);
|
|
1336
|
+
if (!(sumHrs > 0)) throw new Halt(`${TAB_TEAM}: Total Hrs sum to 0 — cannot split the roster`);
|
|
1337
|
+
for (const p of people) fte[p.d] = round2((fte[p.d] ? fte[p.d] : 0) + sumFte * p.hours / sumHrs);
|
|
1338
|
+
} else {
|
|
1339
|
+
for (const p of people) fte[p.d] = round2((fte[p.d] ? fte[p.d] : 0) + p.count);
|
|
1340
|
+
}
|
|
1341
|
+
for (const d of Object.keys(fte)) if (!(fte[d] > 0)) delete fte[d];
|
|
1342
|
+
return fte;
|
|
1343
|
+
}
|
|
1344
|
+
|
|
1345
|
+
/** Per-phase Low/High days read from the T-Shirt rollups (phases with hours > 0), in PHASES order. */
|
|
1346
|
+
function phaseTotalsFromSheet(grid, layout) {
|
|
1347
|
+
const out = [];
|
|
1348
|
+
for (const rr of layout.rollupRows) {
|
|
1349
|
+
const ph = textAt(grid, rr, layout.cols.rollupPhase).trim();
|
|
1350
|
+
const low = numAt(grid, rr, layout.cols.lowHrs), high = numAt(grid, rr, layout.cols.highHrs);
|
|
1351
|
+
if (Number.isNaN(low) || Number.isNaN(high)) throw new Halt(`${TAB_TSHIRT}: rollup row ${rr + 1} (${ph}) has no Low Hrs / High Hrs values`);
|
|
1352
|
+
if (low > 0) out.push({ phase: ph, totalDays: round2(low / 8), highDays: round2(high / 8), staffDays: round2((low + high) / 2 / 8) });
|
|
1353
|
+
}
|
|
1354
|
+
if (!out.length) throw new Halt(`${TAB_TSHIRT}: no phase has hours — nothing to staff`);
|
|
1355
|
+
return PHASES.filter((p) => out.some((o) => o.phase === p)).map((p) => out.find((o) => o.phase === p));
|
|
1356
|
+
}
|
|
1357
|
+
|
|
1358
|
+
/** Rebuild the Team Mix tab (one grid per phase) from the sheet's own roster + rollups. No plan needed. */
|
|
1359
|
+
async function verbTeamMix(api, opts) {
|
|
1360
|
+
const tshirt = await api.grid(TAB_TSHIRT);
|
|
1361
|
+
const layout = locateTshirt(tshirt);
|
|
1362
|
+
const team = await api.grid(TAB_TEAM);
|
|
1363
|
+
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 } };
|
|
1367
|
+
const rosters = phaseTotalsFromSheet(tshirt, layout).map((ph) => ({ ...ph, roster: buildRoster(plan, ph.staffDays, layout.mf) }));
|
|
1368
|
+
if (opts.dryRun) return { title, fte, rosters, table: rostersTable(rosters) };
|
|
1369
|
+
const tm = await writeTeamMix(api, plan, rosters);
|
|
1370
|
+
const result = { title, fte, phases: rosters.map((r) => ({ phase: r.phase, lowDays: r.totalDays, highDays: r.highDays, staffDays: r.staffDays, people: r.roster.people.length, months: r.roster.months, columns: r.roster.n, sumDays: r.roster.sumDays })), table: rostersTable(rosters), grids: tm.grids };
|
|
1371
|
+
if (!opts.noAudit) result.audit = await runAudit(api);
|
|
1372
|
+
return result;
|
|
1373
|
+
}
|
|
1374
|
+
|
|
1375
|
+
/**
|
|
1376
|
+
* Normalise a T-Shirt tab's FORMATTING to the spec without touching any size, phase, item text
|
|
1377
|
+
* or formula (spec §1.2 section rows, §1.3 totals row + summary block). Layout-aware via the
|
|
1378
|
+
* column map, so it works on both template generations. What it does, in order:
|
|
1379
|
+
* 1. section heading rows → merged A:<HIGH $>, bg #1C4F8B, white bold Arial 10
|
|
1380
|
+
* 2. blank rows between the last item and the totals row → deleted
|
|
1381
|
+
* 3. totals row → bg #D8DDE8, bold 10, across the table
|
|
1382
|
+
* 4. summary block (Total Days / Total Hrs / Total Cost) → rewritten directly under the totals
|
|
1383
|
+
* row in the standard shape; blank rows in between deleted; labels bold; Cost in dollars
|
|
1384
|
+
* HALTS when the rows under the totals row hold anything other than the old summary block or
|
|
1385
|
+
* blanks — it never overwrites content it did not expect.
|
|
1386
|
+
*/
|
|
1387
|
+
async function verbFormat(api, opts) {
|
|
1388
|
+
const grid = await api.grid(TAB_TSHIRT);
|
|
1389
|
+
const sheetId = grid.properties.sheetId;
|
|
1390
|
+
const layout = locateTshirt(grid);
|
|
1391
|
+
const { cols } = layout;
|
|
1392
|
+
const width = cols.high + 1;
|
|
1393
|
+
const L = colLetter;
|
|
1394
|
+
const first0 = layout.firstItemRow;
|
|
1395
|
+
const sections = [];
|
|
1396
|
+
let lastItem0 = -1, totalRow0 = -1;
|
|
1397
|
+
for (let r = first0; r < rowCount(grid); r++) {
|
|
1398
|
+
const a = textAt(grid, r, 0);
|
|
1399
|
+
if (/^Total \(Days\)/i.test(a)) { totalRow0 = r; break; }
|
|
1400
|
+
if (rowIsEmpty(grid, r, width)) continue;
|
|
1401
|
+
if (a !== "" && textAt(grid, r, 2) === "" && textAt(grid, r, cols.days) === "") sections.push(r);
|
|
1402
|
+
else lastItem0 = r;
|
|
1403
|
+
}
|
|
1404
|
+
if (lastItem0 < 0) throw new Halt(`${TAB_TSHIRT}: no item rows`);
|
|
1405
|
+
if (totalRow0 < 0) throw new Halt(`${TAB_TSHIRT}: no 'Total (Days)' row below the items — add it first`);
|
|
1406
|
+
// the rows under the totals row must be the old summary block or blank
|
|
1407
|
+
// Old summary variants seen across the 19 Hilo sheets (2026-09-21): the standard 3 rows;
|
|
1408
|
+
// a 'Low | High' header row above them; 'Total Cost (Low)' / '(High)' split rows; nothing at
|
|
1409
|
+
// all. Anything else under the totals row (a second table, notes) ends the block and is
|
|
1410
|
+
// NEVER touched — the new block goes in the free rows above it, inserting rows if needed.
|
|
1411
|
+
const summaryRe = /^(Total (Days|Hrs|Cost)( \((Low|High)\))?|Low|High)$/i;
|
|
1412
|
+
const totRef = new RegExp(`\\b[A-Z]{1,2}\\$?${totalRow0 + 1}\\b`);
|
|
1413
|
+
let summaryEnd0 = totalRow0; // last row of the old summary block (or the totals row if none)
|
|
1414
|
+
let nextContent0 = -1; // first row of foreign content below the block, if any
|
|
1415
|
+
for (let r = totalRow0 + 1; r < Math.min(rowCount(grid), totalRow0 + 10); r++) {
|
|
1416
|
+
let rowHas = false, foreign = false;
|
|
1417
|
+
for (let c = 0; c < width; c++) {
|
|
1418
|
+
const v = textAt(grid, r, c).trim();
|
|
1419
|
+
if (v === "") continue;
|
|
1420
|
+
rowHas = true;
|
|
1421
|
+
const ok = summaryRe.test(v) || (v.startsWith("=") && (totRef.test(v) || new RegExp(`\\b[A-Z]{1,2}(${totalRow0 + 2}|${totalRow0 + 3}|${totalRow0 + 4})\\b`).test(v)));
|
|
1422
|
+
if (!ok) foreign = true;
|
|
1423
|
+
}
|
|
1424
|
+
if (foreign) { nextContent0 = r; break; }
|
|
1425
|
+
if (rowHas) summaryEnd0 = r;
|
|
1426
|
+
}
|
|
1427
|
+
const blankBefore = totalRow0 - lastItem0 - 1;
|
|
1428
|
+
const freeAfter = (nextContent0 < 0 ? Infinity : nextContent0 - 1) - totalRow0; // rows available under the totals row
|
|
1429
|
+
const plan = { sections: sections.map((r) => r + 1), lastItem1: lastItem0 + 1, totalRow1: totalRow0 + 1, blankRowsBeforeTotals: blankBefore, oldSummaryRows: summaryEnd0 > totalRow0 ? `${totalRow0 + 2}–${summaryEnd0 + 1}` : "none", foreignContentAtRow: nextContent0 < 0 ? null : nextContent0 + 1, rowsToInsertForSummary: Math.max(0, 3 - Math.min(freeAfter, 3)) };
|
|
1430
|
+
if (opts.dryRun) return { layout: { headerRow1: layout.headerRow + 1, sizeColumns: cols.sizeLabels, totalCol: L(cols.total) }, plan };
|
|
1431
|
+
|
|
1432
|
+
// 1. section rows (before any row deletion — indices are current)
|
|
1433
|
+
const reqs = [];
|
|
1434
|
+
const merges = Array.isArray(grid.merges) ? grid.merges : [];
|
|
1435
|
+
for (const r0 of sections) {
|
|
1436
|
+
// Google only unmerges an EXACT existing range — use the sheet's own merge list (two Hilo
|
|
1437
|
+
// sheets had section rows merged wider than the table, and A:<HIGH $> 400'd).
|
|
1438
|
+
for (const m of merges) if (m.startRowIndex <= r0 && m.endRowIndex > r0) reqs.push({ unmergeCells: { range: { sheetId, startRowIndex: m.startRowIndex, endRowIndex: m.endRowIndex, startColumnIndex: m.startColumnIndex, endColumnIndex: m.endColumnIndex } } });
|
|
1439
|
+
reqs.push({ mergeCells: { range: gridRange(sheetId, r0, r0 + 1, 0, width), mergeType: "MERGE_ALL" } });
|
|
1440
|
+
reqs.push(fmtReq(sheetId, r0, r0 + 1, 0, width,
|
|
1441
|
+
{ backgroundColor: hexToColor(COLOR.sectionBg), textFormat: { fontFamily: "Arial", fontSize: 10, bold: true, foregroundColor: hexToColor(COLOR.white) }, horizontalAlignment: "LEFT" },
|
|
1442
|
+
"userEnteredFormat(backgroundColor,textFormat,horizontalAlignment)"));
|
|
1443
|
+
}
|
|
1444
|
+
if (reqs.length) await api.batch(reqs);
|
|
1445
|
+
// 2. clear the old summary block (values + formats), then delete the blank rows before the totals row
|
|
1446
|
+
if (summaryEnd0 > totalRow0) {
|
|
1447
|
+
await api.clearValues(TAB_TSHIRT, `A${totalRow0 + 2}:${L(width - 1)}${summaryEnd0 + 1}`);
|
|
1448
|
+
await api.batch([fmtReq(sheetId, totalRow0 + 1, summaryEnd0 + 1, 0, width, {}, "userEnteredFormat")]);
|
|
1449
|
+
}
|
|
1450
|
+
let tot0 = totalRow0;
|
|
1451
|
+
if (blankBefore > 0) {
|
|
1452
|
+
await api.batch([{ deleteDimension: { range: { sheetId, dimension: "ROWS", startIndex: lastItem0 + 1, endIndex: totalRow0 } } }]);
|
|
1453
|
+
tot0 = lastItem0 + 1;
|
|
1454
|
+
}
|
|
1455
|
+
// make room for the 3 summary rows when other content sits close under the totals row
|
|
1456
|
+
if (plan.rowsToInsertForSummary > 0) {
|
|
1457
|
+
const at = tot0 + 1 + (freeAfter === Infinity ? 3 : Math.max(0, freeAfter)); // insert right before the foreign content
|
|
1458
|
+
await api.batch([{ insertDimension: { range: { sheetId, dimension: "ROWS", startIndex: at, endIndex: at + plan.rowsToInsertForSummary }, inheritFromBefore: false } }]);
|
|
1459
|
+
}
|
|
1460
|
+
// 3. totals row band
|
|
1461
|
+
await api.batch([
|
|
1462
|
+
fmtReq(sheetId, tot0, tot0 + 1, 0, width, { backgroundColor: hexToColor(COLOR.tshirtTotalBg), textFormat: { fontSize: 10, bold: true }, horizontalAlignment: "LEFT" }, "userEnteredFormat(backgroundColor,textFormat,horizontalAlignment)"),
|
|
1463
|
+
fmtReq(sheetId, tot0, tot0 + 1, cols.days, width, { horizontalAlignment: "RIGHT" }, "userEnteredFormat.horizontalAlignment"),
|
|
1464
|
+
]);
|
|
1465
|
+
// 4. summary block directly under the totals row
|
|
1466
|
+
const t1 = tot0 + 1;
|
|
1467
|
+
const hf = `$${L(cols.highFactor.c)}$${cols.highFactor.r + 1}`;
|
|
1468
|
+
const rows = [
|
|
1469
|
+
["Total Days", `=${L(cols.total)}${t1}`, `=${L(cols.total)}${t1}*${hf}`],
|
|
1470
|
+
["Total Hrs", `=${L(cols.total)}${t1}*8`, `=${L(cols.total)}${t1}*${hf}*8`],
|
|
1471
|
+
["Total Cost", `=${L(cols.low)}${t1}`, `=${L(cols.high)}${t1}`],
|
|
1472
|
+
];
|
|
1473
|
+
await api.putValues(TAB_TSHIRT, `${L(cols.total)}${t1 + 1}:${L(cols.total + 2)}${t1 + 3}`, rows);
|
|
1474
|
+
await api.batch([
|
|
1475
|
+
fmtReq(sheetId, t1, t1 + 3, cols.total, cols.total + 3, { textFormat: { fontSize: 10, bold: true } }, "userEnteredFormat.textFormat"),
|
|
1476
|
+
fmtReq(sheetId, t1, t1 + 2, cols.total + 1, cols.total + 3, { horizontalAlignment: "RIGHT", numberFormat: { type: "NUMBER", pattern: "0.00" } }, "userEnteredFormat(horizontalAlignment,numberFormat)"),
|
|
1477
|
+
fmtReq(sheetId, t1 + 2, t1 + 3, cols.total + 1, cols.total + 3, { horizontalAlignment: "RIGHT", numberFormat: { type: "CURRENCY", pattern: "$#,##0.00" } }, "userEnteredFormat(horizontalAlignment,numberFormat)"),
|
|
1478
|
+
]);
|
|
1479
|
+
const result = { plan, totalRow1: t1 };
|
|
1480
|
+
if (!opts.noAudit) result.audit = await runAudit(api);
|
|
1481
|
+
return result;
|
|
1482
|
+
}
|
|
1483
|
+
|
|
1204
1484
|
async function verbWrite(api, plan, opts) {
|
|
1205
1485
|
const ts = await writeTshirt(api, plan, opts);
|
|
1206
1486
|
const rosters = buildRosters(plan, ts.layout);
|
|
@@ -1208,7 +1488,7 @@ async function verbWrite(api, plan, opts) {
|
|
|
1208
1488
|
const tech = await writeTechStack(api, plan);
|
|
1209
1489
|
const result = {
|
|
1210
1490
|
tshirt: { mode: plan.tshirt.mode, itemsWritten: ts.itemsWritten, firstItem1: ts.firstItem1, lastItem1: ts.lastItem1, totalRow1: ts.totalRow1, totals: ts.totals },
|
|
1211
|
-
teamMix: { grids: tm.grids, phases: rosters.map((r) => ({ phase: r.phase,
|
|
1491
|
+
teamMix: { grids: tm.grids, phases: rosters.map((r) => ({ phase: r.phase, lowDays: r.totalDays, highDays: r.highDays, staffDays: r.staffDays, people: r.roster.people.length, months: r.roster.months, columns: r.roster.n, sumDays: r.roster.sumDays })), table: rostersTable(rosters), lastRow: tm.lastRow },
|
|
1212
1492
|
techStack: tech,
|
|
1213
1493
|
};
|
|
1214
1494
|
if (!opts.noAudit) result.audit = await runAudit(api);
|
|
@@ -1221,7 +1501,7 @@ function parseArgs(argv) {
|
|
|
1221
1501
|
const out = { _: [] };
|
|
1222
1502
|
for (let i = 0; i < argv.length; i++) {
|
|
1223
1503
|
const a = argv[i];
|
|
1224
|
-
if (a === "--json" || a === "--replace" || a === "--no-audit") out[a.slice(2)] = true;
|
|
1504
|
+
if (a === "--json" || a === "--replace" || a === "--no-audit" || a === "--dry-run") out[a.slice(2)] = true;
|
|
1225
1505
|
else if (a.startsWith("--")) { out[a.slice(2)] = argv[i + 1]; i++; }
|
|
1226
1506
|
else out._.push(a);
|
|
1227
1507
|
}
|
|
@@ -1233,7 +1513,7 @@ function printChecks(audit) {
|
|
|
1233
1513
|
console.log(`${audit.ok ? "AUDIT PASS" : `AUDIT FAIL (${audit.failed})`} — ${audit.title}`);
|
|
1234
1514
|
}
|
|
1235
1515
|
|
|
1236
|
-
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]";
|
|
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]";
|
|
1237
1517
|
|
|
1238
1518
|
/** Runs a verb; returns the exit code. Throws Halt (or any error) — the runner below turns that into exit 4/64. */
|
|
1239
1519
|
async function main(args) {
|
|
@@ -1249,6 +1529,26 @@ async function main(args) {
|
|
|
1249
1529
|
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)); }
|
|
1250
1530
|
return 0;
|
|
1251
1531
|
}
|
|
1532
|
+
if (verb === "teammix") {
|
|
1533
|
+
// rebuild the Team Mix from the sheet's own roster + phase rollups (no plan file)
|
|
1534
|
+
let fte;
|
|
1535
|
+
if (args.fte) { try { fte = JSON.parse(args.fte); } catch (e) { throw new Halt(`--fte must be JSON like {"backend":1.5,"qa":0.4}: ${e.message}`, 64); } }
|
|
1536
|
+
const r = await verbTeamMix(api, { fte, title: args.title, dryRun: !!args["dry-run"], noAudit: !!args["no-audit"] });
|
|
1537
|
+
const ok = !r.audit || r.audit.ok;
|
|
1538
|
+
if (json) console.log(JSON.stringify({ ok, exitCode: ok ? 0 : 4, ...r }, null, 2));
|
|
1539
|
+
else {
|
|
1540
|
+
console.log(`title: ${r.title}`); console.log(`fte: ${JSON.stringify(r.fte)}`); console.log(r.table);
|
|
1541
|
+
if (r.audit) printChecks(r.audit); else if (args["dry-run"]) console.log("(dry run — nothing written)");
|
|
1542
|
+
}
|
|
1543
|
+
return ok ? 0 : 4;
|
|
1544
|
+
}
|
|
1545
|
+
if (verb === "format") {
|
|
1546
|
+
const r = await verbFormat(api, { dryRun: !!args["dry-run"], noAudit: !!args["no-audit"] });
|
|
1547
|
+
const ok = !r.audit || r.audit.ok;
|
|
1548
|
+
if (json) console.log(JSON.stringify({ ok, exitCode: ok ? 0 : 4, ...r }, null, 2));
|
|
1549
|
+
else { console.log(JSON.stringify(r.plan)); if (r.audit) printChecks(r.audit); else if (args["dry-run"]) console.log("(dry run — nothing written)"); }
|
|
1550
|
+
return ok ? 0 : 4;
|
|
1551
|
+
}
|
|
1252
1552
|
if (verb === "audit") {
|
|
1253
1553
|
const a = await runAudit(api);
|
|
1254
1554
|
if (json) console.log(JSON.stringify({ ok: a.ok, exitCode: a.ok ? 0 : 4, ...a }, null, 2)); else printChecks(a);
|
|
@@ -1271,7 +1571,7 @@ async function main(args) {
|
|
|
1271
1571
|
if (json) console.log(JSON.stringify({ ok, exitCode: ok ? 0 : 4, ...r }, null, 2));
|
|
1272
1572
|
else {
|
|
1273
1573
|
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}`);
|
|
1274
|
-
for (const ph of r.teamMix.phases) console.log(`Team Mix — ${ph.phase}: ${ph.people} people, ${ph.months} months, ${ph.columns} columns, Σ Days ${ph.sumDays}`);
|
|
1574
|
+
for (const ph of r.teamMix.phases) console.log(`Team Mix — ${ph.phase}: ${ph.people} people, ${ph.months} months, ${ph.columns} columns, Σ Days ${ph.sumDays} (midpoint of ${ph.lowDays}–${ph.highDays})`);
|
|
1275
1575
|
console.log(r.teamMix.table);
|
|
1276
1576
|
console.log(`Technology Stack: ${r.techStack.rows} rows`);
|
|
1277
1577
|
if (r.audit) printChecks(r.audit);
|
|
@@ -1291,7 +1591,7 @@ function haltAndExit(e, json) {
|
|
|
1291
1591
|
|
|
1292
1592
|
module.exports = {
|
|
1293
1593
|
validatePlan, splitRoster, rosterViolations, mfCoverageViolations, monthPlan, resampleWeights, rampHours, buildRoster,
|
|
1294
|
-
tshirtTotals, phaseTotals, buildRosters, itemFormulas, rollupFormulas, tshirtRows, teamMixValues, teamMixFormatReqs, remainderFormula, locateTshirt, findPhaseSource,
|
|
1594
|
+
tshirtTotals, phaseTotals, buildRosters, midDays, deriveFteFromTeamMix, phaseTotalsFromSheet, verbTeamMix, itemFormulasFor, rollupFormulasFor, findCell, itemFormulas, rollupFormulas, tshirtRows, teamMixValues, teamMixFormatReqs, remainderFormula, locateTshirt, findPhaseSource,
|
|
1295
1595
|
auditTshirt, auditTeamMix, auditTechStack, auditOverview, colLetter, hexToColor, colorToHex, sheetIdFromArg,
|
|
1296
1596
|
constants: { SIZE_CODES, PHASES, COLOR, RAMP, ROLE_LABEL, SOFT_CEILING, FOLD_THRESHOLD, TAB_TSHIRT, TAB_TEAM, TAB_TECH, PLAN_SCHEMA },
|
|
1297
1597
|
Halt, SheetsApi, getToken, runAudit, main,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tekyzinc/gsd-t",
|
|
3
|
-
"version": "5.20.
|
|
3
|
+
"version": "5.20.14",
|
|
4
4
|
"description": "GSD-T: Contract-Driven Development for Claude Code — 54 slash commands with headless-by-default workflow spawning, unattended supervisor relay with event stream, graph-powered code analysis, real-time agent dashboard, task telemetry, doc-ripple enforcement, backlog management, impact analysis, test sync, milestone archival, and PRD generation",
|
|
5
5
|
"author": "Tekyz, Inc.",
|
|
6
6
|
"license": "MIT",
|
|
@@ -130,14 +130,15 @@ The old `Month / Days / Tot Days / Hrs` layout is retired: `Days` IS the per-per
|
|
|
130
130
|
|
|
131
131
|
### 2.2 The math
|
|
132
132
|
|
|
133
|
-
- `
|
|
134
|
-
-
|
|
133
|
+
- **The Team Mix staffs the MIDPOINT of the Low and High figures** (David, 2026-09-21): `staffDays = Low Total Days × (1 + high factor) / 2` — per phase, from that phase's `Low Hrs` and `High Hrs` rollups. Never the Low figure alone.
|
|
134
|
+
- `months = staffDays / (Σ Count × 20)` — solve so `Σ Days` equals that midpoint.
|
|
135
|
+
- Month columns `N = ceil(months)`, EXCEPT when the fractional tail is under ~0.1 month AND a full-time person still fits under the soft ceiling (`months × 160 ≤ N × 172`): then fold it into the last full month instead of opening a near-empty column.
|
|
135
136
|
- **Recompute `months` and `N` after ANY roster change.** Adding a role raises Σ Count, shortens the duration, and turns the remainder column negative (−12.80 hrs was the symptom).
|
|
136
137
|
- The remainder formula makes each row sum exactly. Never write a fractional last month as a value.
|
|
137
138
|
|
|
138
139
|
### 2.6 One grid per phase
|
|
139
140
|
|
|
140
|
-
Every phase (`MVP` / `Phase 1` / `Phase 2` / `Phase 3`) whose T-Shirt items have hours > 0 gets its own Team Mix grid, in phase order, on the same tab, with 2 blank unformatted rows between grids. Each grid's `Σ Days` reconciles to **that phase's
|
|
141
|
+
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`.
|
|
141
142
|
|
|
142
143
|
### 2.3 Roster shape — one row per PERSON
|
|
143
144
|
|
|
@@ -171,6 +172,8 @@ Monthly hours follow when the work happens. Normalise the weights, multiply by t
|
|
|
171
172
|
| DevOps | 0.45 / 0.85 / 1.70 | back-loaded — mirror of BA |
|
|
172
173
|
| Project Manager | 1.00 / 1.00 / 1.00 | flat |
|
|
173
174
|
| Tech Lead / Architect | 1.30 / 0.80 / 0.90 | design-heavy, uptick at integration |
|
|
175
|
+
| Design / UX | 1.60 / 0.90 / 0.50 | heaviest at the start, a tail for revisions |
|
|
176
|
+
| Mobile | 1.35 / 1.05 / 0.60 | same shape as frontend |
|
|
174
177
|
|
|
175
178
|
- A flat `160 / 160 / 147` for every role is the tell that no ramping was applied.
|
|
176
179
|
- **The only ceiling is 160 hrs per person per month, soft to ~172.** There is NO `Count × 160` per-row ceiling — a 0.40 QA can work 106 hrs in their heavy month; that is what ramping means.
|
|
@@ -226,7 +229,7 @@ TEAM MIX (each grid)
|
|
|
226
229
|
[ ] font Arial 10 everywhere; sage on exactly E, F, Total Hrs of role rows; monthly cells white
|
|
227
230
|
[ ] Total row (#E5E5E5) and Total Days row (#9EC1EF) span every month column + Total Hrs
|
|
228
231
|
[ ] nothing formatted outside the data area
|
|
229
|
-
[ ] each grid's Σ Days == that phase's
|
|
232
|
+
[ ] each grid's Σ Days == midpoint of that phase's Low/High days; all grids together == midpoint of Total Days and Total Days × high factor
|
|
230
233
|
TECH STACK
|
|
231
234
|
[ ] at least the categories that apply are filled; none says TBD
|
|
232
235
|
OVERVIEW
|
|
@@ -245,8 +248,17 @@ gsd-t estimate-sheet read --sheet <id|url> [--tab <name>] # read-before-
|
|
|
245
248
|
gsd-t estimate-sheet plan-check --sheet <id|url> --plan plan.json # validate + the roster it WOULD write (the Step 4 pause)
|
|
246
249
|
gsd-t estimate-sheet write --sheet <id|url> --plan plan.json [--replace] # T-Shirt + Team Mix + Tech Stack, then audit
|
|
247
250
|
gsd-t estimate-sheet audit --sheet <id|url> # §5 checklist by read-back
|
|
251
|
+
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
|
+
# the last item, standard summary block directly under it (§1.3); no size, phase, text or item formula is touched;
|
|
253
|
+
# 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]
|
|
255
|
+
# rebuild the Team Mix (one grid per phase) from the sheet's OWN roster and phase rollups — no plan needed;
|
|
256
|
+
# the roster is derived from the existing grid (entered Counts summed per discipline; older peak-utilisation
|
|
257
|
+
# rosters split the sheet's total FTE by each role's hours); --fte overrides it; halts on a role it cannot map
|
|
248
258
|
```
|
|
249
259
|
|
|
260
|
+
Both the current template and the older layout (Project/Client rows on top, legend from row 8, up to four size columns, header row 17) are READ by `audit` and `teammix` — every coordinate comes from the labels, never a fixed row. `write` produces the current template only.
|
|
261
|
+
|
|
250
262
|
The plan (judgment only):
|
|
251
263
|
|
|
252
264
|
```json
|
|
@@ -263,5 +275,5 @@ The plan (judgment only):
|
|
|
263
275
|
```
|
|
264
276
|
|
|
265
277
|
- `tshirt.mode` `items` writes whole rows below the header (halts if rows exist unless `--replace`); `sizes` fills `E:L` on rows that already exist (a gap-analysis sheet), matched by the `(id)` suffix in column C — never by position.
|
|
266
|
-
- `teamMix.fte` is per-discipline FTE (`backend` `frontend` `qa` `pm` `ba` `devops` `techlead`). The tool splits it into people (saturate then spill), computes months and the column count, ramps by discipline, writes the remainder formula, and refuses a roster that leaves a weighted MF factor unstaffed. It writes **one grid per phase with hours** (§2.6); `teamMix.phases: { "Phase 1": { "fte": {…} } }` overrides the mix for one phase.
|
|
278
|
+
- `teamMix.fte` is per-discipline FTE (`backend` `frontend` `qa` `pm` `ba` `devops` `techlead` `design` `mobile`). The tool splits it into people (saturate then spill), computes months and the column count, ramps by discipline, writes the remainder formula, and refuses a roster that leaves a weighted MF factor unstaffed. It writes **one grid per phase with hours** (§2.6); `teamMix.phases: { "Phase 1": { "fte": {…} } }` overrides the mix for one phase.
|
|
267
279
|
- The MF list, legend, rate and high factor are READ from the sheet; the plan never carries them.
|