Skip to content

Nothing answered "is my setup live?" — add scripts/jit-doctor.sh (#183) - #185

Merged
fdaviddpt merged 2 commits into
mainfrom
fix/183
Aug 18, 2026
Merged

fdaviddpt merged 2 commits into
mainfrom
fix/183

Conversation

@fdaviddpt

@fdaviddpt fdaviddpt commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

Closes #183.

The gap it fills

Four tools under scripts/ answered three good questions — can this pattern be honoured, what does a match cost, what does the agent keep failing to find — and all three are downstream of one nobody asked: is any of this running at all, and against which tree?

This repository's own worst trap is the reason. .claude/settings.json registers enabledPlugins, so the hooks firing in a contributor's session are served from the plugin cache while JIT_BASE resolves against $CLAUDE_PROJECT_DIR. The entries are the checkout and the code reading them is not. Nothing errors.

Run against this repository, doctor answers it and then some:

hooks
  which copy runs      the plugin cache serves the hooks
                       so an edit to scripts/ in a checkout changes nothing in your session
  plugin copy          ~/.claude/plugins/cache/dpt-plugins/claude-jit-context/0.3.3 (version 0.3.3)
  plugin copy          ~/.claude/plugins/cache/dpt-plugins/claude-jit-context/0.3.4 (version 0.3.4)
  plugin copy          ~/.claude/plugins/cache/dpt-plugins/claude-jit-context/0.3.5 (version 0.3.5)
  plugin copy          ~/.claude/plugins/cache/dpt-plugins/claude-jit-context/0.4.0 (version 0.4.0)
                       4 copies are installed; which one loads is not decidable from here

The four-versions line was not in the issue, and nothing else in the repository could have produced it.

The two judgment calls the issue left open

1. Doctor POINTS AT jit-dry-run.sh rather than calling it. The issue says no second linter and does not pick between the two designs. Calling it would fold two verdicts into one status: a doctor 1 would then mean either your rules are inert or one pattern is unhonourable, which is this repository's own collapsed-outcome defect arriving through the exit code. It would also make doctor inherit that tool's per-row untrusted-text surface and its runtime, to answer a question about whether anything runs at all. The subjects are different — is it live against are the rules any good — so REFUSED and STALE stay there, doctor prints neither word, and the next section hands the reader over with the tree already named.

2. Which copy of the hooks runs is a TEXTUAL scan that says so, and cannot tell is a first-class verdict. There is no jq in this plugin, and Claude Code merges settings from locations no script in a repository can enumerate. Four states: the plugin cache, a hooks block in settings, BOTH, and cannot tell. The cache side requires an enabledPlugins block and the value true, which also scopes it away from the plugin name appearing inside a hook command path. The residual is stated in the comment rather than papered over.

3. Log freshness is whole days from perl -M, not date -d, which is GNU-only and on neither the macOS nor the Git Bash leg. perl is already a runtime dependency of every hook here. Coarse and right on three platforms beats precise and wrong on two.

Where I disagreed with the issue, and built the weaker thing on purpose

The mtime staleness check is ADVISORY, not a defect. The issue calls it the inert-rule trap and it is — but mtime is a proxy, and a fresh clone writes 00-index.tsv before the .md files beside it, so mtime alone accuses a tree that is perfectly current. jit-dry-run.sh compares the frontmatter against the row and owns that verdict. Exit 1 is reserved for the one thing doctor establishes exactly: a layer holding entries and no index, whose every rule is inert.

Entries that have never fired "despite existing for N days" is not computable from what this repository keeps, so only the first half is claimed. hooks.log records carry a time and no date, and an entry's mtime is rewritten by every clone and every checkout. Either input would give a threshold a confident number with nothing under it. The advisory says no record in the log and stops there.

The heuristics, costed on this repo's own tree rather than assumed

The brief flagged this as the part most likely to be worth less than it costs. Measured:

check flagged verdict
fat entries (>4096) 5 of 13 worth it — see below
short keywords (<3) 0 of 16 free and silent on this corpus
no record in the log 2 of 13 both genuinely 01-oss, not firing in a non-oss session

A 38% flag rate reads as noise until the fire count is beside it, which is exactly what the issue asked for:

tooling.md is over 4096 bytes (26549), fired 43x in this log   ~1.14 MB injected in one session
hooks.md   is over 4096 bytes (17883), fired 42x in this log   ~751 KB

Only release.md (4,153 bytes, 57 over the threshold, 3 fires) is arguable. I would not raise the default on this evidence — raising it to 8192 silences entries.md and tests.md, which are ~229 KB between them in one session.

Thresholds and the typo case

JIT_CONTEXT_DOCTOR_MAX_BYTES (4096) and JIT_CONTEXT_DOCTOR_MIN_KEYWORD (3), both defaulting in the script so it works against a tree with no config.env. Doctor prints each effective value and where it came from, which is the whole point: config.env accepts any JIT_CONTEXT_* key without refusing it, so JIT_CONTEXT_DOCTOR_MAX_BYTE — singular — parses clean, is read by nothing, and would otherwise be indistinguishable from a setting that applied. It is named as unread beside a threshold still saying (default). A non-numeric value is refused, named, and the default stands.

Every heuristic is ADVISORY and none moves the exit code (#47 has CI consuming these).

Rider, called out rather than slipped in

jit_report_keyword() and JIT_KEYWORD_WITHHELD move from rebuild-tsv.sh to common.sh. jit-doctor.sh prints keywords and is the second bash caller — the point at which a second copy stops being a duplicate and starts being drift, a term printed by one tool and withheld by the other. This is the move jit_report_name()'s copy made in #131, for the reason stated there. The awk half stays put; awk cannot source a bash file.

Self-review found three real bugs, all fixed and pinned

Second commit. Each was mutation-verified: revert the fix, watch the named test go red.

  1. The fire-count key was wrong for tools/. The tools half of pre-tool-hook.sh writes the literal tool:file.md and never the layer name (lines 535, 556, 577, 595), so every tools entry came back with a count of zero and was reported never fired however often it had matched — this tool's own defect class, inside the section written to end it. The fixture had no tools/ layer, so nothing saw it; mk_tree() now builds one.
  2. The enabledPlugins scan matched the key and ignored the value, so a plugin explicitly turned off printed the plugin cache serves the hooks — the confident wrong answer the section's own header says is worse than none.
  3. A dimension directory with no layer under it printed nothing at all — the glob stays literal without nullglob — so it was indistinguishable from a scan that died. The missing-dimension case already had its own line; this one now does too.

Tests

tests/test-jit-doctor.sh, 85 assertions, written before the script existed and watched fail. Almost every assertion is paired: a fixture where doctor must reach a verdict, and one where it must decline. A doctor that always said cannot tell satisfies half of them; one that always said the plugin cache satisfies the other half. Only the pairs bind.

One assertion is pinned by mutation: the keyword length is measured under local LC_ALL=C, because ${#s} counts characters under a UTF-8 locale and bytes under C. Unpinned, a two-character three-byte keyword is flagged as short and is not. That section skips loudly, naming what went untested, where no UTF-8 locale exists.

Three existing suites picked the new work up with no registration: run-all.sh globs test-*.sh, test-assertion-helpers.sh drives the new suite's two declared helpers against its 1 MB SIGPIPE payload, and test-dogfood-entries.sh enumerates git ls-files -- scripts and would have gone red had tooling.md's alternation not been widened.

bash tests/run-all.sh — 33 suites, all passed, no SKIPPED suites. shellcheck -S warning scripts/*.sh tests/*.sh exits 0. Both observed on macOS only; Linux and Windows/Git Bash are reasoned from the workflow matrix, not run.

Docs

README.md gains an Is any of this running at all? section and the two config.env keys. CLAUDE.md gains a layout row, a fifth script in the trigger table, and a pointer from the dogfood-trap paragraph to the tool now written for it. .claude/jit-context/paths/00-manual/tooling.md gains the fifth script in its match, a row in the exit-code table, and its suite line — index rebuilt and committed alongside. Changelog fragment at changelog.d/183.added.md.

Verified by the maintainer

Red re-run, mine, against main at 3553f5f with the script absent: tests/test-jit-doctor.sh exits 1 with exit 127 ... scripts/jit-doctor.sh: No such file or directory. That is the honest strength of this particular red and it is worth naming: for a brand-new script the suite can only prove the file is missing, not that any assertion is non-vacuous. What carries the weight instead is the paired structure the author describes — a doctor that always answered cannot tell satisfies half the assertions and a doctor that always answered the plugin cache satisfies the other half — plus the three self-review bugs, each mutation-verified by reverting the fix and watching a named test go red.

The rider is accepted. Moving jit_report_keyword() and JIT_KEYWORD_WITHHELD into common.sh at the second bash caller is the same move jit_report_name() made in #131, and the alternative — a second copy — is a term printed by one tool and withheld by the other, which is the drift this repository's own comments name as its defect class. It is called out in the body rather than slipped in, which is what makes it acceptable in this diff.

On the heuristics question I put to the author: the answer came back with a measurement rather than a preference, and I accept it. A 38% flag rate on fat entries reads as noise until the fire count is beside it — tooling.md at 26.5 KB × 43 fires is roughly 1.14 MB injected in one session. The 4096 default stays.

Two things this PR surfaced are filed rather than carried: #188tests/test-arg-flag-values.sh drives its scripts from a hand-written list, so its header's promise is true of flags and false of scripts, and a new tool is untested there with every printed number still going up. #189 — the four installed plugin versions this diff's own output revealed, and the fact that which one loads is not decidable from a shell.

Florian DAVID and others added 2 commits August 18, 2026 13:06
Four tools under scripts/ answered three good questions and all three are
downstream of one nobody asked. This repository's own worst trap is the reason:
.claude/settings.json registers enabledPlugins, so the hooks firing in a
contributor's session are served from the plugin cache while JIT_BASE resolves
against $CLAUDE_PROJECT_DIR. The entries are the checkout and the code reading
them is not, nothing errors, and every observable signal reports health.

jit-doctor.sh names the tree it is judging first, then answers which copy of the
hooks would run in four states — the plugin cache, a hooks block in settings,
BOTH, and `cannot tell`. The fourth is a first-class answer: the scan is textual
because this plugin has no jq, and Claude Code merges settings from locations no
script in a repository can enumerate. Run against this repo it reports the cache,
four installed plugin versions, and that which one loads is not decidable from
where it stands.

Per layer it reports entries, whether the matcher loads that layer at all —
measured through the same jit_scan_layers() the hooks call — whether the index is
there, and whether an entry beside it is newer. It reports whether hooks.log
exists and when it was last written, because "never ran" and "ran and matched
nothing" have been indistinguishable until now.

Exit 1 is reserved for the one thing it establishes exactly: a layer holding
entries and no index, whose rules are inert. The mtime comparison is advisory
rather than a defect, deliberately — a fresh clone writes 00-index.tsv before the
.md files beside it, so mtime alone accuses a tree that is current. jit-dry-run.sh
compares frontmatter against the row and keeps that verdict; doctor points at it
and reimplements neither it nor the pattern lint, so a doctor exit code can never
mean two different things.

Every heuristic is ADVISORY and none moves the exit code (#47 has CI consuming
these). Both thresholds default in the script and print where they came from,
which is what makes JIT_CONTEXT_DOCTOR_MAX_BYTE — singular — visible: config.env
accepts any JIT_CONTEXT_* key, so a typo reads as a setting that applied.

#183 also asks for entries never fired "for N days" and only the first half is
computable: hooks.log records carry a time and no date, and an entry mtime is
rewritten by every clone. The advisory says "no record in the log" and builds no
threshold on a number with nothing under it.

jit_report_keyword() moves from rebuild-tsv.sh to common.sh. jit-doctor.sh was
the second bash caller, which is where a second copy stops being a duplicate and
starts being drift — the way jit_report_name()'s copy moved in #131. The awk half
stays put; awk cannot source a bash file.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…scan, a silent empty dimension (#183)

Three findings from the self-review, all of them the same defect class this
script exists to report, reproduced inside it.

1. The fire-count key was `layer:file.md` for all three dimensions. The tools
   half of pre-tool-hook.sh writes the literal `tool:file.md` and never the layer
   name (pre-tool-hook.sh:535, 556, 577, 595), so every tools entry came back
   with a count of zero and was reported `never fired` however often it had
   actually matched. The fixture had no tools/ layer at all, so nothing saw it.

2. The enabledPlugins scan matched the KEY and ignored the VALUE, so
   `"claude-jit-context@dpt-plugins": false` — a plugin explicitly turned off —
   printed `the plugin cache serves the hooks`. That is the confident wrong
   answer the section's own header says is worse than none, in the one check the
   whole tool exists for. It now requires the value `true` and requires the file
   to carry an enabledPlugins block, which also scopes it away from the plugin
   name appearing inside a hook command path. The residual is stated in the
   comment rather than papered over: everything it cannot establish still falls
   through to `cannot tell`.

3. A dimension directory that existed with no layer under it printed nothing at
   all — the glob stays literal without nullglob and the `[ -d ]` drops it — so
   the dimension simply did not appear, which is what a scan that died halfway
   also looks like. The missing-dimension case already had its own line; this one
   now does too.

Each is pinned by a test that goes red when the fix is reverted, verified by
mutation. mk_tree() now builds a tools/ layer, so that whole branch is covered.

CLAUDE.md was stale in two places the diff should have touched: the trigger table
still named four tooling scripts, and the layout table had no row for the fifth.
The dogfood-trap paragraph now points at the tool written for it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@fdaviddpt
fdaviddpt merged commit 19f6c46 into main Aug 18, 2026
5 checks passed
@fdaviddpt
fdaviddpt deleted the fix/183 branch August 18, 2026 12:13
fdaviddpt pushed a commit that referenced this pull request Aug 18, 2026
…ching (#191)

A `<file>.sh:NNN` pointer is exact the day it is written and wrong on the next
PR that inserts a line above it. It rots silently: a rotted citation reads
exactly like a live one, so the reader follows it and lands on a plausible
comment rather than on an error.

Counted on main at 98386f1, outside the assembled changelog: nine citations of
that shape. Six pointed at the wrong thing, one had drifted off the block it
named, two were still right. #191 names three of the six; the sweep added here
found the other three. #185 added one of them three hours before #190 moved it.

Every citation in an enforced file now points at something greppable -- a
function name, a distinctive literal, or the issue number, which additionally
says why rather than where.

tests/test-line-citations.sh has three outcomes rather than two. It FAILS on a
citation in a tracked scripts or tests shell file; it REPORTS WITHOUT FAILING
over every other tracked file; and it names the assembled changelog as
deliberately not swept, because that file is generated and a finding in it is
unactionable. The advisory half is advisory rather than enforced for one stated
reason: its only remaining finding lives in an entry held by PR #192, and
reddening a file this change may not edit is how a check gets disabled in its
first week. Widening it is one awk pattern.

The false-positive surface was costed before the check was written: over all 96
tracked basenames and the whole tree, 10 hits and 0 false. A bash diagnostic, a
shellcheck line, an awk error and a longer basename are driven as controls that
must not match, alongside a planted citation that must.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
fdaviddpt pushed a commit that referenced this pull request Aug 18, 2026
- CLAUDE.md read as attributing the move to #191 itself, because the issue
  citation sat immediately after "the PR that moved it". #185 added it and #190
  moved it three hours later; the sentence now says so.
- scripts/jit-dry-run.sh: "which #7 is the reason for" -> "which exists because
  of #7". Accurate either way, clunky one way.

A third finding was argued down rather than applied. The reviewer recounted the
audit as 3 right / 1 drifted / 5 wrong and read the tooling.md citation as
correct, on the strength of that entry own sentence "The truncation is
pre-tool-hook.sh:127-144 and it is correct" -- where "it" is the truncation
BEHAVIOUR being deliberate, not the line range being accurate. The same range
in scripts/jit-dry-run.sh was judged wrong in the same review, which is the
contradiction: at 98386f1 those lines are the #182 subagent_type comment, and
the truncation is at 162-185. The count stands at 6 wrong / 1 drifted / 2 right.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
fdaviddpt added a commit that referenced this pull request Aug 18, 2026
…ses new ones (#191) (#197)

* Cross-file line-number citations rotted silently, and nothing was watching (#191)

A `<file>.sh:NNN` pointer is exact the day it is written and wrong on the next
PR that inserts a line above it. It rots silently: a rotted citation reads
exactly like a live one, so the reader follows it and lands on a plausible
comment rather than on an error.

Counted on main at 98386f1, outside the assembled changelog: nine citations of
that shape. Six pointed at the wrong thing, one had drifted off the block it
named, two were still right. #191 names three of the six; the sweep added here
found the other three. #185 added one of them three hours before #190 moved it.

Every citation in an enforced file now points at something greppable -- a
function name, a distinctive literal, or the issue number, which additionally
says why rather than where.

tests/test-line-citations.sh has three outcomes rather than two. It FAILS on a
citation in a tracked scripts or tests shell file; it REPORTS WITHOUT FAILING
over every other tracked file; and it names the assembled changelog as
deliberately not swept, because that file is generated and a finding in it is
unactionable. The advisory half is advisory rather than enforced for one stated
reason: its only remaining finding lives in an entry held by PR #192, and
reddening a file this change may not edit is how a check gets disabled in its
first week. Widening it is one awk pattern.

The false-positive surface was costed before the check was written: over all 96
tracked basenames and the whole tree, 10 hits and 0 false. A bash diagnostic, a
shellcheck line, an awk error and a longer basename are driven as controls that
must not match, alongside a planted citation that must.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Address the audit: an advisory sweep that could report coverage it never had (#191)

Four findings from the self-review, all in tests/test-line-citations.sh unless
noted:

- The advisory sweep had no floor guard on its file count, so a broken selector
  would print `Clean across 0 file(s)` and exit 0 -- byte-identical, to
  run-all.sh which reads only the exit code, to a genuinely clean tree. It now
  takes the same `< 10 -> exit 2` floor the enforced sweep already had.
- The enforced selector was `[^/]*`, so a shell file landing in a subdirectory
  one day would fall silently into the report-only bucket and stay there. Now
  `.*`, which closes the shape rather than half of it.
- A tracked path that is not a readable file was skipped in silence in both
  loops, while the header above claimed a count the sweep never reached. Both
  are counted now; an unreadable path in the enforced set fails the suite.
- The needle accepts an optional directory prefix, and no enforced file
  exercises that branch -- every real citation was bare-basename -- so a dead
  prefix branch would have read as clean. It has its own positive control now.

scripts/jit-dry-run.sh named `jit_log_name()` and `jit_row_id()` as being "in
pre-tool-hook.sh". They are defined in common.sh and only called there, which
is an anchor wrong in the same way the line numbers were. Reworded to what is
true: pre-tool-hook.sh routes every refused row through them.

The 96-basename and 10-hit figures are now pinned to 98386f1 in all three
places that carry them, rather than stated in the present tense. They were
already stale by two the moment this branch added a file, which is the exact
failure mode the change is about.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Two prose findings from the re-review (#191)

- CLAUDE.md read as attributing the move to #191 itself, because the issue
  citation sat immediately after "the PR that moved it". #185 added it and #190
  moved it three hours later; the sentence now says so.
- scripts/jit-dry-run.sh: "which #7 is the reason for" -> "which exists because
  of #7". Accurate either way, clunky one way.

A third finding was argued down rather than applied. The reviewer recounted the
audit as 3 right / 1 drifted / 5 wrong and read the tooling.md citation as
correct, on the strength of that entry own sentence "The truncation is
pre-tool-hook.sh:127-144 and it is correct" -- where "it" is the truncation
BEHAVIOUR being deliberate, not the line range being accurate. The same range
in scripts/jit-dry-run.sh was judged wrong in the same review, which is the
contradiction: at 98386f1 those lines are the #182 subagent_type comment, and
the truncation is at 162-185. The count stands at 6 wrong / 1 drifted / 2 right.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* README said "Two suites are the exception" and this change made it three (#191)

The test section enumerates the suites that are about this repository rather
than the hooks, and the enumeration was a count. Adding tests/test-line-citations.sh
made the sentence wrong in the quietest possible way -- a reader counting two
and finding three has no reason to think anything is missing.

Named rather than counted where it can be: the third one is called out by name
and by what it refuses, and the "assembler they are about" clause now says
which two it means.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Rebase onto 5b46095: two citations #194 wrote while this branch was open (#191)

CI caught these, and the check that caught them is the one this branch adds.
Neither is a defect in this diff -- they are in tests/test-session-markers.sh,
section J, which #194 (issue #177) added to main after this branch was cut.

- `session-start-hook.sh:32 parses the payload with jit_json_fields +
  jit_session_key` -- the line number was ALREADY WRONG on the day it was
  written: line 32 is mid-sentence in the LC_ALL paragraph, and the parse claim
  is three lines earlier. The comment names its own functions, so the number
  was carrying nothing the prose did not already carry. Dropped.
- `the comment at session-start-hook.sh:25-29` -- accurate today, and a pointer
  to a comment rather than to code, which is the shape most likely to move.
  This one wanted more than a rename: it is a claim about TWO hooks agreeing,
  so it now names the pair of functions the claim is about and says which half
  of the claim this leg proves.

The counts pinned to 98386f1 are re-pinned to 5b46095 in all three places that
carry them, and the second measurement is kept beside the first rather than
replacing it. Nine citations at 98386f1, eleven at 5b46095; 96 basenames / 10
hits / 0 false, then 98 / 12 / 0. Pinning was already the right call and the
rebase is what proved it: the pin went stale in under a day and SAID SO,
where a present-tense number would have read true and been wrong.

That interval is now the argument the suite header leads with. The citations
this check first went red on in CI were not the ones it was written for, they
were written by somebody else while it was in review, and one of the two was
born broken. A cleanup would not have caught either.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* The advisory half said it was blocked on #192, which has landed (#191)

A reason that has been spent reads exactly like a reason that still holds --
which is the defect class this whole branch is about, in the file that exists
to refuse it. The suite printed "advisory only while #192 holds one of the
files it would flag" on every run, and #192 merged at eb9cf71.

Replaced with what is true: widening is a scope decision nobody has taken, not
a blocked one. Deliberately NOT widened here. It is unrequested scope on a pull
request already in review, and the question it settles -- whether every future
doc, template, example and jit-context entry is bound by this rule, in every
installed project's contributor path -- is a design decision this branch was
not briefed to make. Filed for the maintainer with the exact cost instead: two
awk alternations in the selectors, one line in the entry currently flagged,
and zero measured false positives on that surface across two commits.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Florian DAVID <fdavid@digital-village.fr>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
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.

No tool answers "is my setup live?" — add scripts/jit-doctor.sh

1 participant