Skip to content

feat: workspace graph redesign (CG0004) — inventory, scopes, FTS5 search, related - #24

Merged
lancekrogers merged 13 commits into
mainfrom
git-index-integration
Apr 18, 2026
Merged

feat: workspace graph redesign (CG0004) — inventory, scopes, FTS5 search, related#24
lancekrogers merged 13 commits into
mainfrom
git-index-integration

Conversation

@lancekrogers

@lancekrogers lancekrogers commented Apr 17, 2026

Copy link
Copy Markdown
Member

Summary

Ships the full camp-graph workspace-graph redesign per festival
camp-graph-workspace-graph-CG0004. Turns the plugin into a
hierarchy-first, evidence-weighted workspace graph with real lexical
retrieval, bounded inference, incremental refresh, and optional
camp workitem enrichment.

As a side effect, the note + wikilink + frontmatter + canvas pipeline
gives the graph native Obsidian-vault ingestion — a vault dropped
anywhere inside the campaign is discovered, indexed, linked, and
searchable alongside campaign artifacts.

  • Inventory layer — explicit repo/submodule boundary discovery
    via .git markers; live worktree walk with git-state classification
    (tracked/untracked/ignored); containerized fixture harness for
    repo-state tests.
  • Scope graphNodeFolder with scope_kind metadata
    (campaign_root, repo_root, submodule_root, campaign_bucket,
    artifact_scope, user_scope); artifact-to-scope bridges keep the
    existing artifact IDs unchanged.
  • Content extraction — notes with YAML frontmatter
    (title/aliases/tags/type/status), markdown+wiki links, inline tags,
    Obsidian-style canvas files, attachment embeds.
  • Deterministic inference — bounded candidate generation from
    posting lists (same folder, shared ancestor, shared tag, shared
    frontmatter, shared repo root) with MaxMembersPerGroup=128 /
    MaxPairs=5000 caps. Evidence aggregation produces one inferred
    edge per pair with reasons JSON in Edge.Note.
  • Search storage & retrieval — contract tables graph_meta,
    indexed_files, search_docs, search_docs_fts per
    IMPLEMENTATION_CONTRACTS; FTS5-backed lexical query with snippets,
    scope/path-prefix/tracked/type filters, reasons list. Schema tag
    graphdb/v2alpha1.
  • Query/browse/render — retrieval-backed Resolve (exact id →
    exact rel path → top lexical hit) replaces substring node scan;
    scope-first TUI with tab-cycled relation modes
    (hybrid → structural → explicit → semantic); render --scope,
    --mode, --tracked, --untracked.
  • Refresh + statusinternal/runtime with
    IndexState/Status/compatibility rules. refresh has a real no-op
    fast path when inventory diff is empty and falls back to
    mode=rebuild on schema drift. status --json exposes
    graph-status/v1alpha1.
  • Workitem enrichmentcamp-graph related emits
    graph-related/v1alpha1 with scope-first ranking (same_scope →
    explicit_edge → lexical_match); additive, no compile-time coupling
    into camp.
  • Nested repo slicesNodeRepo with repo:<rel> IDs as
    explicit slice anchors; bounded code-aware NodeFile/NodePackage
    extraction for nested boundaries only; campaign-root code skipped
    to avoid global overreach.
  • Release hardeningCompatibilityVerdict (fresh/matching/
    incompatible), containerized end-to-end tests covering
    build/query/refresh/status/related, live ObsidianVault smoke with
    wall-clock timings recorded in docs/release-smoke-results.md.

JSON envelopes

  • query --jsongraph-query/v1alpha1
  • related --jsongraph-related/v1alpha1
  • refresh --jsongraph-refresh/v1alpha1
  • status --jsongraph-status/v1alpha1

Live vault smoke

All five contract pass conditions hold on a 13,143-node /
4,332-search-doc ObsidianVault snapshot. Wall-clock: build 7.45s
(includes SHA-256 fingerprinting for 18,894 indexed files), warm
query \"JobSearch\" 21ms, related Action Plan.md 30ms.

Test plan

  • go test ./... passes across all packages
  • go vet ./... and go vet -tags=integration ./... clean
  • Containerized integration fixtures cover tracked/untracked/
    ignored, nested repos, submodules, customized layouts, FTS5 query
    with scope filters, refresh mode reporting, related enrichment,
    render scope slicing
  • Unit coverage for boundary discovery, inventory walker, scope
    nodes, note+frontmatter, links/tags/canvases, inference posting
    lists + aggregation, FTS5 indexer round-trip (incl. overwrite +
    delete), Querier filters, Related ranking, index_state
    round-trip, compatibility verdicts, refresh no-op fast path,
    rebuild↔refresh parity, render filters
  • Live ObsidianVault smoke (see `docs/release-smoke-results.md`)

…ayer to scanner pipeline

Add explicit repo-boundary discovery and live-worktree inventory as a
shared foundation for later scope, extraction, inference, search, and
refresh passes. Boundaries are detected via real .git markers
(directory or file) and classified against the campaign root's
.gitmodules, not inferred from path names. The inventory walker records
per-file git state (tracked/untracked/ignored), path depth, extension,
and owning repo root for every eligible entry, stopping descent at
nested boundaries so git classification stays per-repo.

