@tekyzinc/gsd-t 5.11.19 → 5.11.21

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,67 @@
2
2
 
3
3
  All notable changes to GSD-T are documented here. Updated with each release.
4
4
 
5
+ ## [5.11.21] - 2026-08-10
6
+
7
+ ### Added — a final sweep that re-runs slices the rush broke
8
+
9
+ Two slices failed an otherwise clean run on rate limits alone: ten agents in
10
+ flight, the account throttled, and all three attempts landing inside the same
11
+ squeeze. That failure says nothing about the slice — only about when it ran.
12
+
13
+ After the deep scan, the run now waits 30 seconds for any active rate-limit
14
+ window to pass, then retries every failed slice **one at a time, outside the
15
+ concurrency gate**. Re-running failures through the same crowded gate that
16
+ caused them would reproduce the cause.
17
+
18
+ It costs a few minutes on runs that need it and nothing at all on runs that do
19
+ not. A recovered slice stops being a coverage gap and its findings reach the
20
+ register.
21
+
22
+ The sweep runs **before** the partial-coverage halt, so the run only stops for
23
+ what is genuinely lost.
24
+
25
+ - `templates/workflows/gsd-t-scan.workflow.js`: the sweep; `allFindings` now reads the array the sweep repairs
26
+ - `test/m112-scan-schema-tolerance.test.js`: 4 more tests — serial, before the halt, waits, and recovered findings reach the register
27
+
28
+ ## [5.11.20] - 2026-08-10
29
+
30
+ ### Fixed — the real cause of the scan failures: agents pass their answer as a string
31
+
32
+ Measured on HiloAviation's own transcripts, not inferred: **66 of 110 finders**
33
+ called the output tool with `{"input": "<the whole result as a JSON string>"}`
34
+ instead of real top-level fields. The validator replies with the same message
35
+ every time and never says *you stringified it*, so an agent either guesses the
36
+ unwrapped form or exhausts its attempts. **57 guessed right; 9 did not**, each
37
+ losing ~180k tokens of genuine findings.
38
+
39
+ **The model is the variable, not the slice.** The same scan on 2026-08-02 ran
40
+ its finders on Opus and wrapped **0 times in 64 agents**. Sonnet wrapped 40%
41
+ (08-05) and 52% (08-10) across 680 agents. That is why last week's scan of the
42
+ same project passed cleanly.
43
+
44
+ Three attempts now, instead of two:
45
+
46
+ 1. Sonnet — the working tier for 228 parallel finders.
47
+ 2. Sonnet, **told exactly what went wrong** (including that a 69-character
48
+ payload was rejected, so size is not the problem).
49
+ 3. **Opus** — the tier measured at 0% wrapping. Paid for only by the slices that
50
+ actually stumble.
51
+
52
+ Verify had **no retry at all**: one wrapped call and a finding went through
53
+ unverified. It now retries once on Opus with the same hint.
54
+
55
+ Every `model:` stays a literal so the tier-policy lint can still read it — a
56
+ variable would hide a drifted tier from the guard that exists to catch it. The
57
+ lint caught exactly that during this change.
58
+
59
+ Superseded: v5.11.18's extra-fields fix addressed a real but different problem,
60
+ and never applied to these failures. Both remain.
61
+
62
+ - `templates/workflows/gsd-t-scan.workflow.js`: 3-attempt escalation, `UNWRAP_HINT`, verify retry, `SHAPE_RULE` on all 6 schema prompts
63
+ - `test/m112-scan-schema-tolerance.test.js`: 16 tests
64
+ - evidence preserved at `.gsd-t/evidence/hilo-input-wrapper-2026-08-10/`
65
+
5
66
  ## [5.11.19] - 2026-08-10
6
67
 
7
68
  ### Changed — a scan that lost areas now STOPS instead of writing the report
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # GSD-T: Contract-Driven Development for Claude Code
2
2
 
3
- **v5.11.19** - A methodology for reliable, parallelizable development using Claude Code with optional Agent Teams support.
3
+ **v5.11.21** - 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tekyzinc/gsd-t",
3
- "version": "5.11.19",
3
+ "version": "5.11.21",
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",
@@ -124,6 +124,18 @@ const PROBE_SCHEMA = {
124
124
  },
