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
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 7 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
@@ -1,14 +1,20 @@
.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)

# 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 " ─────────────────────────────────────"
Expand Down
81 changes: 81 additions & 0 deletions docs/devlog/2026-08-03-content-search-noise.org
Original file line number Diff line number Diff line change
@@ -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 '<id>*.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 <id>= 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}' <file> | 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.
126 changes: 126 additions & 0 deletions docs/devlog/2026-08-03-trace-session-dogfood.org
Original file line number Diff line number Diff line change
@@ -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 <path>= 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 <id>= and =trace <id> --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=).
32 changes: 31 additions & 1 deletion internal/catalog/session_query.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package catalog

import (
"fmt"
"regexp"
"sort"
"strings"

Expand Down Expand Up @@ -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
Expand Down
38 changes: 38 additions & 0 deletions internal/catalog/slug_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
2 changes: 1 addition & 1 deletion internal/cmd/insight.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)")
}

Expand Down
Loading
Loading