@tekyzinc/gsd-t 4.0.20 → 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 CHANGED
@@ -2,6 +2,37 @@
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
+
23
+ ## [4.0.21] - 2026-06-02 (M74 Adaptive Rate-Limit Throttle — patch)
24
+
25
+ ### Added — the scan throttle now self-lowers on a rate limit instead of failing
26
+
27
+ M73's fixed 10-permit gate prevents the all-at-once stampede, but couldn't react if a rate limit still occurred. M74 makes the gate ADAPTIVE: on a rate-limit error (`isRateLimit` matches "temporarily limiting requests", 429, overloaded, etc.) `gatedAgent` lowers the global ceiling by 1 (10→9→8…, floor `MIN_CONCURRENT=4`), backs off (2s/4s/6s), and RETRIES the same agent (up to 4 attempts) — so a transient rate limit throttles the run down rather than failing it. After 8 clean completions the ceiling nudges back up toward 10. A non-rate-limit error is not retried (bubbles up normally).
28
+
29
+ - `templates/workflows/gsd-t-scan.workflow.js`: `makeAdaptiveSemaphore` (shrinkable/recoverable ceiling, never yanks in-flight permits), rate-limit-aware `gatedAgent` with backoff+retry, `isRateLimit`, runtime `sleep`.
30
+ - `test/m74-adaptive-throttle.test.js`: +5 tests (rate-limit detection incl. the real server message; floor/recovery bounds; lowered-ceiling stops granting until in-use drops; **all work completes despite 5 injected rate limits, zero failures**).
31
+
32
+ Verified by 3 real sandbox diagnostics: `setTimeout` resolves in the sandbox (backoff is real); the adaptive gate lowered 10→5 under injected rate limits and completed all 12 items with 0 errors.
33
+
34
+ Suite: 1302 pass / 0 fail / 4 skip — zero regressions.
35
+
5
36
  ## [4.0.20] - 2026-06-02 (M73 Scan Concurrency Throttle — patch)
6
37
 
7
38
  ### Fixed — unthrottled fan-out triggered an API rate limit that wiped a whole scan
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tekyzinc/gsd-t",
3
- "version": "4.0.20",
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
- 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",
@@ -294,28 +279,77 @@ const MAX_CONCURRENT = 10;
294
279
 
295
280
  // Minimal counting semaphore: acquire() resolves when a permit is free; release()
296
281
  // hands the permit to the next waiter (FIFO).