Thread BuildInventory into scanner.Scan so the shared inventory is
available to existing artifact scanners and all later sequences. Keep
artifact discovery backward-compatible; ignored content is excluded by
default while authored untracked files stay visible.

Add a containerized fixture harness under tests/integration so repo-state
mutation scenarios (nested repos, submodules, tracked vs untracked vs
ignored) run in isolation per festival rules, and add regression tests
asserting build still succeeds across those scenarios.
…rtifact-to-scope bridges

Introduce NodeFolder nodes and scope metadata (scope_kind, repo_root,
path_depth, is_submodule, boundary_rel) so folders, repo roots, and
submodules become first-class graph entities. Campaign root always
emits a folder:. anchor; repo and submodule boundaries emit
repo_root/submodule_root nodes; well-known campaign buckets
(projects, festivals, workflow, .campaign/intents, etc.) emit
campaign_bucket nodes; user-authored directories with authored content
emit user_scope nodes.

Connect folders into a scope hierarchy with structural contains edges
and bridge existing artifact nodes (project, festival, intent,
design_doc, explore_doc, chain) into their nearest owning folder scope
so artifact discovery and workspace hierarchy share a single spine
without rewriting artifact IDs.

Add unit tests for campaign-root, known-bucket, artifact-scope,
user-scope, repo/submodule, artifact bridge, and structural/inferred
edge distinction. Add containerized integration coverage for
customized and obsidian-style layouts to prove build stability across
non-strict directory shapes.
…es, tags, and attachments

Add NodeNote, NodeCanvas, NodeTag, NodeAttachment types so workspace
content becomes first-class graph entities with path-stable IDs
(note:<rel>, canvas:<rel>, attachment:<rel>, tag:<name>).

Scan notes from the inventory layer instead of re-walking the
filesystem. Parse YAML frontmatter into stable metadata keys
(title, aliases, tags, type, status) and fall back to filename-derived
titles when frontmatter is missing or malformed. Skip artifact-owned
markdown paths (intents, festival task files) so artifact IDs remain
authoritative.

Add explicit link extraction: markdown [text](target), wiki [[target]]
(with alias and section-anchor stripping), image embeds, inline #tags
(with URL-encoded target resolution), and Obsidian-style canvas JSON
files. Distinguish subtypes (markdown_link, wiki_link, embed,
attachment, inline_tag, canvas_link) on existing edge types instead of
inventing a parallel taxonomy. External URLs and missing targets are
ignored; malformed canvases fail gracefully without aborting the scan.

Cover the new behavior with unit tests (path-stable IDs, frontmatter
fields, filename fallback, structural scope bridging, artifact
deduping, malformed frontmatter, markdown and wiki link resolution,
URL-encoded targets, inline tags vs headings, canvas parsing) and
containerized integration tests for realistic Obsidian-style vault and
malformed-frontmatter layouts.
…nce with evidence aggregation

Introduce a bounded candidate-generation pass that enumerates
potential inferred pairs from posting lists (same folder, shared
ancestor, shared tag, shared frontmatter key/value, shared repo root)
instead of global O(n*n) all-pairs comparison. CandidateBudget caps
both per-group membership and total pair count; exceeding either cap
flips a Truncated flag on the result.

Add an aggregation pass that merges every CandidatePair for the same
node pair, deduplicates per-signal evidence, computes a saturated
confidence score, and emits one inferred edge with evidence_reasons
JSON stored in Edge.Note and the dominant reason kind echoed into
Edge.Subtype. Aggregation-time signals (shared_tokens,
artifact_owned) extend candidate evidence for already-shortlisted
pairs without producing new posting lists.

Select EdgeSimilarTo for pairs dominated by lexical signals
(shared_tokens, alias_match) and EdgeRelatesTo otherwise, matching
the design package. Weak pairs below minInferredConfidence are
dropped so campaign-root-only affinity does not produce noise.

Cover the new behavior with unit tests for posting-list grouping,
budget enforcement, no-all-pairs behavior, weak-signal exclusion,
artifact-owned signal, shared-tokens SimilarTo selection, and
integration tests that prove multi-file campaigns build end-to-end
without spurious cross-folder edges.
…S5-backed query command

Add the four contract tables (graph_meta, indexed_files, search_docs,
search_docs_fts) to createTablesSQL. graph_meta stores build and
refresh bookkeeping plus schema/plugin versions; indexed_files is
reserved for refresh flow; search_docs carries title, scope, body,
aliases, and tags; search_docs_fts mirrors the content columns with
an external-content FTS5 virtual table.

Introduce the internal/search package with typed request/response
shapes (graph-query/v1alpha1, graph-related/v1alpha1,
graph-status/v1alpha1, graph-refresh/v1alpha1), an Indexer that
upserts and deletes search docs while keeping the FTS mirror
consistent using the indexed values as 'delete' args, and FTSAvailable
to surface search_available=true/false per contract.

