@tekyzinc/gsd-t 4.0.21 → 4.0.23

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 CHANGED
@@ -2,6 +2,36 @@
2
2
 
3
3
  All notable changes to GSD-T are documented here. Updated with each release.
4
4
 
5
+ ## [4.0.23] - 2026-06-03 (M76 ASCII-Clean Register Output — patch)
6
+
7
+ ### Fixed — register/docs rendered as mojibake in non-UTF-8 terminals
8
+
9
+ The scan register used emoji severity markers (🔴🟠🟡🟢) and em-dashes. In a non-UTF-8 terminal/pager these display as garbage boxes (`βPADCCH`/`πAPCCCH`). The file bytes were valid UTF-8 — purely a render problem — but the emoji added nothing and made the register unreadable in common viewers.
10
+
11
+ - `templates/workflows/gsd-t-scan.workflow.js`: added `ascii()` sanitizer (strips emoji/symbols, normalizes em/en dashes → `-`, smart quotes → ASCII, ellipsis → `...`). `fmtChunks` now emits plain-ASCII headers/tables and sanitizes every user-supplied field (finder text can contain these too). Document-phase agents instructed "ASCII ONLY".
12
+ - `test/m76-ascii-clean-register.test.js`: +5 tests (sanitizer behavior + a structural guard that fmtChunks' output literals contain no emoji/em-dash).
13
+ - One-off: cleaned the existing HiloAviation scan docs (techdebt.md + plain-english + 5 dimension files) — 0 emoji / 0 em-dashes remaining.
14
+
15
+ Suite: 1311 pass / 0 fail / 4 skip — zero regressions.
16
+
17
+ ## [4.0.22] - 2026-06-02 (M75 Deterministic Chunked Register Write — patch)
18
+
19
+ ### Fixed — synthesis no longer stalls writing a large register
20
+
21
+ 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.
22
+
23
+ - `templates/workflows/gsd-t-scan.workflow.js`: synthesis redesigned to separate JUDGMENT from WRITING:
24
+ - A bounded **dedup agent** (small input: title+severity+location per finding) returns merge groups — it never holds the full register.
25
+ - 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).
26
+ - `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.
27
+ - `test/m75-chunked-register.test.js`: +4 tests (every item once, contiguous numbering, no mid-item splits, header isolation, no over-chunking).
28
+
29
+ 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.
30
+
31
+ This closes the scan fix chain: M71 (runs in sandbox) + M72 (coverage honesty) + M73 (concurrency cap) + M74 (adaptive throttle) + M75 (deterministic chunked write).
32
+
33
+ Suite: 1306 pass / 0 fail / 4 skip — zero regressions.
34
+
5
35
  ## [4.0.21] - 2026-06-02 (M74 Adaptive Rate-Limit Throttle — patch)
6
36
 
7
37
  ### 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.21",
3
+ "version": "4.0.23",
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
- const SYNTHESIS_SCHEMA = {
156
- type: "object",
157
- required: ["status", "counts"],
158
- additionalProperties: false,
159
- properties: {
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,184 @@ 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 an agent does the ARCHIVE + REGISTER WRITE + GIT entirely via its
441
- // own Bash/Write tools. The orchestrator does NOT touch fs. The agent is given the
442
- // deterministic tdStart so numbering can't drift, and is told to archive FIRST.
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
- const findingsJson = JSON.stringify(allFindings, null, 2);
445
- const synthesis = await agent(
446
- [
447
- `You are the SYNTHESIS agent for a GSD-T deep scan of \`${projectDir}\`. ${slices.length} slices ran; ${allFindings.length} verified findings came back.`,
448
- scanNumber ? `This is scan #${scanNumber} put it in the register header.` : ``,
449
- ``,
450
- `STEP 1 — ARCHIVE (do this FIRST, deterministically, via Bash): if \`${projectDir}/.gsd-t/techdebt.md\` exists, rename it to \`${projectDir}/.gsd-t/techdebt_YYYY-MM-DD.md\` using its header date (fallback: today's date from \`date +%F\`); on same-day collision append \`_2\`, \`_3\`. Use \`git mv\` if it's a git repo, else \`mv\`. Capture the archive path. ${pre.priorRegisterExists ? "(A prior register EXISTS — you MUST archive it.)" : "(No prior register — skip archiving.)"}`,
451
- ``,
452
- // M72: MANDATORY coverage banner enforced here, not left to the agent's notice.
453
- coverageComplete
454
- ? `COVERAGE: all ${slices.length} slices succeeded — full coverage. Note this in the header.`
455
- : `⚠ COVERAGE IS INCOMPLETE — ${failedSlices.length} of ${slices.length} slices FAILED to return findings and were NOT scanned: ${failedSlices.join(", ")}. You MUST put a prominent "> ⚠ PARTIAL COVERAGE — N of M codebase areas were not scanned this pass (listed below); findings UNDER-COUNT the real debt. Re-run for full coverage." banner at the TOP of the register, AND list the un-scanned slice names. Do NOT present this as a complete picture.`,
456
- ``,
457
- `STEP 2 — WRITE the fresh \`${projectDir}/.gsd-t/techdebt.md\` (Write tool). Start TD numbering at TD-${tdStart} (computed deterministically — do NOT renumber or restart). Structure: the coverage banner (above), a Summary table (CRITICAL/HIGH/MEDIUM/LOW counts), then sections Critical→High→Medium→Low, each finding as \`### TD-NNN — {title}\` with Area / Severity / Status: OPEN / Location (file:line) / Description / Impact / Remediation / Milestone candidate. Re-rank globally by true severity; de-duplicate findings multiple slices surfaced into one item listing all locations. GSD-T effort units only (domain/wave/spawn/token) — never human-hours.`,
458
- `If the findings set is large, WRITE INCREMENTALLY — create the file with the header+summary first (Write), then APPEND each severity section with Edit. Do NOT attempt to emit the entire multi-hundred-item register in a single Write call (it can stall); build it up section by section so progress is durable.`,
459
- ``,
460
- `STEP 3 — COMMIT via Bash if it's a git repo (\`git add .gsd-t/techdebt.md\` + the archive; commit). Do NOT push.`,
461
- ``,
462
- `Verified findings (${allFindings.length} total):`,
463
- "```json",
464
- findingsJson.length > 500000 ? findingsJson.slice(0, 500000) + "\n…(TRUNCATED — too many findings to inline; the above is the first portion. Note in the register that some lower-severity items may be omitted due to volume.)" : findingsJson,
465
- "```",
466
- ``,
467
- `Return JSON per the schema: status "written" once the register file is on disk, the counts, the archivePath (or ""), and tdRange (e.g. "TD-${tdStart}..TD-NNN").`,
468
- ].filter(Boolean).join("\n"),
469
- { label: "synthesis", phase: "Synthesis", schema: SYNTHESIS_SCHEMA, model: "opus" }
470
- );
471
- if (!synthesis || synthesis.status !== "written") {
472
- log("synthesis did not write the register — halting before document phase");
473
- return { status: "failed", reason: "synthesis-failed", synthesis, findingCount: allFindings.length };
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
+ }
474
462
  }
475
- log(`register written: ${JSON.stringify(synthesis.counts)} (${synthesis.tdRange || ""})`);
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
+ // M76: register output must be ASCII-clean — no emoji, no em/en-dashes, no smart
498
+ // quotes. They render as mojibake boxes in non-UTF-8 terminals/pagers (the
499
+ // βPADCCH/πAPCCCH garbage a user saw). Finder text can also contain these, so sanitize
500
+ // every user-supplied field, not just our own template strings.
501
+ function ascii(s) {
502
+ return String(s == null ? "" : s)
503
+ .replace(/[\u{1F300}-\u{1FAFF}\u{2600}-\u{27BF}\u{2190}-\u{21FF}\u{2B00}-\u{2BFF}️]/gu, "") // emoji/symbols
504
+ .replace(/[—–]/g, "-") // em/en dash -> hyphen
505
+ .replace(/[‘’]/g, "'") // smart single quotes
506
+ .replace(/[“”]/g, '"') // smart double quotes
507
+ .replace(/…/g, "...") // ellipsis
508
+ .replace(/[ \t]+\n/g, "\n"); // tidy trailing whitespace
509
+ }
510
+ function fmtChunks(today) {
511
+ const sevHead = { CRITICAL: "Critical", HIGH: "High", MEDIUM: "Medium", LOW: "Low" };
512
+ const head = [];
513
+ head.push(`# Tech Debt Register - ${projectDir.split("/").pop()}`, "");
514
+ if (scanNumber) head.push(`**Scan #${scanNumber}** - Deep codebase scan (runtime-native, ${coverageComplete ? "full coverage" : "PARTIAL coverage"})`);
515
+ head.push(`**Date:** ${today}`);
516
+ head.push(`**Slices run:** ${slices.length} | **Coverage:** ${coverageComplete ? `FULL - all ${slices.length} slices succeeded` : `PARTIAL - ${succeededCount}/${slices.length} succeeded`}`);
517
+ head.push(`**Verified findings:** ${counts.total}`, "");
518
+ head.push(`> Effort estimates use GSD-T-native units (domain / wave / spawn / token-spend). Never human-hours.`);
519
+ head.push(`> TD numbering continues from the prior register (if any, archived). This scan begins at **TD-${tdStart}**.`, "");
520
+ if (!coverageComplete) head.push(`> [!] **PARTIAL COVERAGE - ${failedSlices.length} of ${slices.length} codebase areas were NOT scanned this pass** (failed to return findings): ${ascii(failedSlices.join(", "))}. Findings UNDER-COUNT the real debt. Re-run (resume) for full coverage.`, "");
521
+ head.push(`## Summary`, "", `| Severity | Count |`, `|----------|-------|`,
522
+ `| CRITICAL | ${counts.critical} |`, `| HIGH | ${counts.high} |`,
523
+ `| MEDIUM | ${counts.medium} |`, `| LOW | ${counts.low} |`,
524
+ `| **Total** | **${counts.total}** |`, "", "---", "");
525
+
526
+ function itemMd(f, td) {
527
+ const L = [`### TD-${td} - ${ascii(f.title) || "(untitled)"}`,
528
+ `- **Area:** ${ascii(f.area) || "(none)"}`, `- **Severity:** ${f.severity}`, `- **Status:** OPEN`,
529
+ `- **Location:** ${(f.files && f.files.length) ? ascii(f.files.join(", ")) : "(none)"}`];
530
+ if (f.detail) L.push(`- **Description:** ${ascii(f.detail)}`);
531
+ if (f.impact) L.push(`- **Impact:** ${ascii(f.impact)}`);
532
+ if (f.recommendation) L.push(`- **Remediation:** ${ascii(f.recommendation)}`);
533
+ if (f.slice) L.push(`- **Found in slice:** ${ascii(f.slice)}`);
534
+ L.push("");
535
+ return L.join("\n");
536
+ }
537
+
538
+ const CHUNK_MAX = 30000;
539
+ const chunks = [head.join("\n")];
540
+ let buf = "", n = tdStart, lastSev = null;
541
+ const flush = () => { if (buf) { chunks.push(buf); buf = ""; } };
542
+ for (const f of finalFindings) {
543
+ let piece = "";
544
+ if (f.severity !== lastSev) { piece += `\n## ${sevHead[f.severity] || f.severity} Priority\n\n`; lastSev = f.severity; }
545
+ piece += itemMd(f, n++);
546
+ if (buf.length + piece.length > CHUNK_MAX) flush();
547
+ buf += piece;
548
+ }
549
+ flush();
550
+ return { chunks, lastTd: n - 1 };
551
+ }
552
+
553
+ // (d) Archive the prior register (one bounded agent: mv/git mv), then write the new
554
+ // one (one bounded agent: a SINGLE Write of the pre-formatted string).
555
+ const todayAgent = await agent(
556
+ `Run \`date +%F\` via Bash and return ONLY the date string (YYYY-MM-DD), nothing else.`,
557
+ { label: "synthesis:date", phase: "Synthesis", model: "haiku" }
558
+ ).catch(() => null);
559
+ const today = (typeof todayAgent === "string" && /\d{4}-\d{2}-\d{2}/.test(todayAgent)) ? todayAgent.match(/\d{4}-\d{2}-\d{2}/)[0] : "today";
560
+
561
+ let archivePath = "";
562
+ if (pre.priorRegisterExists) {
563
+ const arch = await agent(
564
+ [
565
+ `Archive the existing tech-debt register in \`${projectDir}\` via Bash, then report the archive path.`,
566
+ `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.`,
567
+ ].join("\n"),
568
+ { label: "synthesis:archive", phase: "Synthesis", model: "haiku" }
569
+ ).catch(() => null);
570
+ archivePath = (typeof arch === "string") ? arch.trim().split("\n").pop() : "";
571
+ }
572
+
573
+ const { chunks, lastTd } = fmtChunks(today);
574
+ // M75: write the register in BOUNDED CHUNKS (≤30KB each). A single write of a large
575
+ // register truncates at ~165KB (verified) — so chunk 0 creates the file (Write), and
576
+ // each subsequent chunk is APPENDED. Done SEQUENTIALLY so the file builds in order and
577
+ // each agent's prompt+output stays small enough to pass intact. The register path is
578
+ // passed to each chunk; only the chunk content varies.
579
+ const regPath = `${projectDir}/.gsd-t/techdebt.md`;
580
+ let chunkOk = 0;
581
+ for (let ci = 0; ci < chunks.length; ci++) {
582
+ const isFirst = ci === 0;
583
+ const res = await agent(
584
+ [
585
+ isFirst
586
+ ? `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.`
587
+ : `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.`,
588
+ `This is chunk ${ci + 1}/${chunks.length} of a tech-debt register. After writing, reply with ONLY "OK".`,
589
+ ``,
590
+ `<<<CHUNK>>>`,
591
+ chunks[ci],
592
+ `<<<END_CHUNK>>>`,
593
+ ].join("\n"),
594
+ { label: `synthesis:write-register ${ci + 1}/${chunks.length}`, phase: "Synthesis", model: "haiku" }
595
+ ).catch((e) => ({ _err: String(e && e.message) }));
596
+ if (typeof res === "string" && /ok/i.test(res)) chunkOk++;
597
+ else log(`⚠ register chunk ${ci + 1}/${chunks.length} write uncertain: ${typeof res === "string" ? res.slice(0, 60) : JSON.stringify(res).slice(0, 80)}`);
598
+ }
599
+ log(`register written in ${chunkOk}/${chunks.length} chunks (${counts.total} findings, TD-${tdStart}..TD-${lastTd})`);
600
+
601
+ const synthesis = { status: "written", counts, archivePath, tdRange: `TD-${tdStart}..TD-${lastTd}`, finalFindings };
602
+ log(`register written: ${JSON.stringify(synthesis.counts)} (${synthesis.tdRange})`);
476
603
 
