Repository navigation
fix(mcp,watch,hooks): no confident zeros for unindexed files, surface state write errors, Codex post-edit always emits JSON - #200
Merged
Conversation
… 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
…, 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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Three small silent-failure fixes, one commit, rebased on #197. Builds on #195's
FileGraph.KnownFiles/Indexed()/ImportersReport.NotIndexedrather than duplicating them.Bug A — MCP
get_file_context/get_importersanswered 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):get_importersfor the same path:No files import 'totally/made/up.go', structuredkind: "empty".After (same requests, hand-built over
codemap mcpstdio):get_importersprints 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.mdanswersnot 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 inKnownFilesand failsMatchesFilters→excluded by config: only/exclude in .codemap/config.json· path component inIgnoredDirs→under ignored directory vendor/·.gitignorematch →ignored by .gitignore· otherwiseabsent from the scan inventory (...)· inKnownFilesbut!IsSourceExt→unsupported file type: .md has no import analysis(KnownFiles holds every walked file, so a.mdis "indexed" by #195's definition yet has no import analysis; that was still a confident zero). It returnsok=falsefor a scanned source file (output unchanged, seemain.goin the test) and forKnownFiles == 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_indexeddid not flow through automatically. MCPget_importersreturns its ownImportersOutput(mcp/analysis_output.go), notscanner.ImportersReport, and its text path never went throughrenderImportersReportCLI. I addednot_indexed+not_indexed_reasontoImportersOutputand the text branch to the handler.get_file_contextandget_importersalways callscanner.BuildFileGraph, which populatesKnownFiles. The only cached-graph reconstruction iswatch.ValidateCachedGraph(watch/graph_state.go:154, leavesKnownFilesnil); its callers arecmd/hooks.go:119andcmd/context_evidence.go:87, both hook/CLI paths.get_structurereadsstate.Importers/state.Hubsmaps from the daemon but never builds aFileGraphfrom 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.goTestTextResultCallerClassificationcounts changed (handleGetImporters2→3,handleGetFileContext1→2) for the new branches.Bug B —
writeStatediscarded the state.json write error (#140 part 1)Since the issue was filed,
writeStatealready returns the error, but every caller funnelled it intoreportPublicationError, which onlyfmt.Printfs under--verboseon every retry tick, and the forked daemon runs with stdout/stderr set to nil (main.gowatch start). So a daemon that could not writestate.jsonrecorded nothing anywhere, andcodemap watch statuskept printing the last good write's numbers as if current.Before (
codemap watch statuswith a live daemon whose writes fail):After:
How:
statePublisher.publish()now callsrecordWriteFailure(err)on failure (in-memory*StateWriteErrorwith path, error, first/last time, attempt count, mirrored best-effort tostate.write-error.jsonbesidestate.json) andclearWriteFailure()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.reportPublicationErrorlogs once per failure streak and once on recovery underverbose([watch] State publication failing: <err> (retrying;codemap watch statusreports this until it recovers)) instead of on every tick.watch.ReadStateWriteError(root)reads the sidecar;watch statusprints 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.WriteAtomicWithnames the path; theNext: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):
After (same command, verified with the built binary;
cat -Ashows exactly one line, no ANSI):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:
emitCodexHookContextincmd/hooks.gotakes analwaysEmitflag thatrunHookOutputsets only forpost-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 withCodex-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, nilKnownFiles→ not flagged),mcp/not_indexed_test.go(both tools, text + structured, indexed zero-importer file unchanged),watch/publication_test.go(TestStatePublisherRecordsWriteFailureForStatuspointsstate.jsonat 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 frommain.gofmt -l .,go vet ./...,staticcheck ./...clean.codemap --diff --ref origin/mainrun 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 ahookSpecificOutputwith an emptyadditionalContext, so nothing empty gets injected as context. If Codex turns out to want the latter shape, it is a one-line change inemitCodexHookContext.Closes #140
Closes #156
🤖 Generated with Claude Code
https://claude.ai/code/session_0159tcanCgA3jxi2E7DdRVWJ
Generated by Claude Code