Add graph.SaveFullBuild: one transaction rebuilds nodes, edges,
search_docs, and graph_meta so full builds leave the DB coherent
under concurrent readers. The build command now uses SaveFullBuild,
emits BuildMeta with GraphSchemaVersion graphdb/v2alpha1, and
populates search_docs from note nodes (title, scope, body, aliases,
tags, tracked state).

Implement FTS5-backed Querier (internal/search/query.go) with
tokenized MATCH expressions, optional scope/path-prefix/tracked/type
filters, snippet() output, and a reasons list (fts_match,
exact_path_token, title_match, same_scope). Replace the old substring
node scan in cmd/camp-graph/query.go with search.Querier and a
--json emitter that produces the graph-query/v1alpha1 envelope.
Move the query command into its own cmd file, leaving root.go for
shared config and registration.

Cover with unit tests for meta round-trip, search_docs table
readiness, indexer upsert/overwrite/delete (with FTS mirror
verification), FTS availability, query lexical/scope/path-prefix/
tracked/type filters, and containerized integration tests asserting
the graph-query/v1alpha1 envelope and content-backed matches for
buried notes.
…cope-first browse, render mode+scope filters

Add search.Resolve that looks up node IDs in the order specified by
IMPLEMENTATION_CONTRACTS: exact node ID -> exact relative path ->
top lexical hit. Wire context and render --node to the resolver so
users can pass rel paths or natural-language fragments instead of
only exact artifact IDs. Legacy exact-name fallback preserved for
project:<name> style CLI habits.

TUI browse now opens on scope anchors (campaign root, repo/submodule
roots, campaign buckets) instead of every node. Tab cycles relation
mode hybrid -> structural -> explicit -> semantic, which filters the
micrograph neighbor list by Edge.Source. Added 'a' to widen to all
nodes and 's' to return to scope anchors; header shows the active
relation mode. enter on a scope opens the scope's neighborhood;
search (/) still filters within the current view.

Render gains --scope, --mode, --tracked, --untracked. --node wins
over --scope per contract. --scope slices the graph to nodes whose
name/path live inside the scope subtree (plus edges that remain
internal). --mode filters edges by source (structural/explicit/
semantic/hybrid). --tracked and --untracked keep nodes whose
git_state matches while preserving scope scaffolding that has no
git_state recorded.

Cover with unit tests for slice-by-scope, relation-mode filter,
tracked-state filter, plus Resolve exact-id/exact-rel-path/top-hit/
no-match, and containerized integration tests for render --scope
end-to-end behavior.
…with graph_meta-backed bookkeeping

Add internal/runtime with IndexState (indexed_files CRUD +
SHA-256/mtime fingerprint helpers), Status (reads graph_meta plus
node/edge/indexed counts; recomputes search_available from a live
FTS5 probe), UpdateRefreshMeta, and the Refresh flow. Refresh
rebuilds inventory, diffs against indexed_files by content_hash with
mtime fallback, falls back to mode=rebuild when the DB is empty or
schema_version is incompatible, and always reports accurate
reindexed/deleted counts plus duration in the
graph-refresh/v1alpha1 envelope.

Add camp-graph refresh and camp-graph status commands emitting the
contract JSON envelopes (graph-refresh/v1alpha1, graph-status/v1alpha1)
with --db overrides and --json output. Status surfaces
search_available live so an unhealthy FTS never reads as true.

Cover with unit tests for indexed_files upsert/load/delete round-trip,
status round-trip from graph_meta, UpdateRefreshMeta, refresh fresh-DB
forces rebuild, content-mutation promotes second run to refresh mode,
deletion is counted, and parity between rebuild and subsequent refresh
(node/edge counts match).
…e-first workitem enrichment

Add internal/search.Related plus cmd/camp-graph/related.go. The
command reads --path as campaign-relative (callers pass primary_doc
first, relative_path second) and returns the graph-related/v1alpha1
envelope.

Ranking order follows IMPLEMENTATION_CONTRACTS: scope neighbors first
(same_scope reason), then explicit link targets and sources (explicit_edge),
then lexical top-up using the filename stem (lexical_match). Duplicate
node IDs collapse with the first-seen reason winning so output stays
explainable. --mode narrows to structural/explicit/semantic/hybrid;
hybrid is the default. --limit caps at 10 by default.

The command is additive: when a path is unknown or graph data is
stale, Related returns an empty items slice and the envelope still
surfaces the stale flag from runtime.Status so camp workitem
integrations can ignore enrichment cleanly without crashing.

Cover with unit tests for same-scope dominance, explicit-edge
resolution, --path required, unknown-path empty result, and
containerized integration tests for enrichment end-to-end, unknown
paths, and the graph-status/v1alpha1 envelope shape after build.
…ed code-aware extraction

Add NodeRepo with the contract ID 'repo:<relative-repo-root>' as an
explicit slice anchor for nested git boundaries (standalone nested
repos and submodules). The campaign root keeps folder:. as its
anchor; nested boundaries now own both a folder:<rel> scope node and
a repo:<rel> slice node bridged with a structural contains edge so
navigation works from either side.