477
604
  // Document — per-doc fan-out. Each agent writes its file via Write/Edit (its tools).
478
605
  // The orchestrator passes the findings + (for plain-english) tells the agent to
@@ -532,7 +659,8 @@ const docResults = await parallel(
532
659
  d.prompt,
533
660
  ``,
534
661
  isLiving ? mergeNote : `Write the file fresh in the format described (use Bash \`mkdir -p\` for parent dirs if needed).`,
535
- `Read the actual code under the relevant slice paths for specifics don't summarize only from findings. Use Write/Edit to write the file, then return JSON per the schema (status "written"/"merged"/"skipped"/"failed"). Do NOT commit — the workflow handles git at the end.`,
662
+ `ASCII ONLY: do NOT use emoji, em-dashes (use " - "), en-dashes, smart quotes, or other non-ASCII punctuation they render as garbage in non-UTF-8 terminals. Plain ASCII markdown only.`,
663
+ `Read the actual code under the relevant slice paths for specifics - don't summarize only from findings. Use Write/Edit to write the file, then return JSON per the schema (status "written"/"merged"/"skipped"/"failed"). Do NOT commit - the workflow handles git at the end.`,
536
664
  ].filter(Boolean).join("\n");
537
665
  try {
538
666
  return await agent(prompt, { label: d.label, phase: "Document", schema: DOC_RESULT_SCHEMA, model: "sonnet" });