125
125
  };
126
126
 
127
+ // Every agent that returns a schema-validated result gets this line.
128
+ //
129
+ // HiloAviation 2026-08-10: agents passed {"input": "{\"slice\":…}"} — the entire
130
+ // result JSON-encoded into a string under one `input` key. The validator found
131
+ // no top-level fields and refused, five times, on payloads as small as 69
132
+ // characters. It hit the volume probe as well as the finders, so it is not
133
+ // slice-specific: any agent can reach for the wrapper. "Return JSON per the
134
+ // schema" reads as satisfied by handing over a JSON string, so the difference
135
+ // between an object and a string is now stated outright.
136
+ const SHAPE_RULE =
137
+ `SHAPE — the most common way this call fails: pass every field as a REAL top-level field of the tool input. Do NOT serialise the result to a string, and do NOT wrap it in an \`input\` key. A JSON string is rejected however small it is.`;
138
+
127
139
  const FINDER_SCHEMA = {
128
140
  type: "object",
129
141
  required: ["slice", "findings"],
@@ -369,6 +381,7 @@ const pre = await agent(
369
381
  `3. Whether \`${projectDir}/.gsd-t/techdebt.md\` exists (priorRegisterExists).`,
370
382
  `4. If it exists, the HIGHEST TD-NNN number in it (grep \`### TD-\`, parse the max integer; priorMaxTd). If absent, priorMaxTd=0.`,
371
383
  `Set ok=true unless something makes scanning impossible (e.g. projectDir does not exist). Return JSON per the schema.`,
384
+ SHAPE_RULE,
372
385
  ].join("\n"),
373
386
  { label: "preflight", phase: "Preflight", schema: PREFLIGHT_SCHEMA, model: "haiku" }
374
387
  );
@@ -404,6 +417,7 @@ const probe = await agent(
404
417
  `Decompose HONESTLY by cohesive responsibility: not so coarse that an agent can't read its whole slice, not so fine that you emit one slice per file. A well-decomposed system has a finite, sensible number of real responsibilities — find them. (A volume-derived backstop cap is enforced after you return ONLY to catch over-slicing; a clean sub-domain decomposition lands under it. Report accurate \`totals\` — they set the backstop. If your count is truncated, you sliced too finely.)`,
405
418
  ``,
406
419
  `Measure with real tooling and report in \`totals\`: files, loc, routes, tables, components, featureDomains (distinct business/feature areas). Read \`${projectDir}/package.json\` for the stack. Return JSON per the schema: totals + slices.`,
420
+ SHAPE_RULE,
407
421
  ].join("\n"),
408
422
  { label: "volume-probe", phase: "Probe", schema: PROBE_SCHEMA, model: "sonnet" }
409
423
  );
@@ -577,6 +591,13 @@ function finderPrompt(slice, graphSliceContext) {
577
591
  `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).`,
578
592
  `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.`,
579
593
  `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.`,
594
+ // HiloAviation 2026-08-10: agents passed {"input": "{\"slice\":…}"} — the whole
595
+ // result JSON-encoded into a string under one `input` key. The validator saw
596
+ // no `slice` and no `findings` at the top level and refused, five times, on
597
+ // payloads as small as 69 characters. "Return a JSON object" reads as
598
+ // satisfied by handing over a JSON string, so the distinction is now spelled
599
+ // out rather than implied.
600
+ `SHAPE — this is the single most common way this call fails: pass \`slice\` and \`findings\` as REAL top-level fields of the tool input. Do NOT serialise the result to a string, and do NOT wrap it in an \`input\` key. Correct: {"slice":"x","findings":[…]}. WRONG: {"input":"{\\"slice\\":\\"x\\",…}"} — that is a string, and it is rejected however small it is.`,
580
601
  ].filter(Boolean).join("\n");
581
602
  }
582
603
  // M73: GLOBAL CONCURRENCY GATE (shared-worker-pool model). The v4.0.19 Hilo run
