@tekyzinc/gsd-t 5.21.10 → 5.23.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +48 -0
- package/README.md +4 -3
- package/bin/gsd-t-estimate-sheet.cjs +270 -20
- package/bin/gsd-t-graph-edge-extract.cjs +113 -2
- package/bin/gsd-t-graph-exclude.cjs +81 -0
- package/bin/gsd-t-graph-freshness.cjs +5 -1
- package/bin/gsd-t-graph-index.cjs +42 -7
- package/bin/gsd-t-graph-query-cli.cjs +210 -20
- package/bin/gsd-t-graph-scip-upgrade.cjs +92 -12
- package/bin/gsd-t-model-tier-policy.cjs +3 -3
- package/bin/gsd-t-scip-reader.cjs +29 -2
- package/bin/gsd-t.js +26 -3
- package/commands/gsd-t-estimate-rescale.md +64 -0
- package/commands/gsd-t-estimate.md +10 -9
- package/commands/gsd-t-help.md +7 -1
- package/commands/gsd-t-status.md +1 -1
- package/package.json +1 -1
- package/scripts/gsd-t-graph-search-guard.js +52 -2
- package/scripts/statusline-command.sh +11 -2
- package/templates/CLAUDE-global.md +1 -1
- package/templates/estimate-config.json +7 -4
- package/templates/estimate-sheet-spec.md +45 -3
- package/templates/playbooks/tekyz-estimation-and-prd-playbook.md +8 -4
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,54 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to GSD-T are documented here. Updated with each release.
|
|
4
4
|
|
|
5
|
+
## [5.23.10] - 2026-09-29
|
|
6
|
+
|
|
7
|
+
### Added — who-calls matches the compiler on route files; graph exclude list; estimate math v2
|
|
8
|
+
|
|
9
|
+
The code graph answered [] or a single `_toplevel` caller for functions called from Hono/Express route files, and labelled files compiler-accurate on one resolved edge. On hilo-figma-atos who-calls now matches TypeScript findReferences (requireLocationTenant 95/95, resolveOrgIdFromSession 547/547, verifyLocationAccess 120/120). Estimates now count the human's time in solo minutes, which removes the need to tune multipliers.
|
|
10
|
+
|
|
11
|
+
- `bin/gsd-t-graph-edge-extract.cjs`: calls in anonymous route handlers and in route-registration arguments (middleware) → `file#METHOD /path@line`; other top-level callbacks → `file#anonymous@line`; const-arrow bodies no longer walked twice; call edges carry `col`.
|
|
12
|
+
- `bin/gsd-t-graph-scip-upgrade.cjs`, `bin/gsd-t-scip-reader.cjs`: scip-typescript run with `--max-file-byte-size 64mb` + 8 GB heap (its 1 MB default silently skipped large files); files with no SCIP document → `tree-sitter-floor-SCIP-MISSING`; new `compiler-partial` tier (<90% of repo-name calls resolved).
|
|
13
|
+
- `bin/gsd-t-graph-query-cli.cjs`: `name-matched` callers for names defined once, in unresolved files; `coverage.unresolvedCallSites`; empty-and-incomplete answers name a path forward for the search guard; `graph status` lists SCIP-missing files and active excludes.
|
|
14
|
+
- `bin/gsd-t-graph-exclude.cjs` (new): `.gsd-t/graph-exclude.json` excludes ancillary trees (folder prefixes, `*`/`**` globs); read by indexer and freshness walker; malformed file halts.
|
|
15
|
+
- `bin/gsd-t.js`: `graph index` 30-min limit, killed/timed-out build is an error (was a silent exit 0 with a half-built graph).
|
|
16
|
+
- `bin/gsd-t-estimate-sheet.cjs`, `commands/gsd-t-estimate-rescale.md` (new command), spec, playbook, config: rescale `--suffix`; solo minutes = human + Claude (~10 / 40 min / 2–4½ hr); yellow-field team ×5 / ×7; rescale zeroes the copy's overhead by default.
|
|
17
|
+
- `scripts/statusline-command.sh`: prefers `.transcript_path` from stdin.
|
|
18
|
+
- Tests: `test/m118-*.test.js`, `test/m119-who-calls-golden.test.js` (golden vs findReferences).
|
|
19
|
+
|
|
20
|
+
Re-run `gsd-t graph index` in each project to pick up the new caller attribution and tiers.
|
|
21
|
+
|
|
22
|
+
## [5.22.10] - 2026-09-27
|
|
23
|
+
|
|
24
|
+
### Added — AI-assisted T-shirt sizing for `/gsd-t-estimate`
|
|
25
|
+
|
|
26
|
+
Estimates sized every task as if a person typed the code. Measured against the Hilo Delivery
|
|
27
|
+
Runway build (45 tasks, ~26 solo hours: 13 David + 13 Claude), the old scale priced the same
|
|
28
|
+
work at 1,262–1,577 hours. A size is now the team hours a task takes with AI-assisted
|
|
29
|
+
development: solo AI minutes × project multiplier (greenfield solo ×1, team ×5; yellow-field
|
|
30
|
+
solo ×2, team ×8 isolated / ×12 big blast radius) + task switching added after the multiplier
|
|
31
|
+
(7.5 / 15 / 30 min), mapped to the nearest size on a smaller scale — XS 0.1 · S 0.25 · M 0.5 ·
|
|
32
|
+
L 1 · XL 2 · XXL 4 person-days. The multipliers live in the estimator; the sheet layout is
|
|
33
|
+
unchanged.
|
|
34
|
+
|
|
35
|
+
- `bin/gsd-t-estimate-sheet.cjs`: new `size` verb (`--solo-min`, `--project`, `--switch-min`);
|
|
36
|
+
`write` puts the AI scale in the legend (matched by label); sizes mode halts if a sized row
|
|
37
|
+
outside the plan would be silently re-priced; `plan-check` previews on the new scale
|
|
38
|
+
- `templates/estimate-sheet-spec.md`: §1.4 sizing model; legend + verb list updated
|
|
39
|
+
- `commands/gsd-t-estimate.md`, `templates/playbooks/tekyz-estimation-and-prd-playbook.md`,
|
|
40
|
+
`templates/estimate-config.json`: size in solo AI minutes; new-team familiarization off by default
|
|
41
|
+
- `test/estimate-sheet-writer.test.js`: +6 tests
|
|
42
|
+
|
|
43
|
+
Existing estimate sheets keep the old scale until they are re-estimated.
|
|
44
|
+
|
|
45
|
+
### Fixed — Red Team runs on Opus 5.5 under every profile
|
|
46
|
+
|
|
47
|
+
The `standard` profile put Red Team on Sonnet, contradicting both profile contracts and the
|
|
48
|
+
READMEs. It now runs on `opus` (Opus 5.5) in standard, pro and premium.
|
|
49
|
+
|
|
50
|
+
- `bin/gsd-t-model-tier-policy.cjs`, `commands/gsd-t-status.md`,
|
|
51
|
+
`.gsd-t/contracts/model-profile-config-contract.md`, `test/m86-policy-profiles.test.js`
|
|
52
|
+
|
|
5
53
|
## [5.21.10] - 2026-09-22
|
|
6
54
|
|
|
7
55
|
### Changed — the top tier now points at Opus 5.5
|
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# GSD-T: Contract-Driven Development for Claude Code
|
|
2
2
|
|
|
3
|
-
**v5.
|
|
3
|
+
**v5.23.10** - A methodology for reliable, parallelizable development using Claude Code with optional Agent Teams support.
|
|
4
4
|
|
|
5
5
|
**Eliminates context rot** — task-level fresh dispatch (one subagent per task, ~10-20% context each) means compaction never triggers.
|
|
6
6
|
**Compaction-proof debug loops** — `gsd-t headless --debug-loop` runs test-fix-retest cycles as separate `claude -p` sessions. A JSONL debug ledger persists all hypothesis/fix/learning history across fresh sessions. Anti-repetition preamble injection prevents retrying failed hypotheses. Escalation tiers (sonnet → opus → human) and a hard iteration ceiling enforced externally.
|
|
@@ -82,7 +82,7 @@ GSD-T reads all state files and tells you exactly where you left off.
|
|
|
82
82
|
## CLI Commands
|
|
83
83
|
|
|
84
84
|
```bash
|
|
85
|
-
npx @tekyzinc/gsd-t install # Install commands + global CLAUDE.md (
|
|
85
|
+
npx @tekyzinc/gsd-t install # Install commands + global CLAUDE.md (52 commands)
|
|
86
86
|
npx @tekyzinc/gsd-t update # Update global commands + CLAUDE.md
|
|
87
87
|
npx @tekyzinc/gsd-t update-all # Update globally + all registered project CLAUDE.md files
|
|
88
88
|
npx @tekyzinc/gsd-t init [name] # Scaffold GSD-T project (auto-registers)
|
|
@@ -184,7 +184,8 @@ This will replace changed command files, back up your CLAUDE.md if customized, a
|
|
|
184
184
|
| `/gsd-t-scan` | Deep codebase analysis → techdebt.md | Manual |
|
|
185
185
|
| `/gsd-t-gap-analysis` | Requirements gap analysis — spec vs. existing code | Manual |
|
|
186
186
|
| `/gsd-t-promote-debt` | Convert techdebt items to milestones | Manual |
|
|
187
|
-
| `/gsd-t-estimate` | Turn any work doc (scan, gap-analysis sheet, requirements, feature/app spec) into a Tekyz client estimate Google Sheet — T-Shirt Size + Team Mix + Technology Stack, written by the deterministic `gsd-t estimate-sheet` tool from a JSON plan and read-back-audited against `templates/estimate-sheet-spec.md
|
|
187
|
+
| `/gsd-t-estimate` | Turn any work doc (scan, gap-analysis sheet, requirements, feature/app spec) into a Tekyz client estimate Google Sheet — T-Shirt Size + Team Mix + Technology Stack, written by the deterministic `gsd-t estimate-sheet` tool from a JSON plan and read-back-audited against `templates/estimate-sheet-spec.md`; sizes assume AI-assisted development (solo AI minutes × project multiplier) — supervised, with an operator-arbitrated Estimate Red Team. No PRD (that is `/gsd-t-prd`) | Manual |
|
|
188
|
+
| `/gsd-t-estimate-rescale` | Re-price an existing estimate sheet on the AI-assisted scale into copied `T-Shirt Size Estimate (Rescale)` / `Team Mix (Rescale)` tabs — the originals and the estimates index are never touched; supervised per-row sizing, XXS (0.5 hr) size, read-back audit | Manual |
|
|
188
189
|
| `/gsd-t-stories` | Generate a dev-team handoff doc in the Tekyz user-stories format (stories + workflows + acceptance criteria + Mermaid flow diagrams + mapped test cases) from any source | Manual |
|
|
189
190
|
| `/gsd-t-demo-videos` | Narrated screen-recording walkthroughs of a running app — coverage plan, UI-driven seeding, batched TTS with a measured one-voice gate, continuous recording, mux, silence trim | Manual |
|
|
190
191
|
| `/gsd-t-populate` | Auto-populate docs from existing codebase | Manual |
|
|
@@ -45,8 +45,36 @@ const TAB_TSHIRT = "T-Shirt Size Estimate";
|
|
|
45
45
|
const TAB_TEAM = "Team Mix";
|
|
46
46
|
const TAB_TECH = "Technology Stack";
|
|
47
47
|
const TAB_OVERVIEW = "Overview";
|
|
48
|
+
// rescale (v5.23.10): the AI-assisted re-estimate lives in COPIES of the two tabs; the originals are never written.
|
|
49
|
+
const TAB_TSHIRT_AI = "T-Shirt Size Estimate (Rescale)";
|
|
50
|
+
const TAB_TEAM_AI = "Team Mix (Rescale)";
|
|
48
51
|
|
|
49
52
|
const SIZE_CODES = ["XS", "S", "M", "L", "XL", "XXL"];
|
|
53
|
+
|
|
54
|
+
// AI-assisted sizing model (David, 2026-09-27; calibrated on the Hilo Delivery Runway build:
|
|
55
|
+
// 45 tasks, ~26 solo hours = 13 human + 13 Claude). A task is sized in SOLO AI-assisted minutes (human + Claude time), multiplied by the
|
|
56
|
+
// project type, plus a per-task switching allowance added AFTER the multiplier (switching is
|
|
57
|
+
// one person's pickup time — it does not grow with team size). The result maps to the nearest
|
|
58
|
+
// size on AI_SIZE_DAYS, which `write` puts in the sheet's legend. The multipliers live here,
|
|
59
|
+
// in the estimator — never on the sheet.
|
|
60
|
+
const AI_SIZE_DAYS = { XS: 0.1, S: 0.25, M: 0.5, L: 1, XL: 2, XXL: 4 };
|
|
61
|
+
// XXS (0.5 hr) exists on RESCALE tabs only (David, 2026-09-27) — solo tasks under an hour otherwise
|
|
62
|
+
// round up to XS. A legend carrying XXS needs an EXACT lookup: the 2-letter prefix match the
|
|
63
|
+
// template uses would read "XX*" as XXS + XXL.
|
|
64
|
+
const XXS_DAYS = 0.0625;
|
|
65
|
+
const XXS_LABEL = "XXS - Extra Extra Small";
|
|
66
|
+
// Multipliers set by David 2026-09-28 (×5 isolated / ×7 wide). The same-day recalibrations to ×8/×11 and
|
|
67
|
+
// ×11/×15 were tuning that compensated for solo minutes that left out the human's time; with solo minutes
|
|
68
|
+
// counting human + Claude (Runway: 13 + 13 = 26 h), the untuned values land ATOS at 46% of the original
|
|
69
|
+
// effort vs the tuned 54% (Summary 4, 2026-09-29), so they are the default again. They INCLUDE team
|
|
70
|
+
// overhead — reviews, QA, coordination — so no separate overhead factor belongs on top of them.
|
|
71
|
+
const PROJECT_MULTIPLIER = {
|
|
72
|
+
"greenfield-solo": 1,
|
|
73
|
+
"greenfield-team": 3,
|
|
74
|
+
"yellowfield-solo": 2,
|
|
75
|
+
"yellowfield-team-isolated": 5,
|
|
76
|
+
"yellowfield-team-wide": 7,
|
|
77
|
+
};
|
|
50
78
|
const PHASES = ["MVP", "Phase 1", "Phase 2", "Phase 3"];
|
|
51
79
|
|
|
52
80
|
const COLOR = {
|
|
@@ -202,12 +230,13 @@ class SheetsApi {
|
|
|
202
230
|
body: body == null ? undefined : JSON.stringify(body),
|
|
203
231
|
});
|
|
204
232
|
const text = await res.text();
|
|
205
|
-
if (res.status === 429 && attempt <=
|
|
233
|
+
if (res.status === 429 && attempt <= 6) {
|
|
206
234
|
// Rate limit (60 reads/min/user — hit 2026-09-21 auditing 19 sheets in a row). This is
|
|
207
235
|
// flow control, not a masked failure: the same request is re-sent unchanged after the
|
|
208
|
-
// documented wait, and the
|
|
209
|
-
|
|
210
|
-
|
|
236
|
+
// documented wait, and the 7th 429 halts.
|
|
237
|
+
// 6 tries, up to 90s apart: several estimates rescaled in parallel share one quota (2026-09-27)
|
|
238
|
+
const wait = Math.min(20000 * attempt, 90000);
|
|
239
|
+
console.error(` … Sheets API 429 (rate limit) — waiting ${wait / 1000}s, retry ${attempt}/6`);
|
|
211
240
|
await new Promise((r) => setTimeout(r, wait));
|
|
212
241
|
return this.call(method, url, body, attempt + 1);
|
|
213
242
|
}
|
|
@@ -247,6 +276,15 @@ class SheetsApi {
|
|
|
247
276
|
});
|
|
248
277
|
}
|
|
249
278
|
|
|
279
|
+
/** Many ranges in ONE request (values:batchUpdate) — one write against the per-minute quota instead of N. */
|
|
280
|
+
async putValuesBatch(entries) {
|
|
281
|
+
if (!entries.length) return {};
|
|
282
|
+
return this.call("POST", `${this.base}/values:batchUpdate`, {
|
|
283
|
+
valueInputOption: "USER_ENTERED",
|
|
284
|
+
data: entries.map((e) => ({ range: `'${e.tab}'!${e.a1}`, majorDimension: "ROWS", values: e.values })),
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
|
|
250
288
|
async clearValues(tab, a1) {
|
|
251
289
|
const rng = encodeURIComponent(`'${tab}'!${a1}`);
|
|
252
290
|
return this.call("POST", `${this.base}/values/${rng}:clear`, {});
|
|
@@ -347,10 +385,11 @@ function locateTshirt(grid) {
|
|
|
347
385
|
cols.sizeLabels = cols.sizes.map((c) => headerText[c]);
|
|
348
386
|
// legend: the XS..XXL rows in column A
|
|
349
387
|
const legend = {};
|
|
388
|
+
const legendRow1 = {};
|
|
350
389
|
let legendFirst = -1, legendLast = -1;
|
|
351
390
|
for (let r = 0; r < headerRow; r++) {
|
|
352
|
-
const m = textAt(grid, r, 0).trim().match(/^(XS|S|M|L|XL|XXL)\s*-/);
|
|
353
|
-
if (m) { legend[m[1]] = numAt(grid, r, 1); if (legendFirst < 0) legendFirst = r; legendLast = r; }
|
|
391
|
+
const m = textAt(grid, r, 0).trim().match(/^(XXS|XS|S|M|L|XL|XXL)\s*-/);
|
|
392
|
+
if (m) { legend[m[1]] = numAt(grid, r, 1); legendRow1[m[1]] = r + 1; if (legendFirst < 0) legendFirst = r; legendLast = r; }
|
|
354
393
|
}
|
|
355
394
|
for (const code of SIZE_CODES) {
|
|
356
395
|
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)`);
|
|
@@ -391,7 +430,7 @@ function locateTshirt(grid) {
|
|
|
391
430
|
const lowDol = findCell(grid, "Low ($)", headerRow, 20), highDol = findCell(grid, "High ($)", headerRow, 20);
|
|
392
431
|
if (!lowHrs || !highHrs || !lowDol || !highDol) throw new Halt(`${TAB_TSHIRT}: rollup headers 'Low ($)' / 'Low Hrs' / 'High ($)' / 'High Hrs' not all found`);
|
|
393
432
|
cols.rollupPhase = mvp.c; cols.lowHrs = lowHrs.c; cols.highHrs = highHrs.c; cols.lowDol = lowDol.c; cols.highDol = highDol.c;
|
|
394
|
-
return { headerRow, firstItemRow: headerRow + 1, legend, legendFirst1: legendFirst + 1, legendLast1: legendLast + 1, mf, mfTotal, mfTotalCell, highFactor, rate, rollupRows, cols };
|
|
433
|
+
return { headerRow, firstItemRow: headerRow + 1, legend, legendRow1, legendExact: legend.XXS != null, legendFirst1: legendFirst + 1, legendLast1: legendLast + 1, mf, mfTotal, mfTotalCell, highFactor, rate, rollupRows, cols };
|
|
395
434
|
}
|
|
396
435
|
|
|
397
436
|
/** The standard template's column map (what `write` produces). */
|
|
@@ -604,6 +643,35 @@ function buildRoster(plan, totalDays, mfList) {
|
|
|
604
643
|
|
|
605
644
|
// ───────────────────────── T-Shirt totals (pure) ─────────────────────────
|
|
606
645
|
|
|
646
|
+
/** Switching allowance (minutes) for a task of `teamMin` minutes: small 7.5, medium 15, large 30. */
|
|
647
|
+
function defaultSwitchMin(teamMin) { return teamMin < 120 ? 7.5 : teamMin < 480 ? 15 : 30; }
|
|
648
|
+
|
|
649
|
+
/**
|
|
650
|
+
* The estimator's per-task math: solo AI minutes × project multiplier + switching (after the
|
|
651
|
+
* multiplier), then the nearest AI_SIZE_DAYS size (geometric midpoints between neighbours).
|
|
652
|
+
*/
|
|
653
|
+
function aiTaskSize({ soloMin, project, switchMin, xxs }) {
|
|
654
|
+
const mult = PROJECT_MULTIPLIER[project];
|
|
655
|
+
if (mult == null) throw new Halt(`--project must be one of ${Object.keys(PROJECT_MULTIPLIER).join(" | ")}`, 64);
|
|
656
|
+
if (typeof soloMin !== "number" || !(soloMin > 0)) throw new Halt("--solo-min must be a positive number of minutes", 64);
|
|
657
|
+
const teamMin = soloMin * mult;
|
|
658
|
+
const sw = switchMin == null ? defaultSwitchMin(teamMin) : switchMin;
|
|
659
|
+
if (typeof sw !== "number" || sw < 0) throw new Halt("--switch-min must be a number of minutes ≥ 0", 64);
|
|
660
|
+
const hours = (teamMin + sw) / 60;
|
|
661
|
+
const days = hours / 8;
|
|
662
|
+
const scale = xxs ? { XXS: XXS_DAYS, ...AI_SIZE_DAYS } : AI_SIZE_DAYS;
|
|
663
|
+
const codes = Object.keys(scale);
|
|
664
|
+
let size = codes[codes.length - 1];
|
|
665
|
+
for (let i = 0; i < codes.length - 1; i++) {
|
|
666
|
+
const cut = Math.sqrt(scale[codes[i]] * scale[codes[i + 1]]);
|
|
667
|
+
if (days < cut) { size = codes[i]; break; }
|
|
668
|
+
}
|
|
669
|
+
return { soloMin, project, multiplier: mult, switchMin: sw, hours: round2(hours), days: round2(days), size, sizeDays: scale[size] };
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
/** True when the sheet's legend already carries the AI scale. */
|
|
673
|
+
function legendIsAi(legend) { return SIZE_CODES.every((c) => legend[c] === AI_SIZE_DAYS[c]); }
|
|
674
|
+
|
|
607
675
|
function sizeDays(code, legend) {
|
|
608
676
|
const v = sizeOf(code);
|
|
609
677
|
if (v === "") return 0;
|
|
@@ -625,7 +693,8 @@ function tshirtTotals(plan, layout) {
|
|
|
625
693
|
function itemFormulasFor(r, cols, legend) {
|
|
626
694
|
const L = colLetter;
|
|
627
695
|
const lg = `$A$${legend.first1}:$A$${legend.last1}`, lv = `$B$${legend.first1}:$B$${legend.last1}`;
|
|
628
|
-
const
|
|
696
|
+
const key = (c) => legend.exact ? `${L(c)}${r}&" -*"` : `LEFT(${L(c)}${r},2)&"*"`;
|
|
697
|
+
const days = "=(" + cols.sizes.map((c) => `IF(${L(c)}${r}="",0,SUMIF(${lg},${key(c)},${lv}))`).join("+") + ")";
|
|
629
698
|
const abs = (cell) => `$${L(cell.c)}$${cell.r + 1}`;
|
|
630
699
|
const f = {
|
|
631
700
|
H: days,
|
|
@@ -721,11 +790,15 @@ async function writeTshirt(api, plan, opts) {
|
|
|
721
790
|
const layout = locateTshirt(grid);
|
|
722
791
|
const std = ["phase", "days", "mfactor", "total", "low", "high"].every((k) => layout.cols[k] === STANDARD_COLS[k]) && layout.cols.sizes.length === 2 && layout.legendFirst1 === 4 && layout.headerRow === 12;
|
|
723
792
|
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`);
|
|
793
|
+
// New estimates use the AI-assisted scale: the legend is rewritten to AI_SIZE_DAYS (after the
|
|
794
|
+
// checks below pass, so a halted run changes nothing), and totals are computed on it.
|
|
795
|
+
const legendChange = !legendIsAi(layout.legend);
|
|
796
|
+
layout.legend = { ...AI_SIZE_DAYS };
|
|
724
797
|
const totals = tshirtTotals(plan, layout);
|
|
725
798
|
const phaseSrc = findPhaseSource(grid, 0);
|
|
726
799
|
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`);
|
|
727
800
|
|
|
728
|
-
if (plan.tshirt.mode === "sizes") return writeTshirtSizes(api, grid, sheetId, layout, plan, totals, phaseSrc);
|
|
801
|
+
if (plan.tshirt.mode === "sizes") return writeTshirtSizes(api, grid, sheetId, layout, plan, totals, phaseSrc, legendChange);
|
|
729
802
|
|
|
730
803
|
const first0 = layout.firstItemRow;
|
|
731
804
|
let occupied = 0;
|
|
@@ -735,6 +808,7 @@ async function writeTshirt(api, plan, opts) {
|
|
|
735
808
|
}
|
|
736
809
|
const built = tshirtRows(plan, first0);
|
|
737
810
|
const lastWritten1 = built.rows[built.rows.length - 1].row1;
|
|
811
|
+
if (legendChange) await writeAiLegend(api, layout);
|
|
738
812
|
|
|
739
813
|
// 1. clear values + formats + merges in the item area (clear-then-paint)
|
|
740
814
|
const clearTo = Math.max(lastWritten1 + 5, rowCount(grid) + 1);
|
|
@@ -792,7 +866,12 @@ async function writeTshirt(api, plan, opts) {
|
|
|
792
866
|
}
|
|
793
867
|
|
|
794
868
|
/** sizes mode — rows exist (gap-analysis sheet); fill E:L on rows matched by "(id)" in column C. */
|
|
795
|
-
|
|
869
|
+
/** Put AI_SIZE_DAYS into the legend's value column, row by row by label (never by position). */
|
|
870
|
+
async function writeAiLegend(api, layout) {
|
|
871
|
+
for (const c of SIZE_CODES) await api.putValues(TAB_TSHIRT, `B${layout.legendRow1[c]}`, [[AI_SIZE_DAYS[c]]]);
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
async function writeTshirtSizes(api, grid, sheetId, layout, plan, totals, phaseSrc, legendChange) {
|
|
796
875
|
const rowsById = new Map();
|
|
797
876
|
for (let r = layout.firstItemRow; r < rowCount(grid); r++) {
|
|
798
877
|
const m = textAt(grid, r, 2).match(/\(([A-Za-z]+-\d+(?:\.\d+)*)\)\s*$/);
|
|
@@ -809,6 +888,18 @@ async function writeTshirtSizes(api, grid, sheetId, layout, plan, totals, phaseS
|
|
|
809
888
|
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] });
|
|
810
889
|
}
|
|
811
890
|
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);
|
|
891
|
+
if (legendChange) {
|
|
892
|
+
// Changing the legend re-prices EVERY sized row on the tab. A sized row the plan does not
|
|
893
|
+
// cover would silently move to the new scale — halt instead, naming the rows.
|
|
894
|
+
const planned = new Set(writes.map((w) => w.r0));
|
|
895
|
+
const stray = [];
|
|
896
|
+
for (const [id, r0] of rowsById) {
|
|
897
|
+
if (planned.has(r0)) continue;
|
|
898
|
+
if (layout.cols.sizes.some((c) => textAt(grid, r0, c).trim())) stray.push(id);
|
|
899
|
+
}
|
|
900
|
+
if (stray.length) throw new Halt(`${TAB_TSHIRT}: the sheet is on the old day scale and these sized rows are not in the plan — ${stray.join(", ")}. Moving the legend to the AI-assisted scale would re-price them silently. Size every row in the plan, or clear those rows first.`, 4, stray);
|
|
901
|
+
await writeAiLegend(api, layout);
|
|
902
|
+
}
|
|
812
903
|
let totalRow0 = -1;
|
|
813
904
|
for (let r = lastItem0 + 1; r < rowCount(grid); r++) if (/^Total \(Days\)/i.test(textAt(grid, r, 0))) { totalRow0 = r; break; }
|
|
814
905
|
if (totalRow0 < 0) throw new Halt(`${TAB_TSHIRT}: no 'Total (Days)' row found below the items — add it (spec §1.3) and re-run`);
|
|
@@ -936,8 +1027,8 @@ function teamMixFormatReqs(sheetId, v) {
|
|
|
936
1027
|
}
|
|
937
1028
|
|
|
938
1029
|
/** Write every phase grid on the one Team Mix tab: clear-then-paint, GRID_GAP blank rows between grids. */
|
|
939
|
-
async function writeTeamMix(api, plan, rosters) {
|
|
940
|
-
const grid = await api.grid(
|
|
1030
|
+
async function writeTeamMix(api, plan, rosters, tab = TAB_TEAM) {
|
|
1031
|
+
const grid = await api.grid(tab);
|
|
941
1032
|
const sheetId = grid.properties.sheetId;
|
|
942
1033
|
const grids = [];
|
|
943
1034
|
let top = 0;
|
|
@@ -948,7 +1039,7 @@ async function writeTeamMix(api, plan, rosters) {
|
|
|
948
1039
|
}
|
|
949
1040
|
const lastRow = grids[grids.length - 1].v.bottom;
|
|
950
1041
|
const clearRows = Math.max(60, rowCount(grid) + 2, lastRow + 10);
|
|
951
|
-
await api.clearValues(
|
|
1042
|
+
await api.clearValues(tab, `A1:Z${clearRows}`);
|
|
952
1043
|
await api.batch([
|
|
953
1044
|
{ unmergeCells: { range: gridRange(sheetId, 0, clearRows, 0, 26) } },
|
|
954
1045
|
fmtReq(sheetId, 0, clearRows, 0, 26, {}, "userEnteredFormat"),
|
|
@@ -956,7 +1047,7 @@ async function writeTeamMix(api, plan, rosters) {
|
|
|
956
1047
|
const reqs = [];
|
|
957
1048
|
let maxN = 0;
|
|
958
1049
|
for (const g of grids) {
|
|
959
|
-
await api.putValues(
|
|
1050
|
+
await api.putValues(tab, `A${g.v.titleR}:${colLetter(g.v.width - 1)}${g.v.bottom}`, g.v.rows);
|
|
960
1051
|
reqs.push(...teamMixFormatReqs(sheetId, g.v));
|
|
961
1052
|
maxN = Math.max(maxN, g.v.totalC - g.v.firstMonthC);
|
|
962
1053
|
}
|
|
@@ -998,7 +1089,7 @@ function auditTshirt(grid) {
|
|
|
998
1089
|
const out = [];
|
|
999
1090
|
const layout = locateTshirt(grid);
|
|
1000
1091
|
const { cols } = layout;
|
|
1001
|
-
const legendRows = { first1: layout.legendFirst1, last1: layout.legendLast1 };
|
|
1092
|
+
const legendRows = { first1: layout.legendFirst1, last1: layout.legendLast1, exact: layout.legendExact };
|
|
1002
1093
|
const first0 = layout.firstItemRow;
|
|
1003
1094
|
const badSizes = [], noDv = [], badFormula = [], sectionsWithSizes = [], badSectionFmt = [], noWrap = [], notTop = [];
|
|
1004
1095
|
let lastItem0 = -1, totalRow0 = -1;
|
|
@@ -1015,7 +1106,7 @@ function auditTshirt(grid) {
|
|
|
1015
1106
|
lastItem0 = r;
|
|
1016
1107
|
for (const c of cols.sizes) {
|
|
1017
1108
|
const v = textAt(grid, r, c).trim();
|
|
1018
|
-
if (v && !SIZE_CODES.includes(v)) badSizes.push(`${colLetter(c)}${r + 1}='${v}'`);
|
|
1109
|
+
if (v && !(SIZE_CODES.includes(v) || (v === "XXS" && layout.legendExact))) badSizes.push(`${colLetter(c)}${r + 1}='${v}'`);
|
|
1019
1110
|
}
|
|
1020
1111
|
const cell = cellAt(grid, r, cols.phase);
|
|
1021
1112
|
if (!(cell && cell.dataValidation && cell.dataValidation.condition && cell.dataValidation.condition.type === "ONE_OF_LIST")) noDv.push(`${colLetter(cols.phase)}${r + 1}`);
|
|
@@ -1314,6 +1405,7 @@ function rostersTable(rosters) {
|
|
|
1314
1405
|
|
|
1315
1406
|
async function verbPlanCheck(api, plan) {
|
|
1316
1407
|
const layout = locateTshirt(await api.grid(TAB_TSHIRT));
|
|
1408
|
+
layout.legend = { ...AI_SIZE_DAYS }; // preview what `write` produces — it puts the AI scale in the legend
|
|
1317
1409
|
const totals = tshirtTotals(plan, layout);
|
|
1318
1410
|
const rosters = buildRosters(plan, layout);
|
|
1319
1411
|
return { totals, mf: layout.mf, mfTotal: round2(layout.mfTotal), highFactor: layout.highFactor, rate: layout.rate, rosters, table: rostersTable(rosters) };
|
|
@@ -1591,13 +1683,151 @@ async function verbWrite(api, plan, opts) {
|
|
|
1591
1683
|
return result;
|
|
1592
1684
|
}
|
|
1593
1685
|
|
|
1686
|
+
// ───────────────────────── rescale (AI-assisted re-estimate into copied tabs) ─────────────────────────
|
|
1687
|
+
|
|
1688
|
+
/** Every sized item row on a T-Shirt grid: row (1-based), text, phase and its size codes in size-column order. */
|
|
1689
|
+
function sizedRows(grid, layout) {
|
|
1690
|
+
const out = [];
|
|
1691
|
+
for (let r = layout.firstItemRow; r < rowCount(grid); r++) {
|
|
1692
|
+
if (/^Total \(Days\)/i.test(textAt(grid, r, 0).trim())) break;
|
|
1693
|
+
const sizes = layout.cols.sizes.map((c) => textAt(grid, r, c).trim());
|
|
1694
|
+
if (!sizes.some((v) => v)) continue;
|
|
1695
|
+
out.push({ row: r + 1, module: textAt(grid, r, 0).trim(), userType: textAt(grid, r, 1).trim(), functionality: textAt(grid, r, 2).trim(), requirement: textAt(grid, r, 3).trim(), phase: textAt(grid, r, layout.cols.phase).trim(), sizes });
|
|
1696
|
+
}
|
|
1697
|
+
return out;
|
|
1698
|
+
}
|
|
1699
|
+
|
|
1700
|
+
/**
|
|
1701
|
+
* Validate a rescale plan against the source rows. Every sized row must be re-sized exactly once,
|
|
1702
|
+
* matched by row AND its Functionality text (a row that moved since the list was taken HALTS).
|
|
1703
|
+
*/
|
|
1704
|
+
function rescaleViolations(plan, rows, sizeCount) {
|
|
1705
|
+
const errors = [];
|
|
1706
|
+
const items = plan && Array.isArray(plan.items) ? plan.items : null;
|
|
1707
|
+
if (!items || !items.length) return ["items: non-empty array required — [{ row, functionality, sizes: [...] }]"];
|
|
1708
|
+
const byRow = new Map(rows.map((r) => [r.row, r]));
|
|
1709
|
+
const seen = new Set();
|
|
1710
|
+
items.forEach((it, i) => {
|
|
1711
|
+
const where = `items[${i}]`;
|
|
1712
|
+
const src = byRow.get(it.row);
|
|
1713
|
+
if (!src) { errors.push(`${where}: row ${it.row} is not a sized item row on the source tab`); return; }
|
|
1714
|
+
if (seen.has(it.row)) errors.push(`${where}: row ${it.row} appears twice`);
|
|
1715
|
+
seen.add(it.row);
|
|
1716
|
+
if (String(it.functionality == null ? "" : it.functionality).trim() !== src.functionality) errors.push(`${where}: row ${it.row} Functionality does not match the sheet ('${src.functionality.slice(0, 60)}') — re-list the rows and rebuild the plan`);
|
|
1717
|
+
if (!Array.isArray(it.sizes) || it.sizes.length !== sizeCount) { errors.push(`${where}.sizes: array of ${sizeCount} size code(s) required, in size-column order`); return; }
|
|
1718
|
+
it.sizes.forEach((v, k) => { const c = sizeOf(v); if (c !== "" && c !== "XXS" && !SIZE_CODES.includes(c)) errors.push(`${where}.sizes[${k}]: '${v}' is not a bare size code (XXS ${SIZE_CODES.join(" ")})`); });
|
|
1719
|
+
if (it.sizes.every((v) => sizeOf(v) === "")) errors.push(`${where}: every size is blank — an item with no size is a defect`);
|
|
1720
|
+
});
|
|
1721
|
+
const missing = rows.filter((r) => !seen.has(r.row)).map((r) => r.row);
|
|
1722
|
+
if (missing.length) errors.push(`rows not re-sized: ${missing.join(", ")} — every sized row must be in the plan, or its copy would silently re-price on the new scale`);
|
|
1723
|
+
return errors;
|
|
1724
|
+
}
|
|
1725
|
+
|
|
1726
|
+
async function verbRescale(api, opts) {
|
|
1727
|
+
const meta = await api.meta();
|
|
1728
|
+
const byTitle = new Map(meta.sheets.map((s) => [s.properties.title, s.properties]));
|
|
1729
|
+
for (const t of [TAB_TSHIRT, TAB_TEAM]) if (!byTitle.has(t)) throw new Halt(`tab '${t}' is missing — this is not a Tekyz estimate sheet`);
|
|
1730
|
+
const srcGrid = await api.grid(TAB_TSHIRT);
|
|
1731
|
+
const layout = locateTshirt(srcGrid);
|
|
1732
|
+
const rows = sizedRows(srcGrid, layout);
|
|
1733
|
+
if (opts.list) return { sizeColumns: layout.cols.sizeLabels, legend: layout.legend, mfTotal: round2(layout.mfTotal), rows };
|
|
1734
|
+
|
|
1735
|
+
const errors = rescaleViolations(opts.plan, rows, layout.cols.sizes.length);
|
|
1736
|
+
if (errors.length) throw new Halt(`rescale plan rejected:\n - ${errors.join("\n - ")}`, 4, errors);
|
|
1737
|
+
// --suffix names a separate pair of copies (e.g. "AI v2") so a second re-estimate never
|
|
1738
|
+
// overwrites the (Rescale) tabs other sheets already import by name.
|
|
1739
|
+
const suffix = opts.suffix == null ? "Rescale" : String(opts.suffix).trim();
|
|
1740
|
+
if (!suffix || /[()'!]/.test(suffix)) throw new Halt("--suffix must be plain text (no parentheses, quotes or '!')", 64);
|
|
1741
|
+
const T_TS = `${TAB_TSHIRT} (${suffix})`, T_TM = `${TAB_TEAM} (${suffix})`;
|
|
1742
|
+
const aiScale = { XXS: XXS_DAYS, ...AI_SIZE_DAYS };
|
|
1743
|
+
const aiRaw = opts.plan.items.reduce((sum, it) => sum + it.sizes.reduce((a, v) => a + (sizeOf(v) ? aiScale[sizeOf(v)] : 0), 0), 0);
|
|
1744
|
+
const oldRaw = rows.reduce((sum, r) => sum + r.sizes.reduce((a, v) => a + (layout.legend[v] || 0), 0), 0);
|
|
1745
|
+
const noOverhead = opts.plan.noOverhead !== false; // default ON: the multipliers include team overhead
|
|
1746
|
+
const newMf = noOverhead ? 0 : layout.mfTotal;
|
|
1747
|
+
const preview = { items: rows.length, oldLowHours: round2(oldRaw * (1 + layout.mfTotal) * 8), newLowHours: round2(aiRaw * (1 + newMf) * 8) };
|
|
1748
|
+
const exists = [T_TS, T_TM].filter((t) => byTitle.has(t));
|
|
1749
|
+
if (exists.length && !opts.replace) throw new Halt(`tab(s) ${exists.map((t) => `'${t}'`).join(", ")} already exist — pass --replace to rebuild them (the original tabs are never touched)`);
|
|
1750
|
+
if (opts.dryRun) return { dryRun: true, ...preview };
|
|
1751
|
+
|
|
1752
|
+
// 1. copies. A rebuild (--replace) refreshes the existing (Rescale) tabs IN PLACE — never delete and
|
|
1753
|
+
// re-create them: other sheets import these tabs by name (IMPORTRANGE), and a view that refreshes while
|
|
1754
|
+
// a tab is missing caches #REF! (David, 2026-09-28). The original tabs are only ever the copy source.
|
|
1755
|
+
const src = byTitle.get(TAB_TSHIRT);
|
|
1756
|
+
if (byTitle.has(T_TS)) {
|
|
1757
|
+
const dst = byTitle.get(T_TS).sheetId;
|
|
1758
|
+
const rowsN = Math.max(src.gridProperties.rowCount, byTitle.get(T_TS).gridProperties.rowCount);
|
|
1759
|
+
const colsN = Math.max(src.gridProperties.columnCount, byTitle.get(T_TS).gridProperties.columnCount);
|
|
1760
|
+
const widths = (srcGrid.data && srcGrid.data[0] && srcGrid.data[0].columnMetadata ? srcGrid.data[0].columnMetadata : []).map((c) => c.pixelSize);
|
|
1761
|
+
await api.batch([
|
|
1762
|
+
{ updateSheetProperties: { properties: { sheetId: dst, gridProperties: { rowCount: rowsN, columnCount: colsN, frozenRowCount: src.gridProperties.frozenRowCount || 0 } }, fields: "gridProperties(rowCount,columnCount,frozenRowCount)" } },
|
|
1763
|
+
{ unmergeCells: { range: gridRange(dst, 0, rowsN, 0, colsN) } },
|
|
1764
|
+
{ updateCells: { range: gridRange(dst, 0, rowsN, 0, colsN), fields: "*" } },
|
|
1765
|
+
{ copyPaste: { source: gridRange(src.sheetId, 0, src.gridProperties.rowCount, 0, src.gridProperties.columnCount), destination: gridRange(dst, 0, src.gridProperties.rowCount, 0, src.gridProperties.columnCount), pasteType: "PASTE_NORMAL" } },
|
|
1766
|
+
...widths.map((w, c) => ({ updateDimensionProperties: { range: { sheetId: dst, dimension: "COLUMNS", startIndex: c, endIndex: c + 1 }, properties: { pixelSize: w }, fields: "pixelSize" } })),
|
|
1767
|
+
]);
|
|
1768
|
+
} else {
|
|
1769
|
+
await api.batch([{ duplicateSheet: { sourceSheetId: src.sheetId, newSheetName: T_TS, insertSheetIndex: src.index + 1 } }]);
|
|
1770
|
+
}
|
|
1771
|
+
if (!byTitle.has(T_TM)) {
|
|
1772
|
+
const meta2 = await api.meta();
|
|
1773
|
+
const teamIdx = meta2.sheets.find((s) => s.properties.title === TAB_TEAM).properties.index;
|
|
1774
|
+
await api.batch([{ duplicateSheet: { sourceSheetId: byTitle.get(TAB_TEAM).sheetId, newSheetName: T_TM, insertSheetIndex: teamIdx + 1 } }]);
|
|
1775
|
+
} // an existing Team Mix (Rescale) is cleared and rewritten by writeTeamMix below, in place
|
|
1776
|
+
|
|
1777
|
+
// 2. the copied T-Shirt tab: AI legend (+ XXS in the row under XXL), exact-match Days formulas, new sizes
|
|
1778
|
+
const xxsRow1 = layout.legendRow1.XXS || layout.legendLast1 + 1;
|
|
1779
|
+
if (!layout.legendRow1.XXS && (textAt(srcGrid, xxsRow1 - 1, 0) || textAt(srcGrid, xxsRow1 - 1, 1))) {
|
|
1780
|
+
throw new Halt(`${TAB_TSHIRT}: row ${xxsRow1} under the size legend is not empty (A${xxsRow1}/B${xxsRow1}) — there is nowhere to add XXS without overwriting it`);
|
|
1781
|
+
}
|
|
1782
|
+
const writes = SIZE_CODES.map((c) => ({ tab: T_TS, a1: `B${layout.legendRow1[c]}`, values: [[AI_SIZE_DAYS[c]]] }));
|
|
1783
|
+
writes.push({ tab: T_TS, a1: `A${xxsRow1}:B${xxsRow1}`, values: [[XXS_LABEL, XXS_DAYS]] });
|
|
1784
|
+
const first = layout.cols.sizes[0], last = layout.cols.sizes[layout.cols.sizes.length - 1];
|
|
1785
|
+
for (const it of opts.plan.items) writes.push({ tab: T_TS, a1: `${colLetter(first)}${it.row}:${colLetter(last)}${it.row}`, values: [it.sizes.map(sizeOf)] });
|
|
1786
|
+
const legendAi = { first1: Math.min(layout.legendFirst1, xxsRow1), last1: Math.max(layout.legendLast1, xxsRow1), exact: true };
|
|
1787
|
+
const hRows = [];
|
|
1788
|
+
for (let r = layout.firstItemRow; r < rowCount(srcGrid); r++) {
|
|
1789
|
+
if (/^Total \(Days\)/i.test(textAt(srcGrid, r, 0).trim())) break;
|
|
1790
|
+
if (formulaAt(srcGrid, r, layout.cols.days)) hRows.push(r + 1);
|
|
1791
|
+
}
|
|
1792
|
+
for (const r1 of hRows) writes.push({ tab: T_TS, a1: `${colLetter(layout.cols.days)}${r1}`, values: [[itemFormulasFor(r1, layout.cols, legendAi).H]] });
|
|
1793
|
+
// plan.noOverhead: the multipliers already include team overhead (David, 2026-09-28) — zero the
|
|
1794
|
+
// COPY's overhead factors so nothing is charged twice. The original tab's factors are never touched.
|
|
1795
|
+
if (noOverhead) for (const f of layout.mf) writes.push({ tab: T_TS, a1: `${colLetter(layout.cols.mfTotal.c)}${f.row}`, values: [[0]] });
|
|
1796
|
+
await api.putValuesBatch(writes); // legend + XXS + sizes + Days formulas (+ zeroed overhead): one request
|
|
1797
|
+
// the copy meets the spec's formatting on its own (C/D wrap, every cell top-aligned) — a template
|
|
1798
|
+
// that predates those rules would otherwise hand its failures to the new tabs
|
|
1799
|
+
const aiTshirtId = (await api.meta()).sheets.find((x) => x.properties.title === T_TS).properties.sheetId;
|
|
1800
|
+
const lastRow1 = rows[rows.length - 1].row;
|
|
1801
|
+
const xxlRow0 = layout.legendRow1.XXL - 1;
|
|
1802
|
+
await api.batch([
|
|
1803
|
+
{ copyPaste: { source: gridRange(aiTshirtId, xxlRow0, xxlRow0 + 1, 0, 2), destination: gridRange(aiTshirtId, xxsRow1 - 1, xxsRow1, 0, 2), pasteType: "PASTE_FORMAT" } },
|
|
1804
|
+
wrapReq(aiTshirtId, layout.firstItemRow, lastRow1, 2, 4), topAlignReq(aiTshirtId, lastRow1 + 5),
|
|
1805
|
+
]);
|
|
1806
|
+
|
|
1807
|
+
// 3. the copied Team Mix: same roster as the original, staffed from the copy's phase rollups
|
|
1808
|
+
const aiGrid = await api.grid(T_TS);
|
|
1809
|
+
const aiLayout = locateTshirt(aiGrid);
|
|
1810
|
+
const fte = deriveFteFromTeamMix(await api.grid(TAB_TEAM));
|
|
1811
|
+
const tmPlan = { teamMix: { fte } };
|
|
1812
|
+
const rosters = phaseTotalsFromSheet(aiGrid, aiLayout).map((ph) => ({ ...ph, roster: buildRoster(tmPlan, ph.staffDays, aiLayout.mf) }));
|
|
1813
|
+
await writeTeamMix(api, tmPlan, rosters, T_TM);
|
|
1814
|
+
|
|
1815
|
+
// 4. audit the copies by read-back
|
|
1816
|
+
const checks = [];
|
|
1817
|
+
const ts = auditTshirt(await api.grid(T_TS));
|
|
1818
|
+
checks.push(...ts.checks);
|
|
1819
|
+
checks.push(...auditTeamMix(await api.grid(T_TM), ts.layout.mf, ts.staffDays, ts.phaseDays).checks);
|
|
1820
|
+
const failed = checks.filter((c) => !c.ok);
|
|
1821
|
+
return { ...preview, fte, table: rostersTable(rosters), audit: { title: `${meta.properties.title} — (${suffix}) tabs`, checks, failed: failed.length, ok: failed.length === 0 } };
|
|
1822
|
+
}
|
|
1823
|
+
|
|
1594
1824
|
// ───────────────────────── CLI ─────────────────────────
|
|
1595
1825
|
|
|
1596
1826
|
function parseArgs(argv) {
|
|
1597
1827
|
const out = { _: [] };
|
|
1598
1828
|
for (let i = 0; i < argv.length; i++) {
|
|
1599
1829
|
const a = argv[i];
|
|
1600
|
-
if (a === "--json" || a === "--replace" || a === "--no-audit" || a === "--dry-run") out[a.slice(2)] = true;
|
|
1830
|
+
if (a === "--json" || a === "--replace" || a === "--no-audit" || a === "--dry-run" || a === "--list" || a === "--xxs") out[a.slice(2)] = true;
|
|
1601
1831
|
else if (a.startsWith("--")) { out[a.slice(2)] = argv[i + 1]; i++; }
|
|
1602
1832
|
else out._.push(a);
|
|
1603
1833
|
}
|
|
@@ -1609,7 +1839,7 @@ function printChecks(audit) {
|
|
|
1609
1839
|
console.log(`${audit.ok ? "AUDIT PASS" : `AUDIT FAIL (${audit.failed})`} — ${audit.title}`);
|
|
1610
1840
|
}
|
|
1611
1841
|
|
|
1612
|
-
const USAGE = "usage: gsd-t estimate-sheet <read|plan-check|write|teammix|format|phases|titles|audit|plan-schema> --sheet <id|url> [--tab <name>] [--plan <plan.json>] [--replace] [--fte '{\"backend\":1.5}'] [--title <t>] [--dry-run] [--no-audit] [--key <path>] [--json]";
|
|
1842
|
+
const USAGE = "usage: gsd-t estimate-sheet <read|plan-check|write|teammix|format|phases|titles|audit|plan-schema|size|rescale> --sheet <id|url> [--tab <name>] [--plan <plan.json>] [--replace] [--fte '{\"backend\":1.5}'] [--title <t>] [--dry-run] [--no-audit] [--key <path>] [--json]\n gsd-t estimate-sheet size --solo-min <n> --project <greenfield-solo|greenfield-team|yellowfield-solo|yellowfield-team-isolated|yellowfield-team-wide> [--switch-min <n>] [--xxs] [--json]\n gsd-t estimate-sheet rescale --sheet <id|url> (--list | --plan <rescale.json> [--dry-run] [--replace] [--suffix <name>]) [--json]";
|
|
1613
1843
|
|
|
1614
1844
|
/** Runs a verb; returns the exit code. Throws Halt (or any error) — the runner below turns that into exit 4/64. */
|
|
1615
1845
|
async function main(args) {
|
|
@@ -1617,6 +1847,12 @@ async function main(args) {
|
|
|
1617
1847
|
const json = !!args.json;
|
|
1618
1848
|
if (!verb || verb === "help") { console.log(USAGE); return 0; }
|
|
1619
1849
|
if (verb === "plan-schema") { console.log(JSON.stringify(PLAN_SCHEMA, null, 2)); return 0; }
|
|
1850
|
+
if (verb === "size") {
|
|
1851
|
+
const r = aiTaskSize({ soloMin: Number(args["solo-min"]), project: args.project, switchMin: args["switch-min"] == null ? undefined : Number(args["switch-min"]), xxs: !!args.xxs });
|
|
1852
|
+
if (json) console.log(JSON.stringify({ ok: true, exitCode: 0, ...r }, null, 2));
|
|
1853
|
+
else console.log(`${r.soloMin} solo min × ${r.multiplier} (${r.project}) + ${r.switchMin} min switching = ${r.hours} h (${r.days} d) → ${r.size} (${r.sizeDays} d)`);
|
|
1854
|
+
return 0;
|
|
1855
|
+
}
|
|
1620
1856
|
const sheetId = sheetIdFromArg(args.sheet);
|
|
1621
1857
|
const api = new SheetsApi(await getToken(args.key), sheetId);
|
|
1622
1858
|
if (verb === "read") {
|
|
@@ -1665,6 +1901,20 @@ async function main(args) {
|
|
|
1665
1901
|
if (json) console.log(JSON.stringify({ ok: a.ok, exitCode: a.ok ? 0 : 4, ...a }, null, 2)); else printChecks(a);
|
|
1666
1902
|
return a.ok ? 0 : 4;
|
|
1667
1903
|
}
|
|
1904
|
+
if (verb === "rescale") {
|
|
1905
|
+
const list = !!args.list;
|
|
1906
|
+
let plan = null;
|
|
1907
|
+
if (!list) { if (!args.plan) throw new Halt("rescale needs --plan <rescale.json> (or --list to print the rows to re-size)", 64); try { plan = JSON.parse(fs.readFileSync(args.plan, "utf8")); } catch (e) { throw new Halt(`cannot read --plan ${args.plan}: ${e.message}`, 64); } }
|
|
1908
|
+
const r = await verbRescale(api, { list, plan, replace: !!args.replace, dryRun: !!args["dry-run"], suffix: args.suffix });
|
|
1909
|
+
const ok = !r.audit || r.audit.ok;
|
|
1910
|
+
if (json || list) console.log(JSON.stringify({ ok, exitCode: ok ? 0 : 4, ...r }, null, 2));
|
|
1911
|
+
else {
|
|
1912
|
+
console.log(`${r.items} items: low ${r.oldLowHours} h on the original tabs → ${r.newLowHours} h on the (${args.suffix || "Rescale"}) tabs`);
|
|
1913
|
+
if (r.dryRun) console.log("(dry run — nothing written)");
|
|
1914
|
+
else { console.log(r.table); printChecks(r.audit); }
|
|
1915
|
+
}
|
|
1916
|
+
return ok ? 0 : 4;
|
|
1917
|
+
}
|
|
1668
1918
|
const plan = loadPlan(args.plan);
|
|
1669
1919
|
if (verb === "plan-check") {
|
|
1670
1920
|
const r = await verbPlanCheck(api, plan);
|
|
@@ -1701,10 +1951,10 @@ function haltAndExit(e, json) {
|
|
|
1701
1951
|
}
|
|
1702
1952
|
|
|
1703
1953
|
module.exports = {
|
|
1704
|
-
validatePlan, splitRoster, rosterViolations, mfCoverageViolations, monthPlan, resampleWeights, rampHours, buildRoster,
|
|
1954
|
+
validatePlan, sizedRows, rescaleViolations, verbRescale, aiTaskSize, legendIsAi, defaultSwitchMin, splitRoster, rosterViolations, mfCoverageViolations, monthPlan, resampleWeights, rampHours, buildRoster,
|
|
1705
1955
|
tshirtTotals, phaseTotals, buildRosters, midDays, deriveFteFromTeamMix, phaseTotalsFromSheet, verbTeamMix, phaseGapMap, verbPhases, verbTitles, itemFormulasFor, rollupFormulasFor, findCell, itemFormulas, rollupFormulas, tshirtRows, teamMixValues, teamMixFormatReqs, remainderFormula, locateTshirt, findPhaseSource,
|
|
1706
1956
|
auditTshirt, auditTeamMix, auditTechStack, auditOverview, colLetter, hexToColor, colorToHex, sheetIdFromArg,
|
|
1707
|
-
constants: { SIZE_CODES, PHASES, COLOR, RAMP, ROLE_LABEL, SOFT_CEILING, FOLD_THRESHOLD, TAB_TSHIRT, TAB_TEAM, TAB_TECH, PLAN_SCHEMA },
|
|
1957
|
+
constants: { TAB_TSHIRT_AI, TAB_TEAM_AI, XXS_DAYS, SIZE_CODES, AI_SIZE_DAYS, PROJECT_MULTIPLIER, PHASES, COLOR, RAMP, ROLE_LABEL, SOFT_CEILING, FOLD_THRESHOLD, TAB_TSHIRT, TAB_TEAM, TAB_TECH, PLAN_SCHEMA },
|
|
1708
1958
|
Halt, SheetsApi, getToken, runAudit, main,
|
|
1709
1959
|
};
|
|
1710
1960
|
|