@tekyzinc/gsd-t 4.0.18 → 4.0.20

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.20] - 2026-06-02 (M73 Scan Concurrency Throttle — patch)
6
+
7
+ ### Fixed — unthrottled fan-out triggered an API rate limit that wiped a whole scan
8
+
9
+ A v4.0.19 Hilo run produced an EMPTY register (0 findings) — not a workflow-logic bug: the scan fanned out 29 finders + their verifiers ALL AT ONCE (~58 concurrent Sonnet agents), hit a server-side API rate limit ("temporarily limiting requests · Rate limited"), and all 58 agents errored out empty. (M72's coverage-honesty correctly flagged it: 0/29, pointed to the prior 133-item register — no false "complete".)
10
+
11
+ - `templates/workflows/gsd-t-scan.workflow.js`: added a single GLOBAL counting semaphore (`makeSemaphore`) of 10 permits. EVERY finder + verify agent acquires a permit before running and releases after (`gatedAgent`); freed permits are handed FIFO to the next waiter. All slices + findings still fan out at once, but total in-flight never exceeds 10 — a shared worker-pool: the instant any agent finishes, the next queued one starts, so throughput stays maximal at a safe ceiling. (Finders + verifiers are Sonnet, fine at 10; the lone Opus synthesis runs after, ungated.)
12
+ - This replaces the initial batched approach (which left worker slots idle while a slice serialized its verifies) with a global queue — better throughput AND simpler.
13
+
14
+ Verified by two real sandbox diagnostics: a 30-agent `mapLimit` probe and a 56-agent (8 finders × 6 verifies, fanned out at once) semaphore probe — both measured peakConcurrency = 10, never exceeded.
15
+
16
+ Suite: 1297 pass / 0 fail / 4 skip — zero regressions.
17
+
18
+ ## [4.0.19] - 2026-06-02 (M72 Scan Dropped-Slice Recovery + Coverage Honesty — patch)
19
+
20
+ ### Fixed — a scan that under-covers no longer presents partial results as complete
21
+
22
+ The first real Hilo run (v4.0.18) surfaced 133 findings but 7 of 19 deep-finder slices had silently FAILED — they returned no schema-valid output (the runtime nudged them twice then dropped them), and the workflow treated a dropped slice identically to a genuinely-clean one (`findings: []`). The register confidently presented ~⅔ coverage as complete. For a quality tool, silent partial coverage is worse than a shallow scan.
23
+
24
+ - `templates/workflows/gsd-t-scan.workflow.js`:
25
+ - **Retry**: each finder is now retried once on a null/invalid result (`runFinder`).
26
+ - **Detect, don't conflate**: a slice that still fails is flagged `failed:true` — never merged with an empty-but-successful slice. Coverage accounting computes `failedSlices`, `slicesSucceeded`, `coverageComplete` deterministically; dropped slices' (absent) findings are excluded from the count.
27
+ - **Surface loudly**: synthesis is REQUIRED (not relying on the agent's notice) to put a "⚠ PARTIAL COVERAGE — N of M areas not scanned" banner at the top of the register and list the un-scanned slices. The workflow return status downgrades to `"complete-partial-coverage"` (not `"complete"`) and includes `slicesFailed[]`.
28
+ - **Synthesis robustness**: instructed to write the register INCREMENTALLY (header+summary, then append each severity section) so a multi-hundred-item register can't stall on one giant Write; synthesis-input truncation raised 200KB→500KB.
29
+ - `test/m72-coverage-accounting.test.js`: +4 tests (dropped slice excluded + flagged; clean slice not a gap; null pipeline result = failed; full coverage = complete). Coverage logic also verified by a real sandbox diagnostic run.
30
+
31
+ Effect: a scan with any failed slice is clearly marked incomplete; resuming the run re-scans only the failed slices (cached successes are reused).
32
+
33
+ Suite: 1297 pass / 0 fail / 4 skip — zero regressions.
34
+
5
35
  ## [4.0.18] - 2026-06-02 (M71 Runtime-Native Scan Workflow — patch)
6
36
 
7
37
  ### Fixed — the scan Workflow now actually RUNS in the Workflow sandbox (it never did)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tekyzinc/gsd-t",
3
- "version": "4.0.18",
3
+ "version": "4.0.20",
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",
@@ -261,52 +261,132 @@ const deep = budget && budget.total && budget.total > 300000 ? "MAXIMUM" : "thor
261
261
 
262
262
  // Deep scan — pipeline: per-slice deep finder → single verify (no barrier).
263
263
  phase("Deep Scan");
264
- const sliceResults = await pipeline(
265
- slices,
266
- (slice) => agent(
267
- [
268
- `⛔ Scan ONLY files under the absolute project path \`${projectDir}\`. \`cd ${projectDir}\` first; never read outside this tree.`,
269
- `You are a DEEP tech-debt finder for ONE slice of a scan of \`${projectDir}\`: \`${slice.key}\` (dimension: ${slice.dimension}).`,
270
- `Owned paths (relative to \`${projectDir}\`): ${JSON.stringify(slice.paths)}.`,
271
- slice.why ? `Why this slice matters: ${slice.why}` : ``,
272
- ``,
273
- `MANDATE: ENUMERATE, do NOT sample. Read EVERY file under your owned paths (use Read/Grep). The legacy scan failed because one agent sampled ~5 issues across the whole repo and stopped — you own only this slice, so go to the bottom of it.`,
274
- `Depth = ${deep}. "thorough" = every file, every non-trivial real defect (high+medium confidence). "MAXIMUM" = also lower-confidence/speculative items worth review.`,
275
- `Surface: bugs, security holes, missing validation, broken invariants, race conditions, dead/duplicated code, N+1s, untested critical paths, contract drift, domain-specific correctness (money math, state-machine gaps, timezone bugs, idempotency holes).`,
276
- `For each finding: title, severity (CRITICAL/HIGH/MEDIUM/LOW), human area label, concrete file:line refs, detail, impact, remediation, honest confidence. If a substantial slice yields only 1-2 findings, re-check before concluding it's clean. Empty findings array if genuinely clean. Return JSON per the schema.`,
277
- ].filter(Boolean).join("\n"),
278
- { label: `find:${slice.key}`, phase: "Deep Scan", schema: FINDER_SCHEMA, model: "sonnet" }
279
- ),
280
- async (finderResult, originalItem) => {
281
- const slice = originalItem || {};
282
- const sliceKey = slice.key || (finderResult && finderResult.slice) || "unknown-slice";
283
- if (!finderResult || !Array.isArray(finderResult.findings)) return { slice: sliceKey, findings: [] };
284
- if (verifyMode === "none" || finderResult.findings.length === 0) {
285
- return { slice: sliceKey, findings: finderResult.findings || [] };
264
+
265
+ // M72: a finder that returns no schema-valid output (runtime nudged it twice then
266
+ // dropped it) must NOT be silently treated as a clean slice — that presents PARTIAL
267
+ // coverage as complete (7/19 slices dropped this way on the Hilo run). So: (1) the
268
+ // finder call is RETRIED once on a null/invalid result; (2) a slice that still fails
269
+ // is flagged `failed:true` and tracked never conflated with a genuinely-empty slice.
270
+ function finderPrompt(slice) {
271
+ return [
272
+ `⛔ Scan ONLY files under the absolute project path \`${projectDir}\`. \`cd ${projectDir}\` first; never read outside this tree.`,
273
+ `You are a DEEP tech-debt finder for ONE slice of a scan of \`${projectDir}\`: \`${slice.key}\` (dimension: ${slice.dimension}).`,
274
+ `Owned paths (relative to \`${projectDir}\`): ${JSON.stringify(slice.paths)}.`,
275
+ slice.why ? `Why this slice matters: ${slice.why}` : ``,
276
+ ``,
277
+ `MANDATE: ENUMERATE, do NOT sample. Read EVERY file under your owned paths (use Read/Grep). You own only this slice, so go to the bottom of it.`,
278
+ `Depth = ${deep}. "thorough" = every file, every non-trivial real defect (high+medium confidence). "MAXIMUM" = also lower-confidence/speculative items worth review.`,
279
+ `Surface: bugs, security holes, missing validation, broken invariants, race conditions, dead/duplicated code, N+1s, untested critical paths, contract drift, domain-specific correctness (money math, state-machine gaps, timezone bugs, idempotency holes).`,
280
+ `For each finding: title, severity (CRITICAL/HIGH/MEDIUM/LOW), human area label, concrete file:line refs, detail, impact, remediation, honest confidence. If a substantial slice yields only 1-2 findings, re-check before concluding it's clean. Empty findings array ONLY if genuinely clean.`,
281
+ `CRITICAL: you MUST return a JSON object matching the schema (slice + findings array) as your FINAL output — even if findings is empty. Do not end without the structured result.`,
282
+ ].filter(Boolean).join("\n");
283
+ }
284
+ // M73: GLOBAL CONCURRENCY GATE (shared-worker-pool model). The v4.0.19 Hilo run
285
+ // fanned out 29 finders + their verifiers ALL AT ONCE → ~58 concurrent Sonnet agents
286
+ // → server-side API rate limit → all errored empty → 0 findings. Rather than batch
287
+ // per-slice (which leaves slots idle while a slice serializes its verifies), use ONE
288
+ // global semaphore of MAX_CONCURRENT permits that EVERY agent call (finder OR verify,
289
+ // from any slice) must acquire. Work fans out naturally; the gate alone enforces the
290
+ // cap. The instant any agent finishes, the next queued one starts → always ≈10 in
291
+ // flight while work remains = max throughput at a safe ceiling. (All gated agents are
292
+ // Sonnet; the lone Opus synthesis runs after, ungated.)
293
+ const MAX_CONCURRENT = 10;
294
+
295
+ // Minimal counting semaphore: acquire() resolves when a permit is free; release()
296
+ // hands the permit to the next waiter (FIFO).
297
+ function makeSemaphore(permits) {
298
+ let avail = permits;
299
+ const waiters = [];
300
+ return {
301
+ 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).
305
+ },
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
310
+ },
311
+ };
312
+ }
313
+ const gate = makeSemaphore(MAX_CONCURRENT);
314
+ async function gatedAgent(prompt, opts) {
315
+ await gate.acquire();
316
+ try { return await agent(prompt, opts); }
317
+ finally { gate.release(); }
318
+ }
319
+
320
+ async function runFinder(slice) {
321
+ // up to 2 attempts; a null/invalid (non-array findings) result counts as a drop.
322
+ for (let attempt = 1; attempt <= 2; attempt++) {
323
+ try {
324
+ const r = await gatedAgent(finderPrompt(slice), {
325
+ label: attempt === 1 ? `find:${slice.key}` : `find:${slice.key} (retry)`,
326
+ phase: "Deep Scan", schema: FINDER_SCHEMA, model: "sonnet",
327
+ });
328
+ if (r && Array.isArray(r.findings)) return r; // valid (incl. empty)
329
+ log(`⚠ finder slice "${slice.key}" attempt ${attempt} returned no valid output${attempt < 2 ? " — retrying" : ""}`);
330
+ } catch (e) {
331
+ log(`⚠ finder slice "${slice.key}" attempt ${attempt} threw: ${e && e.message}${attempt < 2 ? " — retrying" : ""}`);
286
332
  }
287
- const verified = await parallel(
288
- finderResult.findings.map((f) => async () => {
289
- try {
290
- const v = await agent(
291
- [
292
- `You are a VERIFIER for one tech-debt finding in \`${projectDir}\`. Confirm it against the ACTUAL code (open the referenced files with Read) — do not trust the finder.`,
293
- `Finding: ${JSON.stringify(f)}`,
294
- `confirmed=true only if the defect genuinely exists. If misread → verdict="false-positive". If real but wrong severity → set correctedSeverity. If real but underspecified → verdict="needs-detail" (kept). Return JSON per the schema.`,
295
- ].join("\n"),
296
- { label: `verify:${sliceKey}`, phase: "Deep Scan", schema: VERIFY_SCHEMA, model: "sonnet" }
297
- );
298
- if (!v || v.verdict === "false-positive" || v.confirmed === false) return null;
299
- return { ...f, severity: v.correctedSeverity || f.severity, _verify: v.verdict };
300
- } catch (e) {
301
- return { ...f, _verify: "verify-errored" };
302
- }
303
- })
304
- );
305
- return { slice: sliceKey, findings: verified.filter(Boolean) };
306
333
  }
307
- );
308
- const allFindings = sliceResults.filter(Boolean).flatMap((r) => (r.findings || []).map((f) => ({ ...f, slice: r.slice })));
309
- log(`deep scan complete: ${allFindings.length} verified findings across ${sliceResults.filter(Boolean).length} slices`);
334
+ return null; // both attempts failed → dropped slice
335
+ }
336
+
337
+ async function scanSlice(slice) {
338
+ const sliceKey = slice.key || "unknown-slice";
339
+ const finderResult = await runFinder(slice);
340
+ // M72: distinguish a FAILED finder (null after retries) from a genuinely-clean slice.
341
+ if (!finderResult || !Array.isArray(finderResult.findings)) {
342
+ return { slice: sliceKey, findings: [], failed: true };
343
+ }
344
+ if (verifyMode === "none" || finderResult.findings.length === 0) {
345
+ return { slice: sliceKey, findings: finderResult.findings || [], failed: false };
346
+ }
347
+ // Fan out ALL verifies for this slice — the global gate (not a per-slice limit)
348
+ // bounds total in-flight, so this is safe AND keeps every worker slot busy.
349
+ const verified = await parallel(
350
+ finderResult.findings.map((f) => async () => {
351
+ try {
352
+ const v = await gatedAgent(
353
+ [
354
+ `You are a VERIFIER for one tech-debt finding in \`${projectDir}\`. Confirm it against the ACTUAL code (open the referenced files with Read) — do not trust the finder.`,
355
+ `Finding: ${JSON.stringify(f)}`,
356
+ `confirmed=true only if the defect genuinely exists. If misread → verdict="false-positive". If real but wrong severity → set correctedSeverity. If real but underspecified → verdict="needs-detail" (kept). Return JSON per the schema.`,
357
+ ].join("\n"),
358
+ { label: `verify:${sliceKey}`, phase: "Deep Scan", schema: VERIFY_SCHEMA, model: "sonnet" }
359
+ );
360
+ if (!v || v.verdict === "false-positive" || v.confirmed === false) return null;
361
+ return { ...f, severity: v.correctedSeverity || f.severity, _verify: v.verdict };
362
+ } catch (e) {
363
+ return { ...f, _verify: "verify-errored" };
364
+ }
365
+ })
366
+ );
367
+ return { slice: sliceKey, findings: verified.filter(Boolean), failed: false };
368
+ }
369
+
370
+ // Fan out ALL slices at once — every finder + verify acquires the shared gate, so
371
+ // total in-flight never exceeds MAX_CONCURRENT regardless of slice/finding counts.
372
+ log(`deep scan: ${slices.length} slices via a shared ${MAX_CONCURRENT}-permit gate (Sonnet finders+verifiers; max throughput at a safe ceiling)`);
373
+ const sliceResults = await parallel(slices.map((slice) => () => scanSlice(slice)));
374
+
375
+ // M72: coverage accounting — a dropped pipeline result (null) OR a failed:true slice
376
+ // is a COVERAGE GAP. Surface it deterministically; never present partial as complete.
377
+ const resultsByIndex = sliceResults; // pipeline preserves order
378
+ const failedSlices = [];
379
+ slices.forEach((s, i) => {
380
+ const r = resultsByIndex[i];
381
+ if (!r || r.failed) failedSlices.push(s.key);
382
+ });
383
+ const succeededCount = slices.length - failedSlices.length;
384
+ const coverageComplete = failedSlices.length === 0;
385
+ const allFindings = sliceResults.filter(Boolean).filter((r) => !r.failed).flatMap((r) => (r.findings || []).map((f) => ({ ...f, slice: r.slice })));
386
+ if (!coverageComplete) {
387
+ log(`⚠ PARTIAL COVERAGE — ${failedSlices.length}/${slices.length} slices failed after retry and produced NO findings: ${failedSlices.join(", ")}. The register will be flagged INCOMPLETE. Resume the run to re-scan only the failed slices.`);
388
+ }
389
+ log(`deep scan complete: ${allFindings.length} verified findings across ${succeededCount}/${slices.length} slices${coverageComplete ? " (full coverage)" : " (PARTIAL)"}`);
310
390
 
311
391
  // Synthesis — an agent does the ARCHIVE + REGISTER WRITE + GIT entirely via its
312
392
  // own Bash/Write tools. The orchestrator does NOT touch fs. The agent is given the
@@ -320,13 +400,19 @@ const synthesis = await agent(
320
400
  ``,
321
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.)"}`,
322
402
  ``,
323
- `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: 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.`,
403
+ // M72: MANDATORY coverage bannerenforced 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.`,
324
410
  ``,
325
411
  `STEP 3 — COMMIT via Bash if it's a git repo (\`git add .gsd-t/techdebt.md\` + the archive; commit). Do NOT push.`,
326
412
  ``,
327
- `Verified findings:`,
413
+ `Verified findings (${allFindings.length} total):`,
328
414
  "```json",
329
- findingsJson.length > 200000 ? findingsJson.slice(0, 200000) + "\n…(truncated)" : findingsJson,
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,
330
416
  "```",
331
417
  ``,
332
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").`,
@@ -429,8 +515,12 @@ log(`docs commit: ${commitAgent && commitAgent.status}`);
429
515
  // projectDir. The fragile, wrong-tree HTML report is not worth that risk; dropped.
430
516
 
431
517
  return {
432
- status: "complete",
433
- slices: slices.length,
518
+ // M72: status reflects coverage — a partial scan is NOT "complete".
519
+ status: coverageComplete ? "complete" : "complete-partial-coverage",
520
+ coverageComplete,
521
+ slicesTotal: slices.length,
522
+ slicesSucceeded: succeededCount,
523
+ slicesFailed: failedSlices, // names of un-scanned areas (empty if full coverage)
434
524
  findings: allFindings.length,
435
525
  counts: synthesis.counts,
436
526
  tdRange: synthesis.tdRange,