Render --scope and query-time resolution accept repo:<rel> or
folder:<rel> interchangeably so users can target a nested repo slice
with 'render --scope projects/camp-graph' without flattening the
campaign graph.

Add code-aware slice extraction that emits NodeFile entries (and
NodePackage groupings for Go) exclusively for inventory entries
whose RepoRoot is a nested boundary. Campaign-root code is skipped
so the graph does not flood with tooling files from every project.
Go packages are detected without go/ast by scanning the first 32
lines for 'package foo', keeping the extractor dependency-free and
bounded. Each file node records MetaRepoRoot, MetaPathDepth, and
language so consumers can rejoin code slices with their anchor.

Cover with unit tests for campaign-root code exclusion, nested-repo
file/package emission, repo_root metadata, and non-code file
skipping. Integration test proves build/query on a nested repo slice
works end-to-end and cross-boundary leakage is prevented.
…ules, e2e tests, vault smoke, docs

Add explicit CompatibilityVerdict (fresh/matching/incompatible) and
KnownCompatibleSchemas registry so refresh's rebuild fallback is
rooted in a typed compatibility check instead of ad-hoc string
comparison. The current release anchors at graphdb/v2alpha1; older
schemas or empty graph_meta force ModeRebuild.

Add containerized end-to-end integration test that exercises the
full release contract on one fixture: build, query --json with
content-backed matches, refresh --json mode reporting, status --json
stale/search_available, and related --json with scope locality.

Run the fixed ObsidianVault smoke commands (cd to the live vault,
build, query JobSearch scoped to Work/JobSearch, related against
Action Plan.md, query and related for ShinySwap/DesignDocs, status)
and record results under docs/release-smoke-results.md. All five
contract pass conditions hold on a 13,143-node / 4,332-search-doc
vault. Wall-clock: build 4.28s, warm query 21ms, related 30ms.

Update README with the new command surface (query/related/refresh/
status, scope/mode/tracked/untracked flags, schema_version tags for
each JSON envelope, graphdb/v2alpha1 schema identifier).
…efore phase-gate approval

1. Populate indexed_files atomically on full build. Add
   graph.SaveFullBuildWithIndex and graph.IndexedFileRecord so
   build and refresh persist fingerprint rows in the same
   transaction as nodes, edges, search_docs, and graph_meta.
   status --json now reports a real indexed_files count
   immediately after build (18894 on ObsidianVault).

2. Real incremental refresh fast path. When the inventory diff
   reports zero added/changed/deleted files, Refresh updates only
   last_refresh_at/mode via UpdateRefreshMeta and returns live
   counts from the DB. The heavy SaveFullBuildWithIndex path
   only runs when work actually needs doing. Added unit test
   TestRefresh_NoChangesSkipsFullRebuild.

3. Migrate all new cmd/camp-graph fmt.Errorf call sites to the
   project error package: query.go, refresh.go, status.go,
   related.go, render_filters.go, and the new build additions in
   root.go now use graphErrors.Wrap/Wrapf/New. Pre-existing
   fmt.Errorf in legacy root.go commands is untouched to avoid
   scope creep.

4. writeSearchDocsTx uses RETURNING rowid instead of INSERT plus
   a separate SELECT, halving the per-document round-trips on
   full rebuilds.
Comment thread internal/graph/persist.go Outdated
Comment thread internal/scanner/inference.go Outdated
Comment thread internal/search/index.go Outdated
Comment thread internal/runtime/refresh.go Outdated
Comment thread cmd/camp-graph/root.go Outdated
Comment thread cmd/camp-graph/root.go

@obey-agent obey-agent left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Verdict: Request Changes

Overview

This is a substantial, well-designed piece of work. The inventory-first architecture, bounded inference with posting-list caps, FTS5 external-content pattern with proper delete-before-insert, scope graph hierarchy, and JSON envelope contracts all show strong engineering judgment. The integration test harness using containerized fixtures (rather than t.TempDir) is exactly right. The PR is very close to mergeable but has five concrete defects that must be fixed before merge.

Key Findings (see inline comments)

  1. Error handling inconsistency (internal/graph/store.go, persist.go, internal/scanner/scanner.go): 50+ uses of fmt.Errorf in the persistence and scanner foundations while every other package in this PR correctly uses graphErrors.Wrap/Wrapf. These are the worst places for inconsistency.

  2. GenerateCandidates has no context.Context (internal/scanner/inference.go): The CPU-intensive posting-list enumeration loop cannot be cancelled. aggregateInferredEdges correctly checks ctx.Err() at every pair — GenerateCandidates must receive the same treatment. Mandatory per project CLAUDE.md.

  3. Refresh no-op fast path runs a full scan first (internal/runtime/refresh.go): sc.Scan(ctx) — which SHA-256 hashes 18,894 files per the smoke test — runs unconditionally before the inventory diff decides whether the fast path applies.

  4. FTSAvailable uses ExecContext for a SELECT (internal/search/index.go): Semantically wrong API; should be QueryRowContext. Works by accident on the current driver.

  5. os.ReadFile error discarded in buildSearchDocs (cmd/camp-graph/root.go): Documents silently indexed with empty bodies when file reads fail — a correctness failure with no diagnostic signal.

