@tekyzinc/gsd-t 5.11.18 → 5.11.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,63 @@
2
2
 
3
3
  All notable changes to GSD-T are documented here. Updated with each release.
4
4
 
5
+ ## [5.11.20] - 2026-08-10
6
+
7
+ ### Fixed — the real cause of the scan failures: agents pass their answer as a string
8
+
9
+ Measured on HiloAviation's own transcripts, not inferred: **66 of 110 finders**
10
+ called the output tool with `{"input": "<the whole result as a JSON string>"}`
11
+ instead of real top-level fields. The validator replies with the same message
12
+ every time and never says *you stringified it*, so an agent either guesses the
13
+ unwrapped form or exhausts its attempts. **57 guessed right; 9 did not**, each
14
+ losing ~180k tokens of genuine findings.
15
+
16
+ **The model is the variable, not the slice.** The same scan on 2026-08-02 ran
17
+ its finders on Opus and wrapped **0 times in 64 agents**. Sonnet wrapped 40%
18
+ (08-05) and 52% (08-10) across 680 agents. That is why last week's scan of the
19
+ same project passed cleanly.
20
+
21
+ Three attempts now, instead of two:
22
+
23
+ 1. Sonnet — the working tier for 228 parallel finders.
24
+ 2. Sonnet, **told exactly what went wrong** (including that a 69-character
25
+ payload was rejected, so size is not the problem).
26
+ 3. **Opus** — the tier measured at 0% wrapping. Paid for only by the slices that
27
+ actually stumble.
28
+
29
+ Verify had **no retry at all**: one wrapped call and a finding went through
30
+ unverified. It now retries once on Opus with the same hint.
31
+
32
+ Every `model:` stays a literal so the tier-policy lint can still read it — a
33
+ variable would hide a drifted tier from the guard that exists to catch it. The
34
+ lint caught exactly that during this change.
35
+
36
+ Superseded: v5.11.18's extra-fields fix addressed a real but different problem,
37
+ and never applied to these failures. Both remain.
38
+
39
+ - `templates/workflows/gsd-t-scan.workflow.js`: 3-attempt escalation, `UNWRAP_HINT`, verify retry, `SHAPE_RULE` on all 6 schema prompts
40
+ - `test/m112-scan-schema-tolerance.test.js`: 16 tests
41
+ - evidence preserved at `.gsd-t/evidence/hilo-input-wrapper-2026-08-10/`
42
+
43
+ ## [5.11.19] - 2026-08-10
44
+
45
+ ### Changed — a scan that lost areas now STOPS instead of writing the report
46
+
47
+ It already warned and stamped the register PARTIAL. It kept going anyway, so the
48
+ register got written, read, and acted on. On HiloAviation the two lost areas
49
+ were repositories and data-access — the areas with the most to find. A report
50
+ that under-counts the debt while looking finished is worse than no report.
51
+
52
+ The stop happens **before synthesis and before any document is written**, names
53
+ every area that was missed, and returns a failure rather than a quiet success.
54
+ It also says plainly that a failed area is not a clean one.
55
+
56
+ `allowPartial: true` continues deliberately, says so out loud, and the register
57
+ still carries its PARTIAL banner.
58
+
59
+ - `templates/workflows/gsd-t-scan.workflow.js`: the halt + the `allowPartial` option
60
+ - `test/m112-scan-schema-tolerance.test.js`: 4 more tests — the halt precedes writing, names the areas, fails rather than passes, and cannot be bypassed by a truthy-ish value
61
+
5
62
  ## [5.11.18] - 2026-08-10
6
63
 
7
64
  ### Fixed — a scan finding was thrown away over its FORM, not its truth
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # GSD-T: Contract-Driven Development for Claude Code
2
2
 
3
- **v5.11.18** - A methodology for reliable, parallelizable development using Claude Code with optional Agent Teams support.
3
+ **v5.11.20** - 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.18",
3
+ "version": "5.11.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",
@@ -56,6 +56,9 @@ const maxSlicesOverride = _args.maxSlicesHint || null; // optional power-user ce
56
56
  // M94-D6: graph wiring — "wired" (default) or "disabled" (no-graph baseline for AC-4).
57
57
  // [RULE] scan-injects-structural-slice / [RULE] no-graph-baseline-proven-graph-free