297
- function makeSemaphore(permits) {
298
- let avail = permits;
282
+ // M74: ADAPTIVE semaphore — the ceiling can shrink (on a rate limit) and recover.
283
+ // `inUse` = permits currently held; a permit is grantable only while inUse < ceiling.
284
+ // Lowering the ceiling doesn't yank in-flight permits; it just stops granting new
285
+ // ones until enough release that inUse drops below the new ceiling.
286
+ const MIN_CONCURRENT = 4; // never throttle below this — keeps the run moving
287
+ function makeAdaptiveSemaphore(initial) {
288
+ let ceiling = initial;
289
+ let inUse = 0;
299
290
  const waiters = [];
291
+ function pump() {
292
+ while (inUse < ceiling && waiters.length) { inUse++; waiters.shift()(); }
293
+ }
300
294
  return {
301
295
  async acquire() {
302
- if (avail > 0) { avail--; return; }
303
- await new Promise((res) => waiters.push(res));
304
- // resumed: a release() handed us the permit (avail already decremented there).
296
+ if (inUse < ceiling) { inUse++; return; }
297
+ await new Promise((res) => waiters.push(res)); // pump() increments inUse on grant
298
+ },
299
+ release() { inUse--; pump(); },
300
+ lower() { // shrink ceiling by 1 on a rate limit (floor at MIN_CONCURRENT)
301
+ if (ceiling > MIN_CONCURRENT) { ceiling--; return true; }
302
+ return false;
305
303
  },
306
- release() {
307
- const next = waiters.shift();
308
- if (next) next(); // hand permit directly to the next waiter
309
- else avail++; // no waiter: return permit to the pool
304
+ raise() { // gentle recovery toward the initial ceiling after sustained success
305
+ if (ceiling < initial) { ceiling++; pump(); return true; }
306
+ return false;
310
307
  },
308
+ get ceiling() { return ceiling; },
311
309
  };
312
310
  }
313
- const gate = makeSemaphore(MAX_CONCURRENT);
311
+ const gate = makeAdaptiveSemaphore(MAX_CONCURRENT);
312
+
313
+ function isRateLimit(err) {
314
+ const s = String((err && (err.message || err)) || "").toLowerCase();
315
+ return /rate.?limit|temporarily limiting|429|overloaded|too many requests|capacity/.test(s);
316
+ }
317
+
318
+ // M74: gatedAgent now reacts to rate limits in REAL TIME — on a rate-limit error it
319
+ // lowers the global ceiling (10→9→8…, floor MIN_CONCURRENT), backs off, and retries
320
+ // the SAME agent (up to a few times) instead of letting it fail. After a streak of
321
+ // clean completions it nudges the ceiling back up. This means a transient rate limit
322
+ // throttles the run down automatically rather than failing it (the v4.0.19 wipeout).
323
+ let _cleanStreak = 0;
314
324
  async function gatedAgent(prompt, opts) {
315
325
  await gate.acquire();
316
- try { return await agent(prompt, opts); }
317
- finally { gate.release(); }
326
+ try {
327
+ for (let attempt = 1; attempt <= 4; attempt++) {
328
+ try {
329
+ const r = await agent(prompt, opts);
330
+ // success: nudge the ceiling back up after every 8 clean completions.
331
+ if (++_cleanStreak >= 8) { _cleanStreak = 0; if (gate.raise()) log(`↑ throttle recovered to ${gate.ceiling} concurrent (clean streak)`); }
332
+ return r;
333
+ } catch (e) {
334
+ if (isRateLimit(e) && attempt < 4) {
335
+ _cleanStreak = 0;
336
+ const lowered = gate.lower();
337
+ const backoffMs = 2000 * attempt; // 2s, 4s, 6s
338
+ log(`⚠ rate limit hit — ${lowered ? `throttling down to ${gate.ceiling} concurrent` : `already at floor ${gate.ceiling}`}; backing off ${backoffMs}ms then retry ${attempt + 1}/4 (${(opts && opts.label) || "agent"})`);
339
+ await sleep(backoffMs);
340
+ continue;
341
+ }
342
+ throw e; // non-rate-limit error, or out of retries → bubble up
343
+ }
344
+ }
345
+ } finally {
346
+ gate.release();
347
+ }
318
348
  }
349
+ // Backoff sleep. setTimeout is available in the Workflow sandbox (verified by a
350
+ // real sandbox probe, run wf_7e90a974 — NOT assumed; Date.now/Math.random ARE banned
351
+ // but setTimeout is not). Used only for rate-limit backoff between retries.
352
+ function sleep(ms) { return new Promise((res) => setTimeout(res, ms)); }
319
353
 
320
354
  async function runFinder(slice) {
321
355
  // up to 2 attempts; a null/invalid (non-array findings) result counts as a drop.
@@ -388,42 +422,171 @@ if (!coverageComplete) {
388
422
  }
389
423
  log(`deep scan complete: ${allFindings.length} verified findings across ${succeededCount}/${slices.length} slices${coverageComplete ? " (full coverage)" : " (PARTIAL)"}`);
390
424
 
391
- // Synthesis an agent does the ARCHIVE + REGISTER WRITE + GIT entirely via its
392
- // own Bash/Write tools. The orchestrator does NOT touch fs. The agent is given the
393
- // 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.
394
436
  phase("Synthesis");
395
- const findingsJson = JSON.stringify(allFindings, null, 2);
396
- const synthesis = await agent(
397
- [
398
- `You are the SYNTHESIS agent for a GSD-T deep scan of \`${projectDir}\`. ${slices.length} slices ran; ${allFindings.length} verified findings came back.`,
399
- scanNumber ? `This is scan #${scanNumber} put it in the register header.` : ``,
400
- ``,
401
- `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.)"}`,
402
- ``,
403
- // M72: MANDATORY coverage banner enforced here, not left to the agent's notice.
404
- coverageComplete
405
- ? `COVERAGE: all ${slices.length} slices succeeded — full coverage. Note this in the header.`
406
- : `⚠ 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.`,
407
- ``,
408
- `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.`,
409
- `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.`,
410
- ``,
411
- `STEP 3 — COMMIT via Bash if it's a git repo (\`git add .gsd-t/techdebt.md\` + the archive; commit). Do NOT push.`,
412
- ``,
413
- `Verified findings (${allFindings.length} total):`,
414
- "```json",
415
- 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,
416
- "```",
417
- ``,
418
- `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").`,
419
- ].filter(Boolean).join("\n"),
420
- { label: "synthesis", phase: "Synthesis", schema: SYNTHESIS_SCHEMA, model: "opus" }
421
- );
422
- if (!synthesis || synthesis.status !== "written") {
423
- log("synthesis did not write the register halting before document phase");
424
- 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
+ }
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++;
425
488
  }
426
- log(`register written: ${JSON.stringify(synthesis.counts)} (${synthesis.tdRange || ""})`);
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() : "";
558
+ }
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})`);
427
590
 
428
591
  // Document — per-doc fan-out. Each agent writes its file via Write/Edit (its tools).
429
592
  // The orchestrator passes the findings + (for plain-english) tells the agent to