@tekyzinc/gsd-t 5.11.19 → 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,44 @@
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
+
5
43
  ## [5.11.19] - 2026-08-10
6
44
 
7
45
  ### 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.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.19",
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",
@@ -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.
@@ -1183,6 +1268,7 @@ const docResults = await parallel(
1183
1268
  isLiving ? mergeNote : `Write the file fresh in the format described (use Bash \`mkdir -p\` for parent dirs if needed).`,
1184
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.)`,
1185
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,
1186
1272
  ].filter(Boolean).join("\n");
1187
1273
  try {
1188
1274
  return await agent(prompt, { label: d.label, phase: "Document", schema: DOC_RESULT_SCHEMA, model: "sonnet" });
@@ -1315,6 +1401,7 @@ const commitAgent = await agent(
1315
1401
  [
1316
1402
  `Commit the GSD-T scan's generated documents in \`${projectDir}\` via Bash git, IF it is a git repo (else report skipped).`,
1317
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,
1318
1405
  ].join("\n"),
1319
1406
  { label: "commit-docs", phase: "Document", schema: RENDER_SCHEMA, model: "haiku" }
1320
1407
  ).catch((e) => ({ status: "failed", notes: String(e && e.message) }));