@@ -664,22 +685,75 @@ async function gatedAgent(prompt, opts) {
664
685
  // but setTimeout is not). Used only for rate-limit backoff between retries.
665
686
  function sleep(ms) { return new Promise((res) => setTimeout(res, ms)); }
666
687
 
688
+ // Attempt 2 names the mistake; attempt 3 changes the model.
689
+ //
690
+ // Measured on this project's own transcripts (HiloAviation, 110-agent run):
691
+ // 66 of 110 finders passed {"input": "<the whole result as a string>"} instead
692
+ // of the real fields. The validator answers with the same message every time
693
+ // and never says "you stringified it", so an agent either guesses the unwrapped
694
+ // form or exhausts its attempts. 57 guessed right; 9 did not, and each lost
695
+ // ~180k tokens of real findings.
696
+ //
697
+ // The model is the variable, not the slice: the same scan on 2026-08-02 ran its
698
+ // finders on Opus and wrapped ZERO times in 64 agents. Sonnet wrapped 40% on
699
+ // 08-05 and 52% on 08-10 across 680 agents.
700
+ //
701
+ // So: stay on Sonnet for cost, tell attempt 2 exactly what went wrong, and send
702
+ // attempt 3 to the model that has never done it. Opus is paid for only by the
703
+ // slices that actually stumble.
704
+ const UNWRAP_HINT = [
705
+ ``,
706
+ `!! YOUR PREVIOUS ATTEMPT WAS REJECTED. The most likely reason, by far:`,
707
+ `You passed the result as a JSON STRING — {"input": "{\\"slice\\": ...}"} — instead of as real fields.`,
708
+ `The validator looks for \`slice\` and \`findings\` at the TOP LEVEL of the tool input and found neither.`,
709
+ `Call StructuredOutput with slice and findings as ACTUAL top-level fields. Do not stringify. Do not use an \`input\` key.`,
710
+ `A payload of 69 characters was rejected for this reason, so size is not the problem — the shape is.`,
711
+ ].join("\n");
712
+
667
713
  async function runFinder(slice, graphSliceContext) {
668
- // up to 2 attempts; a null/invalid (non-array findings) result counts as a drop.
669
714
  // M94-D6: graphSliceContext passed through to finderPrompt for ADDITIVE injection.
670
- for (let attempt = 1; attempt <= 2; attempt++) {
671
- try {
672
- const r = await gatedAgent(finderPrompt(slice, graphSliceContext), {
673
- label: attempt === 1 ? `find:${slice.key}` : `find:${slice.key} (retry)`,
674
- phase: "Deep Scan", schema: FINDER_SCHEMA, model: "sonnet",
675
- });
676
- if (r && Array.isArray(r.findings)) return r; // valid (incl. empty)
677
- log(`⚠ finder slice "${slice.key}" attempt ${attempt} returned no valid output${attempt < 2 ? " — retrying" : ""}`);
678
- } catch (e) {
679
- log(`⚠ finder slice "${slice.key}" attempt ${attempt} threw: ${e && e.message}${attempt < 2 ? " — retrying" : ""}`);
715
+ const basePrompt = finderPrompt(slice, graphSliceContext);
716
+ // Attempt 1 — Sonnet, the working tier for 228 parallel finders.
717
+ try {
718
+ const r = await gatedAgent(basePrompt, {
719
+ label: `find:${slice.key}`, phase: "Deep Scan", schema: FINDER_SCHEMA, model: "sonnet",
720
+ });
721
+ if (r && Array.isArray(r.findings)) return r;
722
+ log(`⚠ finder slice "${slice.key}" attempt 1 (sonnet) returned no valid output — retrying`);
723
+ } catch (e) {
724
+ log(`⚠ finder slice "${slice.key}" attempt 1 (sonnet) threw: ${e && e.message} — retrying`);
725
+ }
726
+
727
+ // Attempt 2 — same tier, but now TOLD what went wrong.
728
+ try {
729
+ const r = await gatedAgent(basePrompt + UNWRAP_HINT, {
730
+ label: `find:${slice.key} (retry)`, phase: "Deep Scan", schema: FINDER_SCHEMA, model: "sonnet",
731
+ });
732
+ if (r && Array.isArray(r.findings)) {
733
+ log(`✓ finder slice "${slice.key}" recovered on attempt 2 (sonnet)`);
734
+ return r;
680
735
  }
736
+ log(`⚠ finder slice "${slice.key}" attempt 2 (sonnet) returned no valid output — escalating to opus`);
737
+ } catch (e) {
738
+ log(`⚠ finder slice "${slice.key}" attempt 2 (sonnet) threw: ${e && e.message} — escalating to opus`);
681
739
  }
682
- return null; // both attempts failed → dropped slice
740
+
741
+ // Attempt 3 — the model that has never done this. Paid for only by the
742
+ // slices that actually stumble.
743
+ try {
744
+ const r = await gatedAgent(basePrompt + UNWRAP_HINT, {
745
+ label: `find:${slice.key} (retry on opus)`, phase: "Deep Scan", schema: FINDER_SCHEMA, model: "opus",
746
+ });
747
+ if (r && Array.isArray(r.findings)) {
748
+ log(`✓ finder slice "${slice.key}" recovered on attempt 3 (opus)`);
749
+ return r;
750
+ }
751
+ log(`⚠ finder slice "${slice.key}" attempt 3 (opus) returned no valid output`);
752
+ } catch (e) {
753
+ log(`⚠ finder slice "${slice.key}" attempt 3 (opus) threw: ${e && e.message}`);
754
+ }
755
+
756
+ return null; // every attempt failed → dropped slice, and the run HALTS on it
683
757
  }
684
758
 
685
759
  async function scanSlice(slice) {
@@ -701,14 +775,25 @@ async function scanSlice(slice) {
701
775
  const verified = await parallel(
702
776
  finderResult.findings.map((f) => async () => {
703
777
  try {
704
- const v = await gatedAgent(
705
- [
706
- `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.`,
707
- `Finding: ${JSON.stringify(f)}`,
708
- `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.`,
709
- ].join("\n"),
710
- { label: `verify:${sliceKey}`, phase: "Deep Scan", schema: VERIFY_SCHEMA, model: "sonnet" }
711
- );
778
+ const verifyPrompt = [
779
+ `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.`,
780
+ `Finding: ${JSON.stringify(f)}`,
781
+ `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.`,
782
+ SHAPE_RULE,
783
+ ].join("\n");
784
+
785
+ // Verify had NO retry: one wrapped call and the finding went through
786
+ // unverified. Same escalation as the finder, one step shorter — a lost
787
+ // verdict costs one finding, not a whole slice.
788
+ let v = await gatedAgent(verifyPrompt, {
789
+ label: `verify:${sliceKey}`, phase: "Deep Scan", schema: VERIFY_SCHEMA, model: "sonnet",
790
+ });
791
+ if (!v) {
792
+ v = await gatedAgent(verifyPrompt + UNWRAP_HINT, {
793
+ label: `verify:${sliceKey} (retry on opus)`, phase: "Deep Scan",
794
+ schema: VERIFY_SCHEMA, model: "opus",
795
+ });
796
+ }
712
797
  // Compared case-INSENSITIVELY: the schema now accepts "false-positive"
713
798
  // in any casing, so an exact match would silently KEEP a finding the
714
799
  // verifier had rejected.
@@ -739,9 +824,57 @@ slices.forEach((s, i) => {
739
824
  const r = resultsByIndex[i];
740
825
  if (!r || r.failed) failedSlices.push(s.key);
741
826
  });
827
+ // ── Final sweep: re-run what the rush broke ─────────────────────────────────
828
+ //
829
+ // A slice can exhaust its three attempts purely because the run was at full
830
+ // tilt — 10 agents in flight, the account rate-limited, and all three tries
831
+ // landing inside the same squeeze. That failure says nothing about the slice.
832
+ //
833
+ // So once the deep scan is over and the burst has drained, try the stragglers
834
+ // again: one at a time, unhurried, with the whole machine to themselves. It
835
+ // costs a few minutes on the runs that need it and nothing at all on the runs
836
+ // that do not.
837
+ //
838
+ // Serial and ungated on purpose. Re-running failures through the same crowded
839
+ // gate that caused them would reproduce the cause.
840
+ if (failedSlices.length > 0) {
841
+ log(`↻ final sweep — retrying ${failedSlices.length} failed slice(s) one at a time, now that the run has drained: ${failedSlices.join(", ")}`);
842
+ await sleep(30000); // let any active rate-limit window pass before starting
843
+
844
+ const stillFailed = [];
845
+ for (const key of failedSlices) {
846
+ const slice = slices.find((sl) => sl.key === key);
847
+ if (!slice) { stillFailed.push(key); continue; }
848
+
849
+ const recovered = await scanSlice(slice); // scanSlice resolves its own graph context
850
+
851
+ if (recovered && !recovered.failed) {
852
+ const idx = slices.indexOf(slice);
853
+ resultsByIndex[idx] = recovered;
854
+ log(`✓ sweep recovered "${key}" — ${(recovered.findings || []).length} finding(s)`);
855
+ } else {
856
+ stillFailed.push(key);
857
+ log(`✗ sweep could not recover "${key}"`);
858
+ }
859
+ }
860
+
861
+ // Only the failed list needs updating by hand — coverage and the findings
862
+ // list are computed from `resultsByIndex` below, which the sweep has already
863
+ // repaired in place.
864
+ failedSlices.length = 0;
865
+ failedSlices.push(...stillFailed);
866
+
867
+ log(stillFailed.length === 0
868
+ ? `✓ final sweep restored full coverage — ${slices.length}/${slices.length} slices`
869
+ : `⚠ final sweep left ${stillFailed.length} slice(s) unrecovered: ${stillFailed.join(", ")}`);
870
+ }
871
+
742
872
  const succeededCount = slices.length - failedSlices.length;
743
873
  const coverageComplete = failedSlices.length === 0;
744
- const allFindings = sliceResults.filter(Boolean).filter((r) => !r.failed).flatMap((r) => (r.findings || []).map((f) => ({ ...f, slice: r.slice })));
874
+ // Read from resultsByIndex, not sliceResults: the final sweep writes recovered
875
+ // slices back into resultsByIndex, and a recovered slice's findings must reach
876
+ // the register.
877
+ const allFindings = resultsByIndex.filter(Boolean).filter((r) => !r.failed).flatMap((r) => (r.findings || []).map((f) => ({ ...f, slice: r.slice })));
745
878
  if (!coverageComplete) {
746
879
  log(`⚠ PARTIAL COVERAGE — ${failedSlices.length}/${slices.length} slices failed after retry and produced NO findings: ${failedSlices.join(", ")}.`);
747
880
 
@@ -1183,6 +1316,7 @@ const docResults = await parallel(
1183
1316
  isLiving ? mergeNote : `Write the file fresh in the format described (use Bash \`mkdir -p\` for parent dirs if needed).`,
1184
1317
  `PUNCTUATION: do NOT use em-dashes (use " - "), en-dashes, smart quotes, or ellipsis characters — those render as garbage in non-UTF-8 terminals. Use plain ASCII hyphens and straight quotes. (Severity color bullets 🔴🟠🟡🟢 are fine to keep where used for severity.)`,
1185
1318
  `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.`,
1319
+ SHAPE_RULE,
1186
1320
  ].filter(Boolean).join("\n");
1187
1321
  try {
1188
1322
  return await agent(prompt, { label: d.label, phase: "Document", schema: DOC_RESULT_SCHEMA, model: "sonnet" });
@@ -1315,6 +1449,7 @@ const commitAgent = await agent(
1315
1449
  [
1316
1450
  `Commit the GSD-T scan's generated documents in \`${projectDir}\` via Bash git, IF it is a git repo (else report skipped).`,
1317
1451
  `Stage: \`.gsd-t/scan\`, \`.gsd-t/techdebt.md\`, \`.gsd-t/techdebt_in_plain_english.md\`, \`share\`, \`docs\`, \`README.md\` (do NOT stage \`.gsd-t/scan/.doc-backup\` if present; \`.gsd-t/scan/archive\` MAY be staged — the dated history is worth keeping). Commit message: "scan: deep document cross-population (${docsOk.length} docs) + dimension files + share/ export". Do NOT push. Return JSON per the schema (status "rendered" if committed, "skipped" if not a git repo / nothing to commit, "failed" on error; outputPath optional).`,
1452
+ SHAPE_RULE,
1318
1453
  ].join("\n"),
1319
1454
  { label: "commit-docs", phase: "Document", schema: RENDER_SCHEMA, model: "haiku" }
1320
1455
  ).catch((e) => ({ status: "failed", notes: String(e && e.message) }));