Skip to content

Seven real bugs found running explainmyrepo end-to-end on claude-sonnet-5 (temperature, thinking budget, undersized max_tokens, unwired diagram rows, no-retry + connection-poisoning fetch, refine-cap waste) — 6 patched & verified #17

Description

@pacphi

Target repo: stuinfla/Repo-Explainer (published as explainmyrepo, currently 0.5.0 on npm)
Filed by: running npx explainmyrepo https://github.com/pacphi/tub-vault --no-deploy against a
real private repo, then debugging every failure that came up until the full 17-station pipeline
completed end-to-end. All seven issues below were hit live, root-caused by reading the actual source
(not guessed), and six were verified fixed with a passing re-run (the seventh is flagged for
maintainer judgment rather than patched — see #7). Unified diff patches against the pristine
0.5.0 package are included inline near the end of this report.


Executive summary

A first-time run of explainmyrepo against a real (private, ~9,400-doc) repo failed at the very
first Claude-calling station and, once past that, kept failing at each subsequent brain- or
vision-calling station in turn. Seven distinct, independently-reproducible defects were found,
spanning three files:

# File Defect Symptom
1 src/claude.mjs Anthropic rejects a custom temperature on claude-sonnet-5 Immediate 400, build dead on station 1
2 src/claude.mjs claude-sonnet-5 adaptively spends max_tokens on an invisible thinking block stop_reason=max_tokens, zero visible text, loud failures and at least one silent truncation
3 src/brain.mjs authorContent's max_tokens: 4000 budget too small once (2) is fixed JSON truncated mid-array, parse error
4 src/brain.mjs authorVisualBrief never asks for architecture/flow diagram rows, but make-diagrams.mjs requires them whenever the real dep-graph is trivial make-diagrams refuses to render, build dead
5 tools/quality-grade.mjs Vision-grading fetch() had no retry at all One network blip kills a fully-rendered, fully-graded station
6 tools/quality-grade.mjs, src/claude.mjs fetch()'s pooled HTTP/2 session, once corrupted, poisons every subsequent call to that host in the same process Retries added for (5) still failed every time — not transient, unretryable via the same connection pool; recurred on api.anthropic.com too
7 tools/quality-grade.mjs --max-refine doesn't know about the internal MAX_QUALITY_ITERATIONS=3 cap Refine passes past the cap re-author content for real money but can never be re-graded — silently wasted spend

Once #1#6 were patched, the full pipeline — kb:register → build-kb → primer → concept → content →
visual-brief → generate-image → make-favicon → make-social-card → make-diagrams → assemble-page →
make-pack → quality-grade → (refine loop) — ran to completion with no further crashes. The only thing
still outstanding after those fixes is a subjective content-quality score on this particular repo
(a docs/content vault with a trivial dependency graph is a hard case for the art-direction prompts)
— that's a separate, expected concern, not a bug. #7 was discovered while iterating on that score
and is reported but not patched (a CLI-semantics judgment call, not a straightforward code fix).

One additional item is not an explainmyrepo bug but worth mentioning: make-favicon requires
ImageMagick (magick/convert) on PATH and fails loudly if it's absent, with no mention of it as
a prerequisite anywhere in the README/docs. Worth a one-line "Prerequisites" note.


Environment

  • OS: macOS 26.5.2 (Darwin 25.5.0, arm64 / Apple Silicon)
  • Node: v26.4.0
  • npm: 11.17.0
  • explainmyrepo: 0.5.0 (latest on npm at time of testing)
  • ImageMagick: 7.1.2-29 (Q16-HDRI, installed via Homebrew for make-favicon)
  • Anthropic model: claude-sonnet-5 (the package's own DEFAULT_MODEL)
  • OpenAI vision model: gpt-5.6-sol (the package's own QUALITY_VISION_MODEL default)
  • Target repo graded: a private ~9,400-document content/docs repo (not code-heavy — this matters
    for Explainer request: sindresorhus/ky #4, which is specifically about repos with a near-empty dependency graph)

1. src/claude.mjs — Anthropic rejects a custom temperature on claude-sonnet-5

Severity: Build-breaking — killed the very first brain-calling station (kb:register), 100%
reproducible, every run.

Actual output:

Anthropic API 400 (claude-sonnet-5): {"type":"error","error":{"type":"invalid_request_error","message":"`temperature` is deprecated for this model."}}

Root cause: src/brain.mjs passes an explicit temperature on every brain call (0.3–0.8
depending on station), and src/claude.mjs's callClaudeOnce sends it through unconditionally in
the request body. claude-sonnet-5 rejects it outright. The retry logic in callClaude only retries
on 429/5xx (err.retryable = resp.status === 429 || resp.status >= 500), so this 400 hard-fails
immediately with no retry. Notably, tools/quality-grade.mjs already has the equivalent handling
for OpenAI's reasoning models (gpt-5.x/o-series skip temperature) — this carve-out was just
never added for the Anthropic side.

Fix: catch the specific 400 + "temperature" message and retry once without the parameter,
mirroring the existing OpenAI-side pattern.

Verified: kb:register (the exact station that failed) now passes cleanly; confirmed again at
every other brain-calling station across the whole run.


2. src/claude.mjsclaude-sonnet-5 adaptively burns max_tokens on an invisible thinking block

Severity: Build-breaking on some stations, silent data loss on others — this is the more
serious of the two Anthropic issues.

Actual output (loud case, authorConcept, max_tokens: 2000):

[claude][debug] empty-text response: {"stop_reason":"max_tokens","usage":{"output_tokens":2000,"output_tokens_details":{"thinking_tokens":2000}},"blockTypes":["thinking"]}
✗ concept FAILED: Anthropic returned no text (stop_reason=max_tokens)

The entire declared budget went to an invisible thinking content block; zero tokens were left for
the actual JSON answer.

Silent case (authorPrimer, plain markdown, max_tokens: 3000): no request parameter enables
extended thinking; the model triggered it anyway once the prompt (a large repo-KB brief) was complex
enough, and because authorPrimer has no output-shape validation (unlike the JSON-authoring stations,
which at least throw on malformed JSON), the truncated result was accepted and written to disk
without error. kb/stores/<slug>/<slug>-primer.md ended mid-word:

...distinguishing cur

That file feeds the shipped knowledge pack (make-pack) — this is a real product-facing bug, not
just a build-time inconvenience.

Root cause: confirmed empirically, not guessed — isolated with two standalone probes directly
against the Anthropic API outside the tool entirely:

  1. A short, simple prompt: thinking_tokens: 0 regardless of whether thinking is set.
  2. The real authorConcept system/user prompt at max_tokens: 2000: baseline used 734 thinking
    tokens (barely fit); with thinking: { type: "disabled" }, thinking_tokens: 0, output size
    dropped 1134→345 tokens for the same quality of answer.

So: claude-sonnet-5 scales invisible reasoning to prompt complexity by default, and none of
explainmyrepo's five brain-authoring calls ever set thinking, so all five were exposed — some
loudly (JSON stations, which validate), some silently (authorPrimer, which doesn't).

Fix: send thinking: { type: "disabled" } on every Anthropic request. These are bounded,
single-turn structured-authoring calls (JSON schemas or a fixed markdown outline) with no need for a
visible reasoning chain.

Verified: re-ran the exact previously-failing authorConcept call — passed, thinking_tokens: 0.
Confirmed across all five brain-authoring stations for the rest of the run, all thinking_tokens: 0.
Regenerated authorPrimer's output too (see environment note below) — no more mid-word truncation.


3. src/brain.mjsauthorContent's max_tokens: 4000 too small once thinking is disabled

Severity: Build-breaking, 100% reproducible once (2) above is fixed (this bug was previously
masked — the build never got far enough to hit it before).

Actual output:

[claude][debug] ok: {"stop_reason":"max_tokens","textLen":12129,"maxTokens":4000,"output_tokens":4000,"thinking_tokens":0}
✗ content FAILED: Expected ',' or ']' after array element in JSON at position 12017 (line 110 column 245)

output_tokens landed exactly at the 4000 cap with thinking_tokens: 0 — confirmed pure content
truncation (cut off mid-array), not the thinking-budget issue above.

Root cause: authorContent authors 8 rich sections (hero/problem/whatItIs/insight/howItWorks/
useCases/getStarted/pack) plus a citations array, and 4000 tokens simply isn't enough room once the
full budget goes to visible output instead of partially to invisible reasoning.

Fix: raised to max_tokens: 8000.

Verified: re-run completed at output_tokens: 4291 — comfortably under the new cap, end_turn
stop reason, valid JSON.

(Also bumped authorVisualBrief 2000→3000 and authorPrimer 3000→4000 as precautionary headroom —
same class of risk, not yet independently confirmed to have been hitting the cap, but cheap insurance
since raising max_tokens costs nothing unless actually used.)


4. src/brain.mjsauthorVisualBrief never authors architecture/flow diagram rows, but make-diagrams.mjs requires them for repos with a trivial dependency graph

Severity: Build-breaking for any repo whose real dependency graph is trivial (e.g. a docs/content
repo, or genuinely any small/flat project) — not an edge case.

Actual output:

make-diagrams: warning: dep-graph has 0 internal edges (2 modules) — a dependency map would show
nothing; drawing the authored CONCEPT architecture instead
✗ make-diagrams FAILED: architectureDiagram: this repo's dep-graph has 2 module(s) and 0 internal
edges, so a dependency map would show nothing. Author visuals.architectureDiagram.rows — the CONCEPT
of how the thing is built...

Root cause: this is a half-wired feature, not a missing one — tools/make-diagrams.mjs's own
comments describe the intended design explicitly:

"Here we don't skip — INV-18 requires an architecture diagram — we DEMOTE to the concept renderer
and draw what the repo actually IS, from rows the brain authored."

"Prefer a real runtime flow the brain authored; only fall back to the lifecycle..."

and its code reads visuals.architectureDiagram.rows / checks Array.isArray(visualsIn.flowDiagram.rows)
accordingly — but src/brain.mjs's authorVisualBrief prompt schema only ever asks the model for
architecture: { altText } / flow: { altText } (compare to bigIdea/insight, which do ask for
rows), and visualsSlotFromBrief only ever forwards altText. The renderer's read side was built;
the brain's write side never was.

Fix: extended the authorVisualBrief JSON schema to request rows for architecture and flow
(same shape as bigIdea/insight), and wired visualsSlotFromBrief to pass them through via the
existing conceptModel() normalizer (previously applied only to bigIdea/insight).

Verified: re-authored just the visual brief (surgically, via a standalone script reusing
authorVisualBrief + patching build.json, to avoid needlessly re-running paid image generation
that had already succeeded) and confirmed real rows came back:

"architecture rows": [{"items":["tub-connect-sync: fetch & normalize","tub-library-site: build & serve","Shared RVF knowledge base"],"connect":true}]
"flow rows": [{"items":["Pull tubconnect / superhuman / Slack","Rewrite links & resolve embeds","Package vault into release zip","Embed passages into RVF index","Query resolves by similarity"],"connect":true}]

Full pipeline re-run then confirmed via make-diagrams' own DOM check: arch(present=true,vis=true) flow(present=true,vis=true).


5. tools/quality-grade.mjs — vision-grading fetch() has no retry at all

Severity: Build-breaking on any transient network blip, at the very last (expensive) stage of the
pipeline — after full-page rendering + screenshotting has already completed.

Actual output:

✗ quality-grade FAILED: quality grading failed: vision API request failed for desktop(1440): fetch failed

No retry attempted — compare to src/claude.mjs's callClaude, which already retries transient
Anthropic failures with backoff (RETRY_DELAYS_MS = [2000, 8000]). gradeCrops's OpenAI call had no
equivalent at all: a bare try { fetch(...) } catch (e) { throw ... }.

Fix: added the same backoff-retry convention already used on the Anthropic side (retryable on
network-level throw or HTTP 429/5xx; fails fast, no wasted retries, on other 4xx).

Verified: retries visibly fire and recover from real blips in later runs (see #6 for why this
alone wasn't sufficient here).


6. tools/quality-grade.mjs — a corrupted pooled HTTP/2 session poisons every subsequent fetch() call in the same process

Severity: Build-breaking, and importantly: the retry logic added in #5 cannot fix this on its
own
, which is why it's a separate, deeper finding.

Actual output — after adding retries (#5), still failed, but the failure signature changed:

attempt 1: FAILED after 352ms — fetch failed ERR_SSL_SSL/TLS_ALERT_BAD_RECORD_MAC
attempt 2: FAILED after 2ms  — fetch failed ERR_HTTP2_INVALID_SESSION
attempt 3: FAILED after 1ms  — fetch failed ERR_HTTP2_INVALID_SESSION

Attempts 2 and 3 failed in 1–2ms — far too fast for any real network I/O. That's the signature of an
immediate client-side failure, not a network-layer one.

Root cause: confirmed with a standalone repro entirely outside this tool. Node's global fetch()
opportunistically negotiates HTTP/2 and pools/reuses that session per-host within a process. A large
(~5MB, multi-image) upload occasionally corrupts that session
(SSL alert number 20: bad record mac) — evidently not deterministic, but real, and apparently more
likely under sustained heavy bandwidth use (this session had also just run a ~150MB Playwright
Chromium download and several image-generation calls). Once corrupted, the poisoned session is never
discarded, so every subsequent fetch() call to that host in the same process fails instantly,
retry count irrelevant, because every retry reuses the same broken pooled connection.

Options considered: node:undici's setGlobalDispatcher(new Agent()) would let a fresh dispatcher
be installed on retry, but node:undici is not a built-in on this Node version and isn't an installed
dependency — consistent with the project's own stated zero-npm-deps design goal
(src/claude.mjs: "ZERO npm deps, so the package installs and node --test stays green without an
SDK"
).

Fix: replaced the vision-grading fetch() call with a small node:https-based helper
(postJsonHttps) using an explicit new https.Agent({ keepAlive: false }) per call — plain HTTP/1.1,
a genuinely fresh TCP+TLS connection every attempt, nothing to pool or poison. Zero new dependencies.

Verified: isolated repro — the exact desktop-crop payload that had just failed 3/3 times via
fetch() succeeded 3/3 times immediately after switching to postJsonHttps. Full pipeline re-run
then completed quality-grade cleanly with no network errors on either device.

Same bug recurred on api.anthropic.com: later in the same session, the content-refine loop's
Anthropic call (src/claude.mjs) failed with the same plain fetch failed after quality-grade's
large uploads had already run in-process. Applied the identical fix — replaced src/claude.mjs's
fetch() call with the same node:https + keepAlive:false pattern — and verified with a direct
smoke test of the patched callClaude() before resuming the real build. This confirms the underlying
issue is generic to Node's global fetch() connection pooling on this environment, not specific to
either API vendor or to large multi-image payloads specifically (the Anthropic prompts here are
ordinary JSON/text, far smaller than the vision payloads).


7. tools/quality-grade.mjs — the CLI's --max-refine doesn't know about the internal 3-iteration cap, so exhausted refine passes silently waste real API spend

Severity: Cost/efficiency bug, not a crash — but a real one: it spends actual Claude API tokens
re-authoring content that can provably never be re-graded.

Root cause: gradeCrops's caller hard-codes MAX_QUALITY_ITERATIONS = 3 (1 initial grade + 2
refines) and persists progress as ctx.quality.iterations in build.json. Once
priorIterations >= MAX_QUALITY_ITERATIONS, quality-grade logs REFINE CAP REACHED and returns
the last cached scorecard without re-rendering or re-grading anything — by design, to avoid
wasting render/vision-API cost. But the orchestrator's own --max-refine <n> CLI flag (default 2,
independently settable) has no knowledge of this internal cap or of ctx.quality.iterations's
current value: it will happily run n more full content-re-authoring passes (each a real, billed
Claude call) even when the internal cap is already exhausted, and every single one of those passes
is graded against the same stale cached scorecard. The final "held" result is then reported as
"keeping the best-scoring iteration," which is misleading — no iteration was actually scored higher
or lower; they're all identical because none of them were graded at all.

How this was hit in practice: repeated manual --from quality-grade resumes during debugging
(each a genuine quality-grade invocation, even the ones that then failed downstream for unrelated
network reasons) had already incremented ctx.quality.iterations to 3 by the time the underlying
network issues (#5, #6) were fixed. The subsequent --max-refine 3 run then spent two full
content-re-authoring passes with zero possibility of ever improving the score, confirmed by the
final scorecard being byte-identical to the one before those two passes ran.

Fix (not yet patched — flagging for maintainer judgment): either have the orchestrator check
ctx.quality.iterations against MAX_QUALITY_ITERATIONS before spending a refine pass and stop
early with a clear message ("internal refine cap already reached, --max-refine has no further
effect"), or expose/reconcile the two caps as one setting so they can't diverge. Also worth
considering whether ctx.quality.iterations should increment on invocations that never completed a
real grade (e.g. aborted by an infrastructure error before rendering), since that inflates the
counter for reasons unrelated to genuine content-quality iteration.


Patches

Three unified diffs against the pristine explainmyrepo@0.5.0 npm package, included inline below
(GitHub issues don't support real file attachments for text/patch files via the API/CLI — only
images can be drag-dropped in the web UI — so these are pasted in full rather than linked):

Note: patch 1 includes a couple of console.error('[claude][debug] ...') lines that were added as
diagnostic instrumentation while root-causing #2 — they're not required for the fix itself and a
maintainer may want to drop them or gate them behind a debug env var before merging.

Apply with patch -p1 < 01-....patch etc. from the package root, or copy from the collapsible
sections below.


Bonus (not a code bug): missing prerequisite

make-favicon requires ImageMagick (magick v7 or convert v6) on PATH and fails loudly and
clearly if absent — that part is good — but nothing in the README or a prerequisites doc mentions
this ahead of time. A one-line "Prerequisites: Node 18+, ImageMagick" note would save the first-run
surprise.


Happy to open this as an actual PR instead of just attached patches if that's preferred — let me know.

01-claude-mjs-temperature-and-thinking.patch (fixes #1, #2 — includes the https hardening from #6)
--- /private/tmp/claude-501/-Users-cphillipson-Development-active-ai-tub-vault/f7d81ebb-ac2e-4299-bcc5-87b7c8aeedfb/scratchpad/pristine/package/src/claude.mjs	1985-10-26 01:15:00
+++ /Users/cphillipson/.npm/_npx/c50e6f7934620b79/node_modules/explainmyrepo/src/claude.mjs	2026-08-07 07:42:33
@@ -22,6 +22,7 @@
 //   callClaudeJSON({ …same… }) -> parsed JSON  (asks for JSON-only, strips fences, retries once)
 
 import { spawnSync } from 'node:child_process';
+import https from 'node:https';
 
 const ANTHROPIC_URL = 'https://api.anthropic.com/v1/messages';
 const ANTHROPIC_VERSION = '2023-06-01';
@@ -109,41 +110,93 @@
   throw lastErr;
 }
 
+// Global fetch() opportunistically negotiates HTTP/2 and pools/reuses that session per-host within
+// a process; once corrupted (e.g. by a large upload elsewhere in the same run — see the
+// quality-grade.mjs fix for the full diagnosis) every subsequent fetch() to that host fails
+// instantly, retry count irrelevant. Hit this live on api.anthropic.com too, not just OpenAI's
+// vision endpoint. node:undici isn't a built-in on this Node version and isn't an installed
+// dependency (this module is deliberately ZERO npm deps), so use node:https with a fresh,
+// non-keep-alive connection per call instead — plain HTTP/1.1, nothing to pool or poison.
+function postJsonHttps(urlStr, headers, bodyStr, timeoutMs) {
+  return new Promise((resolve, reject) => {
+    const u = new URL(urlStr);
+    const req = https.request(u, {
+      method: 'POST',
+      headers: { ...headers, 'content-length': Buffer.byteLength(bodyStr) },
+      agent: new https.Agent({ keepAlive: false }),
+    }, (res) => {
+      const chunks = [];
+      res.on('data', (c) => chunks.push(c));
+      res.on('end', () => resolve({ status: res.statusCode, text: Buffer.concat(chunks).toString('utf8') }));
+      res.on('error', reject);
+    });
+    req.on('error', reject);
+    req.setTimeout(timeoutMs, () => {
+      const err = new Error(`Anthropic request timed out after ${timeoutMs}ms`);
+      err.name = 'AbortError';
+      req.destroy(err);
+    });
+    req.end(bodyStr);
+  });
+}
+
+async function postToAnthropic({ apiKey, model, maxTokens, temperature, system, user, timeoutMs }) {
+  // claude-sonnet-5 adaptively spends part of max_tokens on an invisible `thinking` block whose
+  // size scales with prompt complexity — on the real (large) authoring briefs it consumed the
+  // entire fixed budget and left zero tokens for the actual JSON/text answer (stop_reason=
+  // max_tokens, blockTypes=["thinking"]). These are bounded, single-turn structured-authoring
+  // calls with no need for a visible reasoning chain, so disable it outright and give the whole
+  // budget to the answer — verified via a probe against the real API (see UPSTREAM_ISSUES.md).
+  const body = {
+    model, max_tokens: maxTokens, system, messages: [{ role: 'user', content: user }],
+    thinking: { type: 'disabled' },
+  };
+  if (temperature !== undefined) body.temperature = temperature;
+  return postJsonHttps(ANTHROPIC_URL, {
+    'x-api-key': apiKey,
+    'anthropic-version': ANTHROPIC_VERSION,
+    'content-type': 'application/json',
+  }, JSON.stringify(body), timeoutMs);
+}
+
 async function callClaudeOnce({ apiKey, model, system, user, maxTokens, temperature, timeoutMs }) {
-  const ctrl = new AbortController();
-  const timer = setTimeout(() => ctrl.abort(), timeoutMs);
   let resp;
   try {
-    resp = await fetch(ANTHROPIC_URL, {
-      method: 'POST',
-      signal: ctrl.signal,
-      headers: {
-        'x-api-key': apiKey,
-        'anthropic-version': ANTHROPIC_VERSION,
-        'content-type': 'application/json',
-      },
-      body: JSON.stringify({
-        model, max_tokens: maxTokens, temperature,
-        system,
-        messages: [{ role: 'user', content: user }],
-      }),
-    });
+    resp = await postToAnthropic({ apiKey, model, maxTokens, temperature, system, user, timeoutMs });
   } catch (e) {
-    clearTimeout(timer);
     const err = new Error(e.name === 'AbortError' ? `Anthropic request timed out after ${timeoutMs}ms` : `Anthropic request failed: ${e.message}`);
     err.retryable = true;
     throw err;
   }
-  clearTimeout(timer);
-  if (!resp.ok) {
-    const body = await resp.text().catch(() => '');
-    const err = new Error(`Anthropic API ${resp.status} (${model}): ${body.slice(0, 400)}`);
-    err.retryable = resp.status === 429 || resp.status >= 500;
-    throw err;
+  const ok = (r) => r.status >= 200 && r.status < 300;
+  if (!ok(resp)) {
+    let body = resp.text;
+    // Newer Claude models (e.g. claude-sonnet-5) reject a custom `temperature` outright — mirror
+    // tools/quality-grade.mjs's reasoning-model handling and retry once without it.
+    if (resp.status === 400 && temperature !== undefined && /temperature/i.test(body)) {
+      resp = await postToAnthropic({ apiKey, model, maxTokens, temperature: undefined, system, user, timeoutMs });
+      if (!ok(resp)) body = resp.text;
+    }
+    if (!ok(resp)) {
+      const err = new Error(`Anthropic API ${resp.status} (${model}): ${body.slice(0, 400)}`);
+      err.retryable = resp.status === 429 || resp.status >= 500;
+      throw err;
+    }
   }
-  const j = await resp.json();
+  const j = JSON.parse(resp.text);
   const text = (j.content || []).filter((b) => b && b.type === 'text').map((b) => b.text).join('');
-  if (!text.trim()) throw new Error(`Anthropic returned no text (stop_reason=${j.stop_reason || 'unknown'})`);
+  if (!text.trim()) {
+    console.error('[claude][debug] empty-text response:', JSON.stringify({
+      stop_reason: j.stop_reason, usage: j.usage,
+      blockTypes: (j.content || []).map((b) => b && b.type),
+      maxTokens,
+    }));
+    throw new Error(`Anthropic returned no text (stop_reason=${j.stop_reason || 'unknown'})`);
+  }
+  console.error('[claude][debug] ok:', JSON.stringify({
+    stop_reason: j.stop_reason, textLen: text.length, maxTokens,
+    output_tokens: j.usage?.output_tokens, thinking_tokens: j.usage?.output_tokens_details?.thinking_tokens,
+  }));
   return text;
 }
 
02-brain-mjs-maxtokens-and-diagram-rows.patch (fixes #3, #4)
--- /private/tmp/claude-501/-Users-cphillipson-Development-active-ai-tub-vault/f7d81ebb-ac2e-4299-bcc5-87b7c8aeedfb/scratchpad/pristine/package/src/brain.mjs	1985-10-26 01:15:00
+++ /Users/cphillipson/.npm/_npx/c50e6f7934620b79/node_modules/explainmyrepo/src/brain.mjs	2026-08-07 07:14:01
@@ -162,7 +162,10 @@
 }
 Rules: 2-4 paragraphs max per section; useCases has 2-3 cases; getStarted.install + steps must come from the brief's INSTALL/COMMANDS/QUICKSTART; cite real passage ids.
 GET-STARTED must give real IMPLEMENTATION CONFIDENCE (this is the most-failed axis): the steps must include (a) any PREREQUISITES (toolchain/version), (b) the EXACT command(s) to run, copyable and grounded in the brief, (c) WHAT THE READER WILL SEE when it succeeds (the concrete result/output), (d) what they HAVE at the end, and (e) the NEXT step. Prefer { "strong": "...", "text": "..." } steps so each has a bolded action + concrete detail. If the repo genuinely has no install command or CLI (a pure library), SAY so honestly, then give the real clone → build → test commands and what each produces — never a vague "just explore the code".`;
-  const out = await callClaudeJSON({ apiKey, model, system, user, maxTokens: 4000, temperature: 0.6 });
+  // maxTokens was 4000; a live run with `thinking` disabled hit stop_reason=max_tokens at exactly
+  // 4000/4000 output tokens and got cut off mid-array (JSON parse error) — the 8-section structured
+  // output plus citations genuinely needs more room. Confirmed via debug logging, not a guess.
+  const out = await callClaudeJSON({ apiKey, model, system, user, maxTokens: 8000, temperature: 0.6 });
   const need = ['hero', 'problem', 'whatItIs', 'insight', 'howItWorks', 'useCases', 'getStarted', 'pack'];
   if (!out?.sections) throw new Error('authorContent: missing sections');
   for (const s of need) if (!out.sections[s]) throw new Error(`authorContent: missing section "${s}"`);
@@ -204,15 +207,24 @@
       "rows": [ { "items": ["2 to 4 SHORT concept-card labels (<=42 chars each)"], "connect": true } ],
       "altText": "one-line takeaway describing the key insight"
     },
-    "architecture":{ "altText": "one-line description of the architecture diagram" },
-    "flow":        { "altText": "one-line description of the runtime/process flow diagram" }
+    "architecture":{
+      "rows": [ { "items": ["3 to 4 SHORT labels (<=42 chars each) for the CONCEPT of how the thing is built — the parts a reader must hold in their head, in order", "..."], "connect": true } ],
+      "altText": "one-line description of the architecture diagram"
+    },
+    "flow": {
+      "rows": [ { "items": ["3 to 6 SHORT labels (<=42 chars each) for the REAL RUNTIME data/process flow — what actually happens when it runs, NOT the install/build/test lifecycle", "..."], "connect": true } ],
+      "altText": "one-line description of the runtime/process flow diagram"
+    }
   }
 }
 DIAGRAM RULES (bigIdea + insight are DRAWN as real glassmorphic concept-cards joined by glowing arrows — NEVER ASCII):
 - Each "items" entry is ONE short card label: a concrete noun-phrase grounded in the brief (a real component, artifact, or step), <= 42 characters. NO ASCII art, NO box-drawing or pipe characters, NO arrows inside a label.
 - Use ONE row with "connect": true for a SEQUENCE (cards joined top-to-bottom by arrows). Use MULTIPLE rows (each "connect": false) for parallel/grouped ideas drawn without an arrow between groups.
-- bigIdea = the central mechanism in 3-6 cards (how the pieces combine to do the one big thing). insight = the single clever move in 2-4 cards. Keep BOTH distinct from the architecture diagram — do not just relist every module.`;
-  const out = await callClaudeJSON({ apiKey, model, system, user, maxTokens: 2000, temperature: 0.7 });
+- bigIdea = the central mechanism in 3-6 cards (how the pieces combine to do the one big thing). insight = the single clever move in 2-4 cards. Keep BOTH distinct from the architecture diagram — do not just relist every module.
+- architecture.rows is a FALLBACK the renderer uses only when the repo's real dependency graph is too trivial to draw on its own (e.g. a content/docs repo with no meaningful internal module edges); flow.rows, when authored, is PREFERRED over the derived install/build lifecycle diagram whenever it represents a real runtime flow. Author both anyway, every time, grounded in the real brief.`;
+  // Unconfirmed-but-at-risk sibling of the authorContent truncation above (same class of prompt,
+  // similar output size) — modest headroom bump as cheap insurance before this station runs.
+  const out = await callClaudeJSON({ apiKey, model, system, user, maxTokens: 3000, temperature: 0.7 });
   if (!out?.hero?.prompt) throw new Error('authorVisualBrief: missing hero.prompt');
   const okRows = (d) => d && Array.isArray(d.rows) && d.rows.length
     && d.rows.every((r) => r && Array.isArray(r.items) && r.items.length
@@ -243,8 +255,11 @@
     })),
     bigIdeaDiagram: conceptModel(brief.diagrams.bigIdea, 'How it all fits together'),
     insightDiagram: conceptModel(brief.diagrams.insight, 'The clever move'),
-    architectureDiagram: { altText: brief.diagrams.architecture?.altText || '' },
-    flowDiagram: { altText: brief.diagrams.flow?.altText || '' },
+    // rows wired through to match what make-diagrams.mjs's degenerate-graph fallback (architecture)
+    // and authored-flow preference (flow) already read — see its `existing.rows` / `flowAuthored`
+    // checks. Previously only altText was passed, so those paths always had nothing to render.
+    architectureDiagram: { rows: conceptModel(brief.diagrams.architecture).rows, altText: brief.diagrams.architecture?.altText || '' },
+    flowDiagram: { rows: conceptModel(brief.diagrams.flow).rows, altText: brief.diagrams.flow?.altText || '' },
   };
 }
 
@@ -263,7 +278,9 @@
 ## 5. How do I install and use it
 ## 6. Honest scope and limits
 Keep it tight and real; ground every statement in the brief above.`;
-  const md = await callClaude({ apiKey, model, system, user, maxTokens: 3000, temperature: 0.4 });
+  // Plain markdown, not JSON — a truncation here wouldn't throw, it would silently ship a
+  // cut-off primer. Modest headroom bump alongside the confirmed authorContent fix, same reasoning.
+  const md = await callClaude({ apiKey, model, system, user, maxTokens: 4000, temperature: 0.4 });
   const primerRel = ctx.kb?.primerPath;
   if (!primerRel) throw new Error('authorPrimer: build.json has no kb.primerPath (run build-kb first)');
   const primerAbs = path.isAbsolute(primerRel) ? primerRel : path.resolve(repoRoot, primerRel);
03-quality-grade-mjs-retry-and-https.patch (fixes #5, #6)
--- /private/tmp/claude-501/-Users-cphillipson-Development-active-ai-tub-vault/f7d81ebb-ac2e-4299-bcc5-87b7c8aeedfb/scratchpad/pristine/package/tools/quality-grade.mjs	1985-10-26 01:15:00
+++ /Users/cphillipson/.npm/_npx/c50e6f7934620b79/node_modules/explainmyrepo/tools/quality-grade.mjs	2026-08-07 07:29:20
@@ -42,6 +42,7 @@
 import fs from 'node:fs';
 import path from 'node:path';
 import http from 'node:http';
+import https from 'node:https';
 import { fileURLToPath, pathToFileURL } from 'node:url';
 
 const _ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
@@ -655,6 +656,34 @@
 // ----------------------------------------------------------------------------
 function isScore(n) { return typeof n === 'number' && Number.isFinite(n) && n >= 0 && n <= 100; }
 function isText(s) { return typeof s === 'string' && s.trim().length > 0; }
+
+// Global fetch() opportunistically negotiates HTTP/2 and pools/reuses that session across calls
+// in the same process. When a multi-MB upload corrupts that session (`SSL alert number 20: bad
+// record mac`), every SUBSEQUENT fetch() call to the same host in this process fails INSTANTLY
+// (~1-2ms, before any I/O) with ERR_HTTP2_INVALID_SESSION — the pooled session is poisoned and
+// never gets discarded, so retrying with backoff cannot help; it just re-hits the same broken
+// session every time. Confirmed via a standalone repro outside this tool. `node:undici` isn't a
+// built-in on this Node version and isn't an installed dependency here (this project deliberately
+// carries zero npm deps for its Claude-calling code — see src/claude.mjs), so bypass the problem
+// entirely with node:https + keepAlive:false: plain HTTP/1.1, a genuinely fresh TCP+TLS connection
+// per call, no session to poison or reuse.
+function postJsonHttps(urlStr, headers, bodyStr) {
+  return new Promise((resolve, reject) => {
+    const u = new URL(urlStr);
+    const req = https.request(u, {
+      method: 'POST',
+      headers: { ...headers, 'content-length': Buffer.byteLength(bodyStr) },
+      agent: new https.Agent({ keepAlive: false }),
+    }, (res) => {
+      const chunks = [];
+      res.on('data', (c) => chunks.push(c));
+      res.on('end', () => resolve({ status: res.statusCode, text: Buffer.concat(chunks).toString('utf8') }));
+      res.on('error', reject);
+    });
+    req.on('error', reject);
+    req.end(bodyStr);
+  });
+}
 
 async function gradeCrops({ apiKey, model, baseUrl, crops, deviceLabel }) {
   if (!Array.isArray(crops) || crops.length < 2) {
@@ -688,18 +717,40 @@
     ],
   };
 
-  let resp;
-  try {
-    resp = await fetch(`${baseUrl.replace(/\/$/, '')}/chat/completions`, {
-      method: 'POST',
-      headers: { 'content-type': 'application/json', authorization: `Bearer ${apiKey}` },
-      body: JSON.stringify(body),
-    });
-  } catch (e) {
-    throw new Error(`vision API request failed for ${deviceLabel}: ${e?.message || e}`);
+  // Each request carries several full-resolution base64 crops (multi-MB body); no timeout, and
+  // originally zero retry — a single transient connection blip (`fetch failed`) killed the whole
+  // station. src/claude.mjs already retries transient Anthropic failures with backoff; this path
+  // had no equivalent. Isolated via standalone repro (outside this tool): a solo ~5MB POST to this
+  // endpoint succeeds most of the time but occasionally fails instantly with an SSL "bad record mac"
+  // (a stale/reused pooled connection, not a deterministic size/concurrency trigger) — so 2 retries
+  // wasn't enough headroom against a real (if infrequent) per-attempt failure rate. 4 retries pushes
+  // the odds of 5 straight failures below ~0.5% at the observed rate.
+  const VISION_RETRY_DELAYS_MS = [2000, 5000, 10000, 15000];
+  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
+  let resp, raw, lastErr;
+  for (let attempt = 0; attempt <= VISION_RETRY_DELAYS_MS.length; attempt++) {
+    if (attempt > 0) {
+      const delay = VISION_RETRY_DELAYS_MS[attempt - 1];
+      console.error(`[quality-grade] ${lastErr.message} — retry ${attempt}/${VISION_RETRY_DELAYS_MS.length} in ${delay / 1000}s`);
+      await sleep(delay);
+    }
+    try {
+      resp = await postJsonHttps(
+        `${baseUrl.replace(/\/$/, '')}/chat/completions`,
+        { 'content-type': 'application/json', authorization: `Bearer ${apiKey}` },
+        JSON.stringify(body),
+      );
+    } catch (e) {
+      lastErr = new Error(`vision API request failed for ${deviceLabel}: ${e?.message || e}`);
+      continue;
+    }
+    raw = resp.text;
+    if (resp.status >= 200 && resp.status < 300) { lastErr = null; break; }
+    const httpErr = new Error(`vision API HTTP ${resp.status} for ${deviceLabel}: ${raw.slice(0, 300)}`);
+    if (resp.status === 429 || resp.status >= 500) { lastErr = httpErr; continue; }
+    throw httpErr; // non-retryable (e.g. bad auth/request) — fail fast, don't waste retries
   }
-  const raw = await resp.text();
-  if (!resp.ok) throw new Error(`vision API HTTP ${resp.status} for ${deviceLabel}: ${raw.slice(0, 300)}`);
+  if (lastErr) throw lastErr;
 
   let envelope;
   try { envelope = JSON.parse(raw); } catch { throw new Error(`vision API returned non-JSON envelope for ${deviceLabel}: ${raw.slice(0, 200)}`); }
@@ -944,13 +995,19 @@
       rendered.push({ d, domInv18, fullPagePath, crops });
     }
 
-    // Grade both devices CONCURRENTLY — two independent vision-API round-trips with no data
-    // dependency between them; this was a serial for-loop, doubling every grade cycle's
-    // wall-clock for zero reason (pure concurrency win — same tokens, same $, same model).
-    log(`grading ${rendered.length} device(s) with ${model} concurrently …`);
-    const gradedAll = await Promise.all(rendered.map((r) =>
-      gradeCrops({ apiKey, model, baseUrl, crops: r.crops, deviceLabel: r.d.label })
-    ));
+    // Was CONCURRENT (two independent vision-API round-trips, no data dependency) but two
+    // concurrent multi-MB POSTs to the SAME host reproducibly corrupted the shared TLS
+    // connection on this environment: `SSL alert number 20: bad record mac`, every time,
+    // isolated with a minimal Node fetch repro outside this tool entirely (Node/undici
+    // connection-pooling issue under concurrent large uploads, not an application bug). The
+    // per-request retry-with-backoff added above cannot recover from this — it's not transient,
+    // it reproduces on every attempt. Serialize instead: same tokens/cost, slower wall-clock,
+    // but reliable.
+    log(`grading ${rendered.length} device(s) with ${model} sequentially …`);
+    const gradedAll = [];
+    for (const r of rendered) {
+      gradedAll.push(await gradeCrops({ apiKey, model, baseUrl, crops: r.crops, deviceLabel: r.d.label }));
+    }
     for (let i = 0; i < rendered.length; i++) {
       const r = rendered[i];
       const { scorecard: card, refineNotes: notes } = buildScorecard(r.d.label, gradedAll[i], r.domInv18, r.fullPagePath, r.crops.map((c) => c.path), flowExpected);

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions