@tekyzinc/gsd-t 5.22.10 → 5.24.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 +32 -0
- package/README.md +3 -2
- package/bin/gsd-t-estimate-sheet.cjs +206 -28
- package/bin/gsd-t-graph-edge-extract.cjs +454 -2
- package/bin/gsd-t-graph-exclude.cjs +106 -0
- package/bin/gsd-t-graph-freshness.cjs +16 -5
- package/bin/gsd-t-graph-index.cjs +102 -10
- package/bin/gsd-t-graph-query-cli.cjs +546 -27
- package/bin/gsd-t-graph-scip-upgrade.cjs +92 -12
- package/bin/gsd-t-scip-reader.cjs +29 -2
- package/bin/gsd-t.js +43 -6
- package/commands/gsd-t-estimate-rescale.md +64 -0
- package/commands/gsd-t-estimate.md +2 -2
- package/commands/gsd-t-help.md +6 -0
- package/package.json +1 -1
- package/scripts/gsd-t-graph-search-guard.js +98 -4
- package/scripts/statusline-command.sh +11 -2
- package/templates/CLAUDE-global.md +1 -1
- package/templates/estimate-config.json +2 -2
- package/templates/estimate-sheet-spec.md +27 -7
- package/templates/playbooks/tekyz-estimation-and-prd-playbook.md +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,38 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to GSD-T are documented here. Updated with each release.
|
|
4
4
|
|
|
5
|
+
## [5.24.10] - 2026-09-29
|
|
6
|
+
|
|
7
|
+
### Added — the code graph indexes Drizzle database tables
|
|
8
|
+
|
|
9
|
+
"Which code uses this table?" had no graph answer: tables are declared as `const X = pgTable('x', {…})`, which the indexer never recorded, and the graph-search guard then blocked grep with nothing to offer. On hilo-figma-atos the graph now holds all 431 tables and 63 enums, and `who-uses scheduleEvents` agrees with TypeScript findReferences on 179 of 181 users (the gaps are explained in the progress log).
|
|
10
|
+
|
|
11
|
+
- `bin/gsd-t-graph-edge-extract.cjs`: `table` / `enum` entities (pg / mysql / sqlite), columns, foreign keys (`.references()` and `foreignKey()`), READ / WRITE usage edges (from / joins / `db.query.T.findMany|findFirst` / insert / update / delete) attributed to the enclosing function or route handler.
|
|
12
|
+
- `bin/gsd-t-graph-query-cli.cjs`, `bin/gsd-t.js`: `who-uses <table> [--writes|--reads]`, `table <name>`; `body` and `blast-radius` accept tables; not-found answers say the table is not indexed.
|
|
13
|
+
- `scripts/gsd-t-graph-search-guard.js`: table-shaped searches point at who-uses / table / blast-radius.
|
|
14
|
+
- `bin/gsd-t-graph-index.cjs`, `bin/gsd-t-graph-freshness.cjs`: `graph status` counts every indexed file (hilo: 4,301 → 4,322, matching the index) with per-tier counts and exclude suggestions; freshness no longer applies folder skips to file names (`build-analytics.ts` was dropped on every query); deleted / newly-excluded files leave no stale records.
|
|
15
|
+
- `bin/gsd-t-graph-exclude.cjs`: GSD-T's own copied `bin/` tools are excluded by default outside the GSD-T repo.
|
|
16
|
+
- Tests: `test/graph-drizzle-tables.test.js` (21) + `test/fixtures/drizzle-graph/`.
|
|
17
|
+
|
|
18
|
+
Known gaps: code that reaches tables through `import { schema }` objects or CommonJS `require` is not seen; an imported non-table sharing a table's name counts as a use. Re-run `gsd-t graph index` to pick up tables.
|
|
19
|
+
|
|
20
|
+
## [5.23.10] - 2026-09-29
|
|
21
|
+
|
|
22
|
+
### Added — who-calls matches the compiler on route files; graph exclude list; estimate math v2
|
|
23
|
+
|
|
24
|
+
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.
|
|
25
|
+
|
|
26
|
+
- `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`.
|
|
27
|
+
- `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).
|
|
28
|
+
- `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.
|
|
29
|
+
- `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.
|
|
30
|
+
- `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).
|
|
31
|
+
- `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.
|
|
32
|
+
- `scripts/statusline-command.sh`: prefers `.transcript_path` from stdin.
|
|
33
|
+
- Tests: `test/m118-*.test.js`, `test/m119-who-calls-golden.test.js` (golden vs findReferences).
|
|
34
|
+
|
|
35
|
+
Re-run `gsd-t graph index` in each project to pick up the new caller attribution and tiers.
|
|
36
|
+
|
|
5
37
|
## [5.22.10] - 2026-09-27
|
|
6
38
|
|
|
7
39
|
### Added — AI-assisted T-shirt sizing for `/gsd-t-estimate`
|
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# GSD-T: Contract-Driven Development for Claude Code
|
|
2
2
|
|
|
3
|
-
**v5.
|
|
3
|
+
**v5.24.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)
|
|
@@ -185,6 +185,7 @@ This will replace changed command files, back up your CLAUDE.md if customized, a
|
|
|
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
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,22 +45,35 @@ 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"];
|
|
50
53
|
|
|
51
54
|
// AI-assisted sizing model (David, 2026-09-27; calibrated on the Hilo Delivery Runway build:
|
|
52
|
-
// 45 tasks, ~26 solo hours). A task is sized in SOLO AI-assisted minutes, multiplied by the
|
|
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
|
|
53
56
|
// project type, plus a per-task switching allowance added AFTER the multiplier (switching is
|
|
54
57
|
// one person's pickup time — it does not grow with team size). The result maps to the nearest
|
|
55
58
|
// size on AI_SIZE_DAYS, which `write` puts in the sheet's legend. The multipliers live here,
|
|
56
59
|
// in the estimator — never on the sheet.
|
|
57
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.
|
|
58
71
|
const PROJECT_MULTIPLIER = {
|
|
59
72
|
"greenfield-solo": 1,
|
|
60
|
-
"greenfield-team":
|
|
73
|
+
"greenfield-team": 3,
|
|
61
74
|
"yellowfield-solo": 2,
|
|
62
|
-
"yellowfield-team-isolated":
|
|
63
|
-
"yellowfield-team-wide":
|
|
75
|
+
"yellowfield-team-isolated": 5,
|
|
76
|
+
"yellowfield-team-wide": 7,
|
|
64
77
|
};
|
|
65
78
|
const PHASES = ["MVP", "Phase 1", "Phase 2", "Phase 3"];
|
|
66
79
|
|
|
@@ -217,12 +230,13 @@ class SheetsApi {
|
|
|
217
230
|
body: body == null ? undefined : JSON.stringify(body),
|
|
218
231
|
});
|
|
219
232
|
const text = await res.text();
|
|
220
|
-
if (res.status === 429 && attempt <=
|
|
233
|
+
if (res.status === 429 && attempt <= 6) {
|
|
221
234
|
// Rate limit (60 reads/min/user — hit 2026-09-21 auditing 19 sheets in a row). This is
|
|
222
235
|
// flow control, not a masked failure: the same request is re-sent unchanged after the
|
|
223
|
-
// documented wait, and the
|
|
224
|
-
|
|
225
|
-
|
|
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`);
|
|
226
240
|
await new Promise((r) => setTimeout(r, wait));
|
|
227
241
|
return this.call(method, url, body, attempt + 1);
|
|
228
242
|
}
|
|
@@ -262,6 +276,15 @@ class SheetsApi {
|
|
|
262
276
|
});
|
|
263
277
|
}
|
|
264
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
|
+
|
|
265
288
|
async clearValues(tab, a1) {
|
|
266
289
|
const rng = encodeURIComponent(`'${tab}'!${a1}`);
|
|
267
290
|
return this.call("POST", `${this.base}/values/${rng}:clear`, {});
|
|
@@ -365,7 +388,7 @@ function locateTshirt(grid) {
|
|
|
365
388
|
const legendRow1 = {};
|
|
366
389
|
let legendFirst = -1, legendLast = -1;
|
|
367
390
|
for (let r = 0; r < headerRow; r++) {
|
|
368
|
-
const m = textAt(grid, r, 0).trim().match(/^(XS|S|M|L|XL|XXL)\s*-/);
|
|
391
|
+
const m = textAt(grid, r, 0).trim().match(/^(XXS|XS|S|M|L|XL|XXL)\s*-/);
|
|
369
392
|
if (m) { legend[m[1]] = numAt(grid, r, 1); legendRow1[m[1]] = r + 1; if (legendFirst < 0) legendFirst = r; legendLast = r; }
|
|
370
393
|
}
|
|
371
394
|
for (const code of SIZE_CODES) {
|
|
@@ -407,7 +430,7 @@ function locateTshirt(grid) {
|
|
|
407
430
|
const lowDol = findCell(grid, "Low ($)", headerRow, 20), highDol = findCell(grid, "High ($)", headerRow, 20);
|
|
408
431
|
if (!lowHrs || !highHrs || !lowDol || !highDol) throw new Halt(`${TAB_TSHIRT}: rollup headers 'Low ($)' / 'Low Hrs' / 'High ($)' / 'High Hrs' not all found`);
|
|
409
432
|
cols.rollupPhase = mvp.c; cols.lowHrs = lowHrs.c; cols.highHrs = highHrs.c; cols.lowDol = lowDol.c; cols.highDol = highDol.c;
|
|
410
|
-
return { headerRow, firstItemRow: headerRow + 1, legend, legendRow1, 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 };
|
|
411
434
|
}
|
|
412
435
|
|
|
413
436
|
/** The standard template's column map (what `write` produces). */
|
|
@@ -627,7 +650,7 @@ function defaultSwitchMin(teamMin) { return teamMin < 120 ? 7.5 : teamMin < 480
|
|
|
627
650
|
* The estimator's per-task math: solo AI minutes × project multiplier + switching (after the
|
|
628
651
|
* multiplier), then the nearest AI_SIZE_DAYS size (geometric midpoints between neighbours).
|
|
629
652
|
*/
|
|
630
|
-
function aiTaskSize({ soloMin, project, switchMin }) {
|
|
653
|
+
function aiTaskSize({ soloMin, project, switchMin, xxs }) {
|
|
631
654
|
const mult = PROJECT_MULTIPLIER[project];
|
|
632
655
|
if (mult == null) throw new Halt(`--project must be one of ${Object.keys(PROJECT_MULTIPLIER).join(" | ")}`, 64);
|
|
633
656
|
if (typeof soloMin !== "number" || !(soloMin > 0)) throw new Halt("--solo-min must be a positive number of minutes", 64);
|
|
@@ -636,12 +659,14 @@ function aiTaskSize({ soloMin, project, switchMin }) {
|
|
|
636
659
|
if (typeof sw !== "number" || sw < 0) throw new Halt("--switch-min must be a number of minutes ≥ 0", 64);
|
|
637
660
|
const hours = (teamMin + sw) / 60;
|
|
638
661
|
const days = hours / 8;
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
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; }
|
|
643
668
|
}
|
|
644
|
-
return { soloMin, project, multiplier: mult, switchMin: sw, hours: round2(hours), days: round2(days), size, sizeDays:
|
|
669
|
+
return { soloMin, project, multiplier: mult, switchMin: sw, hours: round2(hours), days: round2(days), size, sizeDays: scale[size] };
|
|
645
670
|
}
|
|
646
671
|
|
|
647
672
|
/** True when the sheet's legend already carries the AI scale. */
|
|
@@ -668,7 +693,8 @@ function tshirtTotals(plan, layout) {
|
|
|
668
693
|
function itemFormulasFor(r, cols, legend) {
|
|
669
694
|
const L = colLetter;
|
|
670
695
|
const lg = `$A$${legend.first1}:$A$${legend.last1}`, lv = `$B$${legend.first1}:$B$${legend.last1}`;
|
|
671
|
-
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("+") + ")";
|
|
672
698
|
const abs = (cell) => `$${L(cell.c)}$${cell.r + 1}`;
|
|
673
699
|
const f = {
|
|
674
700
|
H: days,
|
|
@@ -1001,8 +1027,8 @@ function teamMixFormatReqs(sheetId, v) {
|
|
|
1001
1027
|
}
|
|
1002
1028
|
|
|
1003
1029
|
/** Write every phase grid on the one Team Mix tab: clear-then-paint, GRID_GAP blank rows between grids. */
|
|
1004
|
-
async function writeTeamMix(api, plan, rosters) {
|
|
1005
|
-
const grid = await api.grid(
|
|
1030
|
+
async function writeTeamMix(api, plan, rosters, tab = TAB_TEAM) {
|
|
1031
|
+
const grid = await api.grid(tab);
|
|
1006
1032
|
const sheetId = grid.properties.sheetId;
|
|
1007
1033
|
const grids = [];
|
|
1008
1034
|
let top = 0;
|
|
@@ -1013,7 +1039,7 @@ async function writeTeamMix(api, plan, rosters) {
|
|
|
1013
1039
|
}
|
|
1014
1040
|
const lastRow = grids[grids.length - 1].v.bottom;
|
|
1015
1041
|
const clearRows = Math.max(60, rowCount(grid) + 2, lastRow + 10);
|
|
1016
|
-
await api.clearValues(
|
|
1042
|
+
await api.clearValues(tab, `A1:Z${clearRows}`);
|
|
1017
1043
|
await api.batch([
|
|
1018
1044
|
{ unmergeCells: { range: gridRange(sheetId, 0, clearRows, 0, 26) } },
|
|
1019
1045
|
fmtReq(sheetId, 0, clearRows, 0, 26, {}, "userEnteredFormat"),
|
|
@@ -1021,7 +1047,7 @@ async function writeTeamMix(api, plan, rosters) {
|
|
|
1021
1047
|
const reqs = [];
|
|
1022
1048
|
let maxN = 0;
|
|
1023
1049
|
for (const g of grids) {
|
|
1024
|
-
await api.putValues(
|
|
1050
|
+
await api.putValues(tab, `A${g.v.titleR}:${colLetter(g.v.width - 1)}${g.v.bottom}`, g.v.rows);
|
|
1025
1051
|
reqs.push(...teamMixFormatReqs(sheetId, g.v));
|
|
1026
1052
|
maxN = Math.max(maxN, g.v.totalC - g.v.firstMonthC);
|
|
1027
1053
|
}
|
|
@@ -1063,7 +1089,7 @@ function auditTshirt(grid) {
|
|
|
1063
1089
|
const out = [];
|
|
1064
1090
|
const layout = locateTshirt(grid);
|
|
1065
1091
|
const { cols } = layout;
|
|
1066
|
-
const legendRows = { first1: layout.legendFirst1, last1: layout.legendLast1 };
|
|
1092
|
+
const legendRows = { first1: layout.legendFirst1, last1: layout.legendLast1, exact: layout.legendExact };
|
|
1067
1093
|
const first0 = layout.firstItemRow;
|
|
1068
1094
|
const badSizes = [], noDv = [], badFormula = [], sectionsWithSizes = [], badSectionFmt = [], noWrap = [], notTop = [];
|
|
1069
1095
|
let lastItem0 = -1, totalRow0 = -1;
|
|
@@ -1080,7 +1106,7 @@ function auditTshirt(grid) {
|
|
|
1080
1106
|
lastItem0 = r;
|
|
1081
1107
|
for (const c of cols.sizes) {
|
|
1082
1108
|
const v = textAt(grid, r, c).trim();
|
|
1083
|
-
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}'`);
|
|
1084
1110
|
}
|
|
1085
1111
|
const cell = cellAt(grid, r, cols.phase);
|
|
1086
1112
|
if (!(cell && cell.dataValidation && cell.dataValidation.condition && cell.dataValidation.condition.type === "ONE_OF_LIST")) noDv.push(`${colLetter(cols.phase)}${r + 1}`);
|
|
@@ -1657,13 +1683,151 @@ async function verbWrite(api, plan, opts) {
|
|
|
1657
1683
|
return result;
|
|
1658
1684
|
}
|
|
1659
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
|
+
|
|
1660
1824
|
// ───────────────────────── CLI ─────────────────────────
|
|
1661
1825
|
|
|
1662
1826
|
function parseArgs(argv) {
|
|
1663
1827
|
const out = { _: [] };
|
|
1664
1828
|
for (let i = 0; i < argv.length; i++) {
|
|
1665
1829
|
const a = argv[i];
|
|
1666
|
-
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;
|
|
1667
1831
|
else if (a.startsWith("--")) { out[a.slice(2)] = argv[i + 1]; i++; }
|
|
1668
1832
|
else out._.push(a);
|
|
1669
1833
|
}
|
|
@@ -1675,7 +1839,7 @@ function printChecks(audit) {
|
|
|
1675
1839
|
console.log(`${audit.ok ? "AUDIT PASS" : `AUDIT FAIL (${audit.failed})`} — ${audit.title}`);
|
|
1676
1840
|
}
|
|
1677
1841
|
|
|
1678
|
-
const USAGE = "usage: gsd-t estimate-sheet <read|plan-check|write|teammix|format|phases|titles|audit|plan-schema|size> --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>] [--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]";
|
|
1679
1843
|
|
|
1680
1844
|
/** Runs a verb; returns the exit code. Throws Halt (or any error) — the runner below turns that into exit 4/64. */
|
|
1681
1845
|
async function main(args) {
|
|
@@ -1684,7 +1848,7 @@ async function main(args) {
|
|
|
1684
1848
|
if (!verb || verb === "help") { console.log(USAGE); return 0; }
|
|
1685
1849
|
if (verb === "plan-schema") { console.log(JSON.stringify(PLAN_SCHEMA, null, 2)); return 0; }
|
|
1686
1850
|
if (verb === "size") {
|
|
1687
|
-
const r = aiTaskSize({ soloMin: Number(args["solo-min"]), project: args.project, switchMin: args["switch-min"] == null ? undefined : Number(args["switch-min"]) });
|
|
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 });
|
|
1688
1852
|
if (json) console.log(JSON.stringify({ ok: true, exitCode: 0, ...r }, null, 2));
|
|
1689
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)`);
|
|
1690
1854
|
return 0;
|
|
@@ -1737,6 +1901,20 @@ async function main(args) {
|
|
|
1737
1901
|
if (json) console.log(JSON.stringify({ ok: a.ok, exitCode: a.ok ? 0 : 4, ...a }, null, 2)); else printChecks(a);
|
|
1738
1902
|
return a.ok ? 0 : 4;
|
|
1739
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
|
+
}
|
|
1740
1918
|
const plan = loadPlan(args.plan);
|
|
1741
1919
|
if (verb === "plan-check") {
|
|
1742
1920
|
const r = await verbPlanCheck(api, plan);
|
|
@@ -1773,10 +1951,10 @@ function haltAndExit(e, json) {
|
|
|
1773
1951
|
}
|
|
1774
1952
|
|
|
1775
1953
|
module.exports = {
|
|
1776
|
-
validatePlan, aiTaskSize, legendIsAi, defaultSwitchMin, splitRoster, rosterViolations, mfCoverageViolations, monthPlan, resampleWeights, rampHours, buildRoster,
|
|
1954
|
+
validatePlan, sizedRows, rescaleViolations, verbRescale, aiTaskSize, legendIsAi, defaultSwitchMin, splitRoster, rosterViolations, mfCoverageViolations, monthPlan, resampleWeights, rampHours, buildRoster,
|
|
1777
1955
|
tshirtTotals, phaseTotals, buildRosters, midDays, deriveFteFromTeamMix, phaseTotalsFromSheet, verbTeamMix, phaseGapMap, verbPhases, verbTitles, itemFormulasFor, rollupFormulasFor, findCell, itemFormulas, rollupFormulas, tshirtRows, teamMixValues, teamMixFormatReqs, remainderFormula, locateTshirt, findPhaseSource,
|
|
1778
1956
|
auditTshirt, auditTeamMix, auditTechStack, auditOverview, colLetter, hexToColor, colorToHex, sheetIdFromArg,
|
|
1779
|
-
constants: { SIZE_CODES, AI_SIZE_DAYS, PROJECT_MULTIPLIER, 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 },
|
|
1780
1958
|
Halt, SheetsApi, getToken, runAudit, main,
|
|
1781
1959
|
};
|
|
1782
1960
|
|