diff --git a/CHANGELOG.md b/CHANGELOG.md index aea48c5..161d6de 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,21 @@ Versioning: [Semantic Versioning](https://semver.org/spec/v2.0.0.html) ## [Unreleased] +### Changed +- **`ccx search` shows last-activity time, labeled `LAST`.** The old `TIME` column showed session start while `--after`/`--before` filter on end time, so a 7-day session could pass `--after 2026-07-19` yet display `2026-07-15` — reading as a broken filter until you traced the session. The column now shows the timestamp the filter matches and results sort by. (dogfood finding, docs/devlog/2026-08-03-trace-session-dogfood.org) +- **`ccx search --content` now ranks conversation, not boilerplate.** The v0.13.0 raw-line scan counted every transcript line, so injected noise dominated ranking: a Stop-hook line fired every turn put a 327-hit session first while the session where the topic was actually designed ranked #3 (docs/devlog/2026-08-03-content-search-noise.org). The default now parses each candidate session (all providers, sidechains included) and counts only conversation text — user prompts and assistant text/thinking; hook attachments, tool results, command echoes, and meta lines contribute zero. New `--raw` keeps the old behavior verbatim: grep parity over raw lines, no parse, misses nothing grep would find. A cheap raw-line prefilter keeps the default at par with `--raw` speed (only hit files pay the parse); queries whose bytes JSON-escaping could hide (`"`, `\`, non-ASCII) skip the prefilter for correctness. + +### Added +- **`ccx sessions` recovers from near-miss lookups instead of dead-ending.** Sessions are keyed by exact session cwd, so a path one level below the real workspace root returned a bare "No sessions found". Three-part fix: project-name lookup slug-folds both sides (`260715_ccx-session-watch` finds slug `260715-ccx-session-watch` without guessing the mapping); on zero hits, path-like queries walk up parent directories and show the nearest ancestor workspace with a note; if still empty, the closest project slugs are suggested by token overlap. `ccx projects` gains a `PATH` column (table and `--json`) so the slug ↔ directory mapping is visible. +- **`ccx search` explains itself on zero results.** Help now states phrase semantics (multiple words match adjacent and in order, exit 0 either way); zero results print hints — try a single term, try `--content`. Previously a multi-word query silently over-narrowed to nothing with zero guidance. +- **Content results show what matched and where the file lives.** Each `--content` result now carries a role-labeled matched-text snippet (`[user]`/`[assistant]`/`[agent]`) in the table, and `--json` gains `path` (session/content results) plus up to 3 `previews` — noise is distinguishable from signal, and drill-down no longer needs `find` + `grep` outside ccx. + +### Fixed +- **From-source builds stamp a real version.** `make build` defaulted VERSION to `dev`, making every source build indistinguishable when debugging "which ccx am I running". VERSION now defaults to `git describe --tags --dirty --always` (e.g. `v0.13.0-1-g3e3a235-dirty`); releases still override it. +- **One git-root warning per trace, not two.** Every `ccx trace` against an archived workspace printed both `session_git_root_missing` and `git_root_missing` — two lines saying one thing (the cwd is gone). The generic line now fires only when there was no session cwd to blame. +- **`-p/--provider` help lists `gx`** for `sessions`, `search`, and `insight` (grok worked but was undocumented in the flag help). `ccx log` keeps `cc, cx, all`: it truly has no grok source wired yet. +- **`make build` refreshes a stale root `./ccx`.** A bare `go build ./cmd/ccx` drops `./ccx` at the repo root where `.gitignore` hides it; `make build` writes `bin/ccx`, so the root copy silently went stale and shadowed fresh builds ("built unknown"). `make build` now overwrites the root copy when one exists. + ## [0.13.0] - 2026-08-02 ### Added diff --git a/Makefile b/Makefile index 826196a..53729d0 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,9 @@ .PHONY: build build-all build-darwin-arm64 build-darwin-amd64 build-linux-amd64 build-linux-arm64 .PHONY: test clean install lint fmt deps run dev run-projects run-doctor tools skill verify-pricing audit-schema -VERSION ?= dev +# Stamp from-source builds with the real commit so `ccx --version` +# can answer "which ccx am I running"; releases override VERSION. +VERSION ?= $(shell git describe --tags --dirty --always 2>/dev/null || echo dev) BUILD_TIME := $(shell date -u '+%Y-%m-%dT%H:%M:%SZ') LDFLAGS := -ldflags "-X main.version=$(VERSION) -X main.buildTime=$(BUILD_TIME)" GOBIN := $(or $(shell go env GOBIN),$(shell go env GOPATH)/bin) @@ -9,6 +11,10 @@ GOBIN := $(or $(shell go env GOBIN),$(shell go env GOPATH)/bin) # Default: build for current OS/arch build: @go build $(LDFLAGS) -o bin/ccx ./cmd/ccx + @# A bare `go build ./cmd/ccx` drops ./ccx at the repo root and + @# .gitignore hides it; refresh it so a stale copy can't shadow + @# this build. + @if [ -f ccx ]; then cp -f bin/ccx ccx; fi @echo "" @echo " ccx built successfully" @echo " ─────────────────────────────────────" diff --git a/docs/devlog/2026-08-03-content-search-noise.org b/docs/devlog/2026-08-03-content-search-noise.org new file mode 100644 index 0000000..8b3b2f7 --- /dev/null +++ b/docs/devlog/2026-08-03-content-search-noise.org @@ -0,0 +1,81 @@ +* [2026-08-03] Dev Log: Dogfood findings — content search noise :SEARCH: + +** Context +Topic-mining session: find where "deadman" / "auto-handoff" were +actually discussed across the session store, using the fresh +integration build (9bbbab3). The answer was findable, but only by +dropping out of ccx to raw grep — the exact fallback =search +--content= was built to remove. Findings recorded, not fixed. + +** Findings +1. Hit-count ranking is noise-dominated. =search --content deadman= + ranks a session with 327 hits first; classification of the raw + file showed 100% of those hits are Stop-hook boilerplate + ("deadman armed: auto-handoff in 50m..." fired every turn as a + =hook_system_message= attachment). The session where deadman was + actually designed and shipped (ac342f0d, deva-chore) ranked #3. + "deadman" and "auto-handoff" return near-identical result lists + because the same injected sentence contains both terms. A search + that ranks boilerplate above the answer fails its one job. +2. No matched-line preview. Results show hit count + session summary + only; noise is indistinguishable from signal without leaving ccx. + Repro: the 327-hit result and the 13-hit real-discussion result + look the same modulo the number. +3. Results don't carry the session file path — even =--json= (only + type/project/session/summary/time/matches). Drill-down meant + =find ~/.claude/projects -name '*.jsonl'= + =/usr/bin/grep=, + losing all of ccx's provider abstraction. +4. search/view can't cross-verify: search reports 327 hits in + e1a0fa78; =ccx view e1a0fa78= renders zero occurrences (hits live + in hook attachments and raw tool-result lines that view never + renders). No in-ccx way to see what a content hit matched. +5. Stale-binary trap: =make build= writes =bin/ccx=, but a previous + build sits at repo root as =./ccx= and README/help examples use + =./ccx=. First run picked up yesterday's binary ("built + unknown"). One canonical output path, or have =make build= + refresh both. + +** Fix directions (for the FTS follow-up issue) +- The planned FTS index issue (HANDOFF Next #1, plugs in at + internal/cmd/search.go:countContentMatches) should cover relevance, + not just speed: a scan that defaults to user/assistant text and + gates attachments/tool-results/hook lines behind =--raw= would have + put ac342f0d at #1 for this query. +- Matched-line previews (grep =-m 3= style) per result; include + =path= in =--json=. +- A drill-down that stays inside ccx, e.g. =view --grep= or + =search --content --show = printing matching lines with role + labels. + +** Resolution [2026-08-03] +Findings 1-3 and 5 adopted and shipped on feat/search-content-signal; +finding 4 (in-ccx drill-down) and the FTS index stay as follow-up +issues. +- =CHANGE= --content default now parses candidate sessions and counts + only conversation text (KindUserPrompt/KindAssistant text+thinking + blocks; sidechains included, role-labeled =agent=). Hook + attachments, tool results, command echoes, meta: zero. New =--raw= + is the old scan verbatim (implies --content). +- =FEAT= role-labeled matched-text preview per result; =--json= gains + =path= (session+content) and =previews= (max 3). +- =FIX= =make build= refreshes a stale root =./ccx= when present. +- Perf: cheap raw-line prefilter gates the parse; only hit files pay + it. Skipped when the query contains bytes JSON escaping could hide + (=\"=, =\\=, non-ASCII) — a zero-hit raw scan proves nothing there. +- Acceptance rerun on the live store: =search --content deadman= now + ranks ac342f0d #1 (27 hits, preview shows the naming verdict); the + boilerplate session e1a0fa78 (330 raw hits) is absent from default + output and still #1 under =--raw=. Timing at par: 20s signal vs 23s + raw. The ~20s floor is the crawl itself — FTS issue's territory. +- Decision: the "grep parity by design" default (v0.13.0, 428defd) + lost its first real dogfood test — parity moved to =--raw=, + relevance became the default. Recall is preserved, just not silent: + help text says what each mode reads. + +** Notes +- Evidence classification one-liner: + =/usr/bin/grep -o -E '.{60}deadman.{60}' | sort | uniq -c | + sort -rn= — 109x "deadman armed" attachment + 109x its stdout twin + accounted for the top session's count. +- Prior friction round (2026-08-02) is fully closed: PR #32 merged in + v0.13.0. These are new, post-release findings. diff --git a/docs/devlog/2026-08-03-trace-session-dogfood.org b/docs/devlog/2026-08-03-trace-session-dogfood.org new file mode 100644 index 0000000..f9a6eed --- /dev/null +++ b/docs/devlog/2026-08-03-trace-session-dogfood.org @@ -0,0 +1,126 @@ +* [2026-08-03] Dev Log: Dogfood findings — tracing the claudex watch sessions :TRACE:SEARCH: + +** Context +Dogfood task: build latest ccx (worktree @ d01a53a + uncommitted +search --content work) and trace the sessions that added the new +statusline + pace-advisor algorithms to claudex/claude.py. The +answer was found (project 260715-ccx-session-watch, sessions +d8618ef2 / 80d112e0 / 1ee2a82b / 420b622a / af319072 / d22aa3ae), +but the path there had friction. Findings recorded, not fixed. + +** Findings +1. =make build= stamps VERSION=dev. A from-source binary reports + =ccx version dev= — indistinguishable from any other dev build + when debugging "which ccx am I running". Makefile has + =VERSION ?= dev= and never consults git. + Fix direction: default VERSION to =git describe --tags --dirty=. +2. =ccx sessions = dead-ends on subdirectories. Sessions are + keyed by exact session cwd, so a path one level below the real + workspace root returns a bare "No sessions found" — no hint, no + parent-walk, no nearest-slug suggestion. Recovery required + =ccx projects= plus guessing that + =WIP/260715_ccx-session-watch= maps to slug + =260715-ccx-session-watch=. =ccx projects= shows slugs only, no + path column, so the mapping is opaque. + Fix direction: walk up parent dirs; on zero hits suggest closest + project slugs; add path to =projects= output. +3. Multi-word query is a literal substring match, sold as search. + =ccx search "claude.py statusline"= → 0 results while each term + alone hits. Help's example comment says "Multi-word search", + implying term-level matching that doesn't exist. Zero-result + output gives no guidance (split terms? try --content?). + Fix direction: document phrase semantics, or AND the terms; + print a hint on 0 results. +4. Date filter and TIME column disagree. =search --content + claude.py --after 2026-07-19= returns d8618ef2 displayed as + 2026-07-15. The session is a 7-day container (started 07-15, + ended 07-22): the filter passes on end/activity time, the column + shows start time. Reads as a broken filter until you trace the + session. + Fix direction: display the timestamp the filter matched on, or + label the column. +5. Warning noise repeats per invocation. Every command against an + archived workspace prints both =session_git_root_missing= and + =git_root_missing= — two lines saying one thing (cwd gone), + every time. Expected state for archived projects, not a warning + worth two lines each run. + Fix direction: collapse to one line. +6. =trace= has no time or turn-range slicing. d8618ef2 spans 7 days, + 24 turns, 205 steps; finding "what happened Jul 21" meant piping + the outline through =sed -n '60,130p'=. =log= slices by time but + =trace= can't; =--turn N= is single-turn only. + Fix direction: =--turns N-M= and/or =--since/--until= on trace. +7. =-p/--provider= help says "cc, cx, all" — gx is supported (works, + documented in the skill) but missing from the flag help. + +** Addendum [2026-08-03, second run of the same task] +Task re-run end-to-end on a fresh build. Findings 2, 3, and 5 +reproduced verbatim (claudex subdir → bare "No sessions found"; +="statusline claude.py"= → 0 hits while each term alone hits; +double git-root warning on every command against the archived +workspace). Worked around finding 1 by stamping the version via +ldflags (=dev-d01a53a-dirty=) — Makefile still doesn't do this. +New findings: +8. =trace = and =trace --turn N= disagree on audience. + The outline is a human-readable terminal view; the only + drill-down from it is =--turn N=, which emits the raw + ccx.turn.v1 JSON bundle where per-edit mutation records + (message_id, tool_id, timestamps) bury the turn's user text and + final response. There is no human-shaped middle layer between + "one narration line per step" and "full JSON evidence" — the + natural next question after reading an outline is "show me turn + N like the outline, but complete". + Fix direction: make =--turn N= render human-readable by default + (user text, step narrations, final text, files edited); + keep the JSON bundle behind =--json=, consistent with how the + outline itself already treats =--json=. +9. =search= exits 1 on zero results ("No results found."). Grep + parity, but it is undocumented in --help and combines badly + with finding 3: a phrase query silently over-narrowed to zero + now also fails the invocation, so scripted report pipelines + under =set -e= die on what is really a query-semantics problem. + Fix direction: document the exit contract; pair with the + 0-result hint from finding 3. + +** Resolution [2026-08-03] +Adopted and fixed in fix/dogfood-friction-20260803: +1. Makefile defaults VERSION to =git describe --tags --dirty + --always= (falls back to =dev= outside a checkout); releases + still override VERSION. +2. Three-part fix: (a) =projects= grew a PATH column (table and + =--json=), so the slug ↔ directory mapping is visible; (b) + project-name lookup slug-folds both sides (every + non-alphanumeric run → =-=, lowercased), so + =260715_ccx-session-watch= finds =260715-ccx-session-watch= + without guessing; (c) on zero hits, =sessions= walks up parent + directories (sessions are keyed by exact cwd) and shows the + nearest ancestor workspace with a note; if still empty it + suggests the closest project slugs by token overlap. +3. Help now states phrase semantics ("adjacent and in order"); + zero results print hints (single term, =--content=). +5. The generic =git_root_missing= warning is emitted only when + there is no session-cwd warning already saying the same thing: + one line per run, not two. +7. =-p= help says =cc, cx, gx, all= for =sessions=, =search=, + =insight=. NOT for =log=: =ccx log= genuinely has no grok + source wired (=validLogProvider=, =logSources=) — advertising + =gx= there would be a lie. Wiring grok into =log= is a real + feature, left open. +4. =search= results now display last-activity time (header =LAST=) + — the same timestamp =--after=/=--before= filter on and results + sort by, so a filtered row can no longer display a date outside + the requested window. (=sessions= already labels its column + STARTED; its filter still matches on end time, judged tolerable + because the label is truthful.) + +Corrected: +9. Does not reproduce: =search= exits 0 on zero results, both at + v0.13.0 and in this tree (verified empirically). The exit-1 + claim likely came from a =| grep= in the repro pipeline. The + documentation half was adopted anyway: --help now states the + exit contract. + +Deferred (follow-up issues, with FTS index + sidechain nesting): +6. =trace= turn-range/time slicing (=--turns N-M=, =--since/--until=). +8. Human-readable =trace --turn N= (outline-shaped single-turn + view; JSON stays behind =--json=). diff --git a/internal/catalog/session_query.go b/internal/catalog/session_query.go index 70bc37d..6bbae36 100644 --- a/internal/catalog/session_query.go +++ b/internal/catalog/session_query.go @@ -2,6 +2,7 @@ package catalog import ( "fmt" + "regexp" "sort" "strings" @@ -148,9 +149,38 @@ func ProjectMatchesName(project *parser.Project, name string) bool { } } } - return strings.Contains(nameLower, query) || strings.Contains(pathLower, query) + if strings.Contains(nameLower, query) || strings.Contains(pathLower, query) { + return true + } + // Slug-normalized fallback: claude-code encodes every + // non-alphanumeric as '-', so a directory named + // "260715_ccx-session-watch" must find the project slug + // "260715-ccx-session-watch" without the user guessing the + // mapping. + slugQuery := SlugifyProjectQuery(name) + if slugQuery == "" { + return false + } + return strings.Contains(SlugifyProjectQuery(project.Name), slugQuery) || + strings.Contains(SlugifyProjectQuery(project.Path), slugQuery) } +// SlugifyProjectQuery folds a name or path onto claude-code's project +// slug alphabet (every non-alphanumeric run becomes '-'), lowercased, +// so lookups survive the _ vs - vs / differences between directory +// names and encoded project slugs. Returns "" when nothing +// alphanumeric remains. +func SlugifyProjectQuery(s string) string { + slug := nonAlnumRun.ReplaceAllString(strings.ToLower(strings.TrimSpace(s)), "-") + slug = strings.Trim(slug, "-") + if slug == "" { + return "" + } + return slug +} + +var nonAlnumRun = regexp.MustCompile(`[^a-z0-9]+`) + func ProjectMatchesWorkspace(project *parser.Project, workspacePath string) bool { if project == nil { return false diff --git a/internal/catalog/slug_test.go b/internal/catalog/slug_test.go new file mode 100644 index 0000000..5979e1b --- /dev/null +++ b/internal/catalog/slug_test.go @@ -0,0 +1,38 @@ +package catalog + +import ( + "testing" + + "github.com/thevibeworks/ccx/internal/parser" +) + +func TestProjectMatchesNameSlugNormalized(t *testing.T) { + project := &parser.Project{ + Name: "260715-ccx-session-watch", + Path: "/wrk/WIP/260715_ccx-session-watch", + } + if !ProjectMatchesName(project, "260715_ccx-session-watch") { + t.Fatal("underscore directory name should match dash slug") + } + if !ProjectMatchesName(project, "WIP/260715-ccx-session-watch") { + t.Fatal("dash query should match underscore path via slug fold") + } + if ProjectMatchesName(project, "totally-different") { + t.Fatal("unrelated query must not match") + } +} + +func TestSlugifyProjectQuery(t *testing.T) { + cases := map[string]string{ + "260715_ccx-session-watch": "260715-ccx-session-watch", + "/Wrk/WIP/Some_Project": "wrk-wip-some-project", + " spaced out ": "spaced-out", + "///": "", + "": "", + } + for in, want := range cases { + if got := SlugifyProjectQuery(in); got != want { + t.Fatalf("SlugifyProjectQuery(%q) = %q, want %q", in, got, want) + } + } +} diff --git a/internal/cmd/insight.go b/internal/cmd/insight.go index 50f2281..d32eef5 100644 --- a/internal/cmd/insight.go +++ b/internal/cmd/insight.go @@ -49,7 +49,7 @@ func init() { insightCmd.Flags().StringVar(&insightUntil, "until", "", "end date (YYYY-MM-DD)") insightCmd.Flags().BoolVar(&insightJSON, "json", false, "output aggregated data as JSON (for LLM skill)") insightCmd.Flags().BoolVar(&insightAll, "all", false, "across all projects") - insightCmd.Flags().StringVarP(&insightProvider, "provider", "p", "", "filter by provider: cc, cx, all") + insightCmd.Flags().StringVarP(&insightProvider, "provider", "p", "", "filter by provider: cc, cx, gx, all") insightCmd.Flags().StringVarP(&insightOutput, "output", "o", "", "output file path (default: insights dir)") } diff --git a/internal/cmd/projects.go b/internal/cmd/projects.go index c66bb62..2f08306 100644 --- a/internal/cmd/projects.go +++ b/internal/cmd/projects.go @@ -62,19 +62,29 @@ func runProjects(cmd *cobra.Command, args []string) error { func printProjectsTable(projects []*parser.Project) error { w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) - fmt.Fprintln(w, "PROJECT\tSESSIONS\tLAST MODIFIED") + fmt.Fprintln(w, "PROJECT\tSESSIONS\tLAST MODIFIED\tPATH") for _, p := range projects { age := formatAge(p.LastModified) - fmt.Fprintf(w, "%s\t%d\t%s\n", p.Name, len(p.Sessions), age) + fmt.Fprintf(w, "%s\t%d\t%s\t%s\n", p.Name, len(p.Sessions), age, projectPath(p)) } return w.Flush() } +// projectPath resolves the workspace path a project's slug encodes; +// without it the slug ↔ directory mapping is a guessing game. +func projectPath(p *parser.Project) string { + if p.Path != "" { + return p.Path + } + return parser.DecodePath(p.EncodedName) +} + type projectJSON struct { Name string `json:"name"` EncodedName string `json:"encoded_name"` + Path string `json:"path,omitempty"` Sessions int `json:"sessions"` LastModified string `json:"last_modified"` } @@ -85,6 +95,7 @@ func printProjectsJSON(projects []*parser.Project) error { items[i] = projectJSON{ Name: p.Name, EncodedName: p.EncodedName, + Path: projectPath(p), Sessions: len(p.Sessions), LastModified: p.LastModified.Format(time.RFC3339), } diff --git a/internal/cmd/search.go b/internal/cmd/search.go index 8ee0412..7966a8b 100644 --- a/internal/cmd/search.go +++ b/internal/cmd/search.go @@ -10,6 +10,7 @@ import ( "sort" "strings" "text/tabwriter" + "unicode/utf8" "github.com/spf13/cobra" @@ -23,16 +24,26 @@ var searchCmd = &cobra.Command{ Short: "Search across projects and sessions", Long: `Search for projects and sessions by name or summary. -With --content, also scan transcript lines inside session files -(including subagent files) — grep parity, but with session identity, -provider abstraction, and date filters. +The query is one case-insensitive phrase: multiple words must appear +adjacent and in order ("fix bug" won't match "bug ... fix"). For +term-level matching, run one search per term. Exits 0 either way; +zero matches just prints "No results found." + +With --content, also scan conversation text inside session files +(including subagent files): user prompts and assistant replies, +ranked by hit count with a matched-text preview. Injected noise — +tool results, hook attachments, command echoes — doesn't count. + +Add --raw to match every raw transcript line instead: grep parity, +no parse, misses nothing grep would find. Examples: - ccx search auth # Find sessions about authentication - ccx search myproject # Find project by name - ccx search "fix bug" # Multi-word search - ccx search -t session # Only search sessions - ccx search --content goose # Scan message content (slower)`, + ccx search auth # Find sessions about authentication + ccx search myproject # Find project by name + ccx search "fix bug" # Phrase match: adjacent words, in order + ccx search -t session # Only search sessions + ccx search --content goose # Scan conversation text (slower) + ccx search --raw goose # Grep parity over raw lines`, Args: cobra.MinimumNArgs(1), RunE: runSearch, } @@ -46,35 +57,52 @@ var ( searchBefore string searchModel string searchContent bool + searchRaw bool ) func init() { searchCmd.Flags().StringVarP(&searchType, "type", "t", "", "filter by type: project, session") searchCmd.Flags().IntVarP(&searchLimit, "limit", "n", 20, "max results") searchCmd.Flags().BoolVar(&searchJSON, "json", false, "output as JSON") - searchCmd.Flags().StringVarP(&searchProvider, "provider", "p", "", "filter by provider: cc, cx, all") + searchCmd.Flags().StringVarP(&searchProvider, "provider", "p", "", "filter by provider: cc, cx, gx, all") searchCmd.Flags().StringVar(&searchAfter, "after", "", "sessions after date (YYYY-MM-DD)") searchCmd.Flags().StringVar(&searchBefore, "before", "", "sessions before date (YYYY-MM-DD)") searchCmd.Flags().StringVar(&searchModel, "model", "", "filter by model name substring") - searchCmd.Flags().BoolVar(&searchContent, "content", false, "also scan message content in session files (slower)") + searchCmd.Flags().BoolVar(&searchContent, "content", false, "also scan conversation text in session files (slower)") + searchCmd.Flags().BoolVar(&searchRaw, "raw", false, "content scan matches every raw transcript line (grep parity; implies --content)") rootCmd.AddCommand(searchCmd) } type searchResult struct { - Type string `json:"type"` - Project string `json:"project"` - Session string `json:"session,omitempty"` - Summary string `json:"summary"` - Time string `json:"time,omitempty"` - Matches int `json:"matches,omitempty"` - Priority int `json:"-"` + Type string `json:"type"` + Project string `json:"project"` + Session string `json:"session,omitempty"` + Path string `json:"path,omitempty"` + Summary string `json:"summary"` + Time string `json:"time,omitempty"` + Matches int `json:"matches,omitempty"` + Previews []contentPreview `json:"previews,omitempty"` + Priority int `json:"-"` +} + +// contentPreview is one matched conversation snippet, role-labeled so +// noise is distinguishable from signal without leaving ccx. +type contentPreview struct { + Role string `json:"role"` // "user" | "assistant" | "agent" + Text string `json:"text"` } +const maxContentPreviews = 3 + func runSearch(cmd *cobra.Command, args []string) error { query := strings.ToLower(strings.Join(args, " ")) backend := provider.Default() + if searchRaw { + searchContent = true + } + after, err := config.ParseDate(searchAfter) if err != nil { return fmt.Errorf("invalid --after date: %w", err) @@ -152,8 +180,9 @@ func runSearch(cmd *cobra.Command, args []string) error { Type: "session", Project: projDisplay, Session: truncateID(s.ID, 8), + Path: s.FilePath, Summary: sessionSummaryPreview(s.Summary, 64), - Time: formatAge(s.StartTime), + Time: formatAge(s.EndTime), Priority: 0, }) continue @@ -165,28 +194,63 @@ func runSearch(cmd *cobra.Command, args []string) error { Type: "session", Project: projDisplay, Session: truncateID(s.ID, 8), + Path: s.FilePath, Summary: sessionSummaryPreview(s.Summary, 64), - Time: formatAge(s.StartTime), + Time: formatAge(s.EndTime), Priority: 2, }) continue } - // Content scan: raw transcript lines, main file plus subagent - // files. Grep parity by design — no parse, so it works for - // every provider's format and misses nothing grep would find. + // Content scan. The default counts conversation text only — + // user prompts and assistant replies — so ranking follows + // discussion, not injected boilerplate (a hook line fired + // every turn once outranked the real answer 327 hits to 13; + // docs/devlog/2026-08-03-content-search-noise.org). --raw + // keeps grep parity over raw transcript lines, main file + // plus subagent files: no parse, works for every provider's + // format, misses nothing grep would find. if searchContent { - if n := countContentMatches(s.FilePath, query); n > 0 { - results = append(results, searchResult{ - Type: "content", - Project: projDisplay, - Session: truncateID(s.ID, 8), - Summary: fmt.Sprintf("%d hits · %s", n, sessionSummaryPreview(s.Summary, 48)), - Time: formatAge(s.StartTime), - Matches: n, - Priority: 3, - }) + if searchRaw { + if n := countContentMatches(s.FilePath, query); n > 0 { + results = append(results, searchResult{ + Type: "content", + Project: projDisplay, + Session: truncateID(s.ID, 8), + Path: s.FilePath, + Summary: fmt.Sprintf("%d hits · %s", n, sessionSummaryPreview(s.Summary, 48)), + Time: formatAge(s.EndTime), + Matches: n, + Priority: 3, + }) + } + continue + } + + // Cheap line-scan prefilter before the full parse; only + // trustworthy when JSON escaping can't hide the query. + if rawPrefilterSafe(query) && countContentMatches(s.FilePath, query) == 0 { + continue } + n, previews := scanConversationText(backend, s.FilePath, query) + if n == 0 { + continue + } + summary := fmt.Sprintf("%d hits · %s", n, sessionSummaryPreview(s.Summary, 48)) + if len(previews) > 0 { + summary = fmt.Sprintf("%d hits · [%s] %s", n, previews[0].Role, truncateDisplay(previews[0].Text, 56)) + } + results = append(results, searchResult{ + Type: "content", + Project: projDisplay, + Session: truncateID(s.ID, 8), + Path: s.FilePath, + Summary: summary, + Time: formatAge(s.EndTime), + Matches: n, + Previews: previews, + Priority: 3, + }) } } } @@ -232,6 +296,12 @@ func runSearch(cmd *cobra.Command, args []string) error { if len(results) == 0 { fmt.Println("No results found.") + if strings.Contains(query, " ") { + fmt.Fprintln(os.Stderr, "hint: multi-word queries match as one exact phrase; try a single term") + } + if !searchContent { + fmt.Fprintln(os.Stderr, "hint: --content scans conversation text inside sessions") + } return nil } @@ -246,7 +316,10 @@ func runSearch(cmd *cobra.Command, args []string) error { func printSearchResults(results []searchResult) error { w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) - fmt.Fprintln(w, "TYPE\tPROJECT\tSESSION\tSUMMARY\tTIME") + // LAST = last activity (session end time): the same timestamp + // --after/--before filter on and results sort by, so a filtered + // row never displays a date outside the requested window. + fmt.Fprintln(w, "TYPE\tPROJECT\tSESSION\tSUMMARY\tLAST") for _, r := range results { session := r.Session @@ -304,6 +377,109 @@ func truncateID(id string, max int) string { return id[:max] } +// sessionParser is the slice of provider.Backend the conversation +// scan needs; narrowed so tests can stub it. +type sessionParser interface { + ParseSession(filePath string) (*parser.Session, error) +} + +// scanConversationText parses one session (the parser loads sidechain +// files too) and searches only conversation text: text and thinking +// blocks of user prompts and assistant messages. Tool results, hook +// attachments, command echoes, and meta lines never count — that's +// what --raw is for. Returns total occurrences plus up to +// maxContentPreviews role-labeled snippets around the earliest +// matches. query must already be lowercase. +func scanConversationText(p sessionParser, path, query string) (int, []contentPreview) { + sess, err := p.ParseSession(path) + if err != nil { + fmt.Fprintf(os.Stderr, "warning: skipping unparseable %s: %v\n", filepath.Base(path), err) + return 0, nil + } + + count := 0 + var previews []contentPreview + var walk func(msgs []*parser.Message) + walk = func(msgs []*parser.Message) { + for _, m := range msgs { + if m.Kind == parser.KindUserPrompt || m.Kind == parser.KindAssistant { + role := m.Type + if m.IsSidechain { + role = "agent" + } + for _, b := range m.Content { + if b.Type != "text" && b.Type != "thinking" { + continue + } + lower := strings.ToLower(b.Text) + n := strings.Count(lower, query) + if n == 0 { + continue + } + count += n + if len(previews) < maxContentPreviews { + previews = append(previews, contentPreview{ + Role: role, + Text: matchSnippet(b.Text, strings.Index(lower, query), len(query)), + }) + } + } + } + walk(m.Children) + } + } + walk(sess.RootMessages) + return count, previews +} + +// matchSnippet cuts a display window around a match, clamped to rune +// boundaries. idx indexes the lowered copy of text; byte positions can +// drift on the rare rune whose lowercase form changes width, so bounds +// are clamped rather than trusted. +func matchSnippet(text string, idx, qlen int) string { + if idx < 0 { + idx = 0 + } + if idx > len(text) { + idx = len(text) + } + start := idx - 32 + if start < 0 { + start = 0 + } + end := idx + qlen + 56 + if end > len(text) { + end = len(text) + } + for start > 0 && !utf8.RuneStart(text[start]) { + start-- + } + for end < len(text) && !utf8.RuneStart(text[end]) { + end++ + } + out := cleanDisplayText(text[start:end]) + if start > 0 { + out = "..." + out + } + if end < len(text) { + out += "..." + } + return out +} + +// rawPrefilterSafe reports whether a zero-hit raw line scan proves a +// zero-hit conversation scan. JSON writers escape `"`, `\`, control +// chars, and sometimes non-ASCII, so those queries must skip the +// cheap prefilter and parse every candidate session instead. +func rawPrefilterSafe(query string) bool { + for i := 0; i < len(query); i++ { + if query[i] < 0x20 || query[i] > 0x7e || query[i] == '"' || query[i] == '\\' { + return false + } + } + return true +} + // countContentMatches counts transcript lines containing query across // the main session file and any subagent files beside it (layout // knowledge lives in parser.SubagentFiles; providers without subagent diff --git a/internal/cmd/search_test.go b/internal/cmd/search_test.go index 3ffaee0..90324bc 100644 --- a/internal/cmd/search_test.go +++ b/internal/cmd/search_test.go @@ -5,6 +5,8 @@ import ( "path/filepath" "strings" "testing" + + "github.com/thevibeworks/ccx/internal/parser" ) func TestCountMatchingLines(t *testing.T) { @@ -64,6 +66,119 @@ func TestCountContentMatchesIncludesSubagents(t *testing.T) { } } +type stubSessionParser struct{} + +func (stubSessionParser) ParseSession(path string) (*parser.Session, error) { + return parser.ParseSession(path) +} + +// scanConversationText must count only what a human reads in the +// conversation. Hook attachments (isMeta) and tool-result lines are +// exactly the boilerplate that once outranked the real discussion +// 327 hits to 13 — they must contribute zero. +func TestScanConversationTextSignalOnly(t *testing.T) { + dir := t.TempDir() + main := filepath.Join(dir, "abc-123.jsonl") + lines := strings.Join([]string{ + `{"type":"user","uuid":"u1","timestamp":"2026-08-03T00:00:00Z","message":{"role":"user","content":[{"type":"text","text":"let's design the deadman auto-handoff timer"}]}}`, + `{"type":"assistant","uuid":"a1","parentUuid":"u1","timestamp":"2026-08-03T00:00:01Z","message":{"role":"assistant","content":[{"type":"text","text":"deadman fires after idle; the deadman then writes the handoff"}]}}`, + `{"type":"user","uuid":"m1","parentUuid":"a1","isMeta":true,"timestamp":"2026-08-03T00:00:02Z","message":{"role":"user","content":[{"type":"text","text":"deadman armed: auto-handoff in 50m"}]}}`, + `{"type":"user","uuid":"t1","parentUuid":"m1","timestamp":"2026-08-03T00:00:03Z","message":{"role":"user","content":[{"type":"tool_result","content":"deadman armed: auto-handoff stdout twin"}]}}`, + `{"type":"assistant","uuid":"a2","parentUuid":"t1","timestamp":"2026-08-03T00:00:04Z","message":{"role":"assistant","content":[{"type":"thinking","thinking":"the deadman design needs a disarm path"}]}}`, + }, "\n") + "\n" + if err := os.WriteFile(main, []byte(lines), 0o644); err != nil { + t.Fatal(err) + } + subDir := filepath.Join(dir, "abc-123", "subagents") + if err := os.MkdirAll(subDir, 0o755); err != nil { + t.Fatal(err) + } + side := `{"type":"assistant","uuid":"s1","isSidechain":true,"timestamp":"2026-08-03T00:00:05Z","message":{"role":"assistant","content":[{"type":"text","text":"deadman in sidechain"}]}}` + "\n" + if err := os.WriteFile(filepath.Join(subDir, "agent-1.jsonl"), []byte(side), 0o644); err != nil { + t.Fatal(err) + } + + // 6 raw lines match; only 5 occurrences live in conversation text + // (1 user + 2 assistant + 1 thinking + 1 sidechain). + if raw := countContentMatches(main, "deadman"); raw != 6 { + t.Fatalf("raw line matches: got %d, want 6", raw) + } + n, previews := scanConversationText(stubSessionParser{}, main, "deadman") + if n != 5 { + t.Fatalf("signal matches: got %d, want 5 (noise counted?)", n) + } + if len(previews) != maxContentPreviews { + t.Fatalf("previews: got %d, want %d", len(previews), maxContentPreviews) + } + if previews[0].Role != "user" || !strings.Contains(previews[0].Text, "deadman") { + t.Fatalf("first preview should be the user prompt, got [%s] %q", previews[0].Role, previews[0].Text) + } + if previews[1].Role != "assistant" { + t.Fatalf("second preview role: got %q, want assistant", previews[1].Role) + } + + if n, _ := scanConversationText(stubSessionParser{}, main, "auto-handoff"); n != 1 { + t.Fatalf("auto-handoff signal matches: got %d, want 1 (hook noise counted?)", n) + } +} + +func TestScanConversationTextSidechainRole(t *testing.T) { + dir := t.TempDir() + main := filepath.Join(dir, "abc-123.jsonl") + line := `{"type":"user","uuid":"u1","timestamp":"2026-08-03T00:00:00Z","message":{"role":"user","content":[{"type":"text","text":"kick off"}]}}` + "\n" + if err := os.WriteFile(main, []byte(line), 0o644); err != nil { + t.Fatal(err) + } + subDir := filepath.Join(dir, "abc-123", "subagents") + if err := os.MkdirAll(subDir, 0o755); err != nil { + t.Fatal(err) + } + side := `{"type":"assistant","uuid":"s1","isSidechain":true,"timestamp":"2026-08-03T00:00:01Z","message":{"role":"assistant","content":[{"type":"text","text":"goose only lives here"}]}}` + "\n" + if err := os.WriteFile(filepath.Join(subDir, "agent-1.jsonl"), []byte(side), 0o644); err != nil { + t.Fatal(err) + } + + n, previews := scanConversationText(stubSessionParser{}, main, "goose") + if n != 1 || len(previews) != 1 { + t.Fatalf("sidechain match: got n=%d previews=%d, want 1/1", n, len(previews)) + } + if previews[0].Role != "agent" { + t.Fatalf("sidechain preview role: got %q, want agent", previews[0].Role) + } +} + +func TestMatchSnippet(t *testing.T) { + long := strings.Repeat("a", 100) + " needle " + strings.Repeat("b", 100) + got := matchSnippet(long, 101, len("needle")) + if !strings.Contains(got, "needle") { + t.Fatalf("snippet must contain the match, got %q", got) + } + if !strings.HasPrefix(got, "...") || !strings.HasSuffix(got, "...") { + t.Fatalf("mid-text snippet should be marked truncated on both ends, got %q", got) + } + if got := matchSnippet("short text", 0, 5); got != "short text" { + t.Fatalf("untruncated snippet: got %q", got) + } + // Out-of-range indexes must clamp, not panic. + _ = matchSnippet("tiny", 999, 4) +} + +func TestRawPrefilterSafe(t *testing.T) { + cases := map[string]bool{ + "deadman": true, + "fix bug": true, + `say "hi"`: false, + `back\slash`: false, + "路径": false, + "tab\there": false, + } + for q, want := range cases { + if got := rawPrefilterSafe(q); got != want { + t.Errorf("rawPrefilterSafe(%q) = %v, want %v", q, got, want) + } + } +} + // Grep parity must survive lines larger than any fixed scanner budget // (transcript lines with embedded images run past 10MB). func TestCountMatchingLinesOversizedLine(t *testing.T) { diff --git a/internal/cmd/sessions.go b/internal/cmd/sessions.go index afae648..f5a3e8d 100644 --- a/internal/cmd/sessions.go +++ b/internal/cmd/sessions.go @@ -4,6 +4,9 @@ import ( "encoding/json" "fmt" "os" + "path/filepath" + "sort" + "strings" "text/tabwriter" "time" @@ -49,7 +52,7 @@ func init() { sessionsCmd.Flags().StringVar(&sessionsSort, "sort", "time", "sort by: time, messages, prompts") sessionsCmd.Flags().IntVar(&sessionsLimit, "limit", 20, "limit number of sessions (0 = no limit)") sessionsCmd.Flags().BoolVar(&sessionsJSON, "json", false, "output as JSON") - sessionsCmd.Flags().StringVarP(&sessionsProvider, "provider", "p", "", "filter by provider: cc, cx, all") + sessionsCmd.Flags().StringVarP(&sessionsProvider, "provider", "p", "", "filter by provider: cc, cx, gx, all") sessionsCmd.Flags().StringVarP(&sessionsSearch, "search", "s", "", "search in session summaries") sessionsCmd.Flags().StringVar(&sessionsAfter, "after", "", "sessions after date (YYYY-MM-DD)") sessionsCmd.Flags().StringVar(&sessionsBefore, "before", "", "sessions before date (YYYY-MM-DD)") @@ -133,7 +136,25 @@ func runSessions(cmd *cobra.Command, args []string) error { sessions = matched } if len(sessions) == 0 { - fmt.Println("No sessions found.") + // Sessions are keyed by exact session cwd, so a query one + // level below the real workspace root would dead-end here. + // Walk up parent directories before giving up. + start := walkStartPath(query, projectName) + if start != "" { + if recovered, root := sessionsFromParents(backend, query, start); len(recovered) > 0 { + fmt.Fprintf(os.Stderr, "note: no sessions keyed at %s; showing workspace %s\n", start, root) + sessions = recovered + } + } + } + if len(sessions) == 0 { + if projectName != "" { + fmt.Printf("No sessions found for %q.\n", projectName) + suggestClosestProjects(backend, projectName) + } else { + fmt.Println("No sessions found.") + } + fmt.Fprintln(os.Stderr, "hint: `ccx projects` lists project slugs and paths; `ccx sessions --all` spans all projects") return nil } @@ -144,6 +165,109 @@ func runSessions(cmd *cobra.Command, args []string) error { return printSessionsTable(sessions, projectName == "" && sessionsAll) } +// walkStartPath decides where a parent-directory walk starts after a +// zero-hit query: the workspace path for a bare invocation, or a +// path-like project argument (absolute, contains a separator, or an +// existing local directory). A plain project slug never walks. +func walkStartPath(query catalog.SessionQuery, projectName string) string { + if projectName == "" { + if query.Scope == catalog.ScopeWorkspace { + return query.WorkspacePath + } + return "" + } + pathLike := filepath.IsAbs(projectName) || strings.ContainsRune(projectName, filepath.Separator) + if !pathLike { + info, err := os.Stat(projectName) + if err != nil || !info.IsDir() { + return "" + } + } + abs, err := filepath.Abs(projectName) + if err != nil { + return "" + } + return abs +} + +// sessionLister is the slice of provider.Backend the zero-hit +// recovery needs; narrowed so tests can stub it. +type sessionLister interface { + ListSessions(query catalog.SessionQuery) ([]*parser.Session, error) +} + +// projectDiscoverer is the slice of provider.Backend the slug +// suggestions need; narrowed so tests can stub it. +type projectDiscoverer interface { + DiscoverProjects() ([]*parser.Project, error) +} + +// sessionsFromParents retries the session query at each ancestor of +// start, nearest first, until the filesystem root. Returns the +// sessions plus the ancestor that matched. +func sessionsFromParents(backend sessionLister, base catalog.SessionQuery, start string) ([]*parser.Session, string) { + for dir := filepath.Dir(filepath.Clean(start)); ; dir = filepath.Dir(dir) { + q := base + q.Scope = catalog.ScopeWorkspace + q.ProjectName = "" + q.WorkspacePath = dir + sessions, err := backend.ListSessions(q) + if err == nil && len(sessions) > 0 { + return sessions, dir + } + if dir == filepath.Dir(dir) { + return nil, "" + } + } +} + +// suggestClosestProjects prints up to three project slugs sharing a +// token with the query, so a near-miss doesn't dead-end in a guessing +// game against `ccx projects`. +func suggestClosestProjects(backend projectDiscoverer, name string) { + projects, err := backend.DiscoverProjects() + if err != nil { + return + } + base := catalog.SlugifyProjectQuery(filepath.Base(name)) + if base == "" { + return + } + tokens := strings.Split(base, "-") + type scored struct { + name string + score int + } + var candidates []scored + for _, p := range projects { + if p == nil { + continue + } + slug := catalog.SlugifyProjectQuery(p.Name) + score := 0 + for _, tok := range tokens { + if len(tok) >= 3 && strings.Contains(slug, tok) { + score++ + } + } + if score > 0 { + candidates = append(candidates, scored{name: p.Name, score: score}) + } + } + if len(candidates) == 0 { + return + } + sort.SliceStable(candidates, func(i, j int) bool { return candidates[i].score > candidates[j].score }) + if len(candidates) > 3 { + candidates = candidates[:3] + } + names := make([]string, len(candidates)) + for i, c := range candidates { + names[i] = c.name + } + fmt.Fprintf(os.Stderr, "closest projects: %s\n", strings.Join(names, ", ")) +} + func providerTag(p string) string { switch p { case "claude-code": diff --git a/internal/cmd/sessions_recovery_test.go b/internal/cmd/sessions_recovery_test.go new file mode 100644 index 0000000..b47c10f --- /dev/null +++ b/internal/cmd/sessions_recovery_test.go @@ -0,0 +1,126 @@ +package cmd + +import ( + "bytes" + "io" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/thevibeworks/ccx/internal/catalog" + "github.com/thevibeworks/ccx/internal/parser" +) + +func captureStderr(t *testing.T, fn func()) string { + t.Helper() + old := os.Stderr + r, w, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + os.Stderr = w + t.Cleanup(func() { os.Stderr = old }) + + fn() + if err := w.Close(); err != nil { + t.Fatal(err) + } + var buf bytes.Buffer + if _, err := io.Copy(&buf, r); err != nil { + t.Fatal(err) + } + return buf.String() +} + +type stubSessionLister struct { + byPath map[string][]*parser.Session + seen []catalog.SessionQuery +} + +func (s *stubSessionLister) ListSessions(q catalog.SessionQuery) ([]*parser.Session, error) { + s.seen = append(s.seen, q) + return s.byPath[q.WorkspacePath], nil +} + +func TestSessionsFromParentsFindsAncestorWorkspace(t *testing.T) { + root := filepath.Join("/", "wrk", "proj") + lister := &stubSessionLister{byPath: map[string][]*parser.Session{ + root: {{ID: "s1"}}, + }} + + sessions, matched := sessionsFromParents(lister, catalog.SessionQuery{}, filepath.Join(root, "sub", "deeper")) + if len(sessions) != 1 || sessions[0].ID != "s1" { + t.Fatalf("sessions = %v, want the ancestor workspace session", sessions) + } + if matched != root { + t.Fatalf("matched = %q, want %q", matched, root) + } + for _, q := range lister.seen { + if q.Scope != catalog.ScopeWorkspace { + t.Fatalf("walk queried scope %q, want workspace", q.Scope) + } + } +} + +func TestSessionsFromParentsGivesUpAtRoot(t *testing.T) { + lister := &stubSessionLister{} + sessions, matched := sessionsFromParents(lister, catalog.SessionQuery{}, "/nowhere/at/all") + if sessions != nil || matched != "" { + t.Fatalf("got %v %q, want no recovery", sessions, matched) + } + if len(lister.seen) == 0 { + t.Fatal("walk never queried any ancestor") + } +} + +func TestWalkStartPath(t *testing.T) { + wsQuery := catalog.SessionQuery{Scope: catalog.ScopeWorkspace, WorkspacePath: "/wrk/proj/sub"} + if got := walkStartPath(wsQuery, ""); got != "/wrk/proj/sub" { + t.Fatalf("bare workspace invocation: got %q", got) + } + if got := walkStartPath(catalog.SessionQuery{Scope: catalog.ScopeProject}, "my-slug"); got != "" { + t.Fatalf("plain slug must not walk: got %q", got) + } + if got := walkStartPath(catalog.SessionQuery{Scope: catalog.ScopeProject}, "/abs/path/sub"); got != "/abs/path/sub" { + t.Fatalf("absolute path arg: got %q", got) + } + dir := t.TempDir() + t.Chdir(filepath.Dir(dir)) + rel := filepath.Base(dir) + got := walkStartPath(catalog.SessionQuery{Scope: catalog.ScopeProject}, rel) + if got == "" || !filepath.IsAbs(got) || filepath.Base(got) != rel { + t.Fatalf("existing relative dir should walk from its abs path: got %q", got) + } +} + +type stubProjectDiscoverer struct { + projects []*parser.Project +} + +func (s *stubProjectDiscoverer) DiscoverProjects() ([]*parser.Project, error) { + return s.projects, nil +} + +func TestSuggestClosestProjectsRanksTokenOverlap(t *testing.T) { + disc := &stubProjectDiscoverer{projects: []*parser.Project{ + {Name: "260715-ccx-session-watch"}, + {Name: "260324-ccx-codex"}, + {Name: "unrelated-thing"}, + }} + + out := captureStderr(t, func() { + suggestClosestProjects(disc, "/wrk/WIP/260715_ccx-session-watch") + }) + if !strings.Contains(out, "260715-ccx-session-watch") { + t.Fatalf("suggestion missing best match: %q", out) + } + if strings.Contains(out, "unrelated-thing") { + t.Fatalf("suggestion includes zero-overlap project: %q", out) + } + first := strings.Index(out, "260715-ccx-session-watch") + second := strings.Index(out, "260324-ccx-codex") + if second >= 0 && second < first { + t.Fatalf("weaker match ranked first: %q", out) + } +} diff --git a/internal/cmd/trace.go b/internal/cmd/trace.go index da37728..11e0408 100644 --- a/internal/cmd/trace.go +++ b/internal/cmd/trace.go @@ -104,7 +104,10 @@ func runTrace(cmd *cobra.Command, args []string) error { fmt.Fprintf(os.Stderr, "warning: workspace context failed: %v\n", err) } } - } else { + } else if len(gitRootWarnings) == 0 { + // The session-cwd warning already says everything this one + // would; emit the generic line only when there was no session + // cwd to blame (one line per run, not two). result.Warnings = append(result.Warnings, trace.TraceWarning{ Kind: "git_root_missing", Message: "no git repository found from session cwd or current working directory",