Skip to content

feat: Diagnoser (Slice 4) — grounded cause attribution for flagged areas#24

Merged
HumanBean17 merged 19 commits into
mainfrom
spec/diagnoser-slice
Jul 18, 2026
Merged

feat: Diagnoser (Slice 4) — grounded cause attribution for flagged areas#24
HumanBean17 merged 19 commits into
mainfrom
spec/diagnoser-slice

Conversation

@HumanBean17

Copy link
Copy Markdown
Owner

What

Adds the Diagnoser — the next component in the closed loop (detect → diagnose). scan --diagnose classifies each flagged area into a typed cause with a derived-only rationale, so a human knows why an area struggles, not just that it does.

cause ∈ { doc, config-doc, test-gap, refactor-flag, inherent-complexity, unclassified }

Pure-rule, opt-in, and byte-identical when off — the Slice 1/2/3 invariant holds. No LLM, no network, no git, no raw prose.

Why each cause (grounding)

Cause Fires when Grounding
doc explore + reread elevated no doc exists for the unit
config-doc failure streak, config-shaped failures failure cmd-class profile
test-gap oscillation + failures, test-file-heavy edits, low corrections edit file-type mix
refactor-flag oscillation + corrections, code-file-heavy edits high corrections + doc already exists
inherent-complexity expensive + unspecific (high entropy) signal profile

Doc-existence is the key new fact — it stops the tool from recommending a doc for a code problem.

What's new

  • src/diagnoser/classify-util (cmd/file catalogs), evidence (projection), profile (per-unit signal medians + elevation), repo-context (doc-existence, path-confined, fail-open, never-follow-symlinks), classify (rule engine), index (orchestration, per-unit fail-open).
  • Opt-in evidence projection on StruggleRecord (failed-exec cmd-class + edited-file type buckets), computed in the detector only under --diagnose.
  • scan --diagnose flag → cause column in the human table + diagnoses in --json (both opt-in only).
  • Config: docs_dirs (default ['docs']) + diagnose block (floors).
  • Default output unchangedevidence/diagnoses exist only opt-in; snapshot +18/−0 (only a new --diagnose snapshot added).

Contracts held

  • No network (test/egress.test.ts), no git, no raw prose in any diagnosis leaf (new privacy test, non-vacuous), path-confined fs reads, fail-open throughout.
  • Runtime deps still exactly commander + yaml (test/packaging.test.ts).

