@tekyzinc/gsd-t 4.0.21 → 4.0.22
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 +18 -0
- package/package.json +1 -1
- package/templates/workflows/gsd-t-scan.workflow.js +168 -54
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,24 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to GSD-T are documented here. Updated with each release.
|
|
4
4
|
|
|
5
|
+
## [4.0.22] - 2026-06-02 (M75 Deterministic Chunked Register Write — patch)
|
|
6
|
+
|
|
7
|
+
### Fixed — synthesis no longer stalls writing a large register
|
|
8
|
+
|
|
9
|
+
The Hilo Scan #14 achieved full coverage + 322 verified findings, but the single synthesis agent STALLED writing the register — it managed 9 of 322 items then ran out of turn/output budget. Diagnostics confirmed even a single bounded `Write` truncates a large register at ~165KB (a 466KB register lost half, mid-item). One agent cannot write a multi-hundred-item register, chunked-in-its-own-head or not.
|
|
10
|
+
|
|
11
|
+
- `templates/workflows/gsd-t-scan.workflow.js`: synthesis redesigned to separate JUDGMENT from WRITING:
|
|
12
|
+
- A bounded **dedup agent** (small input: title+severity+location per finding) returns merge groups — it never holds the full register.
|
|
13
|
+
- The **orchestrator** deterministically merges dups, sorts by severity, assigns sequential TD numbers, and formats the register markdown as a string (no fs, no agent — pure string-building).
|
|
14
|
+
- `fmtChunks` splits the register into **≤30KB chunks that never split an item**; a sequence of bounded write-agents creates chunk 0 (Write) then APPENDS each subsequent chunk. Each agent step is small enough to pass intact → can't stall or truncate, at any register size.
|
|
15
|
+
- `test/m75-chunked-register.test.js`: +4 tests (every item once, contiguous numbering, no mid-item splits, header isolation, no over-chunking).
|
|
16
|
+
|
|
17
|
+
Verified by real sandbox diagnostics: a single Write of a 466KB register truncated to 161/322 items (the bug); the chunked write produced **all 322 items intact, no gaps, no duplicates, no truncation** across 12 chunks.
|
|
18
|
+
|
|
19
|
+
This closes the scan fix chain: M71 (runs in sandbox) + M72 (coverage honesty) + M73 (concurrency cap) + M74 (adaptive throttle) + M75 (deterministic chunked write).
|
|
20
|
+
|
|
21
|
+
Suite: 1306 pass / 0 fail / 4 skip — zero regressions.
|
|
22
|
+
|
|
5
23
|
## [4.0.21] - 2026-06-02 (M74 Adaptive Rate-Limit Throttle — patch)
|
|
6
24
|
|
|
7
25
|
### Added — the scan throttle now self-lowers on a rate limit instead of failing
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tekyzinc/gsd-t",
|
|
3
|
-
"version": "4.0.
|
|
3
|
+
"version": "4.0.22",
|
|
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",
|
|
@@ -152,26 +152,11 @@ const VERIFY_SCHEMA = {
|
|
|
152
152
|
},
|
|
153
153
|
};
|
|
154
154
|
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
status: { type: "string", enum: ["written", "failed"] },
|
|
161
|
-
registerPath: { type: "string" },
|
|
162
|
-
archivePath: { type: "string" },
|
|
163
|
-
counts: {
|
|
164
|
-
type: "object",
|
|
165
|
-
required: ["critical", "high", "medium", "low"],
|
|
166
|
-
properties: {
|
|
167
|
-
critical: { type: "integer" }, high: { type: "integer" },
|
|
168
|
-
medium: { type: "integer" }, low: { type: "integer" }, total: { type: "integer" },
|
|
169
|
-
},
|
|
170
|
-
},
|
|
171
|
-
tdRange: { type: "string", description: "e.g. 'TD-01..TD-119'" },
|
|
172
|
-
notes: { type: "string" },
|
|
173
|
-
},
|
|
174
|
-
};
|
|
155
|
+
// M75: synthesis no longer writes the register via one agent (the Hilo Scan #14
|
|
156
|
+
// synthesis stalled after 9 of 322 items typing a 466KB file). Instead: a bounded
|
|
157
|
+
// dedup agent (inline DEDUP_SCHEMA, small input) decides merge groups; the
|
|
158
|
+
// orchestrator deterministically sorts/numbers/formats the register STRING (no fs);
|
|
159
|
+
// a bounded write-agent does ONE Write. Each agent step is bounded → can't stall.
|
|
175
160
|
|
|
176
161
|
const DOC_RESULT_SCHEMA = {
|
|
177
162
|
type: "object",
|
|
@@ -437,42 +422,171 @@ if (!coverageComplete) {
|
|
|
437
422
|
}
|
|
438
423
|
log(`deep scan complete: ${allFindings.length} verified findings across ${succeededCount}/${slices.length} slices${coverageComplete ? " (full coverage)" : " (PARTIAL)"}`);
|
|
439
424
|
|
|
440
|
-
// Synthesis
|
|
441
|
-
//
|
|
442
|
-
//
|
|
425
|
+
// Synthesis (M75 redesign). The Hilo Scan #14 failure: one agent asked to dedup +
|
|
426
|
+
// rank + WRITE a 322-item / 466KB register stalled after 9 items (turn/output budget).
|
|
427
|
+
// Fix: split judgment from writing, and do all the heavy lifting DETERMINISTICALLY.
|
|
428
|
+
// (a) The orchestrator already HAS allFindings as data — it sorts by severity and
|
|
429
|
+
// assigns sequential TD numbers itself (no agent needed for that).
|
|
430
|
+
// (b) Dedup is the only real judgment-add; a bounded agent does ONLY dedup over a
|
|
431
|
+
// compact title+location list (small input) and returns merge groups.
|
|
432
|
+
// (c) The orchestrator formats the full markdown as a STRING (no fs, no agent).
|
|
433
|
+
// (d) A dedicated archive-agent renames the prior register (one mv); a dedicated
|
|
434
|
+
// write-agent does ONE Write of the formatted string. Each is bounded → can't
|
|
435
|
+
// stall regardless of register size.
|
|
443
436
|
phase("Synthesis");
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
`
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
437
|
+
|
|
438
|
+
// (b) Dedup pass — small input (title|severity|first-location per finding), so the
|
|
439
|
+
// agent never holds the full register. It returns groups of indices that are the
|
|
440
|
+
// same underlying issue. Best-effort: on failure we proceed with no dedup.
|
|
441
|
+
const dedupList = allFindings.map((f, i) => `${i}: [${f.severity}] ${f.title} @ ${(f.files && f.files[0]) || "?"}`).join("\n");
|
|
442
|
+
let mergeGroups = [];
|
|
443
|
+
if (allFindings.length > 1) {
|
|
444
|
+
const DEDUP_SCHEMA = {
|
|
445
|
+
type: "object", required: ["groups"], additionalProperties: false,
|
|
446
|
+
properties: { groups: { type: "array", items: { type: "array", items: { type: "integer" } } }, notes: { type: "string" } },
|
|
447
|
+
};
|
|
448
|
+
try {
|
|
449
|
+
const d = await agent(
|
|
450
|
+
[
|
|
451
|
+
`You are DEDUPLICATING tech-debt findings from a multi-slice scan. Below are ${allFindings.length} findings as "index: [SEVERITY] title @ location".`,
|
|
452
|
+
`Different slices sometimes surface the SAME underlying issue. Return "groups": arrays of indices that are the SAME root issue (only group true duplicates — same defect, not merely similar). Singletons need not be listed. If nothing is a duplicate, return groups: [].`,
|
|
453
|
+
``,
|
|
454
|
+
dedupList.slice(0, 120000),
|
|
455
|
+
].join("\n"),
|
|
456
|
+
{ label: "synthesis:dedup", phase: "Synthesis", schema: DEDUP_SCHEMA, model: "opus" }
|
|
457
|
+
);
|
|
458
|
+
mergeGroups = (d && Array.isArray(d.groups)) ? d.groups : [];
|
|
459
|
+
} catch (e) {
|
|
460
|
+
log(`dedup pass failed (non-fatal — proceeding with no dedup): ${e && e.message}`);
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
// (a)+(c) Deterministically merge dups, sort by severity, assign TD numbers, format.
|
|
465
|
+
const SEV_ORDER = { CRITICAL: 0, HIGH: 1, MEDIUM: 2, LOW: 3 };
|
|
466
|
+
const dropped = new Set();
|
|
467
|
+
const merged = [];
|
|
468
|
+
for (const group of mergeGroups) {
|
|
469
|
+
const idxs = group.filter((i) => Number.isInteger(i) && i >= 0 && i < allFindings.length && !dropped.has(i));
|
|
470
|
+
if (idxs.length < 2) continue;
|
|
471
|
+
const keep = { ...allFindings[idxs[0]] };
|
|
472
|
+
keep.files = [...new Set(idxs.flatMap((i) => allFindings[i].files || []))];
|
|
473
|
+
keep.slice = [...new Set(idxs.map((i) => allFindings[i].slice).filter(Boolean))].join(", ");
|
|
474
|
+
idxs.forEach((i) => dropped.add(i));
|
|
475
|
+
merged.push(keep);
|
|
476
|
+
}
|
|
477
|
+
const finalFindings = [
|
|
478
|
+
...merged,
|
|
479
|
+
...allFindings.map((f, i) => (dropped.has(i) ? null : f)).filter(Boolean).filter((f) => !merged.includes(f)),
|
|
480
|
+
];
|
|
481
|
+
finalFindings.sort((a, b) => (SEV_ORDER[a.severity] ?? 9) - (SEV_ORDER[b.severity] ?? 9));
|
|
482
|
+
const counts = { critical: 0, high: 0, medium: 0, low: 0 };
|
|
483
|
+
for (const f of finalFindings) {
|
|
484
|
+
if (f.severity === "CRITICAL") counts.critical++;
|
|
485
|
+
else if (f.severity === "HIGH") counts.high++;
|
|
486
|
+
else if (f.severity === "MEDIUM") counts.medium++;
|
|
487
|
+
else if (f.severity === "LOW") counts.low++;
|
|
488
|
+
}
|
|
489
|
+
counts.total = finalFindings.length;
|
|
490
|
+
|
|
491
|
+
|
|
492
|
+
// M75 chunked formatter: returns an ARRAY of markdown chunks, each ≤ ~30KB, so each
|
|
493
|
+
// can be written through one bounded agent prompt WITHOUT truncation (a single write
|
|
494
|
+
// of a 466KB register truncates at ~165KB — verified). Chunk 0 is the header+summary
|
|
495
|
+
// (Write/create); the rest are appends. Items are grouped so a chunk never splits an
|
|
496
|
+
// item, and the severity-section heading rides with its first item's chunk.
|
|
497
|
+
function fmtChunks(today) {
|
|
498
|
+
const sevHead = { CRITICAL: "🔴 Critical", HIGH: "🟠 High", MEDIUM: "🟡 Medium", LOW: "🟢 Low" };
|
|
499
|
+
const head = [];
|
|
500
|
+
head.push(`# Tech Debt Register — ${projectDir.split("/").pop()}`, "");
|
|
501
|
+
if (scanNumber) head.push(`**Scan #${scanNumber}** — Deep codebase scan (runtime-native, ${coverageComplete ? "full coverage" : "PARTIAL coverage"})`);
|
|
502
|
+
head.push(`**Date:** ${today}`);
|
|
503
|
+
head.push(`**Slices run:** ${slices.length} | **Coverage:** ${coverageComplete ? `FULL — all ${slices.length} slices succeeded` : `PARTIAL — ${succeededCount}/${slices.length} succeeded`}`);
|
|
504
|
+
head.push(`**Verified findings:** ${counts.total}`, "");
|
|
505
|
+
head.push(`> Effort estimates use GSD-T-native units (domain / wave / spawn / token-spend). Never human-hours.`);
|
|
506
|
+
head.push(`> TD numbering continues from the prior register (if any, archived). This scan begins at **TD-${tdStart}**.`, "");
|
|
507
|
+
if (!coverageComplete) head.push(`> ⚠ **PARTIAL COVERAGE — ${failedSlices.length} of ${slices.length} codebase areas were NOT scanned this pass** (failed to return findings): ${failedSlices.join(", ")}. Findings UNDER-COUNT the real debt. Re-run (resume) for full coverage.`, "");
|
|
508
|
+
head.push(`## Summary`, "", `| Severity | Count |`, `|----------|-------|`,
|
|
509
|
+
`| 🔴 CRITICAL | ${counts.critical} |`, `| 🟠 HIGH | ${counts.high} |`,
|
|
510
|
+
`| 🟡 MEDIUM | ${counts.medium} |`, `| 🟢 LOW | ${counts.low} |`,
|
|
511
|
+
`| **Total** | **${counts.total}** |`, "", "---", "");
|
|
512
|
+
|
|
513
|
+
function itemMd(f, td) {
|
|
514
|
+
const L = [`### TD-${td} — ${f.title || "(untitled)"}`,
|
|
515
|
+
`- **Area:** ${f.area || "—"}`, `- **Severity:** ${f.severity}`, `- **Status:** OPEN`,
|
|
516
|
+
`- **Location:** ${(f.files && f.files.length) ? f.files.join(", ") : "—"}`];
|
|
517
|
+
if (f.detail) L.push(`- **Description:** ${f.detail}`);
|
|
518
|
+
if (f.impact) L.push(`- **Impact:** ${f.impact}`);
|
|
519
|
+
if (f.recommendation) L.push(`- **Remediation:** ${f.recommendation}`);
|
|
520
|
+
if (f.slice) L.push(`- **Found in slice:** ${f.slice}`);
|
|
521
|
+
L.push("");
|
|
522
|
+
return L.join("\n");
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
const CHUNK_MAX = 30000;
|
|
526
|
+
const chunks = [head.join("\n")];
|
|
527
|
+
let buf = "", n = tdStart, lastSev = null;
|
|
528
|
+
const flush = () => { if (buf) { chunks.push(buf); buf = ""; } };
|
|
529
|
+
for (const f of finalFindings) {
|
|
530
|
+
let piece = "";
|
|
531
|
+
if (f.severity !== lastSev) { piece += `\n## ${sevHead[f.severity] || f.severity} Priority\n\n`; lastSev = f.severity; }
|
|
532
|
+
piece += itemMd(f, n++);
|
|
533
|
+
if (buf.length + piece.length > CHUNK_MAX) flush();
|
|
534
|
+
buf += piece;
|
|
535
|
+
}
|
|
536
|
+
flush();
|
|
537
|
+
return { chunks, lastTd: n - 1 };
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
// (d) Archive the prior register (one bounded agent: mv/git mv), then write the new
|
|
541
|
+
// one (one bounded agent: a SINGLE Write of the pre-formatted string).
|
|
542
|
+
const todayAgent = await agent(
|
|
543
|
+
`Run \`date +%F\` via Bash and return ONLY the date string (YYYY-MM-DD), nothing else.`,
|
|
544
|
+
{ label: "synthesis:date", phase: "Synthesis", model: "haiku" }
|
|
545
|
+
).catch(() => null);
|
|
546
|
+
const today = (typeof todayAgent === "string" && /\d{4}-\d{2}-\d{2}/.test(todayAgent)) ? todayAgent.match(/\d{4}-\d{2}-\d{2}/)[0] : "today";
|
|
547
|
+
|
|
548
|
+
let archivePath = "";
|
|
549
|
+
if (pre.priorRegisterExists) {
|
|
550
|
+
const arch = await agent(
|
|
551
|
+
[
|
|
552
|
+
`Archive the existing tech-debt register in \`${projectDir}\` via Bash, then report the archive path.`,
|
|
553
|
+
`Rename \`${projectDir}/.gsd-t/techdebt.md\` to \`${projectDir}/.gsd-t/techdebt_${today}.md\`; if that exists, append _2/_3. Use \`git mv\` if a git repo else \`mv\`. Reply with ONLY the resulting archive filename.`,
|
|
554
|
+
].join("\n"),
|
|
555
|
+
{ label: "synthesis:archive", phase: "Synthesis", model: "haiku" }
|
|
556
|
+
).catch(() => null);
|
|
557
|
+
archivePath = (typeof arch === "string") ? arch.trim().split("\n").pop() : "";
|
|
474
558
|
}
|
|
475
|
-
|
|
559
|
+
|
|
560
|
+
const { chunks, lastTd } = fmtChunks(today);
|
|
561
|
+
// M75: write the register in BOUNDED CHUNKS (≤30KB each). A single write of a large
|
|
562
|
+
// register truncates at ~165KB (verified) — so chunk 0 creates the file (Write), and
|
|
563
|
+
// each subsequent chunk is APPENDED. Done SEQUENTIALLY so the file builds in order and
|
|
564
|
+
// each agent's prompt+output stays small enough to pass intact. The register path is
|
|
565
|
+
// passed to each chunk; only the chunk content varies.
|
|
566
|
+
const regPath = `${projectDir}/.gsd-t/techdebt.md`;
|
|
567
|
+
let chunkOk = 0;
|
|
568
|
+
for (let ci = 0; ci < chunks.length; ci++) {
|
|
569
|
+
const isFirst = ci === 0;
|
|
570
|
+
const res = await agent(
|
|
571
|
+
[
|
|
572
|
+
isFirst
|
|
573
|
+
? `Create the file \`${regPath}\` (overwrite if it exists) with EXACTLY the content between the markers below, using the Write tool. Verbatim — no edits, no summarizing, no truncation.`
|
|
574
|
+
: `APPEND EXACTLY the content between the markers below to the END of \`${regPath}\` (do not overwrite existing content — append). Verbatim — no edits, no truncation. Use a Bash heredoc append (\`cat >> ${regPath} <<'GSDTEOF'\` … \`GSDTEOF\`) or read-then-Write-concatenation; preserve the file's existing content exactly.`,
|
|
575
|
+
`This is chunk ${ci + 1}/${chunks.length} of a tech-debt register. After writing, reply with ONLY "OK".`,
|
|
576
|
+
``,
|
|
577
|
+
`<<<CHUNK>>>`,
|
|
578
|
+
chunks[ci],
|
|
579
|
+
`<<<END_CHUNK>>>`,
|
|
580
|
+
].join("\n"),
|
|
581
|
+
{ label: `synthesis:write-register ${ci + 1}/${chunks.length}`, phase: "Synthesis", model: "haiku" }
|
|
582
|
+
).catch((e) => ({ _err: String(e && e.message) }));
|
|
583
|
+
if (typeof res === "string" && /ok/i.test(res)) chunkOk++;
|
|
584
|
+
else log(`⚠ register chunk ${ci + 1}/${chunks.length} write uncertain: ${typeof res === "string" ? res.slice(0, 60) : JSON.stringify(res).slice(0, 80)}`);
|
|
585
|
+
}
|
|
586
|
+
log(`register written in ${chunkOk}/${chunks.length} chunks (${counts.total} findings, TD-${tdStart}..TD-${lastTd})`);
|
|
587
|
+
|
|
588
|
+
const synthesis = { status: "written", counts, archivePath, tdRange: `TD-${tdStart}..TD-${lastTd}`, finalFindings };
|
|
589
|
+
log(`register written: ${JSON.stringify(synthesis.counts)} (${synthesis.tdRange})`);
|
|
476
590
|
|
|
477
591
|
// Document — per-doc fan-out. Each agent writes its file via Write/Edit (its tools).
|
|
478
592
|
// The orchestrator passes the findings + (for plain-english) tells the agent to
|