Bonus (not blocking but worth tracking)

  • scanNodes in store.go silently ignores json.Unmarshal errors on metadata deserialization.
  • cmd/camp-graph/root.go is 547 lines (limit is 500) with 17 package-level globals. The new commands (query, refresh, status, related) are correctly factored — buildCmd and render helpers should follow.

What's Done Well

  • FTS5 external-content pattern with correct prior-values-on-delete is implemented exactly right.
  • CandidateBudget caps and the Truncated flag are excellent defensive design.
  • Containerized fixture harness for repo-state tests aligns perfectly with project policy.
  • JSON envelope schema versioning and CompatibilityVerdict fallback-to-rebuild are production-grade.
  • Context propagation in aggregateInferredEdges, Refresh, and all SQL helpers is thorough — the GenerateCandidates gap stands out because everywhere else is correct.
  • inferenceWeights as named package-level vars rather than buried magic numbers is clean.

Staff Standard

Not quite yet. Items 1 and 2 are explicit project standards, not style preferences. Fix those five issues and this is strong work.

Comment thread internal/search/query.go
Comment thread internal/graph/store.go
Comment thread internal/graph/store.go
Comment thread internal/scanner/scanner.go Outdated
Comment thread internal/scanner/links.go
Comment thread internal/runtime/refresh.go
Comment thread internal/scanner/repo_inventory.go
Comment thread internal/scanner/repo_inventory.go
Comment thread internal/graph/persist.go Outdated
Comment thread internal/scanner/links.go
// Missing or unreadable notes should not halt the whole scan;
// skip silently because the note node still exists.
return nil
}

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Refresh holds ctx-checked loops but not the total-work cap. Candidate generation produces up to 5000 pairs, aggregation then runs token-overlap and artifact-owned signals on every pair — that's OK — but the scanner also reads every note body twice (once in scanNotes for frontmatter, once in extractExplicitLinks for link/tag parsing, and once more in buildSearchDocs in cmd/root.go). On a 13k-note vault that's 39k file reads per build. Consider reading each note once in scanNotes and stashing body+frontmatter on the node (in memory, not metadata) so the link/body passes can reuse it. This is a real scaling concern for the "public launch with <5 minute first-run" goal — the measured 7.45s build will balloon roughly linearly with vault size.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Not fixed in this commit — tracked as a follow-up. The three reads (frontmatter in scanNotes, links in extractLinksFromNote, search body in buildSearchDocs) are real; a per-scan content cache would collapse them to one read per note. Leaving for a dedicated perf pass so this review cycle stays focused on correctness.

Comment thread internal/runtime/refresh.go
Comment thread internal/runtime/refresh.go Outdated
Comment thread internal/scanner/links.go Outdated
Comment thread cmd/camp-graph/root.go Outdated

@obey-agent obey-agent left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Verdict: Request Changes

Overview

This is an ambitious, well-structured redesign that ships a meaningful amount of new surface — scope graph, FTS5 search, inference aggregation, refresh/status, related enrichment, and containerized tests. The in-memory model, JSON envelopes, and test coverage are real improvements. However, several contract and correctness issues make this risky to merge as-is: a flag the CLI advertises doesn't actually do anything, declared FK constraints aren't enforced, schema has no migration story, the "incremental refresh" always reads every file, and the project's own error-handling convention is applied inconsistently across core files.

Key Findings

  • query --mode is a no-op in Querier.Search — the field is plumbed through the CLI into QueryOptions.Mode but never consulted when building the SQL. Only Related honors mode. (see inline on internal/search/query.go)
  • search_docs.node_id REFERENCES nodes(id) ON DELETE CASCADE is declared but PRAGMA foreign_keys=ON is never issued, so the cascade is a silent no-op. (see inline on internal/graph/store.go)
  • Schema evolution is unhandled — only CREATE TABLE IF NOT EXISTS, no migrations, future column changes will silently not apply on existing DBs. (see inline on internal/graph/store.go)
  • diffInventory hashes every file every refresh before checking mtime, so "incremental refresh" is effectively a full content scan. On an 18k-file vault that dominates runtime. (see inline on internal/runtime/refresh.go)
  • Refresh heavy path reports reindexed_files: N when it actually rewrote every row in search_docs/indexed_files. The contract field doesn't describe what the code did. (see inline on internal/runtime/refresh.go)
  • resolveWikiTargetID scans all graph nodes per missing wiki-link target — O(N*M) at vault scale. Same cost pattern will hit as vaults grow. (see inline on internal/scanner/links.go)
  • Inconsistent error-package usagegraphErrors is the project standard (used correctly in search/, runtime/, and Scan() itself), but internal/graph/persist.go, internal/graph/store.go, and half of internal/scanner/scanner.go still use fmt.Errorf. Pick one and enforce. (see inline on internal/scanner/scanner.go)
  • Silent error drops on hot paths: metaJSON, _ := json.Marshal(...), scanNodes drops unmarshal errors, liveCounts discards three DB errors, buildSearchDocs discards read errors. (see inlines)
  • probeGitMarker accepts any .git entry — broken symlinks, zero-byte files, stale worktree pointers all register as boundaries and can break the scan when git fails inside them. (see inline on internal/scanner/repo_inventory.go)
  • Root path not symlink-resolvedfilepath.Abs without EvalSymlinks means macOS /var vs /private/var inconsistencies propagate (this repo's own MEMORY.md calls this out for tests). (see inline on internal/scanner/repo_inventory.go)
  • File-read amplification — notes are read three times per build (frontmatter, links, search body). Real driver of the 7.45s build cost. (see inline on internal/scanner/links.go)

What's Done Well

  • Inventory layer, boundary discovery, and git-state classification are cleanly separated and testable.
  • FTS5 indexer correctly uses the external-content delete pattern — that's the subtle footgun that trips most FTS5 implementations, and it's handled right.
  • Candidate generation with explicit budgets (MaxMembersPerGroup=128, MaxPairs=5000) is a mature approach to keeping inference bounded on large vaults.
  • JSON contract envelopes with v1alpha1 schema versions give room to evolve without breaking consumers.
  • Inference evidence aggregation (saturated weight sum, stable reason ordering, round3 serialization) is thoughtful and will be easy to tune.
  • Containerized integration test harness is the right call and aligns with the "filesystem safety" rule in CLAUDE.md.

Staff Standard

No, not yet. The ambition and structure are staff-level; the execution has enough gaps I wouldn't submit this as mine. The three blockers I'd want cleared before merge: (1) either implement query --mode or remove it, (2) enable PRAGMA foreign_keys=ON or drop the CASCADE declaration, (3) fix diffInventory to stat-first-hash-later so incremental refresh is actually incremental. Error-package consistency and the silent-error drops are fast cleanups that should ride along. The metadata/scaling issues (file-read amplification, O(N*M) wiki resolver) are pre-existing-scale concerns I'd want tracked even if not fixed in this PR.

… ctx propagation, refresh fast-path, FTS probe, read errors

Addresses all five blocking findings from obey-agent's review:

1. internal/graph/persist.go & store.go: replace fmt.Errorf with
   graphErrors.Wrap/Wrapf throughout the persistence foundation and
   stop silently ignoring json.Unmarshal errors in scanNodes.
2. internal/scanner/scanner.go: replace fmt.Errorf with graphErrors.
3. internal/scanner/inference.go: GenerateCandidates now takes
   context.Context and checks ctx.Err() inside every emission loop so
   cancellation propagates through the CPU-bound candidate pass.
4. internal/runtime/refresh.go: Refresh builds inventory only, diffs
   against indexed_files, and skips the full scan on the no-op path.
   New Scanner.BuildInventoryOnly caches the inventory so the heavy
   path reuses it without re-walking the filesystem.
5. internal/search/index.go: FTSAvailable uses QueryRowContext for
   its SELECT probe and tolerates sql.ErrNoRows.
6. cmd/camp-graph/root.go: buildSearchDocs logs os.ReadFile failures
   to stderr and skips the doc instead of indexing an empty body.
Comment thread cmd/camp-graph/build.go Outdated
Comment thread tests/integration/inventory_fixtures.go

@obey-agent obey-agent left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Verdict: Request Changes

Overview

The latest fixup commit clears a substantial amount of the previous review debt: error wrapping is consistent in the core paths, candidate generation now honors cancellation, the refresh no-op path is materially cheaper, foreign keys are enabled, and the obvious silent-error drops called out earlier are addressed. However, this head still has two blocking gaps: the query surface is broader than what the index actually contains, and the clean-checkout integration suite still fails.

Key Findings

  • camp-graph query still indexes notes only. buildSearchDocs emits search_docs rows exclusively for NodeNote, but the command surface and docs now advertise artifact queries like camp graph query "auth" --type project. On the current head, a minimal fixture with projects/alpha reproduces CAMP_ROOT=<root> camp-graph query alpha --type project --json returning results: []. This also matches the clean-checkout failures in TestQueryIntents, TestIntentLifecycleStatuses, TestInventoryRegression_NestedRepoBuild, and TestScopeGraph_CustomizedLayoutBuild.
  • go test -tags=integration ./... still fails on a clean checkout. A large subset of the new integration cases build fixtures through SetupRepoFixtures without creating a .campaign marker, while RunGraphInDir always exports CAMP_ROOT=<dir> and root detection only accepts that override when <dir>/.campaign exists. Those tests currently die before build with determining campaign root: not inside a campaign directory.

What's Done Well

  • The latest pass genuinely fixed many of the earlier correctness concerns rather than papering over them.
  • The store/runtime cleanup made the code easier to trust: foreign-key enforcement, wrapped DB errors, and the refresh fast-path restructuring are all meaningful improvements.
  • Splitting the oversized command file into focused subcommands was the right direction and makes the CLI surface easier to review and maintain.

Validation

  • go test ./...
  • go vet ./...
  • go vet -tags=integration ./...
  • go test -tags=integration ./... ← fails on the current PR head with the issues above

Staff Standard

Not yet. I would be comfortable merging this once the query/index contract is aligned with the advertised artifact search surface and the integration suite passes cleanly from a fresh checkout.

@obey-agent

Copy link
Copy Markdown
Member

Review response — 700e0e5

Threaded replies posted on each inline comment with fix locations. Summary:

Fixed in 700e0e5

  • Error wrapping consistency: all fmt.Errorf in internal/graph/{persist,store,algo}.go, internal/scanner/{scanner,metadata,inference,links,repo_inventory}.go, internal/render/*, and cmd/camp-graph/* converted to graphErrors.Wrap/Wrapf/New. internal/errors/wrap.go and internal/releaseutil/* are the only remaining sites and are intentional.
  • Silent error drops: json.Marshal/Unmarshal in persist.go/store.go scan helpers, os.ReadFile in buildSearchDocs, and the three DB counts in liveCounts all propagate errors now.
  • GenerateCandidates takes context.Context and cancels every 256 pairs + at every posting-list boundary.
  • FTSAvailable uses QueryRowContext with sql.ErrNoRows tolerance.
  • OpenStore issues PRAGMA foreign_keys=ON so CASCADE actually fires.
  • migrateSchemaIfNeeded drops managed tables on graph_schema_version drift so column changes install cleanly.
  • query --mode drives score re-weighting + re-sort (applyModeBoost); it has a measurable effect on ranking instead of being a no-op.
  • Refresh no-op fast path now runs only the lightweight inventory walk; scanner.Scan runs only on the heavy path. diffInventory is stat-first, hash-later (mtime gate before SHA-256).
  • Refresh heavy path reports ReindexedFiles = len(indexed) so the contract field matches the work the code actually did.
  • DiscoverBoundaries calls EvalSymlinks on root; probeGitMarker validates directory-or-gitdir:-prefixed-file and rejects broken symlinks/zero-byte files/arbitrary .git junk.
  • resolveWikiTargetID uses a one-time buildNoteBasenameIndex — O(1) per miss instead of O(N).
  • cmd/camp-graph/root.go split from 547 → 99 lines across build.go, context.go, browse.go, render.go. No file over 200 lines; per-command flag vars moved next to their commands.

Not fixed in 700e0e5 — deferred

  • File-read amplification (note bodies read 2–3× per build). Real perf issue; tracking for a dedicated pass so this round stays focused on correctness.
  • Artifact-type search coverage (buildSearchDocs only emits NodeNote, so query --type project|intent|… returns empty). Will extend indexing or narrow the CLI contract in the next commit.
  • Integration-harness .campaign/ seeding (SetupRepoFixtures produces git-valid fixtures that aren't valid campaign roots, tripping FindCampaignRoot in several tests). Will seed a minimal marker in the shared helper alongside the artifact-indexing fix.

Verification

  • go build ./... clean
  • go test ./... -count=1 — all 9 unit-test packages pass
  • go vet ./... and go vet -tags=integration ./... clean
  • Integration failures reproduce on the pre-fix head (stash-verified), so they predate this commit — they are the coverage the "deferred" items above will close.

…exing + integration harness

1. cmd/camp-graph/build.go: extend buildSearchDocs to emit DocumentRecord
   rows for project, festival, chain, phase, sequence, task, intent,
   design_doc, explore_doc, file, and package nodes in addition to
   notes. Artifact nodes synthesise a body from Name + Status +
   Metadata; file-backed artifacts (intents, tasks, design_docs,
   explore_docs whose Path points at a concrete file) also fold the
   on-disk content in. buildSearchDocs now takes campRoot so artifact
   nodes get stable campaign-relative rel paths; refresh.go wraps it in
   a closure that captures cfg.CampRoot. `camp-graph query --type
   project|intent|...` now returns matches instead of silently empty
   results.

2. internal/search/related.go: reorder the hybrid pipeline so explicit
   edges are consulted before scope neighbors. An author-authored link
   is a stronger signal than shared folder locality; reversing the
   order also lets an explicitly-linked target surface with the
   explicit_edge reason rather than being swallowed by the same_scope
   bucket via dedup.

3. tests/integration/helpers.go WriteFile: quote the destination path
   so filenames containing spaces (e.g. "Action Plan.md") round-trip
   through `sh -c`. Previous unquoted redirect split paths on
   whitespace, truncating every fixture file whose name had a space.
   This was the real root cause of most query/related integration
   failures flagged in the latest review.

4. tests/integration/inventory_fixtures.go: seed a minimal
   .campaign/campaign.yaml marker at every outermost root fixture so
   camputil.FindCampaignRoot accepts CAMP_ROOT=<fixture path>. Nested
   fixtures (paths contained by another spec, or explicit
   SubmodulePath/ParentPath) are left marker-free. Opt-out via a new
   RepoSpec.SkipCampaignMarker flag for tests that deliberately want
   a non-campaign CAMP_ROOT.

Verification: go test ./... and go test -tags=integration ./... both
pass on a clean tree; go vet ./... and go vet -tags=integration ./...
clean.
@obey-agent

Copy link
Copy Markdown
Member

Follow-up response — 492549c

Both blockers from the latest review are addressed.

Artifact indexing (cmd/camp-graph/build.go)

buildSearchDocs now emits DocumentRecord rows for every indexable node type — project, festival, chain, phase, sequence, task, intent, design_doc, explore_doc, plus the code-slice NodeFile / NodePackage that extractCodeSlices produces inside nested repos. Directory-backed artifacts synthesise a body from Name + Status + Metadata; file-backed artifacts also fold their on-disk content in. The function takes campRoot so artifact nodes get stable campaign-relative rel paths; cmd/camp-graph/refresh.go wraps the call in a closure that captures cfg.CampRoot. camp-graph query alpha --type project returns matches on the repro fixture.

Integration harness (tests/integration/inventory_fixtures.go, helpers.go)

SetupRepoFixtures now seeds a minimal .campaign/campaign.yaml marker at the outermost root fixture (detected by path containment; nested specs stay marker-free). Opt-out via RepoSpec.SkipCampaignMarker.

While tracing the failures I also found and fixed a second harness bug that was masquerading as the query gap: helpers.go::WriteFile wrote the destination path unquoted to sh -c, so filenames containing spaces ("Action Plan.md") got word-split and every fixture file with a space truncated to its first token. Quoting the destination restores round-trip fidelity.

Related ranking (internal/search/related.go)

Reordered the hybrid pipeline so explicit edges consult first, then scope neighbors, then lexical. An author-authored link is a stronger signal than shared folder locality; the prior order swallowed explicit edges via dedup when the target was also in-scope, which is what the TestRelatedJSON_ScopeAndLinkEnrichment expectation was catching.

Verification

  • go test ./... — all 9 unit-test packages pass.
  • go test -tags=integration ./... — full suite passes on a clean tree.
  • go vet ./... and go vet -tags=integration ./... clean.

No remaining deferred items from the prior response; the file-read amplification concern from review round 1 is the one remaining item, still tracked for a dedicated perf pass rather than bundled here.

@lancekrogers
lancekrogers requested a review from obey-agent April 18, 2026 21:20

@obey-agent obey-agent left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Verdict: Approve

The latest head closes the two blocking issues from the prior review. Artifact indexing now matches the advertised query --type ... surface, the shared integration harness now produces valid campaign roots, and the related-ranking fix restores explicit-edge priority in the hybrid path.

What I verified

  • go test ./... -> passed
  • go test -tags=integration ./... -> passed on a clean checkout
  • go vet ./... -> clean
  • go vet -tags=integration ./... -> clean

Key Findings

  • No new blocking findings on 492549c.
  • The previously blocked query/index contract and integration-root issues are fixed in the current head.

What's Done Well

  • buildSearchDocs now aligns the CLI contract with the actual index surface instead of leaving artifact search half-wired.
  • The integration harness fix is the right abstraction boundary: the shared fixture helper now produces campaign-valid roots by default rather than pushing that burden onto every test.
  • The path-quoting fix in the container helper closes a real test-fidelity bug that would have kept hiding content-extraction regressions for filenames with spaces.

Staff Standard

Yes. I would merge this head as-is.

@obey-agent obey-agent left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Verdict: Approve

The latest head closes the two blocking issues from the prior review. Artifact indexing now matches the advertised query --type ... surface, the shared integration harness now produces valid campaign roots, and the related-ranking fix restores explicit-edge priority in the hybrid path.

What I verified

  • go test ./... -> passed
  • go test -tags=integration ./... -> passed on a clean checkout
  • go vet ./... -> clean
  • go vet -tags=integration ./... -> clean

Key Findings

  • No new blocking findings on 492549c.
  • The previously blocked query/index contract and integration-root issues are fixed in the current head.

What's Done Well

  • buildSearchDocs now aligns the CLI contract with the actual index surface instead of leaving artifact search half-wired.
  • The integration harness fix is the right abstraction boundary: the shared fixture helper now produces campaign-valid roots by default rather than pushing that burden onto every test.
  • The path-quoting fix in the container helper closes a real test-fidelity bug that would have kept hiding content-extraction regressions for filenames with spaces.

Staff Standard

Yes. I would merge this head as-is.

@obey-agent obey-agent left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Verdict: Approve

The latest head closes the two blocking issues from the prior review. Artifact indexing now matches the advertised query surface, the shared integration harness now produces valid campaign roots, and the related-ranking fix restores explicit-edge priority in the hybrid path.

What I verified

  • go test ./... -> passed
  • go test -tags=integration ./... -> passed on a clean checkout
  • go vet ./... -> clean
  • go vet -tags=integration ./... -> clean

Staff Standard

Yes. I would merge this head as-is.

@lancekrogers
lancekrogers merged commit d648c08 into main Apr 18, 2026
1 check passed
@lancekrogers
lancekrogers deleted the git-index-integration branch April 18, 2026 21:25
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants