diff --git a/.agents/skills/sdd/SKILL.md b/.agents/skills/sdd/SKILL.md index e6350a1d..2e6d8cf9 100644 --- a/.agents/skills/sdd/SKILL.md +++ b/.agents/skills/sdd/SKILL.md @@ -3,7 +3,7 @@ allowed-tools: Read Grep Bash(sdd *) compatibility: Designed for OpenAI Codex description: Work with the SDD decision graph. Check in on project state, capture signals, make decisions, evaluate completed work. Use when starting a session, capturing observations, or making project decisions. metadata: - sdd-content-hash: 1870280cb5840da0e6dfd3a71ed7b20a1396f2b352c5630b4dfc76e95bbc875d + sdd-content-hash: 9d13bc30e49c05613821b8ad47bf9ec302a5e06312e7d8bb04a7f42ccdd9e7a9 sdd-version: dev name: sdd --- @@ -189,6 +189,12 @@ See [CLI reference](references/cli-reference.md) for full command syntax and fla - **Kind for signals**: Default is gap. Use `--kind done` for completion records (must carry `closes` or `refs`). Use `--kind fact`, `question`, or `insight` when the narrative is unambiguously observational, an open question, or a synthesis. Use `--kind actor` for a first-class participant identity — canonical name in frontmatter, prose covers stable identity facts (affiliation, background, domain expertise) that hold independent of any project frame. - **Acceptance criteria for plan decisions**: `--kind plan` decisions must include an `## Acceptance criteria` section in the description (not the attachment) with `- [ ]` checklist items. Each AC is a single verifiable outcome — not an implementation detail. ACs are the contract between plan author, implementing agent, and pre-flight validator. Pre-flight flags a missing AC section on a plan decision as high. +### Reference across repos when reasoning does + +When an entry's reasoning genuinely builds on another repo's graph — a shared framework's directive, a platform team's decision — reference it across the boundary rather than re-deriving it or reducing it to a local paraphrase. Cross-repo refs take the `:` form in a `--refs` value; every ref kind may cross, but `--closes` / `--supersedes` stay within-graph (a decision can't retire an entry in another repo). + +The target repo must be a **declared dependency** for the ref to resolve — the same resolve-or-block gate that guards local refs. If it isn't connected, offer to `sdd repo add ` first; don't connect a repo the user hasn't signalled they want to depend on. Remember a foreign entry is visible only once it's committed *and pushed* (the cache is pushed state, refreshed by `sdd repo sync`). When you have only a short ID for a foreign entry, resolve it with `sdd show ` — a free read that resolves bare IDs across the local graph and its declared dependencies — and use the full `:` it prints in the `--refs` value. Full mechanics live in [cli-reference → Cross-repo references](references/cli-reference.md). + ### Attachment assessment The entry description is the summary. The attachment is the record. When the conversation that led to capture involved more than a brief exchange, the entry needs an attachment. **Default to attaching** when the dialogue produced any of these: diff --git a/.agents/skills/sdd/references/cli-reference.md b/.agents/skills/sdd/references/cli-reference.md index f27021fd..0eede64f 100644 --- a/.agents/skills/sdd/references/cli-reference.md +++ b/.agents/skills/sdd/references/cli-reference.md @@ -1,6 +1,6 @@ --- metadata: - sdd-content-hash: 811c581f825abe82c2dd5da8ba1ac7771ac105eb573131278384a1233e70741c + sdd-content-hash: 908e2569f24cb40ef23f4c46cac00a16596d2c2ceb441068358882a126ed2564 sdd-version: dev --- # SDD CLI Reference @@ -8,16 +8,20 @@ metadata: ## Commands - `sdd info` — session framing only: `Local participant: ...`, `Language: ...` (when configured), `Search: ...`. The session header surface for skill `!`sdd ...`` injections that need the agent to see who's local and which retrieval modes are available; also the bare-`sdd` default command. -- `sdd view --layout=` — composable pipeline of primitives (source, filter, transform, aggregate, rank, page, render) with named macros as sugar. The overview surface: `decisions` / `signals` / `aspirations` / `contracts` / `participants` / `insights` / `done` macros render the kind-grouped sections, and mechanical catch-up at scale. Bare `sdd view` prints help with vocabulary tables. See "`sdd view` pipeline" below. +- `sdd view --layout=` — composable pipeline of primitives (source, filter, transform, aggregate, rank, page, render) with named macros as sugar. The overview surface: `decisions` / `signals` / `aspirations` / `contracts` / `participants` / `insights` / `done` macros render the kind-grouped sections, and mechanical catch-up at scale. Bare `sdd view` prints help with vocabulary tables. `--repo ` (repeatable) / `--all-repos` fan out across connected repos. See "`sdd view` pipeline" below. - `sdd show ` — full entry plus its upstream (grounding) and downstream (consumers) chains. Both shown by default: upstream depth 2, downstream depth 1. - `sdd show [ ...]` — multiple IDs in one call render their entries back to back (handy for comparing a cluster, e.g. the entries a new one will ref) - `sdd show --up N --down N` — set the upstream and downstream expansion depths independently. Defaults: `--up 2 --down 1` (downstream fans out faster, so it stays shallower). `0` turns a direction off; `--up 0 --down 0` is the primary entry alone. Increase (e.g. `--up 4 --down 3`) to see more of an entry's surroundings on demand. - `sdd new [flags] ` — create entries (output prints the new entry ID, file path, and the LLM-generated summary so the agent can verify fidelity) - `sdd summarize ` — regenerate the named entry's summary unconditionally (summaries are derived on demand with no staleness tracking). `sdd summarize --all` fills only entries that have no summary yet; add `--force` to regenerate every entry. - `sdd summarize --text ""` — write a user-supplied summary directly, bypassing the LLM. Use `--text -` to read from stdin. Single entry only; rejected with `--all` or multiple IDs. -- `sdd lint` — check graph integrity (dangling refs, type mismatches, broken attachment links, missing summaries). When an embedding provider is configured, also reports the search index's fingerprint and drift count. -- `sdd index` — warm up the per-participant search index at `.sdd/index/` over every entry on disk. Skips entries whose stored hash and embedder fingerprint match the manifest; `--force` re-embeds everything regardless. Required only on a fresh clone or after deliberate full rebuilds — `sdd search` lazy-fills missing/stale entries on demand. -- `sdd search [--term ...] [--query ] [--type d|s] [--layer ...] [--kind ...] [--include-superseded] [--limit N] [--max-citations N]` — three-mode retrieval. `--term` runs text mode (live grep with multi-term AND), `--query` runs vector mode, both flags together run hybrid (RRF fusion). At least one of `--term` / `--query` is required; vector and hybrid require `Search: vector,text` in the `sdd info` header. `--max-citations` caps the snippet sub-lines per entry (default 3); `--max-citations 0` suppresses them entirely, rendering entry headers only — the terse "which entries match, and what topics do they carry" lookup. See [search reference](search.md) for citation reading and mode selection guidance. +- `sdd lint` — check graph integrity (dangling refs, type mismatches, broken attachment links, missing summaries, and cross-repo references whose target repo isn't a declared dependency). When an embedding provider is configured, also reports the search index's fingerprint and drift count. +- `sdd index` — warm up the machine-global search index (content-addressed by repo identity and embedder fingerprint, outside the working tree) over every entry on disk. Skips entries whose stored hash and embedder fingerprint match the manifest; `--force` re-embeds everything regardless. `--repo ` (repeatable) / `--all-repos` also eagerly build the connected repos' indexes so the first cross-repo search is warm; `--force` reaches those connected indexes too. Required only on a fresh clone or after deliberate rebuilds — `sdd search` lazy-fills missing/stale entries on demand. +- `sdd search [--term ...] [--query ] [--type d|s] [--layer ...] [--kind ...] [--repo ...] [--all-repos] [--include-superseded] [--limit N] [--max-citations N]` — three-mode retrieval. `--term` runs text mode (live grep with multi-term AND), `--query` runs vector mode, both flags together run hybrid (RRF fusion). At least one of `--term` / `--query` is required; vector and hybrid require `Search: vector,text` in the `sdd info` header. `--repo ` (repeatable) / `--all-repos` fan out across connected repos and fuse results into one ranked list (see "Cross-repo references" below). `--max-citations` caps the snippet sub-lines per entry (default 3); `--max-citations 0` suppresses them entirely, rendering entry headers only — the terse "which entries match, and what topics do they carry" lookup. See [search reference](search.md) for citation reading and mode selection guidance. +- `sdd repo add ` — connect another graph for cross-repo references: clone its graph into a machine-local cache, verify its declared `repo_id`, register the connection, and record + commit a `dependencies:` entry in `.sdd/config.yaml`. Re-running for an already-connected URL just ensures the dependency is declared. +- `sdd repo list` — connected repos and whether each is cached. +- `sdd repo sync [repo-id ...]` — refresh connected caches to their latest pushed state (all, or the named repos). +- `sdd repo remove ` — drop a dependency from `.sdd/config.yaml` (project-scoped; the cache stays). Refuses when any local entry still references that repo — removal would strand those references — unless `--force`, which removes anyway and names each stranded reference. - `sdd wip start --exclusive --participant ` — create WIP marker - `sdd wip start --branch --exclusive --participant ` — create WIP marker, create git branch and check out to it - `sdd wip done ` — remove WIP marker (deletes branch if merged) @@ -33,6 +37,15 @@ Every argument that takes an entry ID — positional args on `sdd show`, `sdd su Short IDs are fine in user-facing narrative (catch-up tables, grooming summaries, dialogue). Never substitute them for full IDs in CLI calls you construct — a suffix collision would break the call later when the graph grows. +### Cross-repo IDs + +When repos are connected (see "Cross-repo references" below), an entry ID can name an entry in another graph: + +- **Cross-repo ID** (`:`, e.g. `github.com/acme/platform:20260709-...-d-cpt-abc`) — a target's canonical repo identity, then the entry within it. This is the form stored on disk for any cross-repo reference. +- **Bare ID across repos** — a short or unprefixed-full ID resolves against the union of the local graph and its declared dependencies, not just the local graph. A unique match resolves; a genuine collision across repos exits non-zero and lists the candidates (the local one bare, foreign ones fully prefixed). This applies wherever a typed ID is resolved — `sdd show` and the `show` MCP tool. Written references always expand to the full `:` form regardless of how you typed them. + +When you have only a short ID for a possibly-foreign entry, resolve it with `sdd show ` (a free read) and use the full ID it prints in any CLI call you construct. + ## `sdd view` pipeline `sdd view` runs a layout pipeline over the graph. Each section is a colon-chained sequence of function calls; multiple sections separate with commas. Render is always the section's terminator. Filters intersect cumulatively; non-filter modifiers (rank, page, name, render) apply last-write-wins per kind. @@ -334,3 +347,13 @@ echo "$PLAN" | sdd new d tac --kind plan --confidence high \ Use quoted `'EOF'` so markdown content with `$`, backticks, or backslashes is preserved verbatim. For scratch files you do want on disk, `.sdd/tmp/` is gitignored. +## Cross-repo references + +References usually stay within one graph. When reasoning genuinely builds on another repo's graph, connect that repo and reference across the boundary instead of re-deriving or paraphrasing it locally. + +- **Connect once** with `sdd repo add ` — clones the repo's graph into a machine-local cache, verifies the `repo_id` it declares, and records + commits a `dependencies:` entry in `.sdd/config.yaml`. The dependency is the portable record (like `go.mod`); a colleague who clones the repo runs `sdd repo add` for the same URL to connect their own cache. +- **Reference by cross-repo ID** — `:` in a `--refs` value. Any ref kind may cross the boundary, but lifecycle edges (`--closes`, `--supersedes`) stay within-graph: a decision can't close an entry in another repo. +- **Declared-dependency precondition** — a cross-repo ref must resolve to a *declared* dependency when captured, the same resolve-or-block gate that guards local refs. If the target repo isn't connected, offer to `sdd repo add` it first (and confirm the target entry is pushed) rather than dropping the connection to a local paraphrase. +- **The cache is pushed state** — a connected cache is a clone of the remote's pushed branch, not a working tree. A foreign entry resolves only after it's committed *and pushed*; `sdd repo sync` (and cross-repo `sdd show`/`sdd search`) refresh the cache. When a cross-repo ref won't resolve, the target is usually unpushed or the cache is stale. +- **Search across repos** with `--repo `/`--all-repos` on `sdd search` and `sdd view` — results fuse into one ranked list under a single shared embedder. `sdd index --repo `/`--all-repos` pre-warms a connected index so the first cross-repo search isn't slow. + diff --git a/.agents/skills/sdd/references/search.md b/.agents/skills/sdd/references/search.md index 9f5ab394..d48ae833 100644 --- a/.agents/skills/sdd/references/search.md +++ b/.agents/skills/sdd/references/search.md @@ -1,6 +1,6 @@ --- metadata: - sdd-content-hash: d1b377a84f9855ea2f041c5d19ff17942967db7e0b370f64545164ebe79565d8 + sdd-content-hash: fc9f862a1117260d72b72b4c56ec1cfd09b4702b9e09bd66307d95ef45829656 sdd-version: dev --- # sdd search @@ -43,6 +43,8 @@ The `--type`, `--layer`, `--kind` filters (the same filter shape `sdd view` comp `--limit N` caps the result count (default 10). +**Cross-repo scope.** `--repo ` (repeatable) and `--all-repos` widen the search to connected repos, fusing every selected graph's hits into one ranked list. Vector search across repos builds and queries every connected index under the single user-global embedder, so all indexes share one vector space and scores stay comparable. The first cross-repo query embeds a connected graph on demand (slow); `sdd index --repo `/`--all-repos` pre-warms it. See the CLI reference's "Cross-repo references" section for connecting repos and the pushed-state cache model. + ## Instruction templates Instruction-tuned encoders (Qwen3, E5, Nomic, BGE) want a small instruction or prefix prepended to each input. The framework applies this transparently via two config keys: @@ -92,7 +94,7 @@ If you switch templates after building, run `sdd index --force` to re-embed eage ## Lifecycle -Vector mode reads from a per-participant local index at `.sdd/index/` (gitignored). Two ways the index gets populated: +Vector mode reads from a machine-global index — a content-addressed store keyed by repo identity and embedder fingerprint, outside the working tree and shared across a repo's checkouts and worktrees. Two ways the index gets populated: - **`sdd index`** — explicit warm-up: builds chunks for every entry on disk. Run once on a fresh clone, after a major batch of new entries, or after changing the embedding model. `--force` re-embeds everything regardless. - **Lazy fill** — `sdd search` automatically chunks and embeds entries that are present on disk but missing from (or stale against) the index before the query runs. The first search after a branch switch or new captures may emit a few `lazy-indexed` lines; subsequent searches are fast. diff --git a/.claude/skills/sdd/SKILL.md b/.claude/skills/sdd/SKILL.md index d453fe32..b8184276 100644 --- a/.claude/skills/sdd/SKILL.md +++ b/.claude/skills/sdd/SKILL.md @@ -2,7 +2,7 @@ allowed-tools: Read Grep Bash(sdd *) description: Work with the SDD decision graph. Check in on project state, capture signals, make decisions, evaluate completed work. Use when starting a session, capturing observations, or making project decisions. name: sdd -sdd-content-hash: 3ecb7e94e699f40f62b5be08378ef5257ddf147c3ca6779f3ad7c3e2c329cd1b +sdd-content-hash: 84d8cfafa3972f5a3ef5e758cd96a98df66ae1574ad9c91613f9fba7712041d5 sdd-version: dev --- @@ -187,6 +187,12 @@ See [CLI reference](references/cli-reference.md) for full command syntax and fla - **Kind for signals**: Default is gap. Use `--kind done` for completion records (must carry `closes` or `refs`). Use `--kind fact`, `question`, or `insight` when the narrative is unambiguously observational, an open question, or a synthesis. Use `--kind actor` for a first-class participant identity — canonical name in frontmatter, prose covers stable identity facts (affiliation, background, domain expertise) that hold independent of any project frame. - **Acceptance criteria for plan decisions**: `--kind plan` decisions must include an `## Acceptance criteria` section in the description (not the attachment) with `- [ ]` checklist items. Each AC is a single verifiable outcome — not an implementation detail. ACs are the contract between plan author, implementing agent, and pre-flight validator. Pre-flight flags a missing AC section on a plan decision as high. +### Reference across repos when reasoning does + +When an entry's reasoning genuinely builds on another repo's graph — a shared framework's directive, a platform team's decision — reference it across the boundary rather than re-deriving it or reducing it to a local paraphrase. Cross-repo refs take the `:` form in a `--refs` value; every ref kind may cross, but `--closes` / `--supersedes` stay within-graph (a decision can't retire an entry in another repo). + +The target repo must be a **declared dependency** for the ref to resolve — the same resolve-or-block gate that guards local refs. If it isn't connected, offer to `sdd repo add ` first; don't connect a repo the user hasn't signalled they want to depend on. Remember a foreign entry is visible only once it's committed *and pushed* (the cache is pushed state, refreshed by `sdd repo sync`). When you have only a short ID for a foreign entry, resolve it with `sdd show ` — a free read that resolves bare IDs across the local graph and its declared dependencies — and use the full `:` it prints in the `--refs` value. Full mechanics live in [cli-reference → Cross-repo references](references/cli-reference.md). + ### Attachment assessment The entry description is the summary. The attachment is the record. When the conversation that led to capture involved more than a brief exchange, the entry needs an attachment. **Default to attaching** when the dialogue produced any of these: diff --git a/.claude/skills/sdd/references/cli-reference.md b/.claude/skills/sdd/references/cli-reference.md index df47e343..093c22a4 100644 --- a/.claude/skills/sdd/references/cli-reference.md +++ b/.claude/skills/sdd/references/cli-reference.md @@ -1,5 +1,5 @@ --- -sdd-content-hash: 811c581f825abe82c2dd5da8ba1ac7771ac105eb573131278384a1233e70741c +sdd-content-hash: 908e2569f24cb40ef23f4c46cac00a16596d2c2ceb441068358882a126ed2564 sdd-version: dev --- # SDD CLI Reference @@ -7,16 +7,20 @@ sdd-version: dev ## Commands - `sdd info` — session framing only: `Local participant: ...`, `Language: ...` (when configured), `Search: ...`. The session header surface for skill `!`sdd ...`` injections that need the agent to see who's local and which retrieval modes are available; also the bare-`sdd` default command. -- `sdd view --layout=` — composable pipeline of primitives (source, filter, transform, aggregate, rank, page, render) with named macros as sugar. The overview surface: `decisions` / `signals` / `aspirations` / `contracts` / `participants` / `insights` / `done` macros render the kind-grouped sections, and mechanical catch-up at scale. Bare `sdd view` prints help with vocabulary tables. See "`sdd view` pipeline" below. +- `sdd view --layout=` — composable pipeline of primitives (source, filter, transform, aggregate, rank, page, render) with named macros as sugar. The overview surface: `decisions` / `signals` / `aspirations` / `contracts` / `participants` / `insights` / `done` macros render the kind-grouped sections, and mechanical catch-up at scale. Bare `sdd view` prints help with vocabulary tables. `--repo ` (repeatable) / `--all-repos` fan out across connected repos. See "`sdd view` pipeline" below. - `sdd show ` — full entry plus its upstream (grounding) and downstream (consumers) chains. Both shown by default: upstream depth 2, downstream depth 1. - `sdd show [ ...]` — multiple IDs in one call render their entries back to back (handy for comparing a cluster, e.g. the entries a new one will ref) - `sdd show --up N --down N` — set the upstream and downstream expansion depths independently. Defaults: `--up 2 --down 1` (downstream fans out faster, so it stays shallower). `0` turns a direction off; `--up 0 --down 0` is the primary entry alone. Increase (e.g. `--up 4 --down 3`) to see more of an entry's surroundings on demand. - `sdd new [flags] ` — create entries (output prints the new entry ID, file path, and the LLM-generated summary so the agent can verify fidelity) - `sdd summarize ` — regenerate the named entry's summary unconditionally (summaries are derived on demand with no staleness tracking). `sdd summarize --all` fills only entries that have no summary yet; add `--force` to regenerate every entry. - `sdd summarize --text ""` — write a user-supplied summary directly, bypassing the LLM. Use `--text -` to read from stdin. Single entry only; rejected with `--all` or multiple IDs. -- `sdd lint` — check graph integrity (dangling refs, type mismatches, broken attachment links, missing summaries). When an embedding provider is configured, also reports the search index's fingerprint and drift count. -- `sdd index` — warm up the per-participant search index at `.sdd/index/` over every entry on disk. Skips entries whose stored hash and embedder fingerprint match the manifest; `--force` re-embeds everything regardless. Required only on a fresh clone or after deliberate full rebuilds — `sdd search` lazy-fills missing/stale entries on demand. -- `sdd search [--term ...] [--query ] [--type d|s] [--layer ...] [--kind ...] [--include-superseded] [--limit N] [--max-citations N]` — three-mode retrieval. `--term` runs text mode (live grep with multi-term AND), `--query` runs vector mode, both flags together run hybrid (RRF fusion). At least one of `--term` / `--query` is required; vector and hybrid require `Search: vector,text` in the `sdd info` header. `--max-citations` caps the snippet sub-lines per entry (default 3); `--max-citations 0` suppresses them entirely, rendering entry headers only — the terse "which entries match, and what topics do they carry" lookup. See [search reference](search.md) for citation reading and mode selection guidance. +- `sdd lint` — check graph integrity (dangling refs, type mismatches, broken attachment links, missing summaries, and cross-repo references whose target repo isn't a declared dependency). When an embedding provider is configured, also reports the search index's fingerprint and drift count. +- `sdd index` — warm up the machine-global search index (content-addressed by repo identity and embedder fingerprint, outside the working tree) over every entry on disk. Skips entries whose stored hash and embedder fingerprint match the manifest; `--force` re-embeds everything regardless. `--repo ` (repeatable) / `--all-repos` also eagerly build the connected repos' indexes so the first cross-repo search is warm; `--force` reaches those connected indexes too. Required only on a fresh clone or after deliberate rebuilds — `sdd search` lazy-fills missing/stale entries on demand. +- `sdd search [--term ...] [--query ] [--type d|s] [--layer ...] [--kind ...] [--repo ...] [--all-repos] [--include-superseded] [--limit N] [--max-citations N]` — three-mode retrieval. `--term` runs text mode (live grep with multi-term AND), `--query` runs vector mode, both flags together run hybrid (RRF fusion). At least one of `--term` / `--query` is required; vector and hybrid require `Search: vector,text` in the `sdd info` header. `--repo ` (repeatable) / `--all-repos` fan out across connected repos and fuse results into one ranked list (see "Cross-repo references" below). `--max-citations` caps the snippet sub-lines per entry (default 3); `--max-citations 0` suppresses them entirely, rendering entry headers only — the terse "which entries match, and what topics do they carry" lookup. See [search reference](search.md) for citation reading and mode selection guidance. +- `sdd repo add ` — connect another graph for cross-repo references: clone its graph into a machine-local cache, verify its declared `repo_id`, register the connection, and record + commit a `dependencies:` entry in `.sdd/config.yaml`. Re-running for an already-connected URL just ensures the dependency is declared. +- `sdd repo list` — connected repos and whether each is cached. +- `sdd repo sync [repo-id ...]` — refresh connected caches to their latest pushed state (all, or the named repos). +- `sdd repo remove ` — drop a dependency from `.sdd/config.yaml` (project-scoped; the cache stays). Refuses when any local entry still references that repo — removal would strand those references — unless `--force`, which removes anyway and names each stranded reference. - `sdd wip start --exclusive --participant ` — create WIP marker - `sdd wip start --branch --exclusive --participant ` — create WIP marker, create git branch and check out to it - `sdd wip done ` — remove WIP marker (deletes branch if merged) @@ -32,6 +36,15 @@ Every argument that takes an entry ID — positional args on `sdd show`, `sdd su Short IDs are fine in user-facing narrative (catch-up tables, grooming summaries, dialogue). Never substitute them for full IDs in CLI calls you construct — a suffix collision would break the call later when the graph grows. +### Cross-repo IDs + +When repos are connected (see "Cross-repo references" below), an entry ID can name an entry in another graph: + +- **Cross-repo ID** (`:`, e.g. `github.com/acme/platform:20260709-...-d-cpt-abc`) — a target's canonical repo identity, then the entry within it. This is the form stored on disk for any cross-repo reference. +- **Bare ID across repos** — a short or unprefixed-full ID resolves against the union of the local graph and its declared dependencies, not just the local graph. A unique match resolves; a genuine collision across repos exits non-zero and lists the candidates (the local one bare, foreign ones fully prefixed). This applies wherever a typed ID is resolved — `sdd show` and the `show` MCP tool. Written references always expand to the full `:` form regardless of how you typed them. + +When you have only a short ID for a possibly-foreign entry, resolve it with `sdd show ` (a free read) and use the full ID it prints in any CLI call you construct. + ## `sdd view` pipeline `sdd view` runs a layout pipeline over the graph. Each section is a colon-chained sequence of function calls; multiple sections separate with commas. Render is always the section's terminator. Filters intersect cumulatively; non-filter modifiers (rank, page, name, render) apply last-write-wins per kind. @@ -333,3 +346,13 @@ echo "$PLAN" | sdd new d tac --kind plan --confidence high \ Use quoted `'EOF'` so markdown content with `$`, backticks, or backslashes is preserved verbatim. For scratch files you do want on disk, `.sdd/tmp/` is gitignored. +## Cross-repo references + +References usually stay within one graph. When reasoning genuinely builds on another repo's graph, connect that repo and reference across the boundary instead of re-deriving or paraphrasing it locally. + +- **Connect once** with `sdd repo add ` — clones the repo's graph into a machine-local cache, verifies the `repo_id` it declares, and records + commits a `dependencies:` entry in `.sdd/config.yaml`. The dependency is the portable record (like `go.mod`); a colleague who clones the repo runs `sdd repo add` for the same URL to connect their own cache. +- **Reference by cross-repo ID** — `:` in a `--refs` value. Any ref kind may cross the boundary, but lifecycle edges (`--closes`, `--supersedes`) stay within-graph: a decision can't close an entry in another repo. +- **Declared-dependency precondition** — a cross-repo ref must resolve to a *declared* dependency when captured, the same resolve-or-block gate that guards local refs. If the target repo isn't connected, offer to `sdd repo add` it first (and confirm the target entry is pushed) rather than dropping the connection to a local paraphrase. +- **The cache is pushed state** — a connected cache is a clone of the remote's pushed branch, not a working tree. A foreign entry resolves only after it's committed *and pushed*; `sdd repo sync` (and cross-repo `sdd show`/`sdd search`) refresh the cache. When a cross-repo ref won't resolve, the target is usually unpushed or the cache is stale. +- **Search across repos** with `--repo `/`--all-repos` on `sdd search` and `sdd view` — results fuse into one ranked list under a single shared embedder. `sdd index --repo `/`--all-repos` pre-warms a connected index so the first cross-repo search isn't slow. + diff --git a/.claude/skills/sdd/references/search.md b/.claude/skills/sdd/references/search.md index d82dc4aa..ac43ee8d 100644 --- a/.claude/skills/sdd/references/search.md +++ b/.claude/skills/sdd/references/search.md @@ -1,5 +1,5 @@ --- -sdd-content-hash: d1b377a84f9855ea2f041c5d19ff17942967db7e0b370f64545164ebe79565d8 +sdd-content-hash: fc9f862a1117260d72b72b4c56ec1cfd09b4702b9e09bd66307d95ef45829656 sdd-version: dev --- # sdd search @@ -42,6 +42,8 @@ The `--type`, `--layer`, `--kind` filters (the same filter shape `sdd view` comp `--limit N` caps the result count (default 10). +**Cross-repo scope.** `--repo ` (repeatable) and `--all-repos` widen the search to connected repos, fusing every selected graph's hits into one ranked list. Vector search across repos builds and queries every connected index under the single user-global embedder, so all indexes share one vector space and scores stay comparable. The first cross-repo query embeds a connected graph on demand (slow); `sdd index --repo `/`--all-repos` pre-warms it. See the CLI reference's "Cross-repo references" section for connecting repos and the pushed-state cache model. + ## Instruction templates Instruction-tuned encoders (Qwen3, E5, Nomic, BGE) want a small instruction or prefix prepended to each input. The framework applies this transparently via two config keys: @@ -91,7 +93,7 @@ If you switch templates after building, run `sdd index --force` to re-embed eage ## Lifecycle -Vector mode reads from a per-participant local index at `.sdd/index/` (gitignored). Two ways the index gets populated: +Vector mode reads from a machine-global index — a content-addressed store keyed by repo identity and embedder fingerprint, outside the working tree and shared across a repo's checkouts and worktrees. Two ways the index gets populated: - **`sdd index`** — explicit warm-up: builds chunks for every entry on disk. Run once on a fresh clone, after a major batch of new entries, or after changing the embedding model. `--force` re-embeds everything regardless. - **Lazy fill** — `sdd search` automatically chunks and embeds entries that are present on disk but missing from (or stale against) the index before the query runs. The first search after a branch switch or new captures may emit a few `lazy-indexed` lines; subsequent searches are fast. diff --git a/README.md b/README.md index 3d61c1fc..55fc6529 100644 --- a/README.md +++ b/README.md @@ -116,6 +116,7 @@ The skill loads the graph state and suggests where to start. Everything after th - Focus decisions that commit the project's attention to a set of entries for a period, with actors assigned per target - A pre-flight validator (LLM based) — reviews a capture before it lands - Three-mode search: keyword, semantic (vector search), or a hybrid that fuses both +- Cross-repo references — connect another graph and reference, search, and traverse across repository boundaries - Composable views — filter, rank, and render the graph to make it accessible for the agent - Mining external material — transcripts, articles, meeting notes — into the graph through dialogue - Multilingual graph authoring, with translated SDD vocabulary in the skill @@ -293,9 +294,40 @@ German (`de`) is the only bundled locale today. **Contributing a locale upstream** — drop the file into `internal/bundledskills/claude/sdd/references/` and submit a PR. +## Connected repos + +Reasoning doesn't always stay in one repo. A service's decision might build on a platform team's directive; a project might ground its choices in a shared framework graph. SDD lets an entry reference across repository boundaries, so that reasoning connects instead of being silently re-derived or reduced to a local paraphrase. + +**Repo identity.** Each graph has a canonical, URL-shaped identity — its `repo_id` (e.g. `github.com/acme/platform`), derived from the origin remote and recorded in `.sdd/config.yaml` at `sdd init` time. A cross-repo reference is written as `:`: the target repo's identity, then the entry within it. + +**Connecting a repo.** To reference another graph, connect it once: + +```bash +sdd repo add https://github.com/acme/platform.git +``` + +This clones the repo's graph into a local cache, verifies the `repo_id` it declares, registers the connection, and records a committed **dependency** — a `dependencies:` entry in your `.sdd/config.yaml`, like a `go.mod` line. The dependency is the portable, shared record of what your graph reaches into; the connection (where the cache lives on your machine) stays per-contributor. `sdd repo add` commits the dependency declaration for you, so a colleague who clones your repo knows what to connect. + +- `sdd repo list` — the repos you've connected, and whether each is cached. +- `sdd repo sync [repo-id ...]` — refresh connected caches to their latest pushed state (all repos, or the named ones). +- `sdd repo remove ` — drop a dependency from `.sdd/config.yaml`. It refuses when any entry in your graph still references that repo (removing it would strand those references); `--force` overrides and names each reference it would strand. Removal is project-scoped — it leaves the local cache in place. + +**Referencing across repos.** With a repo connected, refer to its entries by full ID (`github.com/acme/platform:20260709-...-d-cpt-abc`) or by a **bare ID** — a short form (`d-cpt-abc`) or an unprefixed full ID — which SDD resolves across the union of your local graph and its declared dependencies. A bare ID that matches exactly one entry resolves; a genuine collision across repos is reported with the candidates so you can disambiguate. `sdd show d-cpt-abc` resolves and displays a connected repo's entry the same way it does a local one. References stored on an entry are always written in full `:` form, so the committed graph stays explicit and portable. A cross-repo reference must resolve to a declared dependency when it's captured — the same resolve-or-block rule that guards local references — and `sdd lint` re-checks it afterward as a standing invariant. + +**Searching across repos.** `sdd search` and `sdd view` reach connected graphs with `--repo ` (repeatable) or `--all-repos`: + +```bash +sdd search --query "index fingerprint" --all-repos +sdd view --layout='top(20)' --repo github.com/acme/platform +``` + +Results from every selected graph fuse into one ranked list. Vector search across repos builds and queries every connected index with a **single shared embedder** — the user-global embedding config — so all indexes live in one vector space and rankings stay comparable, regardless of any per-repo embedding override. The first cross-repo search embeds a connected graph on demand, which can be slow; pre-index eagerly with `sdd index --repo ` (or `--all-repos`) so it's warm, and `sdd index --repo --force` rebuilds a connected index that has drifted. + +**The cache is pushed state.** A connected repo's cache is a clone of the remote's *pushed* branch — not anyone's working tree. So a new entry in a connected repo becomes referenceable only after it's committed **and pushed**, and your cache picks it up on the next `sdd repo sync` (cross-repo `sdd show` and `sdd search` also freshen caches as they run). The same holds in reverse: for others to reference your entries, commit and push them. This is the one non-obvious part of the model — when a cross-repo reference won't resolve, the target is usually just unpushed, or the local cache is stale. + ## Configuration -Project-wide config lives in `.sdd/config.yaml` (committed — graph directory, language, skill scope). Per-contributor config lives in `.sdd/config.local.yaml` (gitignored — your participant name, LLM and embedding provider, API keys). +Project-wide config lives in `.sdd/config.yaml` (committed — graph directory, language, skill scope, and connected-repo [dependencies](#connected-repos)). Per-contributor config lives in `.sdd/config.local.yaml` (gitignored — your participant name, LLM and embedding provider, API keys). ### Re-running `sdd init` @@ -413,9 +445,10 @@ Bare `sdd view` prints the macro vocabulary and filter primitives. sdd search --term importer # keyword (live grep) sdd search --query "variants and references" # semantic (vector) sdd search --term importer --query "variants" # hybrid (RRF fusion) +sdd search --query "variants" --all-repos # fan out across connected repos ``` -Vector and hybrid modes require an embedding provider — see [Embedding provider](#embedding-provider-vector-search) above. +Vector and hybrid modes require an embedding provider — see [Embedding provider](#embedding-provider-vector-search) above. `--repo`/`--all-repos` reach connected graphs — see [Connected repos](#connected-repos). **`sdd show `** — one entry in full, with its upstream and downstream chains: @@ -437,10 +470,11 @@ your-project/ ├── graph/ │ ├── YYYY/MM/ # entries, e.g. 08-104102-d-prc-oka.md │ └── wip/ # active WIP markers - ├── index/ # search index (when embedding provider configured) └── tmp/ # scratch files (gitignored) ``` +The vector index and the connected-repo caches don't live in the project tree — they're per-machine derived state in a content-addressed store under your user cache directory (keyed by repo identity and embedder fingerprint), shared across a repo's checkouts and worktrees and rebuilt on demand. Nothing to commit or clean up by hand. + `sdd init` also installs the Claude Code skills at the agent's skill directory (defaults to `~/.claude/skills/`, or `.claude/skills/` with `--scope project`). Those paths are an implementation detail of the target agent — inspect them if you're curious, but they aren't part of your project's source tree. ## Docs diff --git a/cmd/sdd/main.go b/cmd/sdd/main.go index 8a1a4583..afe6c48e 100644 --- a/cmd/sdd/main.go +++ b/cmd/sdd/main.go @@ -3,6 +3,7 @@ package main import ( "context" "encoding/json" + "errors" "fmt" "io" "log/slog" @@ -304,6 +305,14 @@ func main() { } if err := app.Run(context.Background(), os.Args); err != nil { + // Ctrl-C during a long operation (e.g. an embed) cancels the work + // context. That is user intent, not a failure — report it calmly and + // exit with the conventional SIGINT code (130) rather than dumping a + // raw "context canceled" error at the generic error exit. + if errors.Is(err, context.Canceled) { + fmt.Fprintln(os.Stderr, "cancelled.") + os.Exit(130) + } slog.Error(err.Error()) os.Exit(1) } diff --git a/cmd/sdd/repo.go b/cmd/sdd/repo.go index eabcc01b..b53e56fe 100644 --- a/cmd/sdd/repo.go +++ b/cmd/sdd/repo.go @@ -4,13 +4,16 @@ import ( "context" "fmt" "os" + "path/filepath" "github.com/urfave/cli/v3" "github.com/networkteam/sdd/internal/cliout" clitui "github.com/networkteam/sdd/internal/cliout/tui" "github.com/networkteam/sdd/internal/command" + "github.com/networkteam/sdd/internal/git" "github.com/networkteam/sdd/internal/handlers" + "github.com/networkteam/sdd/internal/meta" "github.com/networkteam/sdd/internal/presenters" "github.com/networkteam/sdd/internal/repos" ) @@ -54,10 +57,24 @@ func repoCmd() *cli.Command { return struct{}{}, h.RepoAdd(ctx, addCmd) } + // The already-connected fast path does no clone. Starting the + // transient coordinator for that no-op would drain DEC + // mode-query escape sequences onto the shell prompt, so gate + // the view on a clone actually being needed — the same + // "already connected under this URL" test the handler applies. + willClone := true + if reg, _, rerr := defaultRepos(); rerr == nil { + if cfg, lerr := reg.Load(); lerr == nil { + if _, connected := cfg.ConnectedByURL(cmd.Args().First()); connected { + willClone = false + } + } + } + // The clone is the long, previously-silent step. On a TTY it // runs under the inline coordinator (spinner + streamed // "cloning" log); off-TTY it stays at the plain slog floor. - if cliout.IsInteractive(os.Stderr) { + if cliout.IsInteractive(os.Stderr) && willClone { _, err = clitui.Interactive(ctx, transientViewPolicy(), clitui.View{Label: "connecting", StreamLogs: true}, work) } else { @@ -79,7 +96,7 @@ func repoCmd() *cli.Command { } else { presenters.RenderResultLine(os.Stdout, fmt.Sprintf("declared dependency %s in .sdd/config.yaml", declaredRepoID), - "commit it so clones know what to connect") + "committed so clones know what to connect") } } return nil @@ -113,8 +130,14 @@ func repoCmd() *cli.Command { }, { Name: "remove", - Usage: "Disconnect a repo (its cache stays on disk)", + Usage: "Drop a declared cross-repo dependency from .sdd/config.yaml (ref-safety-guarded)", ArgsUsage: "", + Flags: []cli.Flag{ + &cli.BoolFlag{ + Name: "force", + Usage: "remove even when local entries still reference the repo, stranding those refs", + }, + }, Action: func(ctx context.Context, cmd *cli.Command) error { if cmd.Args().Len() != 1 { return fmt.Errorf("usage: sdd repo remove ") @@ -125,8 +148,15 @@ func repoCmd() *cli.Command { } return h.RepoRemove(ctx, &command.RepoRemoveCmd{ RepoID: cmd.Args().First(), + Force: cmd.Bool("force"), OnRemoved: func(repoID string) { - fmt.Printf("disconnected %s\n", repoID) + fmt.Printf("removed dependency %s from .sdd/config.yaml\n", repoID) + }, + OnStranded: func(repoID string, stranded []command.StrandedRef) { + fmt.Fprintf(os.Stderr, "warning: --force stranded %d ref(s) into %s:\n", len(stranded), repoID) + for _, s := range stranded { + fmt.Fprintf(os.Stderr, " %s %s %s\n", s.EntryID, s.Kind, s.RefID) + } }, }) }, @@ -170,7 +200,20 @@ func repoHandler() (*handlers.Handler, error) { if err != nil { sddDir = "" } - return handlers.New(handlers.Options{Reader: reader, Repos: mgr, SDDDir: sddDir}), nil + // GraphDir backs the ref-safety scan on repo remove; resolved best-effort + // since add/sync run fine outside a repo (where it stays empty). + graphDir := "" + if sddDir != "" { + cfg, _ := meta.ReadConfig(sddDir) + graphDir = meta.ResolveGraphDir(filepath.Dir(sddDir), cfg) + } + return handlers.New(handlers.Options{ + Reader: reader, + Repos: mgr, + SDDDir: sddDir, + GraphDir: graphDir, + Committer: git.CLI{}, + }), nil } // freshenRepoCaches brings the named connected repos' caches up to date diff --git a/cmd/sdd/search.go b/cmd/sdd/search.go index 12090e8e..51909a2e 100644 --- a/cmd/sdd/search.go +++ b/cmd/sdd/search.go @@ -285,7 +285,10 @@ func indexCmd() *cli.Command { return err } } + // --force reaches connected member stores too, repairing a stale + // or corrupt connected index rather than stopping at the local one. fill := &command.BuildConnectedIndexesCmd{ + Force: force, OnRepoStart: func(id string) { curRepo = id; reporter.SetNote("indexing " + id) }, OnPlanned: addPlanned, OnBatchStart: setNote, @@ -585,8 +588,16 @@ func searchCmd() *cli.Command { // The footer tracks the fill (embedding) — that's the work // taking time; the vector query after it is instant. So label it // "indexing", matching what the bar actually measures. - res, err = clitui.Interactive(ctx, transientViewPolicy(), - clitui.View{Label: "indexing", Progress: reporter, StreamLogs: false}, work) + view := clitui.View{Label: "indexing", Progress: reporter, StreamLogs: false} + if !needsVector { + // Text-only cross-repo does no embedding — the work is + // freshening connected caches (first-time clone / pull). A + // "chunks" bar would be meaningless and "indexing" a lie, so + // drop the bar, label it honestly, and stream the "cloning + // connected repo" log rather than hiding the wait. + view = clitui.View{Label: "fetching repos", StreamLogs: true} + } + res, err = clitui.Interactive(ctx, transientViewPolicy(), view, work) } else { res, err = work(ctx) } diff --git a/cmd/sdd/serve.go b/cmd/sdd/serve.go index 404241ee..a3df08da 100644 --- a/cmd/sdd/serve.go +++ b/cmd/sdd/serve.go @@ -123,22 +123,66 @@ func serveCmd() *cli.Command { // per-call lazy index fill when an embedding provider is configured, plain // text-term search otherwise. Cross-repo selection on the query fans out // through the same prepare-then-read split the CLI uses. +// +// It builds two vector stacks so the MCP server matches the CLI per mode: a +// local stack under the local-overlay embedder for local-only queries, and a +// cross-repo stack under the global-first embedder (crossRepoEmbedder) so a +// cross-repo query builds and queries the same one (repo-id, fingerprint) +// vector space the CLI does. When the two embedders coincide (no local +// override) the local stack is reused for both, so no second index store is +// opened. func buildServeSearcher(cmd *cli.Command, graphDir string, reader *finders.Finder, reg *repos.Registry, mgr *repos.Manager) (mcpserver.Searcher, bool, error) { - emb, err := buildEmbedder(cmd) + localEmb, err := buildEmbedder(cmd) if err != nil { return nil, false, err } - if emb == nil { + crossEmb, err := crossRepoEmbedder(cmd) + if err != nil { + return nil, false, err + } + prepare := handlers.New(handlers.Options{Reader: reader, Repos: mgr}) + + // No provider anywhere: text-only. localEmb == nil iff crossEmb == nil + // (both are driven by whether any provider resolves), so one finder serves + // both local and cross-repo queries. + if localEmb == nil { sf := finders.NewSearchFinder(finders.SearchFinderOptions{GraphDir: graphDir, Repos: reg}) - return lazyFillSearcher{sf: sf, prepare: handlers.New(handlers.Options{Reader: reader, Repos: mgr})}, false, nil + return lazyFillSearcher{localSF: sf, crossSF: sf, prepare: prepare}, false, nil } - idxDir, err := resolveIndexStore(emb) + + localSF, localIH, err := buildVectorStack(graphDir, reader, reg, localEmb) if err != nil { return nil, false, err } + + // The cross-repo stack uses the global-first embedder. Reuse the local + // stack when the fingerprints match (no local override) — same vector + // space, and opening the same store twice would contend on its lock. + crossSF, crossIH := localSF, localIH + if crossEmb.Fingerprint() != localEmb.Fingerprint() { + crossSF, crossIH, err = buildVectorStack(graphDir, reader, reg, crossEmb) + if err != nil { + return nil, false, err + } + } + + return lazyFillSearcher{ + localSF: localSF, localIH: localIH, + crossSF: crossSF, crossIH: crossIH, crossEmb: crossEmb, + prepare: prepare, + }, true, nil +} + +// buildVectorStack opens the machine-global index store for emb and returns +// the (search finder, index handler) pair that reads and lazy-fills it. +func buildVectorStack(graphDir string, reader *finders.Finder, reg *repos.Registry, emb llm.Embedder) (*finders.SearchFinder, *handlers.IndexHandler, error) { + idxDir, err := resolveIndexStore(emb) + if err != nil { + return nil, nil, err + } idxStore, err := index.Open(idxDir) if err != nil { - return nil, false, err + return nil, nil, err } sf := finders.NewSearchFinder(finders.SearchFinderOptions{ GraphDir: graphDir, @@ -153,31 +197,45 @@ func buildServeSearcher(cmd *cli.Command, graphDir string, reader *finders.Finde IndexStore: idxStore, Reader: reader, }) - return lazyFillSearcher{sf: sf, ih: ih, prepare: handlers.New(handlers.Options{Reader: reader, Repos: mgr}), emb: emb}, true, nil + return sf, ih, nil } // lazyFillSearcher fills missing or stale index entries before a vector // query — the same flow `sdd search` runs, without the TTY progress view. -// A query selecting connected repos additionally freshens their caches and +// It holds two stacks: a local-overlay stack for local-only queries and a +// global-first stack for cross-repo queries, matching the CLI's per-mode +// embedder resolution so both surfaces share one cross-repo vector space. A +// query selecting connected repos additionally freshens their caches and // member indexes (handler side), then reads via the cross-graph finder. type lazyFillSearcher struct { - sf *finders.SearchFinder - ih *handlers.IndexHandler - prepare *handlers.Handler - emb llm.Embedder + localSF *finders.SearchFinder + localIH *handlers.IndexHandler + crossSF *finders.SearchFinder + crossIH *handlers.IndexHandler + crossEmb llm.Embedder + prepare *handlers.Handler } func (l lazyFillSearcher) Search(ctx context.Context, q query.SearchQuery) (*query.SearchResult, error) { - if q.Phrase != "" && l.ih != nil { - if err := l.ih.LazyFill(ctx, &command.LazyFillIndexCmd{}); err != nil { + if q.AllRepos || len(q.Repos) > 0 { + // Cross-repo: the global-first embedder unifies the local index and + // every member index into one (repo-id, fingerprint) vector space, so + // the MCP server queries exactly what the CLI's cross-repo path does. + if q.Phrase != "" && l.crossIH != nil { + if err := l.crossIH.LazyFill(ctx, &command.LazyFillIndexCmd{}); err != nil { + return nil, err + } + } + if err := l.prepare.PrepareCrossRepoSearch(ctx, q, l.crossEmb, nil); err != nil { return nil, err } + return finders.MultiSearch(ctx, l.crossSF, q) } - if q.AllRepos || len(q.Repos) > 0 { - if err := l.prepare.PrepareCrossRepoSearch(ctx, q, l.emb, nil); err != nil { + // Local-only: the local-overlay embedder. + if q.Phrase != "" && l.localIH != nil { + if err := l.localIH.LazyFill(ctx, &command.LazyFillIndexCmd{}); err != nil { return nil, err } - return finders.MultiSearch(ctx, l.sf, q) } - return l.sf.Search(ctx, q) + return l.localSF.Search(ctx, q) } diff --git a/internal/baseprocedures/entries/20260703-094500-d-prc-cap.md b/internal/baseprocedures/entries/20260703-094500-d-prc-cap.md index 2f2d1e9f..7817fe5c 100644 --- a/internal/baseprocedures/entries/20260703-094500-d-prc-cap.md +++ b/internal/baseprocedures/entries/20260703-094500-d-prc-cap.md @@ -91,6 +91,8 @@ This capture retires{{range .closes}} {{.}}{{end}}. The body must state the reti **Refs** (`refs`, each `{id, kind, desc?}`, full entry IDs). The kind names why the pointer exists, chosen from what the body asserts — never a default: `grounded-in` (a basis reasoned from), `builds-on` (extending a closed line, or the next step after a finished chain), `refines` (sharpening an active target in place), `addresses` (responding to a gap/question/insight, or realizing a decision's commitment), `surfaces` / `surfaced-by` (this work produced the target / was produced by it), `depends-on` / `required-by` (prerequisite, from either side), `related` (the floor — a genuine sibling, only when nothing sharper fits). A terminal done signal is never "addressed" — reach for `builds-on`, `grounded-in`, or `surfaced-by`. The optional `desc` says why this ref exists in this entry's narrative. +A ref may point across a repo boundary, written as `:`. Resolve-or-block applies equally there: a cross-repo ref must resolve to a declared dependency of this graph, or capture blocks — so confirm the target resolves (via `show`) before drafting, and a bare foreign ID is stored in full prefixed form. The lifecycle fields `closes` and `supersedes` never cross the boundary — a decision retires only entries in its own graph. + **Topics** (`topics`). Labels are stable identifiers — reuse beats reinvention. Labels in use across active entries: {{.viewLayout}} diff --git a/internal/baseprocedures/entries/20260704-100000-d-prc-dlg.md b/internal/baseprocedures/entries/20260704-100000-d-prc-dlg.md index 0427a48a..5a557143 100644 --- a/internal/baseprocedures/entries/20260704-100000-d-prc-dlg.md +++ b/internal/baseprocedures/entries/20260704-100000-d-prc-dlg.md @@ -47,9 +47,9 @@ You are an SDD (Signal → Dialogue → Decision) partner in a dialogue session - Language: {{.sessionInfo.language}} — dialogue may flow in any language, but entry content is authored in this one{{end}} - Search: {{.sessionInfo.search}} -**The graph, in short.** An append-only record of the project's thinking. Two entry types: a signal records something noticed (kinds: gap, fact, question, insight, done, actor, annotation) and a decision records something committed to (kinds: directive, activity, plan, contract, aspiration, role, focus, procedure). Layers name thinking depth: strategic (why), conceptual (approach), tactical (structures and trade-offs), operational (steps), process (how we work). One loop drives everything: a signal meets dialogue and becomes a decision; completed work lands as a done signal that closes the commitment and feeds the next loop. Entries are immutable — status is derived from the graph, and retiring an entry means adding a closing or superseding entry, never editing. Summaries are pointers, not facts: before asserting what the graph holds — in dialogue as much as before capture — search from several angles and read the promising entries in full (`show`). +**The graph, in short.** An append-only record of the project's thinking. Two entry types: a signal records something noticed (kinds: gap, fact, question, insight, done, actor, annotation) and a decision records something committed to (kinds: directive, activity, plan, contract, aspiration, role, focus, procedure). Layers name thinking depth: strategic (why), conceptual (approach), tactical (structures and trade-offs), operational (steps), process (how we work). One loop drives everything: a signal meets dialogue and becomes a decision; completed work lands as a done signal that closes the commitment and feeds the next loop. Entries are immutable — status is derived from the graph, and retiring an entry means adding a closing or superseding entry, never editing. Summaries are pointers, not facts: before asserting what the graph holds — in dialogue as much as before capture — search from several angles and read the promising entries in full (`show`); both `search` and `show` reach the local graph and, when the query selects them, its connected dependencies. -**How the dialogue feels.** Be a colleague thinking alongside the user, not a report writer. Keep turns short; ask one question at a time and wait. Use plain, outcome-focused words — tool names, step ids, schema fields, and graph mechanics stay out of the conversation ("I'll record this as a decision", never the tool call that does it). Structured technical detail belongs in exactly one place: playback of a proposed entry, where the user verifies the fields verbatim. Cite entries by short ID in conversation (`d-tac-ry0`); pass full IDs to tools — and to turn a short ID into its full form, resolve it with `show`, which accepts an unambiguous short ID, never by reading graph files (reads are free, so do this up front; file paths don't exist for a remote graph). Anything generated for other people carries no SDD vocabulary at all. Frame SDD activity by what it does for the user — the answer is recorded, the decision is searchable — not by your procedure. +**How the dialogue feels.** Be a colleague thinking alongside the user, not a report writer. Keep turns short; ask one question at a time and wait. Use plain, outcome-focused words — tool names, step ids, schema fields, and graph mechanics stay out of the conversation ("I'll record this as a decision", never the tool call that does it). Structured technical detail belongs in exactly one place: playback of a proposed entry, where the user verifies the fields verbatim. Cite entries by short ID in conversation (`d-tac-ry0`); pass full IDs to tools — and to turn a short ID into its full form, resolve it with `show`, which accepts an unambiguous short or unprefixed ID and resolves it across the local graph and its declared dependencies (so a foreign entry's bare ID resolves too, expanding to full `:` form), never by reading graph files (reads are free, so do this up front; file paths don't exist for a remote graph). This resolve-first step is also how a bare ID becomes a move anchor: resolve it with `show`, then anchor on the full ID — anchor fields take the resolved form, not a bare one. Anything generated for other people carries no SDD vocabulary at all. Frame SDD activity by what it does for the user — the answer is recorded, the decision is searchable — not by your procedure. **Moves.** Start one with its own instance, parented to this one: diff --git a/internal/bundledskills/templates/sdd/SKILL.md.tmpl b/internal/bundledskills/templates/sdd/SKILL.md.tmpl index 53e23848..5598f46d 100644 --- a/internal/bundledskills/templates/sdd/SKILL.md.tmpl +++ b/internal/bundledskills/templates/sdd/SKILL.md.tmpl @@ -188,6 +188,12 @@ See [CLI reference](references/cli-reference.md) for full command syntax and fla - **Kind for signals**: Default is gap. Use `--kind done` for completion records (must carry `closes` or `refs`). Use `--kind fact`, `question`, or `insight` when the narrative is unambiguously observational, an open question, or a synthesis. Use `--kind actor` for a first-class participant identity — canonical name in frontmatter, prose covers stable identity facts (affiliation, background, domain expertise) that hold independent of any project frame. - **Acceptance criteria for plan decisions**: `--kind plan` decisions must include an `## Acceptance criteria` section in the description (not the attachment) with `- [ ]` checklist items. Each AC is a single verifiable outcome — not an implementation detail. ACs are the contract between plan author, implementing agent, and pre-flight validator. Pre-flight flags a missing AC section on a plan decision as high. +### Reference across repos when reasoning does + +When an entry's reasoning genuinely builds on another repo's graph — a shared framework's directive, a platform team's decision — reference it across the boundary rather than re-deriving it or reducing it to a local paraphrase. Cross-repo refs take the `:` form in a `--refs` value; every ref kind may cross, but `--closes` / `--supersedes` stay within-graph (a decision can't retire an entry in another repo). + +The target repo must be a **declared dependency** for the ref to resolve — the same resolve-or-block gate that guards local refs. If it isn't connected, offer to `sdd repo add ` first; don't connect a repo the user hasn't signalled they want to depend on. Remember a foreign entry is visible only once it's committed *and pushed* (the cache is pushed state, refreshed by `sdd repo sync`). When you have only a short ID for a foreign entry, resolve it with `sdd show ` — a free read that resolves bare IDs across the local graph and its declared dependencies — and use the full `:` it prints in the `--refs` value. Full mechanics live in [cli-reference → Cross-repo references](references/cli-reference.md). + ### Attachment assessment The entry description is the summary. The attachment is the record. When the conversation that led to capture involved more than a brief exchange, the entry needs an attachment. **Default to attaching** when the dialogue produced any of these: diff --git a/internal/bundledskills/templates/sdd/references/cli-reference.md.tmpl b/internal/bundledskills/templates/sdd/references/cli-reference.md.tmpl index fbdf8e92..66393a4b 100644 --- a/internal/bundledskills/templates/sdd/references/cli-reference.md.tmpl +++ b/internal/bundledskills/templates/sdd/references/cli-reference.md.tmpl @@ -3,16 +3,20 @@ ## Commands - `sdd info` — session framing only: `Local participant: ...`, `Language: ...` (when configured), `Search: ...`. The session header surface for skill `!`sdd ...`` injections that need the agent to see who's local and which retrieval modes are available; also the bare-`sdd` default command. -- `sdd view --layout=` — composable pipeline of primitives (source, filter, transform, aggregate, rank, page, render) with named macros as sugar. The overview surface: `decisions` / `signals` / `aspirations` / `contracts` / `participants` / `insights` / `done` macros render the kind-grouped sections, and mechanical catch-up at scale. Bare `sdd view` prints help with vocabulary tables. See "`sdd view` pipeline" below. +- `sdd view --layout=` — composable pipeline of primitives (source, filter, transform, aggregate, rank, page, render) with named macros as sugar. The overview surface: `decisions` / `signals` / `aspirations` / `contracts` / `participants` / `insights` / `done` macros render the kind-grouped sections, and mechanical catch-up at scale. Bare `sdd view` prints help with vocabulary tables. `--repo ` (repeatable) / `--all-repos` fan out across connected repos. See "`sdd view` pipeline" below. - `sdd show ` — full entry plus its upstream (grounding) and downstream (consumers) chains. Both shown by default: upstream depth 2, downstream depth 1. - `sdd show [ ...]` — multiple IDs in one call render their entries back to back (handy for comparing a cluster, e.g. the entries a new one will ref) - `sdd show --up N --down N` — set the upstream and downstream expansion depths independently. Defaults: `--up 2 --down 1` (downstream fans out faster, so it stays shallower). `0` turns a direction off; `--up 0 --down 0` is the primary entry alone. Increase (e.g. `--up 4 --down 3`) to see more of an entry's surroundings on demand. - `sdd new [flags] ` — create entries (output prints the new entry ID, file path, and the LLM-generated summary so the agent can verify fidelity) - `sdd summarize ` — regenerate the named entry's summary unconditionally (summaries are derived on demand with no staleness tracking). `sdd summarize --all` fills only entries that have no summary yet; add `--force` to regenerate every entry. - `sdd summarize --text ""` — write a user-supplied summary directly, bypassing the LLM. Use `--text -` to read from stdin. Single entry only; rejected with `--all` or multiple IDs. -- `sdd lint` — check graph integrity (dangling refs, type mismatches, broken attachment links, missing summaries). When an embedding provider is configured, also reports the search index's fingerprint and drift count. -- `sdd index` — warm up the per-participant search index at `.sdd/index/` over every entry on disk. Skips entries whose stored hash and embedder fingerprint match the manifest; `--force` re-embeds everything regardless. Required only on a fresh clone or after deliberate full rebuilds — `sdd search` lazy-fills missing/stale entries on demand. -- `sdd search [--term ...] [--query ] [--type d|s] [--layer ...] [--kind ...] [--include-superseded] [--limit N] [--max-citations N]` — three-mode retrieval. `--term` runs text mode (live grep with multi-term AND), `--query` runs vector mode, both flags together run hybrid (RRF fusion). At least one of `--term` / `--query` is required; vector and hybrid require `Search: vector,text` in the `sdd info` header. `--max-citations` caps the snippet sub-lines per entry (default 3); `--max-citations 0` suppresses them entirely, rendering entry headers only — the terse "which entries match, and what topics do they carry" lookup. See [search reference](search.md) for citation reading and mode selection guidance. +- `sdd lint` — check graph integrity (dangling refs, type mismatches, broken attachment links, missing summaries, and cross-repo references whose target repo isn't a declared dependency). When an embedding provider is configured, also reports the search index's fingerprint and drift count. +- `sdd index` — warm up the machine-global search index (content-addressed by repo identity and embedder fingerprint, outside the working tree) over every entry on disk. Skips entries whose stored hash and embedder fingerprint match the manifest; `--force` re-embeds everything regardless. `--repo ` (repeatable) / `--all-repos` also eagerly build the connected repos' indexes so the first cross-repo search is warm; `--force` reaches those connected indexes too. Required only on a fresh clone or after deliberate rebuilds — `sdd search` lazy-fills missing/stale entries on demand. +- `sdd search [--term ...] [--query ] [--type d|s] [--layer ...] [--kind ...] [--repo ...] [--all-repos] [--include-superseded] [--limit N] [--max-citations N]` — three-mode retrieval. `--term` runs text mode (live grep with multi-term AND), `--query` runs vector mode, both flags together run hybrid (RRF fusion). At least one of `--term` / `--query` is required; vector and hybrid require `Search: vector,text` in the `sdd info` header. `--repo ` (repeatable) / `--all-repos` fan out across connected repos and fuse results into one ranked list (see "Cross-repo references" below). `--max-citations` caps the snippet sub-lines per entry (default 3); `--max-citations 0` suppresses them entirely, rendering entry headers only — the terse "which entries match, and what topics do they carry" lookup. See [search reference](search.md) for citation reading and mode selection guidance. +- `sdd repo add ` — connect another graph for cross-repo references: clone its graph into a machine-local cache, verify its declared `repo_id`, register the connection, and record + commit a `dependencies:` entry in `.sdd/config.yaml`. Re-running for an already-connected URL just ensures the dependency is declared. +- `sdd repo list` — connected repos and whether each is cached. +- `sdd repo sync [repo-id ...]` — refresh connected caches to their latest pushed state (all, or the named repos). +- `sdd repo remove ` — drop a dependency from `.sdd/config.yaml` (project-scoped; the cache stays). Refuses when any local entry still references that repo — removal would strand those references — unless `--force`, which removes anyway and names each stranded reference. - `sdd wip start --exclusive --participant ` — create WIP marker - `sdd wip start --branch --exclusive --participant ` — create WIP marker, create git branch and check out to it - `sdd wip done ` — remove WIP marker (deletes branch if merged) @@ -28,6 +32,15 @@ Every argument that takes an entry ID — positional args on `sdd show`, `sdd su Short IDs are fine in user-facing narrative (catch-up tables, grooming summaries, dialogue). Never substitute them for full IDs in CLI calls you construct — a suffix collision would break the call later when the graph grows. +### Cross-repo IDs + +When repos are connected (see "Cross-repo references" below), an entry ID can name an entry in another graph: + +- **Cross-repo ID** (`:`, e.g. `github.com/acme/platform:20260709-...-d-cpt-abc`) — a target's canonical repo identity, then the entry within it. This is the form stored on disk for any cross-repo reference. +- **Bare ID across repos** — a short or unprefixed-full ID resolves against the union of the local graph and its declared dependencies, not just the local graph. A unique match resolves; a genuine collision across repos exits non-zero and lists the candidates (the local one bare, foreign ones fully prefixed). This applies wherever a typed ID is resolved — `sdd show` and the `show` MCP tool. Written references always expand to the full `:` form regardless of how you typed them. + +When you have only a short ID for a possibly-foreign entry, resolve it with `sdd show ` (a free read) and use the full ID it prints in any CLI call you construct. + ## `sdd view` pipeline `sdd view` runs a layout pipeline over the graph. Each section is a colon-chained sequence of function calls; multiple sections separate with commas. Render is always the section's terminator. Filters intersect cumulatively; non-filter modifiers (rank, page, name, render) apply last-write-wins per kind. @@ -329,3 +342,13 @@ echo "$PLAN" | sdd new d tac --kind plan --confidence high \ Use quoted `'EOF'` so markdown content with `$`, backticks, or backslashes is preserved verbatim. For scratch files you do want on disk, `.sdd/tmp/` is gitignored. +## Cross-repo references + +References usually stay within one graph. When reasoning genuinely builds on another repo's graph, connect that repo and reference across the boundary instead of re-deriving or paraphrasing it locally. + +- **Connect once** with `sdd repo add ` — clones the repo's graph into a machine-local cache, verifies the `repo_id` it declares, and records + commits a `dependencies:` entry in `.sdd/config.yaml`. The dependency is the portable record (like `go.mod`); a colleague who clones the repo runs `sdd repo add` for the same URL to connect their own cache. +- **Reference by cross-repo ID** — `:` in a `--refs` value. Any ref kind may cross the boundary, but lifecycle edges (`--closes`, `--supersedes`) stay within-graph: a decision can't close an entry in another repo. +- **Declared-dependency precondition** — a cross-repo ref must resolve to a *declared* dependency when captured, the same resolve-or-block gate that guards local refs. If the target repo isn't connected, offer to `sdd repo add` it first (and confirm the target entry is pushed) rather than dropping the connection to a local paraphrase. +- **The cache is pushed state** — a connected cache is a clone of the remote's pushed branch, not a working tree. A foreign entry resolves only after it's committed *and pushed*; `sdd repo sync` (and cross-repo `sdd show`/`sdd search`) refresh the cache. When a cross-repo ref won't resolve, the target is usually unpushed or the cache is stale. +- **Search across repos** with `--repo `/`--all-repos` on `sdd search` and `sdd view` — results fuse into one ranked list under a single shared embedder. `sdd index --repo `/`--all-repos` pre-warms a connected index so the first cross-repo search isn't slow. + diff --git a/internal/bundledskills/templates/sdd/references/search.md.tmpl b/internal/bundledskills/templates/sdd/references/search.md.tmpl index 9be8b940..ce20f330 100644 --- a/internal/bundledskills/templates/sdd/references/search.md.tmpl +++ b/internal/bundledskills/templates/sdd/references/search.md.tmpl @@ -38,6 +38,8 @@ The `--type`, `--layer`, `--kind` filters (the same filter shape `sdd view` comp `--limit N` caps the result count (default 10). +**Cross-repo scope.** `--repo ` (repeatable) and `--all-repos` widen the search to connected repos, fusing every selected graph's hits into one ranked list. Vector search across repos builds and queries every connected index under the single user-global embedder, so all indexes share one vector space and scores stay comparable. The first cross-repo query embeds a connected graph on demand (slow); `sdd index --repo `/`--all-repos` pre-warms it. See the CLI reference's "Cross-repo references" section for connecting repos and the pushed-state cache model. + ## Instruction templates Instruction-tuned encoders (Qwen3, E5, Nomic, BGE) want a small instruction or prefix prepended to each input. The framework applies this transparently via two config keys: @@ -87,7 +89,7 @@ If you switch templates after building, run `sdd index --force` to re-embed eage ## Lifecycle -Vector mode reads from a per-participant local index at `.sdd/index/` (gitignored). Two ways the index gets populated: +Vector mode reads from a machine-global index — a content-addressed store keyed by repo identity and embedder fingerprint, outside the working tree and shared across a repo's checkouts and worktrees. Two ways the index gets populated: - **`sdd index`** — explicit warm-up: builds chunks for every entry on disk. Run once on a fresh clone, after a major batch of new entries, or after changing the embedding model. `--force` re-embeds everything regardless. - **Lazy fill** — `sdd search` automatically chunks and embeds entries that are present on disk but missing from (or stale against) the index before the query runs. The first search after a branch switch or new captures may emit a few `lazy-indexed` lines; subsequent searches are fast. diff --git a/internal/command/index.go b/internal/command/index.go index ae79ee65..20cf699c 100644 --- a/internal/command/index.go +++ b/internal/command/index.go @@ -67,9 +67,16 @@ type LazyFillIndexCmd struct { // BuildConnectedIndexesCmd drives progress for filling one or more connected // repos' member indexes — the eager `sdd index --repo/--all-repos` path and // the fill half of a cross-repo search's prepare step. Each repo's fill is a -// LazyFill under the shared embedder; the callbacks aggregate across repos -// (the caller accumulates OnPlanned totals rather than resetting per repo). +// LazyFill under the shared embedder unless Force is set, in which case every +// member entry re-embeds; the callbacks aggregate across repos (the caller +// accumulates OnPlanned totals rather than resetting per repo). type BuildConnectedIndexesCmd struct { + // Force re-embeds every member entry, mirroring `sdd index --force` for + // the local index: it repairs a stale or corrupt connected store rather + // than only filling what a lazy reconcile would touch. Search never + // forces — only the explicit `sdd index --repo/--all-repos --force` sets it. + Force bool + // OnRepoStart fires before each repo's fill begins, naming the repo // whose member index is about to be reconciled. Optional; lets the // caller label the work in flight per repo. diff --git a/internal/command/repo.go b/internal/command/repo.go index d4e2113a..4ccb9543 100644 --- a/internal/command/repo.go +++ b/internal/command/repo.go @@ -32,13 +32,40 @@ func (c *RepoAddCmd) Validate() error { return nil } -// RepoRemoveCmd drops a connected repo from the user-global config. The -// cache directory is left on disk (read-only data; a re-add reuses it). +// RepoRemoveCmd drops a declared cross-repo dependency from the current +// repo's committed .sdd/config.yaml and commits that change. It is +// project-scoped and reference-safety-guarded: because entries are immutable, +// a referenced dependency is permanent, so removal is refused when any entry +// in this graph still holds a cross-repo ref into the target — dropping the +// declaration would strand those refs and retroactively break resolve-or-block. +// The per-user global connection and its on-disk cache are left untouched; +// machine-level teardown is a separate, future command surface. type RepoRemoveCmd struct { RepoID string - // OnRemoved fires after the connection is dropped. + // Force drops the dependency even when local entries still reference the + // target, stranding those refs. Required to proceed past the ref-safety + // guard; the stranded refs are named through OnStranded, never silently. + Force bool + + // OnRemoved fires after the dependency is dropped from the committed + // config and the change is committed. OnRemoved func(repoID string) + + // OnStranded fires when Force drops a still-referenced dependency, + // carrying the refs the removal stranded so the override stays loud. + OnStranded func(repoID string, stranded []StrandedRef) +} + +// StrandedRef names a local entry whose cross-repo reference into a removed +// dependency is left dangling by a forced `sdd repo remove`. +type StrandedRef struct { + // EntryID is the local entry that holds the reference. + EntryID string + // RefID is the full cross-repo ref it points at (repo-id:entry-id). + RefID string + // Kind is the reference kind (e.g. builds-on, grounded-in). + Kind string } // Validate checks the command's required fields. diff --git a/internal/finders/graphsource.go b/internal/finders/graphsource.go index 21517cda..0787290a 100644 --- a/internal/finders/graphsource.go +++ b/internal/finders/graphsource.go @@ -41,7 +41,7 @@ func (f *Finder) NewGraphSource(dir string) *GraphSource { if err != nil { return nil, err } - model.NewMultiGraph(g, f.memberGraphLoader()) + model.NewMultiGraph(g, f.declaredDependencies(), f.memberGraphLoader()) return g, nil }} } diff --git a/internal/finders/lint.go b/internal/finders/lint.go index 2022cd28..fa10d3d4 100644 --- a/internal/finders/lint.go +++ b/internal/finders/lint.go @@ -20,6 +20,7 @@ func (f *Finder) Lint(q query.LintQuery) (*query.LintResult, error) { } validateSummaries(q.Graph) + f.validateCrossRepoDeps(q.Graph) entries := q.Graph.Lint() total := 0 @@ -93,6 +94,40 @@ func (f *Finder) repoIndexLint(result *query.LintResult) { } } +// validateCrossRepoDeps flags every local entry holding a cross-repo ref +// whose target repo-id is not in the declared dependencies. This extends the +// resolve-or-block invariant from a capture-time gate into a standing check: +// it catches post-capture violations a fresh capture never would — a +// dependency dropped from .sdd/config.yaml while an entry still references it, +// or a hand-edited ref prefix. Distinct undeclared repo-ids are reported once +// per entry so the finding points at the exact entry and repo. Embedded base +// entries are skipped: they are framework-shipped and never reference a +// project's declared dependencies. +func (f *Finder) validateCrossRepoDeps(graph *model.Graph) { + declared := make(map[string]bool, len(f.declaredDependencies())) + for _, dep := range f.declaredDependencies() { + declared[dep] = true + } + for _, entry := range graph.Entries { + if entry.Embedded { + continue + } + seen := map[string]bool{} + for _, r := range entry.Refs { + repoID, _, ok := model.SplitCrossRepoID(r.ID) + if !ok || declared[repoID] || seen[repoID] { + continue + } + seen[repoID] = true + entry.Warnings = append(entry.Warnings, model.Warning{ + Field: "refs", + Value: repoID, + Message: fmt.Sprintf("cross-repo ref into %q, which is not a declared dependency in .sdd/config.yaml — declare it with `sdd repo add`, or the ref is stranded", repoID), + }) + } + } +} + // validateSummaries flags entries that have no summary yet. Under the // on-demand summary model (d-cpt-4qi) summaries carry no staleness tracking — // an entry's meaning is fixed at creation, so there is nothing to detect drift diff --git a/internal/finders/lint_test.go b/internal/finders/lint_test.go index 33e24760..5bcdbe77 100644 --- a/internal/finders/lint_test.go +++ b/internal/finders/lint_test.go @@ -23,6 +23,65 @@ func lintEntry(t *testing.T, id, summary string) *model.Entry { return e } +// lintRefEntry builds a parsed entry carrying a single cross-repo ref. +func lintRefEntry(t *testing.T, id, refID string) *model.Entry { + t.Helper() + fm := "---\ntype: signal\nlayer: process\nkind: gap\n" + + "summary: Has a cross-repo ref.\n" + + "refs:\n - id: " + refID + "\n kind: related\n" + + "---\n\nBody.\n" + e, err := model.ParseEntry(id+".md", fm) + if err != nil { + t.Fatalf("ParseEntry(%s): %v", id, err) + } + return e +} + +// TestLint_CrossRepoRefUndeclared flags a ref into a repo missing from the +// declared dependencies, and stays silent when the dependency is declared — +// the standing counterpart to capture-time resolve-or-block. +func TestLint_CrossRepoRefUndeclared(t *testing.T) { + const declaredRepo = "github.com/networkteam/declared" + const undeclaredRepo = "github.com/networkteam/undeclared" + + declaredEntry := lintRefEntry(t, "20260101-120000-s-prc-aaa", declaredRepo+":20260101-100000-s-tac-xxx") + undeclaredEntry := lintRefEntry(t, "20260101-120001-s-prc-bbb", undeclaredRepo+":20260101-100000-s-tac-yyy") + + g := model.NewGraph([]*model.Entry{declaredEntry, undeclaredEntry}) + f := finders.New(finders.Options{Config: &model.PerRepoConfig{Dependencies: []string{declaredRepo}}}) + + res, err := f.Lint(query.LintQuery{Graph: g}) + if err != nil { + t.Fatalf("Lint: %v", err) + } + + warnings := map[string][]model.Warning{} + for _, e := range res.Entries { + warnings[e.ID] = e.Warnings + } + + // The ref into the declared dependency raises no cross-repo warning. + for _, w := range warnings[declaredEntry.ID] { + if w.Field == "refs" { + t.Errorf("declared-dependency ref should not warn, got %+v", w) + } + } + + // The ref into the undeclared repo raises exactly one refs warning naming it. + var refWarnings []model.Warning + for _, w := range warnings[undeclaredEntry.ID] { + if w.Field == "refs" { + refWarnings = append(refWarnings, w) + } + } + if len(refWarnings) != 1 { + t.Fatalf("undeclared cross-repo ref should raise 1 refs warning, got %d: %+v", len(refWarnings), refWarnings) + } + if refWarnings[0].Value != undeclaredRepo { + t.Errorf("warning value = %q, want %q", refWarnings[0].Value, undeclaredRepo) + } +} + // TestLint_MissingSummaryOnly covers the on-demand summary model (d-cpt-4qi): // lint flags entries with no summary and stays silent on entries that have one. // There is no hash check, so no "stale summary hash" or "summary exists but no diff --git a/internal/finders/show.go b/internal/finders/show.go index 39ecd93c..be2fd12e 100644 --- a/internal/finders/show.go +++ b/internal/finders/show.go @@ -14,7 +14,11 @@ func (f *Finder) Show(q query.ShowQuery) (*query.ShowResult, error) { return nil, fmt.Errorf("graph is required") } - resolved, err := q.Graph.ResolveIDs(q.IDs) + // Bare IDs (short or unprefixed-full) resolve across the union of the + // local graph and its declared dependencies, so a foreign entry ID handed + // to `sdd show` or the `show` MCP tool resolves to its full prefixed form + // before the tree build. Both surfaces share this one path. + resolved, err := q.Graph.ResolveUnionIDs(q.IDs) if err != nil { return nil, err } diff --git a/internal/handlers/handler_repo.go b/internal/handlers/handler_repo.go index d0a687c5..56631109 100644 --- a/internal/handlers/handler_repo.go +++ b/internal/handlers/handler_repo.go @@ -5,6 +5,8 @@ import ( "fmt" "os" "path/filepath" + "slices" + "strings" "github.com/networkteam/slogutils" @@ -50,17 +52,15 @@ func (h *Handler) RepoAdd(ctx context.Context, cmd *command.RepoAddCmd) error { // Already connected under this URL: the resolution half is done — only // ensure the committed declaration. Errors when that too is already in // place, so a true no-op still reads as "nothing to do". - for _, existing := range cfg.Repos { - if existing.CloneURL == cmd.CloneURL { - declared, err := h.declareDependency(existing.RepoID, cmd) - if err != nil { - return err - } - if !declared { - return fmt.Errorf("repo %q is already connected and declared", existing.RepoID) - } - return nil + if existing, ok := cfg.ConnectedByURL(cmd.CloneURL); ok { + declared, err := h.declareDependency(existing.RepoID, cmd) + if err != nil { + return err + } + if !declared { + return fmt.Errorf("repo %q is already connected and declared", existing.RepoID) } + return nil } // Clone into a staging location under the cache root first — the @@ -164,37 +164,130 @@ func (h *Handler) declareDependency(repoID string, cmd *command.RepoAddCmd) (boo if err := os.WriteFile(path, patched, 0o644); err != nil { return false, fmt.Errorf("writing %s: %w", path, err) } + // Auto-commit the declaration, consistent with sdd init/new/summarize — + // the committed dependencies list is a shared, go.mod-style record other + // clones read to know what to connect, so it must not linger uncommitted. + if h.committer != nil { + if err := h.committer.Commit(fmt.Sprintf("sdd: repo add %s", repoID), path); err != nil { + return false, fmt.Errorf("committing dependency declaration: %w", err) + } + } if cmd.OnDeclared != nil { cmd.OnDeclared(repoID, false) } return true, nil } -// RepoRemove executes a RepoRemoveCmd, dropping the connection from the -// user-global config. The cache stays on disk. +// RepoRemove executes a RepoRemoveCmd: it drops repoID from the committed +// dependencies of the current repo's .sdd/config.yaml and commits that +// change. Project-scoped and reference-safety-guarded — it refuses when any +// local entry still holds a cross-repo ref into the target (dropping the +// declaration would strand those refs and retroactively break +// resolve-or-block), unless Force is set, in which case the stranded refs are +// named through OnStranded before the removal proceeds. The per-user global +// connection and its cache are left untouched — machine-level teardown is a +// separate command surface, out of scope here. func (h *Handler) RepoRemove(ctx context.Context, cmd *command.RepoRemoveCmd) error { if err := cmd.Validate(); err != nil { return fmt.Errorf("invalid command: %w", err) } - if h.repos == nil { - return errNoRepos + if h.sddDir == "" { + return fmt.Errorf("`sdd repo remove` is project-scoped — run it inside an sdd repo to drop a declared dependency") } - cfg, err := h.repos.Registry().Load() + slogutils.FromContext(ctx).Debug("removing declared dependency", "repo", cmd.RepoID, "force", cmd.Force) + + path := filepath.Join(h.sddDir, "config.yaml") + data, err := os.ReadFile(path) if err != nil { - return err + if os.IsNotExist(err) { + return fmt.Errorf("no .sdd/config.yaml here — nothing to remove") + } + return fmt.Errorf("reading %s: %w", path, err) + } + cfgFile, err := model.ParseConfig(data) + if err != nil { + return fmt.Errorf("%s: %w", path, err) } - if !cfg.RemoveRepo(cmd.RepoID) { - return fmt.Errorf("repo %q is not connected", cmd.RepoID) + if !slices.Contains(cfgFile.Dependencies, cmd.RepoID) { + return fmt.Errorf("repo %q is not a declared dependency in .sdd/config.yaml", cmd.RepoID) } - if err := h.repos.Save(cfg); err != nil { + + // Ref-safety guard: a local entry referencing the target would be + // orphaned by dropping the declaration. Refuse unless forced, and name + // the refs either way so the block (or the override) is never silent. + stranded, err := h.strandedRefs(cmd.RepoID) + if err != nil { return err } + if len(stranded) > 0 { + if !cmd.Force { + return fmt.Errorf("refusing to remove %q: %d local %s still reference it — removal would strand:\n%s\nre-run with --force to remove anyway", + cmd.RepoID, len(stranded), pluralEntries(len(stranded)), formatStranded(stranded)) + } + if cmd.OnStranded != nil { + cmd.OnStranded(cmd.RepoID, stranded) + } + } + + remaining := slices.DeleteFunc(slices.Clone(cfgFile.Dependencies), func(d string) bool { return d == cmd.RepoID }) + patched, err := model.SetYAMLSequence(data, "dependencies", remaining) + if err != nil { + return fmt.Errorf("removing dependency from %s: %w", path, err) + } + if err := os.WriteFile(path, patched, 0o644); err != nil { + return fmt.Errorf("writing %s: %w", path, err) + } + if h.committer != nil { + if err := h.committer.Commit(fmt.Sprintf("sdd: repo remove %s", cmd.RepoID), path); err != nil { + return fmt.Errorf("committing dependency removal: %w", err) + } + } if cmd.OnRemoved != nil { cmd.OnRemoved(cmd.RepoID) } return nil } +// strandedRefs scans the current graph for local entries that hold a +// cross-repo reference into repoID — the refs a `repo remove` of that +// dependency would orphan. Cross-repo lifecycle effects (closes, supersedes) +// are within-graph only, so only Refs can point across the boundary. +func (h *Handler) strandedRefs(repoID string) ([]command.StrandedRef, error) { + if h.reader == nil || h.graphDir == "" { + return nil, nil + } + graph, err := h.reader.CurrentGraph(h.graphDir) + if err != nil { + return nil, fmt.Errorf("loading graph for ref-safety check: %w", err) + } + var out []command.StrandedRef + for _, e := range graph.Entries { + for _, r := range e.Refs { + if target, _, ok := model.SplitCrossRepoID(r.ID); ok && target == repoID { + out = append(out, command.StrandedRef{EntryID: e.ID, RefID: r.ID, Kind: string(r.Kind)}) + } + } + } + return out, nil +} + +// formatStranded renders stranded refs as an indented, aligned block for the +// refusal message and the forced-removal warning. +func formatStranded(stranded []command.StrandedRef) string { + var b strings.Builder + for _, s := range stranded { + fmt.Fprintf(&b, " %s %s %s\n", s.EntryID, s.Kind, s.RefID) + } + return strings.TrimRight(b.String(), "\n") +} + +func pluralEntries(n int) string { + if n == 1 { + return "entry" + } + return "entries" +} + // RepoSync executes a RepoSyncCmd: force-pull the named connected repos' // caches (all of them when none are named), cloning lazily where absent. func (h *Handler) RepoSync(ctx context.Context, cmd *command.RepoSyncCmd) error { @@ -359,7 +452,10 @@ func (h *Handler) PrepareCrossRepoSearch(ctx context.Context, q query.SearchQuer // excluding the repo, so one vector space holds across every selected index. // It is the shared spine for eager pre-indexing (`sdd index --repo`) and the // fill half of a cross-repo search's prepare step. fill's callbacks report -// progress per repo and aggregate across them; a nil fill runs quiet. +// progress per repo and aggregate across them; a nil fill runs quiet. When +// fill.Force is set (only `sdd index --repo/--all-repos --force`), each member +// store is fully rebuilt rather than lazily reconciled, repairing stale or +// corrupt connected indexes. func (h *Handler) BuildConnectedIndexes(ctx context.Context, repoIDs []string, embedder llm.Embedder, fill *command.BuildConnectedIndexesCmd) error { if h.repos == nil { return errNoRepos @@ -406,6 +502,19 @@ func (h *Handler) BuildConnectedIndexes(ctx context.Context, repoIDs []string, e if fill.OnRepoStart != nil { fill.OnRepoStart(repoID) } + // Force rebuilds the whole member store (repairing a stale or corrupt + // index); the default lazy reconcile only touches what changed. + if fill.Force { + if err := ih.Build(ctx, &command.BuildIndexCmd{ + Force: true, + OnPlanned: fill.OnPlanned, + OnBatchStart: fill.OnBatchStart, + OnEntryIndexed: fill.OnEntryIndexed, + }); err != nil { + return fmt.Errorf("indexing %s: %w", repoID, err) + } + continue + } if err := ih.LazyFill(ctx, &command.LazyFillIndexCmd{ OnPlanned: fill.OnPlanned, OnBatchStart: fill.OnBatchStart, diff --git a/internal/handlers/handler_repo_test.go b/internal/handlers/handler_repo_test.go index a5b08c41..85e36ed4 100644 --- a/internal/handlers/handler_repo_test.go +++ b/internal/handlers/handler_repo_test.go @@ -13,6 +13,13 @@ import ( "github.com/networkteam/sdd/internal/repos" ) +// countingCommitter is the internal-package committer fake for the repo +// tests (recordingCommitter lives in the external handlers_test package and +// is not visible here). It records how many commits were made. +type countingCommitter struct{ calls int } + +func (c *countingCommitter) Commit(string, ...string) error { c.calls++; return nil } + // fakeGit is a no-op git for cross-repo cache tests: the cache is seeded // pre-cloned on disk, so Clone is never reached and PullFFOnly just counts // invocations (the cooldown pull EnsureReposFresh performs on a fresh read). @@ -173,6 +180,72 @@ func TestBuildConnectedIndexes_FreshensAndFills(t *testing.T) { } } +// A forced connected-index fill re-embeds every member entry despite an +// up-to-date manifest — the repair path `sdd index --repo --force` needs and +// that a plain lazy fill (which skips converged entries) would not provide. +func TestBuildConnectedIndexes_ForceRebuildsMembers(t *testing.T) { + dir := t.TempDir() + loc := repos.Locations{ + ConfigPath: filepath.Join(dir, "xdg", "sdd", "config.yaml"), + CacheRoot: filepath.Join(dir, "cache"), + } + const repoID = "github.com/networkteam/other" + + gcfg := &repos.GlobalConfig{} + if err := gcfg.AddRepo(repos.ConnectedRepo{RepoID: repoID, CloneURL: "git@github.com:networkteam/other.git"}); err != nil { + t.Fatal(err) + } + if err := repos.SaveConfigTo(loc.ConfigPath, gcfg); err != nil { + t.Fatal(err) + } + reg := repos.NewRegistry(loc) + cacheDir, err := reg.CacheDir(repoID) + if err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Join(cacheDir, ".git"), 0o755); err != nil { + t.Fatal(err) + } + memberSDD := filepath.Join(cacheDir, ".sdd") + if err := os.MkdirAll(memberSDD, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(memberSDD, "config.yaml"), + []byte("repo_id: "+repoID+"\ngraph_dir: .sdd/graph\n"), 0o644); err != nil { + t.Fatal(err) + } + writeEntry(t, filepath.Join(cacheDir, ".sdd", "graph"), + "20260101-100000-s-tac-aaa", "## A\nMember entry body.", "Member summary.") + + h := New(Options{Reader: readFinderFor(t), Repos: repos.NewManager(reg, &fakeGit{})}) + emb := &fakeEmbedder{} + + // First fill populates the member index + manifest. + if err := h.BuildConnectedIndexes(context.Background(), []string{repoID}, emb, &command.BuildConnectedIndexesCmd{}); err != nil { + t.Fatalf("initial lazy fill: %v", err) + } + + // A second lazy fill over the up-to-date index plans nothing. + lazyPlanned := 0 + if err := h.BuildConnectedIndexes(context.Background(), []string{repoID}, emb, + &command.BuildConnectedIndexesCmd{OnPlanned: func(n int) { lazyPlanned += n }}); err != nil { + t.Fatalf("second lazy fill: %v", err) + } + if lazyPlanned != 0 { + t.Errorf("lazy fill over an up-to-date index planned %d chunks, want 0", lazyPlanned) + } + + // A forced fill re-embeds every member entry despite the current manifest. + forcePlanned := 0 + if err := h.BuildConnectedIndexes(context.Background(), []string{repoID}, emb, + &command.BuildConnectedIndexesCmd{Force: true, OnPlanned: func(n int) { forcePlanned += n }}); err != nil { + t.Fatalf("forced fill: %v", err) + } + if forcePlanned == 0 { + t.Error("forced fill planned 0 chunks — --force did not reach the member index") + } +} + func TestRepoAdd_RejectsSelfDependency(t *testing.T) { h, _ := newRepoTestHandler(t, "repo_id: github.com/networkteam/other\n") err := h.RepoAdd(context.Background(), &command.RepoAddCmd{CloneURL: "git@github.com:networkteam/other.git"}) @@ -180,3 +253,149 @@ func TestRepoAdd_RejectsSelfDependency(t *testing.T) { t.Errorf("self-dependency must be rejected, got %v", err) } } + +const removeTargetRepoID = "github.com/networkteam/other" + +// newRemoveTestHandler seeds a project-scoped handler for repo remove: a +// committed .sdd/config.yaml (repoConfig) plus a graph dir and a read finder +// backing the ref-safety scan, with an injected committer to observe commits. +func newRemoveTestHandler(t *testing.T, repoConfig string, committer Committer) (*Handler, string, string) { + t.Helper() + dir := t.TempDir() + sddDir := filepath.Join(dir, ".sdd") + graphDir := filepath.Join(sddDir, "graph") + if err := os.MkdirAll(graphDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(sddDir, "config.yaml"), []byte(repoConfig), 0o644); err != nil { + t.Fatal(err) + } + return New(Options{SDDDir: sddDir, GraphDir: graphDir, Reader: readFinderFor(t), Committer: committer}), sddDir, graphDir +} + +// writeCrossRepoRefEntry writes a local entry that references a foreign entry — +// the kind of ref that makes a dependency unsafe to remove. +func writeCrossRepoRefEntry(t *testing.T, graphDir, id, foreignRef string) { + t.Helper() + yyyy, mm, short := id[:4], id[4:6], id[6:] + dir := filepath.Join(graphDir, yyyy, mm) + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + content := "---\ntype: signal\nlayer: tactical\nkind: gap\n" + + "confidence: medium\nparticipants:\n - Test\n" + + "refs:\n - id: " + foreignRef + "\n kind: related\n" + + "summary: |-\n References a foreign entry.\n---\nBody.\n" + if err := os.WriteFile(filepath.Join(dir, short+".md"), []byte(content), 0o644); err != nil { + t.Fatal(err) + } +} + +// A clean removal — the dependency is declared and nothing references it — +// drops it from the committed config and commits once. +func TestRepoRemove_CleanRemoval(t *testing.T) { + committer := &countingCommitter{} + h, sddDir, graphDir := newRemoveTestHandler(t, + "graph_dir: .sdd/graph\ndependencies: ["+removeTargetRepoID+"]\n# keep me\n", committer) + writeEntry(t, graphDir, "20260202-100000-s-tac-bbb", "## B\nUnrelated entry.", "Unrelated.") + + var removed []string + err := h.RepoRemove(context.Background(), &command.RepoRemoveCmd{ + RepoID: removeTargetRepoID, + OnRemoved: func(id string) { removed = append(removed, id) }, + }) + if err != nil { + t.Fatalf("clean removal: %v", err) + } + data, err := os.ReadFile(filepath.Join(sddDir, "config.yaml")) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(data), removeTargetRepoID) { + t.Errorf("dependency not removed: %s", data) + } + if !strings.Contains(string(data), "# keep me") { + t.Errorf("comment lost on removal: %s", data) + } + if committer.calls != 1 { + t.Errorf("Committer.Commit called %d times, want 1", committer.calls) + } + if len(removed) != 1 || removed[0] != removeTargetRepoID { + t.Errorf("OnRemoved calls = %v", removed) + } +} + +// Removing a repo that is not a declared dependency is an error, and nothing +// is committed. +func TestRepoRemove_NotDeclared(t *testing.T) { + committer := &countingCommitter{} + h, _, _ := newRemoveTestHandler(t, "graph_dir: .sdd/graph\n", committer) + err := h.RepoRemove(context.Background(), &command.RepoRemoveCmd{RepoID: removeTargetRepoID}) + if err == nil || !strings.Contains(err.Error(), "not a declared dependency") { + t.Errorf("undeclared removal should error, got %v", err) + } + if committer.calls != 0 { + t.Errorf("Committer.Commit called %d times, want 0", committer.calls) + } +} + +// The ref-safety guard refuses to remove a dependency that a local entry still +// references, names the stranding ref, and leaves the config untouched. +func TestRepoRemove_RefusesWhenReferenced(t *testing.T) { + committer := &countingCommitter{} + h, sddDir, graphDir := newRemoveTestHandler(t, + "graph_dir: .sdd/graph\ndependencies: ["+removeTargetRepoID+"]\n", committer) + foreignRef := removeTargetRepoID + ":20260101-100000-s-tac-aaa" + writeCrossRepoRefEntry(t, graphDir, "20260202-100000-s-tac-bbb", foreignRef) + + err := h.RepoRemove(context.Background(), &command.RepoRemoveCmd{RepoID: removeTargetRepoID}) + if err == nil || !strings.Contains(err.Error(), "refusing to remove") { + t.Fatalf("referenced removal should refuse, got %v", err) + } + if !strings.Contains(err.Error(), foreignRef) { + t.Errorf("refusal should name the stranding ref %q, got: %v", foreignRef, err) + } + data, err := os.ReadFile(filepath.Join(sddDir, "config.yaml")) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(data), removeTargetRepoID) { + t.Errorf("dependency should be unchanged after refusal: %s", data) + } + if committer.calls != 0 { + t.Errorf("Committer.Commit called %d times, want 0", committer.calls) + } +} + +// --force removes a still-referenced dependency, firing OnStranded with the +// refs it orphaned and committing the change. +func TestRepoRemove_ForceStrandsAndRemoves(t *testing.T) { + committer := &countingCommitter{} + h, sddDir, graphDir := newRemoveTestHandler(t, + "graph_dir: .sdd/graph\ndependencies: ["+removeTargetRepoID+"]\n", committer) + foreignRef := removeTargetRepoID + ":20260101-100000-s-tac-aaa" + writeCrossRepoRefEntry(t, graphDir, "20260202-100000-s-tac-bbb", foreignRef) + + var stranded []command.StrandedRef + err := h.RepoRemove(context.Background(), &command.RepoRemoveCmd{ + RepoID: removeTargetRepoID, + Force: true, + OnStranded: func(_ string, s []command.StrandedRef) { stranded = s }, + }) + if err != nil { + t.Fatalf("forced removal: %v", err) + } + if len(stranded) != 1 || stranded[0].RefID != foreignRef { + t.Errorf("OnStranded = %+v, want one ref %q", stranded, foreignRef) + } + data, err := os.ReadFile(filepath.Join(sddDir, "config.yaml")) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(data), removeTargetRepoID) { + t.Errorf("dependency not removed under --force: %s", data) + } + if committer.calls != 1 { + t.Errorf("Committer.Commit called %d times, want 1", committer.calls) + } +} diff --git a/internal/model/graph.go b/internal/model/graph.go index 299d8a04..0e825d9f 100644 --- a/internal/model/graph.go +++ b/internal/model/graph.go @@ -501,16 +501,19 @@ func (g *Graph) ResolveIDs(inputs []string) ([]string, error) { return out, nil } -// ResolveRefIDs resolves the ID component of each Ref against the graph, -// preserving Kind and Desc. Used by the new-entry handler so refs flow -// through the same short-form-to-full-form resolution as bare-ID fields. +// ResolveRefIDs resolves the ID component of each Ref, preserving Kind and +// Desc. Refs resolve across the union of the local graph and its declared +// dependencies (ResolveUnionID): a bare ID matching a foreign entry expands to +// its full repo-id-prefixed form, so a ref written to an entry is always +// stored in canonical cross-repo form. Local short IDs still expand to their +// full local form, and an already-prefixed cross-repo ID passes through. func (g *Graph) ResolveRefIDs(refs []Ref) ([]Ref, error) { if len(refs) == 0 { return refs, nil } out := make([]Ref, len(refs)) for i, r := range refs { - resolved, err := g.ResolveID(r.ID) + resolved, err := g.ResolveUnionID(r.ID) if err != nil { return nil, err } @@ -519,6 +522,140 @@ func (g *Graph) ResolveRefIDs(refs []Ref) ([]Ref, error) { return out, nil } +// ResolveUnionID resolves a bare ID (short {type}-{layer}-{suffix} or +// unprefixed full form) against the flat union of the local graph and its +// declared dependencies, with no local-first precedence. Exactly one distinct +// match resolves to its canonical ID (bare for a local entry, full +// repo-id-prefixed for a foreign one); zero matches pass through unchanged so +// the caller's "not found" surface fires; more than one is a genuine +// ambiguity and errors with the candidates listed. An already-prefixed +// cross-repo ID (:) passes through verbatim — it is already +// explicit. Framework-shipped entries that appear identically in the local +// graph and every dependency collapse to their single local instance rather +// than false-colliding. +func (g *Graph) ResolveUnionID(input string) (string, error) { + if input == "" { + return "", nil + } + if IsCrossRepoID(input) { + return input, nil + } + match := entryIDMatcher(input) + if match == nil { + // Not a recognizable ID shape — pass through so the caller's + // "entry not found" surface fires against the original text. + return input, nil + } + candidates := g.resolveCandidates(match) + switch len(candidates) { + case 0: + return input, nil + case 1: + return candidates[0].owner.nodeKeyFor(candidates[0].entry), nil + default: + labels := make([]string, len(candidates)) + for i, c := range candidates { + labels[i] = c.owner.nodeKeyFor(c.entry) + } + sort.Strings(labels) + return "", fmt.Errorf("ambiguous ID %q matches %d entries across the local graph and its dependencies:\n %s", + input, len(candidates), strings.Join(labels, "\n ")) + } +} + +// ResolveUnionIDs resolves a slice through ResolveUnionID, stopping on the +// first ambiguity. Used by read surfaces that accept a typed ID from a user or +// agent — `sdd show` and the `show` MCP tool. +func (g *Graph) ResolveUnionIDs(inputs []string) ([]string, error) { + if len(inputs) == 0 { + return inputs, nil + } + out := make([]string, len(inputs)) + for i, in := range inputs { + resolved, err := g.ResolveUnionID(in) + if err != nil { + return nil, err + } + out[i] = resolved + } + return out, nil +} + +// resolvedCandidate is one entry matched during union resolution, paired with +// the graph that owns it (local or a dependency member) so the resolved ID can +// be qualified correctly. +type resolvedCandidate struct { + entry *Entry + owner *Graph +} + +// resolveCandidates gathers the distinct entries matching the predicate across +// the local graph and its declared-dependency members. Candidates are deduped +// by full entry ID, preferring the local owner: a framework-shipped entry (the +// identical full ID merged into every member graph) collapses to its single +// local instance — so a project-superseded base entry keeps its local status — +// while entries that merely share a short ID across repos keep distinct full +// IDs and remain separate candidates, surfacing as an ambiguity. +func (g *Graph) resolveCandidates(match func(*Entry) bool) []resolvedCandidate { + byFullID := map[string]resolvedCandidate{} + var order []string + collect := func(owner *Graph) { + for _, e := range owner.Entries { + if !match(e) { + continue + } + existing, seen := byFullID[e.ID] + if !seen { + byFullID[e.ID] = resolvedCandidate{entry: e, owner: owner} + order = append(order, e.ID) + continue + } + // Same full ID already collected — the framework-entry / project + // -override case. Prefer the local instance so its local status + // (and any project supersession of a base entry) is what resolves. + if existing.owner.repoPrefix != "" && owner.repoPrefix == "" { + byFullID[e.ID] = resolvedCandidate{entry: e, owner: owner} + } + } + } + collect(g) + for _, member := range g.multi.dependencyGraphs() { + collect(member) + } + out := make([]resolvedCandidate, 0, len(order)) + for _, id := range order { + out = append(out, byFullID[id]) + } + return out +} + +// entryIDMatcher builds the entry predicate for a bare ID input: an exact +// full-ID match, or a short {type}-{layer}-{suffix} match. Returns nil when +// the input is neither shape, so the caller passes it through untouched. +func entryIDMatcher(input string) func(*Entry) bool { + if _, err := ParseID(input); err == nil { + return func(e *Entry) bool { return e.ID == input } + } + parts := strings.SplitN(input, "-", 3) + if len(parts) != 3 || parts[2] == "" { + return nil + } + typeCode, layerCode, suffix := parts[0], parts[1], parts[2] + if _, ok := TypeFromAbbrev[typeCode]; !ok { + return nil + } + if _, ok := LayerFromAbbrev[layerCode]; !ok { + return nil + } + return func(e *Entry) bool { + if TypeAbbrev[e.Type] != typeCode || LayerAbbrev[e.Layer] != layerCode { + return false + } + p, err := ParseID(e.ID) + return err == nil && p.Suffix == suffix + } +} + // Lint returns all entries that have validation warnings. func (g *Graph) Lint() []*Entry { var result []*Entry diff --git a/internal/model/multigraph.go b/internal/model/multigraph.go index 5b04a483..c372453a 100644 --- a/internal/model/multigraph.go +++ b/internal/model/multigraph.go @@ -18,6 +18,12 @@ import "sync" type MultiGraph struct { Local *Graph + // deps is the local graph's declared cross-repo dependencies (repo-ids) + // — the bounded scope for bare-ID resolution. Resolution never reaches a + // repo outside this set, matching the declared-dependency precondition + // that governs which repos a ref may point at. + deps []string + loader func(repoID string) (*Graph, error) mu sync.Mutex @@ -30,14 +36,35 @@ type memberState struct { } // NewMultiGraph assembles the cross-graph read model around a local graph. -// The local graph (and every lazily loaded member) gets back-wired so -// traversal code holding any *Graph can resolve across the boundary. -func NewMultiGraph(local *Graph, loader func(repoID string) (*Graph, error)) *MultiGraph { - m := &MultiGraph{Local: local, loader: loader, members: make(map[string]*memberState)} +// deps is the local graph's declared cross-repo dependencies (repo-ids), the +// bounded scope for bare-ID resolution. The local graph (and every lazily +// loaded member) gets back-wired so traversal code holding any *Graph can +// resolve across the boundary. +func NewMultiGraph(local *Graph, deps []string, loader func(repoID string) (*Graph, error)) *MultiGraph { + m := &MultiGraph{Local: local, deps: deps, loader: loader, members: make(map[string]*memberState)} local.multi = m return m } +// dependencyGraphs returns the loaded member graphs for the declared +// dependencies that are connected and cached, skipping any that are absent or +// fail to load — resolution treats an unreachable dependency as contributing +// no candidates, the same honest unresolved state a read renders. +func (m *MultiGraph) dependencyGraphs() []*Graph { + if m == nil { + return nil + } + var out []*Graph + for _, repoID := range m.deps { + g, err := m.Member(repoID) + if err != nil || g == nil { + continue + } + out = append(out, g) + } + return out +} + // Member returns the cached graph for a connected repo, loading it on first // use. nil with no error means the repo is not resolvable (not connected, // no cache) — the legitimate unresolved state; an error means a cache was diff --git a/internal/model/multigraph_test.go b/internal/model/multigraph_test.go index 9cf60658..763991a2 100644 --- a/internal/model/multigraph_test.go +++ b/internal/model/multigraph_test.go @@ -2,6 +2,7 @@ package model import ( "fmt" + "strings" "testing" ) @@ -32,7 +33,7 @@ func multiFixture(t *testing.T) (*Graph, *Graph) { succ := entry("20260401-100000-d-cpt-suc", withSupersedes("20260331-080000-d-cpt-old")) member := NewGraph([]*Entry{baseMember, rem, old, succ}) - NewMultiGraph(local, func(repoID string) (*Graph, error) { + NewMultiGraph(local, []string{otherRepo}, func(repoID string) (*Graph, error) { if repoID == otherRepo { return member, nil } @@ -62,9 +63,68 @@ func TestMultiGraph_ResolveAcross(t *testing.T) { } } +func TestResolveUnionID(t *testing.T) { + local, _ := multiFixture(t) + + cases := []struct { + name string + input string + want string + }{ + {"local short id", "d-tac-ccc", "20260410-100200-d-tac-ccc"}, + {"local full id", "20260410-100200-d-tac-ccc", "20260410-100200-d-tac-ccc"}, + {"foreign short id expands to prefixed", "d-cpt-rem", otherRepo + ":20260401-090000-d-cpt-rem"}, + {"foreign unprefixed-full expands to prefixed", "20260401-090000-d-cpt-rem", otherRepo + ":20260401-090000-d-cpt-rem"}, + // The embedded base entry is present in both local and the member; + // it must dedup to the single local instance (bare), not collide. + {"embedded entry dedups to local", "s-cpt-bas", "20260301-090000-s-cpt-bas"}, + // Already-prefixed cross-repo IDs pass through verbatim. + {"cross-repo prefixed passthrough", otherRepo + ":20260401-090000-d-cpt-rem", otherRepo + ":20260401-090000-d-cpt-rem"}, + // Unrecognized / unmatched inputs pass through so the caller's + // "not found" surface fires against the original text. + {"unknown short id passthrough", "d-tac-zzz", "d-tac-zzz"}, + {"empty passthrough", "", ""}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got, err := local.ResolveUnionID(tc.input) + if err != nil { + t.Fatalf("ResolveUnionID(%q): %v", tc.input, err) + } + if got != tc.want { + t.Errorf("ResolveUnionID(%q) = %q, want %q", tc.input, got, tc.want) + } + }) + } +} + +// A short ID matching genuinely distinct entries in the local graph and a +// dependency is a real ambiguity: resolution refuses (no local-first +// precedence) and lists the candidates, the local one bare and the foreign one +// in full prefixed form. +func TestResolveUnionID_Ambiguity(t *testing.T) { + local := NewGraph([]*Entry{entry("20260101-100000-d-tac-dup")}) + member := NewGraph([]*Entry{entry("20260202-200000-d-tac-dup")}) + NewMultiGraph(local, []string{otherRepo}, func(repoID string) (*Graph, error) { + if repoID == otherRepo { + return member, nil + } + return nil, nil + }) + + _, err := local.ResolveUnionID("d-tac-dup") + if err == nil { + t.Fatal("expected ambiguity error for a short ID matching local and a dependency") + } + if !strings.Contains(err.Error(), "20260101-100000-d-tac-dup") || + !strings.Contains(err.Error(), otherRepo+":20260202-200000-d-tac-dup") { + t.Errorf("ambiguity error should list both candidates (local bare, foreign prefixed), got: %v", err) + } +} + func TestMultiGraph_MemberLoadErrorPropagates(t *testing.T) { local := NewGraph([]*Entry{entry("20260410-100200-d-tac-ccc")}) - NewMultiGraph(local, func(repoID string) (*Graph, error) { + NewMultiGraph(local, []string{"example.com/team/broken"}, func(repoID string) (*Graph, error) { return nil, fmt.Errorf("corrupt cache") }) if _, err := local.MemberGraph("example.com/team/broken"); err == nil { diff --git a/internal/repos/config.go b/internal/repos/config.go index 385d99da..18f7a96e 100644 --- a/internal/repos/config.go +++ b/internal/repos/config.go @@ -132,6 +132,19 @@ func (c *GlobalConfig) Connected(repoID string) (ConnectedRepo, bool) { return ConnectedRepo{}, false } +// ConnectedByURL returns the connection whose clone URL matches cloneURL, if +// any — the `repo add` fast-path check (already connected under this URL), +// shared by the handler and the CLI's view-gate so both read one definition +// of "already connected". +func (c *GlobalConfig) ConnectedByURL(cloneURL string) (ConnectedRepo, bool) { + for _, r := range c.Repos { + if r.CloneURL == cloneURL { + return r, true + } + } + return ConnectedRepo{}, false +} + // AddRepo registers a connection. A connection with the same repo_id must // not already exist — re-registering is a remove + add, never a silent // overwrite.