Skip to content

fix(mcp,watch,hooks): no confident zeros for unindexed files, surface state write errors, Codex post-edit always emits JSON - #200

Merged
JordanCoin merged 3 commits into
mainfrom
fix/honest-zeros-and-codex-envelope
Oct 7, 2026
Merged

JordanCoin merged 3 commits into
mainfrom
fix/honest-zeros-and-codex-envelope

Conversation

@JordanCoin

Copy link
Copy Markdown
Owner

Three small silent-failure fixes, one commit, rebased on #197. Builds on #195's FileGraph.KnownFiles / Indexed() / ImportersReport.NotIndexed rather than duplicating them.

Bug A — MCP get_file_context / get_importers answered confidently about files that were never scanned (#140 part 2)

Before (get_file_context {"path": "<repo>", "file": "totally/made/up.go"} over stdio JSON-RPC):

=== File Context: totally/made/up.go ===

IMPORTS: none (leaf file)

IMPORTED BY: none (entry point or unused)

CONNECTED: 0 files in dependency graph

get_importers for the same path: No files import 'totally/made/up.go', structured kind: "empty".

After (same requests, hand-built over codemap mcp stdio):

=== File Context: totally/made/up.go ===

not indexed: totally/made/up.go is not in the scanned file set (path does not exist)
Imports and importers: unknown, not 0 (codemap never scanned this file).
Next: check the path with find_file, then retry with the path it returns.

get_importers prints the same three lines; its structured content is now {"kind":"not_indexed","not_indexed":true,"not_indexed_reason":"path does not exist","importer_count":0,...}. Against this repo, get_importers docs/OUTPUT-STANDARD.md answers not indexed: docs/OUTPUT-STANDARD.md is not in the scanned file set (excluded by config: only/exclude in .codemap/config.json).

How: new scanner.(*FileGraph).ExplainUnindexed(path, filters) (scanner/filegraph.go) names exactly one reason, checked in order: path does not exist (os.Stat) · path is a directory, not a file · not in KnownFiles and fails MatchesFilters → excluded by config: only/exclude in .codemap/config.json · path component in IgnoredDirs → under ignored directory vendor/ · .gitignore match → ignored by .gitignore · otherwise absent from the scan inventory (...) · in KnownFiles but !IsSourceExt → unsupported file type: .md has no import analysis (KnownFiles holds every walked file, so a .md is "indexed" by #195's definition yet has no import analysis; that was still a confident zero). It returns ok=false for a scanned source file (output unchanged, see main.go in the test) and for KnownFiles == nil (unknown, never "not indexed"). The MCP handlers only consult it when both edge lists are empty, so a file with real edges is never relabelled.

Two things the task asked me to confirm:

  • not_indexed did not flow through automatically. MCP get_importers returns its own ImportersOutput (mcp/analysis_output.go), not scanner.ImportersReport, and its text path never went through renderImportersReportCLI. I added not_indexed + not_indexed_reason to ImportersOutput and the text branch to the handler.
  • No MCP path uses a cached graph. get_file_context and get_importers always call scanner.BuildFileGraph, which populates KnownFiles. The only cached-graph reconstruction is watch.ValidateCachedGraph (watch/graph_state.go:154, leaves KnownFiles nil); its callers are cmd/hooks.go:119 and cmd/context_evidence.go:87, both hook/CLI paths. get_structure reads state.Importers/state.Hubs maps from the daemon but never builds a FileGraph from them. The nil guard is still there (and tested) for anyone who does.

Output standard for the new lines: sourced (the file path, and the reason names the config key / directory / .gitignore / extension that caused it), precise (one reason, no "may/likely"; the structured field carries the same string), bounded (says the zeros are unknown rather than counts, and coverage text still follows), useful (Next: names the tool or config edit).

mcp/surface_hygiene_test.go TestTextResultCallerClassification counts changed (handleGetImporters 2→3, handleGetFileContext 1→2) for the new branches.

Bug B — writeState discarded the state.json write error (#140 part 1)

Since the issue was filed, writeState already returns the error, but every caller funnelled it into reportPublicationError, which only fmt.Printfs under --verbose on every retry tick, and the forked daemon runs with stdout/stderr set to nil (main.go watch start). So a daemon that could not write state.json recorded nothing anywhere, and codemap watch status kept printing the last good write's numbers as if current.

Before (codemap watch status with a live daemon whose writes fail):

Watch daemon running
  Files: 9
  Hubs: 1
  Updated: 15:03:00

After:

Watch daemon running
  state write failing: unsafe runtime file "/…/state.json" (12 attempts since 15:04:05, last 15:04:35)
  Next: make /…/state.json writable; the daemon retries on its own.
  Files and Hubs below are from the last successful write (15:03:00), not live
  Files: 9
  Hubs: 1
  Updated: 15:03:00

How: statePublisher.publish() now calls recordWriteFailure(err) on failure (in-memory *StateWriteError with path, error, first/last time, attempt count, mirrored best-effort to state.write-error.json beside state.json) and clearWriteFailure() on success (removes the sidecar after a tracked failure, and on this daemon's first successful publish so a record left by an earlier daemon does not outlive the condition). Daemon.reportPublicationError logs once per failure streak and once on recovery under verbose ([watch] State publication failing: <err> (retrying; codemap watch status reports this until it recovers)) instead of on every tick. watch.ReadStateWriteError(root) reads the sidecar; watch status prints it in both the with-state and no-state branches. The sidecar is deliberately best effort: when the whole runtime directory is unwritable nothing can land there, and the in-memory record still drives the log.

Output standard: sourced (the error string from runtimefile.WriteAtomicWith names the path; the Next: line repeats it), precise (attempt count, first/last timestamps), bounded (explicitly marks Files/Hubs as from the last successful write, not live), useful (Next: names the fix and that the daemon retries by itself).

Bug C — Codex PostToolUse hook emitted empty stdout for pathless edits (#156)

Before (from the repo root):

$ printf '%s\n' '{"tool_name":"apply_patch","tool_input":{},"tool_response":{}}' | codemap hook post-edit --agent=codex --integration=codex-setup
$ # exit 0, empty stdout → Codex: "hook returned invalid post-tool-use JSON output"

After (same command, verified with the built binary; cat -A shows exactly one line, no ANSI):

{"continue":true}

A file-path payload still records provenance (.codemap/agent_edits.jsonl) and returns its existing context inside the existing envelope, e.g. {"hookSpecificOutput":{"hookEventName":"PostToolUse","additionalContext":"After editing: pkg/types.go still fans out to 3 importers. …"}}.

How: emitCodexHookContext in cmd/hooks.go takes an alwaysEmit flag that runHookOutput sets only for post-edit; an empty capture then encodes {"continue": true} instead of returning silently. The other Codex events (SessionStart, UserPromptSubmit, PreCompact, PreToolUse) keep their stay-silent behaviour, and Claude mode is untouched (TestClaudePostEditKeepsPlainOutput). The regression test was written first and failed with Codex-mode stdout is not one JSON object: EOF "" before the fix.

Output standard: the line is a protocol envelope, not a fact line; it adds no context (so nothing unsourced reaches the agent), it is exactly one JSON record with no prefix or ANSI, and {"continue": true} is the explicit no-op of the common hook-output schema.

Tests

New: scanner/filegraph_unindexed_test.go (TestExplainUnindexedNamesOneReason: every reason, scanned-source → not flagged, nil KnownFiles → not flagged), mcp/not_indexed_test.go (both tools, text + structured, indexed zero-importer file unchanged), watch/publication_test.go (TestStatePublisherRecordsWriteFailureForStatus points state.json at a directory — unwritable even as root — and asserts the in-memory record, the sidecar, log-once, and full clearing on recovery; TestStatePublisherFirstPublishClearsStaleWriteFailure), main_more_test.go (TestRunWatchSubcommandStatusReportsStateWriteFailure), cmd/hooks_codex_envelope_test.go (TestCodexPostEditAlwaysEmitsOneJSONEnvelope, TestClaudePostEditKeepsPlainOutput).

go test ./...: all packages pass except the three pre-existing root-permission failures (TestRunSetupCreatesConfigAndHooks, TestSelectReportsInaccessiblePrimarySetup, TestListProjectsPreservesPerChildScanErrors), unchanged from main. gofmt -l ., go vet ./..., staticcheck ./... clean. codemap --diff --ref origin/main run before committing.

One judgement call for review: the no-op envelope is {"continue": true} (the common-schema no-op shared by Claude Code and Codex hook JSON) rather than a hookSpecificOutput with an empty additionalContext, so nothing empty gets injected as context. If Codex turns out to want the latter shape, it is a one-line change in emitCodexHookContext.

Closes #140
Closes #156

🤖 Generated with Claude Code

https://claude.ai/code/session_0159tcanCgA3jxi2E7DdRVWJ


Generated by Claude Code

… state write errors, Codex post-edit always emits JSON

MCP get_file_context and get_importers answered "leaf file" / "entry point
or unused" / "No files import X" for files the graph never scanned. Both
now check the inventory first and answer "not indexed: X is not in the
scanned file set (<one reason>)" with a next action; get_importers'
structured content carries kind "not_indexed", not_indexed and
not_indexed_reason (closes #140 part 2, building on #195's KnownFiles).

The watch daemon treated a failing state.json write as a successful
publish. The publisher now records the failure streak, mirrors it to a
state.write-error.json sidecar, logs it once per streak, and `codemap
watch status` prints "state write failing: <err>" ahead of the stale
numbers (closes #140 part 1).

Codex PostToolUse hooks got empty stdout for a pathless apply_patch
payload, which Codex rejects as invalid JSON. In Codex mode post-edit now
always emits exactly one envelope, {"continue": true} when there is no
context; Claude mode is unchanged (closes #156).

Closes #140
Closes #156

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159tcanCgA3jxi2E7DdRVWJ
Copilot AI balanced review requested due to automatic review settings October 7, 2026 03:51

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

JordanCoin and others added 2 commits October 6, 2026 23:57
…, TestEventDetection, TestLineDelta)

CI (ubuntu-latest, Go 1.26) failed TestDebounce with "Expected 1-2
debounced events, got 4": the runner scheduled the five-write burst more
than 100ms apart, so the writes were never inside one debounce window.
The window is now a Daemon field (debounceWindow, read once when the
event loop starts) defaulting to the unchanged 100ms; TestDebounce sets
it to 1s so the burst always coalesces, then polls with
waitForWatchCondition for writeCount >= 1 && lastWriteLines == 6 and
keeps writeCount <= 2 as the assertion.

TestEventDetection and TestLineDelta poll for the daemon's watch list
and then for the expected WRITE event with a 5s deadline instead of
fixed 500ms sleeps; their existing fsnotify skip is kept only for the
no-events-at-all case. No assertion was loosened and no sleep constant
raised.

Refs #135

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159tcanCgA3jxi2E7DdRVWJ
Resolves the one conflict in mcp/main.go handleGetImporters: the #140
not-indexed check runs first inside the empty-importers branch (an
unindexed file has no Go package to be a sibling of), and an indexed
file with zero importers falls through to #199's Go package-granularity
wording.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159tcanCgA3jxi2E7DdRVWJ
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

2 participants