Verification

  • 389/389 tests (was 285; +104), typecheck + build clean.
  • Every task passed a spec-compliance + quality review (subagent-driven); one Important fix applied (symlinked docsDir no longer followed — #215c88d).
  • Live spot-check: scan --diagnose runs end-to-end on real transcripts without error.

Deferred (tracked as issues)

LLM refinement + docs_dirs purpose-form → Synthesizer (#19); git churn grounding (#11); per-area evidence attribution (#12); reflect integration (#14); configurable catalogs (#13); structural-absence repo unit (#18). Cause-floor calibration is a dogfood gate (#15). Spec: `docs/superpowers/specs/active/2026-07-18-harnessgap-diagnoser-design.md`.

🤖 Generated with Claude Code

HumanBean17 and others added 19 commits July 18, 2026 00:30
Pure-rule, opt-in (`scan --diagnose`) cause classification for flagged
areas: doc | config-doc | test-gap | refactor-flag | inherent-complexity
(+ unclassified). Grounded by signal profile + doc-existence + a new
opt-in `evidence` projection (failed-exec cmd-class + edited-file type
buckets). Default output byte-identical; no LLM, no network, no git.

Co-Authored-By: Claude <noreply@anthropic.com>
Bite-sized tasks: types+config → classifiers → evidence projection →
profile → repo-context → rule engine → orchestration → pipeline → CLI →
output → snapshot/privacy → docs → verify. Self-contained contracts per
task; byte-identical-default invariant pinned.

Co-Authored-By: Claude <noreply@anthropic.com>
Pure single-pass evidence projection for the Diagnoser (Slice 4, Task 3).
Buckets a NormalizedEvent stream into the SessionEvidence shape that Task 4
and downstream cause rules consume:
  - failed exec tool_calls   -> failures[classifyCmd(cmd, patterns)]++
  - edit tool_call files      -> edit_kinds[classifyFile(file)]++
All seven buckets are zero-filled; null-cmd failed execs are skipped per
spec. No I/O, no mutation of inputs.

Co-Authored-By: Claude <noreply@anthropic.com>
Thread Task 3's computeEvidence through the detector as an opt-in 4th param
on runDetector: opts?.collectEvidence === true computes SessionEvidence
per-envelope and passes it to assembleStruggleRecord's new 5th evidence?
param; otherwise the field is omitted from the returned object literal so
JSON.stringify produces no 'evidence' key.

Default path (no opts / collectEvidence:false) is byte-identical: existing
3-arg callers (runScan at pipeline.ts:178, runReflect at pipeline.ts:436)
are unchanged and the snapshot test passes without regeneration. 322 prior
tests + 8 new tests in test/detector-evidence.test.ts all green.

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Claude <noreply@anthropic.com>
Top-level docsDir symlink guard: if a configured docsDir is itself a
symlink to a directory (e.g. <repoRoot>/docs -> /etc), the lexical
path.resolve confinement check cannot see the target, but readdirSync
would follow the link at the kernel level and leak target files in as
fabricated matches (e.g. billing.md -> docs/sub/billing.md). Add an
lstatSync(dirAbs).isSymbolicLink() check at the docsDir entry point
(before collectFiles) and skip the dir if true; the docsDir is still
recorded in `checked`. Wrapped in try/catch to preserve the never-throws
/ fail-open contract. Mirrors the per-file lstat check already applied
inside collectFiles, just lifted to the entry point.

Test: repo-context test 10 mirrors the existing nested-symlink test at
the TOP level — creates <repoRoot>/docs as a symlink to an outside dir
containing sub/billing.md, asserts gatherRepoContext returns
{ docExists: false, matchedPath: null, checked: ['docs'] }.

Co-Authored-By: Claude <noreply@anthropic.com>
classify(profile, repoContext, cfg) → Diagnosis implements the four specific-
cause gates (doc, config-doc, test-gap, refactor-flag) verbatim from the
brief, scores each eligible cause as the fraction of the shared 5-signal
signature {explore_ratio, reread, failure_streak, corrections, oscillation}
currently elevated (refactor-flag +0.2 boost when repoContext.docExists,
clamped to 1.0), picks the winner with fixed precedence doc > config-doc >
test-gap > refactor-flag, then falls through confidence_floor →
inherent-complexity (wall_clock elevated + meanScore ≥ score_floor) →
unclassified.

Confidence = winner score for specific causes, wall_clock/(2·threshold)
clamped to [0,1] for inherent-complexity, 0 for unclassified. Rationale +
evidence_refs are derived-only (medians, share ratios, repo-relative paths,
integer counts) — no transcript prose, commands, or file bodies.

Pure: no I/O, no mutation; deterministic (gates + fixed precedence → same
inputs give same cause). Style mirrors src/output/hook.ts + src/detector/
ambient.ts.

Tests: 10 cases covering all 8 brief scenarios (doc fires; doc gated off
when docExists → refactor-flag; config-doc via config-share; test-gap via
test-share + low corrections; inherent-complexity via expense; unclassified
when nothing elevated; precedence tie doc>refactor; weak doc score 0.4 <
0.5 floor → unclassified) + purity + determinism. RED → GREEN. typecheck
clean; full suite 367/367 green.

Co-Authored-By: Claude <noreply@anthropic.com>
Thin coordinator tying the Diagnoser slice together: buildProfiles →
per-unit gatherRepoContext → classify, with a per-unit try/catch boundary
that degrades a throwing unit to a derived-only `unclassified` Diagnosis
and never aborts the batch. Output sorted by unit.key ascending;
empty flagged set → [].

Co-Authored-By: Claude <noreply@anthropic.com>
…ult)

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Claude <noreply@anthropic.com>
Task 11 of the Diagnoser slice. Surfaces Diagnoser output in the
user-visible leaderboard, opt-in via `--diagnose`:

- formatHuman: when `diagnoses` is non-empty, renders a CAUSE column on
  each flagged row as `cause(confidence)` (e.g. `doc(0.78)`); unmatched
  or `unclassified` rows render `-`. When `diagnoses` is undefined/empty,
  the table is byte-identical to Slice 3 (no CAUSE column).
- buildJsonEnvelope: includes `diagnoses` ONLY when a non-undefined value
  is passed (key absent otherwise — default `--json` byte-identical).
- runScan: threads `diagnoses` into both builders conditionally.

Default-path snapshot (no `--diagnose`) is unchanged — passes without
regeneration. Updated the obsolete Task-9 pipeline invariant that asserted
`--diagnose` doesn't change the output (it now does, by design).

Co-Authored-By: Claude <noreply@anthropic.com>
…t + privacy

Slice 4, Task 12 — locks two guarantees before docs/verify:

(1) Default-scan snapshot stays byte-identical (Slice 1/2/3 invariant). The
    original test case + snapshot entry are preserved verbatim — no `-u`
    needed there. A second test case + snapshot entry lock the new CAUSE
    column added under `scan --diagnose` (all 7 corpus areas currently
    classify as `unclassified`, so every CAUSE cell renders `-`; locked here
    so future cause drift is deliberate).

(2) New privacy section (e): seeds a distinctive prose marker through every
    event a diagnosis leaf could leak from — 2 user_text corrections, 1
    failing exec cmd, 1 marker-named read file path — then asserts:
      - at least one Diagnosis is produced (non-vacuous; the fixture trips
        cause=doc at confidence 0.6);
      - the marker is absent from `Diagnosis.rationale` and every
        `evidence_refs` member's string-valued leaf (`doc_absent.checked[]`,
        `doc_present.path`);
      - every leaf is a primitive / enum / closed-union literal (no arbitrary
        transcript strings);
      - `doc_absent.checked` entries are exactly cfg.docs_dirs literals;
      - the marker is absent from the serialized diagnoses array as a whole.

Confirms Task 7's derived-only-by-construction contract holds end-to-end.

Co-Authored-By: Claude <noreply@anthropic.com>
Sync §9 testing-strategy with the current test code: section (d) was added
in Slice 2 (baseline/repo_findings/calibrate prose guard), section (e) was
added in Slice 4 Task 12 (diagnosis-leaf prose guard). Updates the section
count and adds concise (d)/(e) summaries derived from the test assertions.

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Claude <noreply@anthropic.com>
…ocs_dirs validation, doc consistency

Consolidated fix wave from the whole-branch review (Minor-Important; no Critical).

Tests (honesty/stronger):
- config.test.ts: share_floor validators now assert field-named messages
- cli.test.ts: removed stale Task-11 NOTE; assert parsed.diagnoses is array
- snapshot.test.ts: softened overstated "CAUSE column locked" framing
  (snapshot content unchanged; only the key string updated to match the
  softened it() title — DEFAULT snapshot byte-identical)
- classify-util.test.ts: precedence test now exercises config>build
  ('npm setup tsc' has both tokens; was 'npm setup install' with no build)
- output.test.ts: formatHuman diagnoses:[] byte-identity + new test 25
  (buildJsonEnvelope diagnoses:[] includes the key in JSON)
- privacy.test.ts: off-by-one comment (30 reads -> 31 with marker read)

Code (robustness):
- src/diagnoser/index.ts: outer try/catch around buildProfiles+per-unit loop
  returns [] on throw — the "never throws" contract now holds unconditionally
  (runScan calls diagnoseUnits unguarded). New test case (h).
- src/config.ts: validate docs_dirs as array of strings (was unvalidated;
  scalar/non-string element would silently replace default and surface as
  confusing internal error later). 3 new tests.

Docs:
- README/ARCHITECTURE/CONSUMER_GUIDE: stale "This is Slice 1" framing softened
  to "default detection-only path + opt-in diagnosis".
- Diagnoser spec §7: computeEvidence path corrected
  (src/detector/evidence.ts -> src/diagnoser/evidence.ts) to match code.

Verified: npm run typecheck clean; npm test 394/394 green; DEFAULT snapshot
byte-identical (snapshot tests pass without -u).

Co-Authored-By: Claude <noreply@anthropic.com>
…gline

docs-watcher follow-ups after the fix-wave commit (00d2a54):

- ARCHITECTURE.md §modules row + §Fail-open: now describe BOTH fail-open
  layers in src/diagnoser/index.ts — outer batch-level try/catch (degrades
  to []) + inner per-unit try/catch (degrades to one unclassified Diagnosis).
  Previously under-documented the new outer boundary added in 00d2a54.
- CLAUDE.md: drop "Slice 1:" tagline label (inconsistent with the Diagnoser
  / Slice 4 sections in the same docs). Keep the accurate "no writes, no
  network" half for the default path; note --diagnose is the opt-in layer.

Doc-only; no code or test changes. Typecheck clean; 394/394 tests still green.

Co-Authored-By: Claude <noreply@anthropic.com>
@HumanBean17
HumanBean17 merged commit d3a49cf into main Jul 18, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant