Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 20 additions & 3 deletions cmd/hooks.go
Original file line number Diff line number Diff line change
Expand Up @@ -342,6 +342,10 @@ func runHookOutput(hookName string, fn func() error) error {
}

event := ""
// Codex treats a PostToolUse hook's empty stdout as an invalid JSON
// response (#156), so post-edit always answers with one envelope, a
// no-op when the payload named no file.
alwaysEmit := false
switch hookName {
case "session-start":
event = "SessionStart"
Expand All @@ -353,10 +357,11 @@ func runHookOutput(hookName string, fn func() error) error {
event = "PreToolUse"
case "post-edit":
event = "PostToolUse"
alwaysEmit = true
default:
return fn()
}
return emitCodexHookContext(event, fn)
return emitCodexHookContext(event, fn, alwaysEmit)
}

func runHookWithoutStdout(fn func() error) error {
Expand Down Expand Up @@ -830,7 +835,13 @@ func hookPostEdit(root string) error {
return nil
}

func emitCodexHookContext(event string, fn func() error) error {
// emitCodexHookContext runs fn with stdout captured and re-emits whatever it
// printed as one Codex hook JSON envelope. With alwaysEmit, an empty capture
// still produces exactly one envelope, the no-op {"continue": true}, which
// carries no context and is valid under the common hook-output schema;
// without it, an empty capture stays silent (session-start, prompt-submit,
// pre-compact and pre-edit keep that behaviour).
func emitCodexHookContext(event string, fn func() error, alwaysEmit bool) error {
if os.Getenv("CODEX") != "1" {
return fn()
}
Expand Down Expand Up @@ -860,9 +871,15 @@ func emitCodexHookContext(event string, fn func() error) error {
if closeErr != nil {
return closeErr
}
if result.err != nil || len(strings.TrimSpace(string(result.output))) == 0 {
if result.err != nil {
return result.err
}
if len(strings.TrimSpace(string(result.output))) == 0 {
if !alwaysEmit {
return nil
}
return json.NewEncoder(original).Encode(map[string]any{"continue": true})
}
return json.NewEncoder(original).Encode(map[string]any{
"hookSpecificOutput": map[string]any{
"hookEventName": event,
Expand Down
116 changes: 116 additions & 0 deletions cmd/hooks_codex_envelope_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
package cmd

import (
"bytes"
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"time"

"codemap/watch"
)

// decodeSingleHookEnvelope asserts stdout holds exactly one JSON value and
// nothing else: no prefix, no trailing record, no ANSI.
func decodeSingleHookEnvelope(t *testing.T, stdout string) map[string]any {
t.Helper()
if strings.Contains(stdout, "\x1b[") {
t.Fatalf("Codex-mode stdout contains ANSI:\n%q", stdout)
}
decoder := json.NewDecoder(bytes.NewReader([]byte(stdout)))
var envelope map[string]any
if err := decoder.Decode(&envelope); err != nil {
t.Fatalf("Codex-mode stdout is not one JSON object: %v\n%q", err, stdout)
}
if decoder.More() {
t.Fatalf("Codex-mode stdout holds more than one JSON record:\n%q", stdout)
}
return envelope
}

// TestCodexPostEditAlwaysEmitsOneJSONEnvelope is #156: Codex requires exactly
// one JSON response from a PostToolUse hook, and codemap answered a pathless
// apply_patch payload with nothing, which Codex reports as "hook returned
// invalid post-tool-use JSON output".
func TestCodexPostEditAlwaysEmitsOneJSONEnvelope(t *testing.T) {
t.Setenv("CODEX", "1")
root := t.TempDir()
target := filepath.Join(root, "pkg", "types.go")
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(target, []byte("package pkg\n"), 0o644); err != nil {
t.Fatal(err)
}
writeWatchState(t, root, watch.State{
UpdatedAt: time.Now(),
FileCount: 4,
Importers: map[string][]string{"pkg/types.go": {"cmd/a.go", "cmd/b.go", "cmd/c.go"}},
Imports: map[string][]string{
"cmd/a.go": {"pkg/types.go"},
"cmd/b.go": {"pkg/types.go"},
"cmd/c.go": {"pkg/types.go"},
},
})

t.Run("pathless apply_patch gets a no-op envelope", func(t *testing.T) {
var hookErr error
var stdout string
withStdinInput(t, `{"tool_name":"apply_patch","tool_input":{},"tool_response":{}}`, func() {
stdout = captureOutput(func() { hookErr = RunHook("post-edit", root) })
})
if hookErr != nil {
t.Fatalf("RunHook(post-edit) = %v", hookErr)
}
envelope := decodeSingleHookEnvelope(t, stdout)
if _, hasContext := envelope["hookSpecificOutput"]; hasContext {
t.Fatalf("pathless edit injected context: %v", envelope)
}
if envelope["continue"] != true {
t.Fatalf("no-op envelope = %v, want {\"continue\": true}", envelope)
}
})

t.Run("file path keeps context inside the envelope and records provenance", func(t *testing.T) {
quoted := strings.ReplaceAll(target, `\`, `\\`)
payload := `{"session_id":"codex-envelope","tool_name":"apply_patch","tool_input":{"command":"*** Update File: ` + quoted + `\n@@\n-a\n+b\n"},"tool_response":{}}`
var hookErr error
var stdout string
withStdinInput(t, payload, func() {
stdout = captureOutput(func() { hookErr = RunHook("post-edit", root) })
})
if hookErr != nil {
t.Fatalf("RunHook(post-edit) = %v", hookErr)
}
envelope := decodeSingleHookEnvelope(t, stdout)
specific, _ := envelope["hookSpecificOutput"].(map[string]any)
context, _ := specific["additionalContext"].(string)
if specific["hookEventName"] != "PostToolUse" || !strings.Contains(context, "After editing: pkg/types.go still fans out to 3 importers.") {
t.Fatalf("envelope lacks the post-edit context: %v", envelope)
}
edits := loadAgentEdits(root, time.Time{})
if !edits.bySession["codex-envelope"]["pkg/types.go"] {
t.Fatalf("Codex post-edit did not record provenance: %+v", edits.bySession)
}
})
}

// Claude mode is unchanged: a pathless payload prints nothing, a file path
// prints the plain context with no envelope.
func TestClaudePostEditKeepsPlainOutput(t *testing.T) {
t.Setenv("CODEX", "")
root := t.TempDir()
var hookErr error
var stdout string
withStdinInput(t, `{"tool_name":"Bash","tool_input":{"command":"go test ./..."}}`, func() {
stdout = captureOutput(func() { hookErr = RunHook("post-edit", root) })
})
if hookErr != nil {
t.Fatalf("RunHook(post-edit) = %v", hookErr)
}
if stdout != "" {
t.Fatalf("Claude-mode pathless post-edit printed %q, want nothing", stdout)
}
}
17 changes: 17 additions & 0 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -943,13 +943,30 @@ func runWatchSubcommand(subCmd, root string) error {
}
if active.PID > 0 {
state := watch.ReadState(absRoot)
// A daemon whose state writes fail is still "running"; without
// this line its stale numbers would read as current (#140).
writeFailure := watch.ReadStateWriteError(absRoot)
reportWriteFailure := func() {
if writeFailure == nil {
return
}
fmt.Printf(" state write failing: %s (%d attempts since %s, last %s)\n",
writeFailure.Error, writeFailure.Attempts,
writeFailure.FirstFailedAt.Local().Format("15:04:05"), writeFailure.LastFailedAt.Local().Format("15:04:05"))
fmt.Printf(" Next: make %s writable; the daemon retries on its own.\n", writeFailure.Path)
}
if state != nil {
fmt.Printf("Watch daemon running\n")
reportWriteFailure()
if writeFailure != nil {
fmt.Printf(" Files and Hubs below are from the last successful write (%s), not live\n", state.UpdatedAt.Local().Format("15:04:05"))
}
fmt.Printf(" Files: %d\n", state.FileCount)
fmt.Printf(" Hubs: %d\n", len(state.Hubs))
fmt.Printf(" Updated: %s\n", state.UpdatedAt.Format("15:04:05"))
} else {
fmt.Println("Watch daemon running (no state)")
reportWriteFailure()
}
} else {
fmt.Println("Watch daemon not running")
Expand Down
48 changes: 48 additions & 0 deletions main_more_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1495,3 +1495,51 @@ func TestRunDepsFromStdinRejectsEscapingPaths(t *testing.T) {
})
}
}

// TestRunWatchSubcommandStatusReportsStateWriteFailure: a running daemon whose
// state.json writes fail must say so in `codemap watch status` instead of
// presenting the last good write's numbers as current (#140 part 1).
func TestRunWatchSubcommandStatusReportsStateWriteFailure(t *testing.T) {
root := t.TempDir()
writeMainWatchState(t, root, watch.State{
UpdatedAt: time.Now().Add(-10 * time.Minute),
FileCount: 9,
Hubs: []string{"pkg/types.go"},
}, true)
runtimeDir := projectpath.ProjectRuntimeDir(root)
record := watch.StateWriteError{
Path: filepath.Join(runtimeDir, "state.json"),
Error: `unsafe runtime file "` + filepath.Join(runtimeDir, "state.json") + `"`,
FirstFailedAt: time.Now().Add(-time.Minute),
LastFailedAt: time.Now(),
Attempts: 12,
}
data, err := json.Marshal(record)
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(runtimeDir, "state.write-error.json"), data, 0o644); err != nil {
t.Fatal(err)
}

stdout, _ := captureMainStreams(t, func() { runWatchSubcommand("status", root) })
for _, check := range []string{
"Watch daemon running",
"state write failing: " + record.Error + " (12 attempts since " + record.FirstFailedAt.Local().Format("15:04:05") + ", last " + record.LastFailedAt.Local().Format("15:04:05") + ")",
"Next: make " + record.Path + " writable; the daemon retries on its own.",
"Files and Hubs below are from the last successful write (",
"Files: 9",
} {
if !strings.Contains(stdout, check) {
t.Fatalf("expected %q in status output, got:\n%s", check, stdout)
}
}

if err := os.Remove(filepath.Join(runtimeDir, "state.write-error.json")); err != nil {
t.Fatal(err)
}
stdout, _ = captureMainStreams(t, func() { runWatchSubcommand("status", root) })
if strings.Contains(stdout, "state write failing") {
t.Fatalf("status reported a write failure after the sidecar was cleared:\n%s", stdout)
}
}
33 changes: 20 additions & 13 deletions mcp/analysis_output.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,19 +26,26 @@ type DiffOutput struct {
}

type ImportersOutput struct {
Kind string `json:"kind"`
Root string `json:"root"`
Mode string `json:"mode"`
File string `json:"file"`
Importers []string `json:"importers"`
Imports []string `json:"imports"`
HubImports []string `json:"hub_imports"`
ImporterCount int `json:"importer_count"`
IsHub bool `json:"is_hub"`
CoverageStatus string `json:"coverage_status"`
CoverageNotes []string `json:"coverage_notes"`
Truncated bool `json:"truncated"`
OmittedCount int `json:"omitted_count"`
Kind string `json:"kind"`
Root string `json:"root"`
Mode string `json:"mode"`
File string `json:"file"`
Importers []string `json:"importers"`
Imports []string `json:"imports"`
HubImports []string `json:"hub_imports"`
ImporterCount int `json:"importer_count"`
IsHub bool `json:"is_hub"`
// NotIndexed mirrors scanner.ImportersReport.NotIndexed: true when the
// queried file was never part of the scanned set, so importer_count 0 is
// a coverage gap, not a confirmed zero. NotIndexedReason names the one
// cause (path does not exist, excluded by config, ...). Kind is
// "not_indexed" in that case.
NotIndexed bool `json:"not_indexed"`
NotIndexedReason string `json:"not_indexed_reason,omitempty"`
CoverageStatus string `json:"coverage_status"`
CoverageNotes []string `json:"coverage_notes"`
Truncated bool `json:"truncated"`
OmittedCount int `json:"omitted_count"`
}

type HandoffOutput struct {
Expand Down
35 changes: 33 additions & 2 deletions mcp/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -896,7 +896,8 @@ func handleGetImporters(ctx context.Context, req *mcp.CallToolRequest, input Imp
if invalid != nil {
return invalid, nil, nil
}
fg, err := scanner.BuildFileGraph(ctx, absRoot, scanner.ConfiguredFilters(absRoot))
filters := scanner.ConfiguredFilters(absRoot)
fg, err := scanner.BuildFileGraph(ctx, absRoot, filters)
if err != nil {
if cancelled := cancellationResult(ctx, "Importer analysis"); cancelled != nil {
return cancelled, nil, nil
Expand Down Expand Up @@ -929,6 +930,16 @@ func handleGetImporters(ctx context.Context, req *mcp.CallToolRequest, input Imp
}
}
if len(importers) == 0 {
// A zero for a file the graph never scanned is a blind spot, not an
// answer (#140): say so, and name the one reason that applies. This
// runs before the Go package wording above is used, since an
// unindexed file has no package to be a sibling of.
if why, unindexed := fg.ExplainUnindexed(file, filters); unindexed {
structured.Kind = "not_indexed"
structured.NotIndexed = true
structured.NotIndexedReason = why.Reason
return textResult(notIndexedText(file, why, fg)), structured, nil
}
return textResult(empty + mcpCoverageText(fg)), structured, nil
}

Expand Down Expand Up @@ -974,6 +985,15 @@ func normalizeImporterFile(root, file string) string {
return filepath.Clean(file)
}

// notIndexedText is the MCP answer for a file the graph cannot vouch for. It
// states the one reason, says the zeros are unknowns rather than counts, and
// names the next action, so an agent that mistyped a path is not told the
// file is a harmless leaf (#140).
func notIndexedText(file string, why scanner.UnindexedExplanation, fg *scanner.FileGraph) string {
return fmt.Sprintf("not indexed: %s is not in the scanned file set (%s)\nImports and importers: unknown, not 0 (codemap never scanned this file).\nNext: %s.%s",
file, why.Reason, why.Next, mcpCoverageText(fg))
}

func mcpCoverageText(fg *scanner.FileGraph) string {
if fg == nil || fg.Coverage.Status == "" {
return ""
Expand Down Expand Up @@ -1417,7 +1437,8 @@ func handleGetFileContext(ctx context.Context, req *mcp.CallToolRequest, input I
if invalid != nil {
return invalid, nil, nil
}
fg, err := scanner.BuildFileGraph(ctx, absRoot, scanner.ConfiguredFilters(absRoot))
filters := scanner.ConfiguredFilters(absRoot)
fg, err := scanner.BuildFileGraph(ctx, absRoot, filters)
if err != nil {
if cancelled := cancellationResult(ctx, "File context analysis"); cancelled != nil {
return cancelled, nil, nil
Expand All @@ -1437,6 +1458,16 @@ func handleGetFileContext(ctx context.Context, req *mcp.CallToolRequest, input I
var sb strings.Builder
sb.WriteString(fmt.Sprintf("=== File Context: %s ===\n\n", file))

// "leaf file" and "entry point or unused" are claims about a scanned
// file. For a file the graph never scanned they are fiction (#140), so
// check the inventory before either zero gets an explanation.
if len(imports) == 0 && len(importers) == 0 {
if why, unindexed := fg.ExplainUnindexed(file, filters); unindexed {
sb.WriteString(notIndexedText(file, why, fg))
return textResult(sb.String()), nil, nil
}
}

// Hub status
if isHub {
sb.WriteString(fmt.Sprintf("⚠️ HUB FILE - %d files depend on this\n", len(importers)))
Expand Down
Loading
Loading