58
58
  const graphMode = (_args.graphMode === "disabled") ? "disabled" : "wired";
59
+ // A scan that lost areas STOPS rather than writing a register that under-counts
60
+ // the debt while looking finished. Set true to accept an incomplete scan.
61
+ const allowPartial = _args.allowPartial === true;
59
62
 
60
63
  // VOLUME-DERIVED CAP — a RUNAWAY BACKSTOP, not the target count. The probe decides
61
64
  // the actual slice count by cohesive sub-domain WITHIN this cap; the cap only fires
@@ -121,6 +124,18 @@ const PROBE_SCHEMA = {
121
124
  },
122
125
  };
123
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
+
124
139
  const FINDER_SCHEMA = {
125
140
  type: "object",
126
141
  required: ["slice", "findings"],
@@ -366,6 +381,7 @@ const pre = await agent(
366
381
  `3. Whether \`${projectDir}/.gsd-t/techdebt.md\` exists (priorRegisterExists).`,
367
382
  `4. If it exists, the HIGHEST TD-NNN number in it (grep \`### TD-\`, parse the max integer; priorMaxTd). If absent, priorMaxTd=0.`,
368
383
  `Set ok=true unless something makes scanning impossible (e.g. projectDir does not exist). Return JSON per the schema.`,
384
+ SHAPE_RULE,
369
385
  ].join("\n"),
370
386
  { label: "preflight", phase: "Preflight", schema: PREFLIGHT_SCHEMA, model: "haiku" }
371
387
  );
@@ -401,6 +417,7 @@ const probe = await agent(
401
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.)`,
402
418
  ``,
403
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,
404
421
  ].join("\n"),
405
422
  { label: "volume-probe", phase: "Probe", schema: PROBE_SCHEMA, model: "sonnet" }
406
423
  );
@@ -574,6 +591,13 @@ function finderPrompt(slice, graphSliceContext) {
574
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).`,
575
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.`,
576
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.`,
577
601
  ].filter(Boolean).join("\n");
578
602
  }
579
603
  // M73: GLOBAL CONCURRENCY GATE (shared-worker-pool model). The v4.0.19 Hilo run
@@ -661,22 +685,75 @@ async function gatedAgent(prompt, opts) {
661
685
  // but setTimeout is not). Used only for rate-limit backoff between retries.
662
686
  function sleep(ms) { return new Promise((res) => setTimeout(res, ms)); }
663
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
+
664
713
  async function runFinder(slice, graphSliceContext) {
665
- // up to 2 attempts; a null/invalid (non-array findings) result counts as a drop.
666
714
  // M94-D6: graphSliceContext passed through to finderPrompt for ADDITIVE injection.
667
- for (let attempt = 1; attempt <= 2; attempt++) {
668
- try {
669
- const r = await gatedAgent(finderPrompt(slice, graphSliceContext), {
670
- label: attempt === 1 ? `find:${slice.key}` : `find:${slice.key} (retry)`,
671
- phase: "Deep Scan", schema: FINDER_SCHEMA, model: "sonnet",
672
- });
673
- if (r && Array.isArray(r.findings)) return r; // valid (incl. empty)
674
- log(`⚠ finder slice "${slice.key}" attempt ${attempt} returned no valid output${attempt < 2 ? " — retrying" : ""}`);
675
- } catch (e) {
676
- 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;
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`);
739
+ }
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;
677
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}`);
678
754
  }
679
- return null; // both attempts failed → dropped slice
755
+
756
+ return null; // every attempt failed → dropped slice, and the run HALTS on it
680
757
  }
681
758
 
682
759
  async function scanSlice(slice) {
@@ -698,14 +775,25 @@ async function scanSlice(slice) {
698
775
  const verified = await parallel(
699
776
  finderResult.findings.map((f) => async () => {
700
777
  try {
701
- const v = await gatedAgent(
702
- [
703
- `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.`,
704
- `Finding: ${JSON.stringify(f)}`,
705
- `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.`,
706
- ].join("\n"),
707
- { label: `verify:${sliceKey}`, phase: "Deep Scan", schema: VERIFY_SCHEMA, model: "sonnet" }
708
- );
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
+ }
709
797
  // Compared case-INSENSITIVELY: the schema now accepts "false-positive"
710
798
  // in any casing, so an exact match would silently KEEP a finding the
711
799
  // verifier had rejected.
@@ -740,7 +828,50 @@ const succeededCount = slices.length - failedSlices.length;
740
828
  const coverageComplete = failedSlices.length === 0;
741
829
  const allFindings = sliceResults.filter(Boolean).filter((r) => !r.failed).flatMap((r) => (r.findings || []).map((f) => ({ ...f, slice: r.slice })));
742
830
  if (!coverageComplete) {
743
- 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.`);
831
+ log(`⚠ PARTIAL COVERAGE — ${failedSlices.length}/${slices.length} slices failed after retry and produced NO findings: ${failedSlices.join(", ")}.`);
832
+
833
+ // HALT. A scan whose densest areas dropped out is not a scan with a caveat —
834
+ // it is a report that under-counts the debt while looking finished. On
835
+ // HiloAviation two of 228 slices failed, and the two were repositories and
836
+ // data-access: the areas with the most to find. Warning and continuing meant
837
+ // the register got written, read, and acted on as if complete.
838
+ //
839
+ // Writing the documents is the point of no return, so the stop goes HERE,
840
+ // before them — not after, where the flawed register already exists.
841
+ //
842
+ // Not a fallback and not a gate on findings: the run stops and says exactly
843
+ // which areas are missing. `allowPartial: true` continues deliberately, and
844
+ // the register still carries its PARTIAL banner in that case.
845
+ if (!allowPartial) {
846
+ const lines = [
847
+ "",
848
+ "════════════════════════════════════════════════════════════════",
849
+ ` SCAN HALTED — ${failedSlices.length} of ${slices.length} areas were never scanned`,
850
+ "════════════════════════════════════════════════════════════════",
851
+ "",
852
+ " Not scanned:",
853
+ ...failedSlices.map((k) => ` · ${k}`),
854
+ "",
855
+ " These areas found nothing because they FAILED, not because they",
856
+ " are clean. Their problems are missing from the register.",
857
+ "",
858
+ " Nothing has been written. Re-run to scan only the failed areas,",
859
+ " or re-run with allowPartial: true to accept an incomplete scan",
860
+ " (the register will say PARTIAL on its first line).",
861
+ "════════════════════════════════════════════════════════════════",
862
+ "",
863
+ ].join("\n");
864
+ log(lines);
865
+ return {
866
+ ok: false,
867
+ halted: "partial-coverage",
868
+ slicesTotal: slices.length,
869
+ slicesFailed: failedSlices,
870
+ findingsDiscarded: allFindings.length,
871
+ message: `${failedSlices.length} of ${slices.length} areas were never scanned. Nothing written.`,
872
+ };
873
+ }
874
+ log("→ allowPartial: true — continuing with an INCOMPLETE scan, as asked.");
744
875
  }
745
876
  log(`deep scan complete: ${allFindings.length} verified findings across ${succeededCount}/${slices.length} slices${coverageComplete ? " (full coverage)" : " (PARTIAL)"}`);
746
877
 
@@ -1137,6 +1268,7 @@ const docResults = await parallel(
1137
1268
  isLiving ? mergeNote : `Write the file fresh in the format described (use Bash \`mkdir -p\` for parent dirs if needed).`,
1138
1269
  `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.)`,
1139
1270
  `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.`,
1271
+ SHAPE_RULE,
1140
1272
  ].filter(Boolean).join("\n");
1141
1273
  try {
1142
1274
  return await agent(prompt, { label: d.label, phase: "Document", schema: DOC_RESULT_SCHEMA, model: "sonnet" });
@@ -1269,6 +1401,7 @@ const commitAgent = await agent(
1269
1401
  [
1270
1402
  `Commit the GSD-T scan's generated documents in \`${projectDir}\` via Bash git, IF it is a git repo (else report skipped).`,
1271
1403
  `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).`,
1404
+ SHAPE_RULE,
1272
1405
  ].join("\n"),
1273
1406
  { label: "commit-docs", phase: "Document", schema: RENDER_SCHEMA, model: "haiku" }
1274
1407
  ).catch((e) => ({ status: "failed", notes: String(e && e.message) }));