From b486a729934386b6ef1c643f8334fa3e3a8aa528 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 17 Jul 2026 23:12:04 +0000 Subject: [PATCH 1/5] Add capture-nomination design to lattice Captures the design conversation on hook-based knowledge capture as lattice nodes (design-first, before any code): - SRC-AGENT-MEMORY-HOOKS: Bratanic's unified agentic memory article/repo - THX-MILESTONE-NOMINATION: git milestones are higher-signal nomination points than session boundaries; capture should nominate, not auto-commit (extends THX-CONTEXT-INFRA) - REQ-CAPTURE-001..005: staging surface, binding-intersection prefilter, pre-commit nominator, pre-push gate, nominate-not-commit guardrail Implements REQ-CAPTURE-001 REQ-CAPTURE-002 REQ-CAPTURE-003 REQ-CAPTURE-004 REQ-CAPTURE-005 (as specifications; no code yet) Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01PoEBdAc9emc4K3k3TfmX1T --- ...ination-staging-surface-outside-the-g.yaml | 15 ++++++++++++++ ...erministic-binding-intersection-relev.yaml | 15 ++++++++++++++ ...e-commit-lattice-worthiness-nominator.yaml | 20 +++++++++++++++++++ ...-push-consistency-gate-and-nomination.yaml | 18 +++++++++++++++++ .../005-nominate-not-commit-guardrail.yaml | 15 ++++++++++++++ .lattice/sources/agent-memory-hooks.yaml | 13 ++++++++++++ .lattice/theses/milestone-nomination.yaml | 20 +++++++++++++++++++ 7 files changed, 116 insertions(+) create mode 100644 .lattice/requirements/capture/001-nomination-staging-surface-outside-the-g.yaml create mode 100644 .lattice/requirements/capture/002-deterministic-binding-intersection-relev.yaml create mode 100644 .lattice/requirements/capture/003-pre-commit-lattice-worthiness-nominator.yaml create mode 100644 .lattice/requirements/capture/004-pre-push-consistency-gate-and-nomination.yaml create mode 100644 .lattice/requirements/capture/005-nominate-not-commit-guardrail.yaml create mode 100644 .lattice/sources/agent-memory-hooks.yaml create mode 100644 .lattice/theses/milestone-nomination.yaml diff --git a/.lattice/requirements/capture/001-nomination-staging-surface-outside-the-g.yaml b/.lattice/requirements/capture/001-nomination-staging-surface-outside-the-g.yaml new file mode 100644 index 0000000..9a86870 --- /dev/null +++ b/.lattice/requirements/capture/001-nomination-staging-surface-outside-the-g.yaml @@ -0,0 +1,15 @@ +id: REQ-CAPTURE-001 +type: requirement +title: Nomination staging surface outside the graph +body: AI-generated capture nominations are written to a staging surface outside .lattice/ (for example a lattice-inbox file), never directly into the graph. The staging surface supports review — accept, edit, or reject — and only approved nominations enter the graph through the lattice CLI. This is the foundational primitive the nominator and gate depend on. +status: active +version: 1.0.0 +created_at: 2026-07-17T23:11:27.481440641+00:00 +created_by: agent:claude +requested_by: Claude +priority: P1 +category: CAPTURE +edges: + derives_from: + - target: THX-MILESTONE-NOMINATION + version: 1.0.0 diff --git a/.lattice/requirements/capture/002-deterministic-binding-intersection-relev.yaml b/.lattice/requirements/capture/002-deterministic-binding-intersection-relev.yaml new file mode 100644 index 0000000..19ec0bd --- /dev/null +++ b/.lattice/requirements/capture/002-deterministic-binding-intersection-relev.yaml @@ -0,0 +1,15 @@ +id: REQ-CAPTURE-002 +type: requirement +title: Deterministic binding-intersection relevance prefilter +body: Before any model call, capture checks whether the staged diff intersects any implementation bind target (file:function). No intersection means no nomination and no LLM call. Intersection escalates to the nominator and passes the specific bound requirement and implementation IDs as context. This keeps evaluation cheap and high-precision and leverages the graph mapping that a flat memory store lacks. +status: active +version: 1.0.0 +created_at: 2026-07-17T23:11:27.521456476+00:00 +created_by: agent:claude +requested_by: Claude +priority: P0 +category: CAPTURE +edges: + derives_from: + - target: THX-MILESTONE-NOMINATION + version: 1.0.0 diff --git a/.lattice/requirements/capture/003-pre-commit-lattice-worthiness-nominator.yaml b/.lattice/requirements/capture/003-pre-commit-lattice-worthiness-nominator.yaml new file mode 100644 index 0000000..7619042 --- /dev/null +++ b/.lattice/requirements/capture/003-pre-commit-lattice-worthiness-nominator.yaml @@ -0,0 +1,20 @@ +id: REQ-CAPTURE-003 +type: requirement +title: pre-commit lattice-worthiness nominator +body: On pre-commit, when the relevance prefilter trips, an AI evaluates the staged diff and commit message against the current graph and emits typed nominations (new requirement, reveals_gap_in, challenges, validates) to the staging surface. The nominator must never block the commit and may run asynchronously if the model call is slow. Output is high-precision, capped at roughly three per commit, and returning nothing is a valid and common result. +status: active +version: 1.0.0 +created_at: 2026-07-17T23:11:27.599613578+00:00 +created_by: agent:claude +requested_by: Claude +priority: P1 +category: CAPTURE +edges: + derives_from: + - target: THX-MILESTONE-NOMINATION + version: 1.0.0 + depends_on: + - target: REQ-CAPTURE-001 + version: 1.0.0 + - target: REQ-CAPTURE-002 + version: 1.0.0 diff --git a/.lattice/requirements/capture/004-pre-push-consistency-gate-and-nomination.yaml b/.lattice/requirements/capture/004-pre-push-consistency-gate-and-nomination.yaml new file mode 100644 index 0000000..30efa68 --- /dev/null +++ b/.lattice/requirements/capture/004-pre-push-consistency-gate-and-nomination.yaml @@ -0,0 +1,18 @@ +id: REQ-CAPTURE-004 +type: requirement +title: pre-push consistency gate and nomination review +body: On pre-push, run lattice health --check as an opt-in hard gate and surface the accumulated unreviewed nominations together with any unresolved drift, prompting the user to review or defer before the change is shared. Push is the last-chance-before-shared moment and tolerates a heavier check than pre-commit. +status: active +version: 1.0.0 +created_at: 2026-07-17T23:11:27.654442887+00:00 +created_by: agent:claude +requested_by: Claude +priority: P1 +category: CAPTURE +edges: + derives_from: + - target: THX-MILESTONE-NOMINATION + version: 1.0.0 + depends_on: + - target: REQ-CAPTURE-001 + version: 1.0.0 diff --git a/.lattice/requirements/capture/005-nominate-not-commit-guardrail.yaml b/.lattice/requirements/capture/005-nominate-not-commit-guardrail.yaml new file mode 100644 index 0000000..fbc3f6b --- /dev/null +++ b/.lattice/requirements/capture/005-nominate-not-commit-guardrail.yaml @@ -0,0 +1,15 @@ +id: REQ-CAPTURE-005 +type: requirement +title: Nominate-not-commit guardrail +body: The capture system may only propose. It must never create, edit, or remove graph nodes or edges unattended. Every graph mutation requires explicit human or agent approval through the lattice CLI. This invariant is what lets passive evaluation run continuously without diluting a curated graph. +status: active +version: 1.0.0 +created_at: 2026-07-17T23:11:27.560755675+00:00 +created_by: agent:claude +requested_by: Claude +priority: P0 +category: CAPTURE +edges: + derives_from: + - target: THX-MILESTONE-NOMINATION + version: 1.0.0 diff --git a/.lattice/sources/agent-memory-hooks.yaml b/.lattice/sources/agent-memory-hooks.yaml new file mode 100644 index 0000000..e722b2f --- /dev/null +++ b/.lattice/sources/agent-memory-hooks.yaml @@ -0,0 +1,13 @@ +id: SRC-AGENT-MEMORY-HOOKS +type: source +title: Bratanic — Unified Agentic Memory Across Harnesses Using Hooks +body: Tomaz Bratanic (Neo4j). Argues agent memory should live below the harness so it is portable across Claude Code, Codex, and Cursor and owned by the user. Uses standardized lifecycle hooks (SessionStart, UserPromptSubmit, PreToolUse, PostToolUse, Stop) for passive deterministic capture into Neo4j as a linked list of session events, plus an offline dream phase where an LLM distills events into merged markdown memories at semantic paths. Injection loads profile memories at session start and fulltext-searched memories per prompt. Companion repo tomasonjo/agent-memory-hooks-neo4j implements hooks/log_event.py, hooks/inject_memory.py, and dream/dream.py. +status: active +version: 1.0.0 +created_at: 2026-07-17T23:11:08.798098348+00:00 +created_by: agent:claude +requested_by: Claude +meta: + url: https://towardsdatascience.com/unified-agentic-memory-across-harnesses-using-hooks/ + reliability: blog + retrieved_at: 2026-07-17 diff --git a/.lattice/theses/milestone-nomination.yaml b/.lattice/theses/milestone-nomination.yaml new file mode 100644 index 0000000..2caef96 --- /dev/null +++ b/.lattice/theses/milestone-nomination.yaml @@ -0,0 +1,20 @@ +id: THX-MILESTONE-NOMINATION +type: thesis +title: Git Milestones Are Higher-Signal Nomination Points Than Session Boundaries +body: 'Passively capturing raw agent session exhaust into the knowledge graph pollutes a curated reasoning DAG. Knowledge capture should instead trigger at semantic git milestones (pre-commit and pre-push), where a diff plus its intent-bearing message form a high-signal, self-contained unit far cleaner than a session transcript. At these milestones an AI evaluates the change against the existing graph and surfaces typed, human-reviewable nominations (new requirement, reveals_gap_in, challenges, validates) rather than writing nodes directly. This removes the cumbersome manual judgment of is-this-lattice-worthy while preserving curation discipline: the AI notices and proposes, a human commits. Lattice implementation bindings (file to requirement) give a deterministic relevance filter that keeps evaluation cheap and precise, something a flat memory store cannot do because it lacks the typed graph to diff against.' +status: draft +version: 1.0.0 +created_at: 2026-07-17T23:11:08.845201457+00:00 +created_by: agent:claude +requested_by: Claude +meta: + category: technical + confidence: 0.7 +edges: + supported_by: + - target: SRC-AGENT-MEMORY-HOOKS + version: 1.0.0 + extends: + - target: THX-CONTEXT-INFRA + version: 1.0.0 + rationale: 'Applies structured-knowledge-infrastructure to the capture path: curated, typed, human-reviewed nomination instead of unstructured passive logging.' From e262e336a7af71436b3de9b3180721d248e4e7a9 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 18 Jul 2026 00:05:15 +0000 Subject: [PATCH 2/5] Implement milestone knowledge capture (capture command group) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a nomination pipeline that surfaces lattice-worthy changes at git milestones without auto-writing the graph — the AI proposes, a human commits. - src/capture.rs: deterministic binding-intersection prefilter (changed files → implementations → satisfied requirements, no model call), plus a staging Inbox held outside .lattice/ (load/save/add/remove nominations) - lattice capture scan|add|list|show|accept|reject|gate|install-hooks wired in src/main.rs; accept materializes via existing add_requirement/ add_edge, the only path that mutates the graph (guardrail) - git pre-commit (non-blocking scan) and pre-push (health --check gate + nomination review) hook installer - 5 unit tests (prefilter hit/miss, inbox roundtrip + outside-.lattice guardrail, id allocation, kind→edge mapping) Recorded as IMP-CAPTURE-001..005; resolves REQ-CAPTURE-001..005; drift clean. Implements REQ-CAPTURE-001 REQ-CAPTURE-002 REQ-CAPTURE-003 REQ-CAPTURE-004 REQ-CAPTURE-005 Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01PoEBdAc9emc4K3k3TfmX1T --- .lattice/implementations/capture-001.yaml | 19 + .lattice/implementations/capture-002.yaml | 18 + .lattice/implementations/capture-003.yaml | 18 + .lattice/implementations/capture-004.yaml | 18 + .lattice/implementations/capture-005.yaml | 19 + ...ination-staging-surface-outside-the-g.yaml | 4 + ...erministic-binding-intersection-relev.yaml | 4 + ...e-commit-lattice-worthiness-nominator.yaml | 4 + ...-push-consistency-gate-and-nomination.yaml | 4 + .../005-nominate-not-commit-guardrail.yaml | 4 + CLAUDE.md | 9 + src/capture.rs | 462 ++++++++++++++++ src/lib.rs | 6 + src/main.rs | 502 ++++++++++++++++++ 14 files changed, 1091 insertions(+) create mode 100644 .lattice/implementations/capture-001.yaml create mode 100644 .lattice/implementations/capture-002.yaml create mode 100644 .lattice/implementations/capture-003.yaml create mode 100644 .lattice/implementations/capture-004.yaml create mode 100644 .lattice/implementations/capture-005.yaml create mode 100644 src/capture.rs diff --git a/.lattice/implementations/capture-001.yaml b/.lattice/implementations/capture-001.yaml new file mode 100644 index 0000000..11adb41 --- /dev/null +++ b/.lattice/implementations/capture-001.yaml @@ -0,0 +1,19 @@ +id: IMP-CAPTURE-001 +type: implementation +title: Nomination staging inbox +body: Inbox model and persistence in src/capture.rs (Inbox, Nomination, load_inbox, save_inbox, add_nomination, remove_nomination) plus the capture add/list/show/reject CLI in src/main.rs. Inbox lives at repo root outside .lattice/. +status: active +version: 1.0.0 +created_at: 2026-07-18T00:03:03.564669505+00:00 +created_by: agent:claude +requested_by: Claude +meta: + language: rust + files: + - path: src/capture.rs + - path: src/main.rs + test_command: 'cargo test --lib capture::' +edges: + satisfies: + - target: REQ-CAPTURE-001 + version: 1.0.0 diff --git a/.lattice/implementations/capture-002.yaml b/.lattice/implementations/capture-002.yaml new file mode 100644 index 0000000..6afd256 --- /dev/null +++ b/.lattice/implementations/capture-002.yaml @@ -0,0 +1,18 @@ +id: IMP-CAPTURE-002 +type: implementation +title: Binding-intersection relevance prefilter +body: Deterministic prefilter in src/capture.rs (prefilter, binding_map, staged_files, changed_files_since) intersecting git-changed files with implementation binding paths and resolving affected requirements via satisfies edges. No model call. +status: active +version: 1.0.0 +created_at: 2026-07-18T00:03:03.616395140+00:00 +created_by: agent:claude +requested_by: Claude +meta: + language: rust + files: + - path: src/capture.rs + test_command: 'cargo test --lib capture::' +edges: + satisfies: + - target: REQ-CAPTURE-002 + version: 1.0.0 diff --git a/.lattice/implementations/capture-003.yaml b/.lattice/implementations/capture-003.yaml new file mode 100644 index 0000000..268ef13 --- /dev/null +++ b/.lattice/implementations/capture-003.yaml @@ -0,0 +1,18 @@ +id: IMP-CAPTURE-003 +type: implementation +title: pre-commit nominator scan and hook +body: handle_capture Scan in src/main.rs emits the deterministic nomination bundle (prefilter + affected requirements + agent instructions); install_capture_hooks writes a non-blocking pre-commit hook that runs 'lattice capture scan --staged'. +status: active +version: 1.0.0 +created_at: 2026-07-18T00:03:03.674996693+00:00 +created_by: agent:claude +requested_by: Claude +meta: + language: rust + files: + - path: src/main.rs + test_command: 'cargo test --lib capture::' +edges: + satisfies: + - target: REQ-CAPTURE-003 + version: 1.0.0 diff --git a/.lattice/implementations/capture-004.yaml b/.lattice/implementations/capture-004.yaml new file mode 100644 index 0000000..6fe1116 --- /dev/null +++ b/.lattice/implementations/capture-004.yaml @@ -0,0 +1,18 @@ +id: IMP-CAPTURE-004 +type: implementation +title: pre-push gate and nomination review +body: handle_capture Gate in src/main.rs surfaces pending nominations and drift; install_capture_hooks writes a pre-push hook that runs 'lattice health --check' as the hard gate then 'lattice capture gate' for review. +status: active +version: 1.0.0 +created_at: 2026-07-18T00:03:03.727306612+00:00 +created_by: agent:claude +requested_by: Claude +meta: + language: rust + files: + - path: src/main.rs + test_command: 'cargo test --lib capture::' +edges: + satisfies: + - target: REQ-CAPTURE-004 + version: 1.0.0 diff --git a/.lattice/implementations/capture-005.yaml b/.lattice/implementations/capture-005.yaml new file mode 100644 index 0000000..215f4d6 --- /dev/null +++ b/.lattice/implementations/capture-005.yaml @@ -0,0 +1,19 @@ +id: IMP-CAPTURE-005 +type: implementation +title: Nominate-not-commit guardrail +body: 'Guardrail enforced structurally: capture writes only to the staging inbox outside .lattice/ (src/capture.rs add_nomination/save_inbox); the graph is mutated only by the explicit ''lattice capture accept'' path in src/main.rs, which routes through the existing add_requirement/add_edge CLI. Covered by the inbox_roundtrip_and_guardrail_stays_outside_lattice test.' +status: active +version: 1.0.0 +created_at: 2026-07-18T00:03:03.776112001+00:00 +created_by: agent:claude +requested_by: Claude +meta: + language: rust + files: + - path: src/capture.rs + - path: src/main.rs + test_command: 'cargo test --lib capture::' +edges: + satisfies: + - target: REQ-CAPTURE-005 + version: 1.0.0 diff --git a/.lattice/requirements/capture/001-nomination-staging-surface-outside-the-g.yaml b/.lattice/requirements/capture/001-nomination-staging-surface-outside-the-g.yaml index 9a86870..3abf964 100644 --- a/.lattice/requirements/capture/001-nomination-staging-surface-outside-the-g.yaml +++ b/.lattice/requirements/capture/001-nomination-staging-surface-outside-the-g.yaml @@ -9,6 +9,10 @@ created_by: agent:claude requested_by: Claude priority: P1 category: CAPTURE +resolution: + status: verified + resolved_at: 2026-07-18T00:03:09.265888931+00:00 + resolved_by: agent:claude-2026-07-18 edges: derives_from: - target: THX-MILESTONE-NOMINATION diff --git a/.lattice/requirements/capture/002-deterministic-binding-intersection-relev.yaml b/.lattice/requirements/capture/002-deterministic-binding-intersection-relev.yaml index 19ec0bd..64c8ffc 100644 --- a/.lattice/requirements/capture/002-deterministic-binding-intersection-relev.yaml +++ b/.lattice/requirements/capture/002-deterministic-binding-intersection-relev.yaml @@ -9,6 +9,10 @@ created_by: agent:claude requested_by: Claude priority: P0 category: CAPTURE +resolution: + status: verified + resolved_at: 2026-07-18T00:03:09.301540877+00:00 + resolved_by: agent:claude-2026-07-18 edges: derives_from: - target: THX-MILESTONE-NOMINATION diff --git a/.lattice/requirements/capture/003-pre-commit-lattice-worthiness-nominator.yaml b/.lattice/requirements/capture/003-pre-commit-lattice-worthiness-nominator.yaml index 7619042..b25f8dc 100644 --- a/.lattice/requirements/capture/003-pre-commit-lattice-worthiness-nominator.yaml +++ b/.lattice/requirements/capture/003-pre-commit-lattice-worthiness-nominator.yaml @@ -9,6 +9,10 @@ created_by: agent:claude requested_by: Claude priority: P1 category: CAPTURE +resolution: + status: verified + resolved_at: 2026-07-18T00:03:09.336230784+00:00 + resolved_by: agent:claude-2026-07-18 edges: derives_from: - target: THX-MILESTONE-NOMINATION diff --git a/.lattice/requirements/capture/004-pre-push-consistency-gate-and-nomination.yaml b/.lattice/requirements/capture/004-pre-push-consistency-gate-and-nomination.yaml index 30efa68..9ac3640 100644 --- a/.lattice/requirements/capture/004-pre-push-consistency-gate-and-nomination.yaml +++ b/.lattice/requirements/capture/004-pre-push-consistency-gate-and-nomination.yaml @@ -9,6 +9,10 @@ created_by: agent:claude requested_by: Claude priority: P1 category: CAPTURE +resolution: + status: verified + resolved_at: 2026-07-18T00:03:09.369924719+00:00 + resolved_by: agent:claude-2026-07-18 edges: derives_from: - target: THX-MILESTONE-NOMINATION diff --git a/.lattice/requirements/capture/005-nominate-not-commit-guardrail.yaml b/.lattice/requirements/capture/005-nominate-not-commit-guardrail.yaml index fbc3f6b..ce3b1bb 100644 --- a/.lattice/requirements/capture/005-nominate-not-commit-guardrail.yaml +++ b/.lattice/requirements/capture/005-nominate-not-commit-guardrail.yaml @@ -9,6 +9,10 @@ created_by: agent:claude requested_by: Claude priority: P0 category: CAPTURE +resolution: + status: verified + resolved_at: 2026-07-18T00:03:09.405366856+00:00 + resolved_by: agent:claude-2026-07-18 edges: derives_from: - target: THX-MILESTONE-NOMINATION diff --git a/CLAUDE.md b/CLAUDE.md index df1a7ca..7e8e958 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -127,6 +127,15 @@ lattice health --check # CI gate — exits 2 on FAIL lattice lint # Check for structural issues lattice diff # Show changes since a git ref lattice plan REQ-001 REQ-002 # Plan implementation order + +# Milestone Capture (nomination, not auto-commit) +lattice capture scan --staged # Prefilter staged diff against bindings; emit nomination bundle +lattice capture add --kind challenges --title ... --body ... --target THX-... # Stage a nomination +lattice capture list # Review pending nominations (inbox lives outside .lattice/) +lattice capture accept NOM-001 --from IMP-... # Materialize a nomination into the graph +lattice capture reject NOM-001 # Drop a nomination +lattice capture gate # Pre-push review — surface nominations + drift +lattice capture install-hooks # Install git pre-commit/pre-push hooks lattice export # Export narrative (overview) lattice export -a investor # Export for investors lattice export -f json # Export as JSON diff --git a/src/capture.rs b/src/capture.rs new file mode 100644 index 0000000..d16c6c1 --- /dev/null +++ b/src/capture.rs @@ -0,0 +1,462 @@ +//! Milestone-triggered knowledge capture. +//! +//! Implements a *nomination* pipeline: at git milestones (pre-commit, pre-push) +//! agent activity is evaluated against the graph and surfaced as typed, +//! human-reviewable proposals in a staging inbox — never written to the graph +//! directly. The AI notices and proposes; a human commits. +//! +//! Two deterministic building blocks live here: +//! * a binding-intersection *prefilter* that answers "does this change touch +//! anything the lattice tracks?" with no model call (REQ-CAPTURE-002), and +//! * a staging *inbox* held outside `.lattice/` so nominations can accumulate +//! without mutating the curated graph (REQ-CAPTURE-001, REQ-CAPTURE-005). +//! +//! Linked requirements: REQ-CAPTURE-001, REQ-CAPTURE-002, REQ-CAPTURE-003, +//! REQ-CAPTURE-004, REQ-CAPTURE-005 + +use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; +use std::process::Command; + +use crate::types::{LatticeNode, NodeMeta, NodeType}; + +/// Staging inbox file. Lives at the repo root, deliberately *outside* `.lattice/` +/// so nominations never masquerade as committed graph nodes (REQ-CAPTURE-001, 005). +pub const INBOX_FILE: &str = "lattice-inbox.yaml"; + +/// The kind of graph change a nomination proposes. Each maps to a typed lattice +/// operation applied only on explicit `accept`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum NominationKind { + /// Propose a brand-new requirement. + NewRequirement, + /// Propose a `reveals_gap_in` edge to an existing node. + RevealsGapIn, + /// Propose a `challenges` edge to an existing thesis. + Challenges, + /// Propose a `validates` edge to an existing thesis. + Validates, +} + +impl NominationKind { + /// Parse from a CLI string (accepts kebab or snake case). + pub fn parse(s: &str) -> Result { + match s.to_lowercase().replace('_', "-").as_str() { + "new-requirement" => Ok(Self::NewRequirement), + "reveals-gap-in" => Ok(Self::RevealsGapIn), + "challenges" => Ok(Self::Challenges), + "validates" => Ok(Self::Validates), + _ => Err(format!( + "Invalid nomination kind '{}'. Must be one of: new-requirement, reveals-gap-in, challenges, validates", + s + )), + } + } + + /// The lattice edge type this kind materializes into, or `None` for + /// `new-requirement` (which creates a node, not an edge). + pub fn edge_type(self) -> Option<&'static str> { + match self { + Self::NewRequirement => None, + Self::RevealsGapIn => Some("reveals_gap_in"), + Self::Challenges => Some("challenges"), + Self::Validates => Some("validates"), + } + } + + pub fn as_str(self) -> &'static str { + match self { + Self::NewRequirement => "new-requirement", + Self::RevealsGapIn => "reveals-gap-in", + Self::Challenges => "challenges", + Self::Validates => "validates", + } + } +} + +/// A single staged proposal awaiting human review. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct Nomination { + /// Stable inbox-local id, e.g. `NOM-001`. + pub id: String, + pub kind: NominationKind, + pub title: String, + pub body: String, + /// For edge kinds: the existing node the edge points to. For `new-requirement`: + /// an optional `derives_from` thesis id. + #[serde(skip_serializing_if = "Option::is_none")] + pub target: Option, + /// Proposed priority for a `new-requirement` (P0/P1/P2). + #[serde(skip_serializing_if = "Option::is_none")] + pub priority: Option, + /// Proposed category for a `new-requirement`. + #[serde(skip_serializing_if = "Option::is_none")] + pub category: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub rationale: Option, + /// Provenance, e.g. `pre-commit` or a commit sha — how the nomination arose. + #[serde(skip_serializing_if = "Option::is_none")] + pub source: Option, + pub created_at: String, + pub created_by: String, +} + +/// The staging inbox: an ordered list of pending nominations. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +pub struct Inbox { + #[serde(default)] + pub nominations: Vec, +} + +impl Inbox { + /// Allocate the next `NOM-NNN` id, one past the current maximum. + pub fn next_id(&self) -> String { + let max = self + .nominations + .iter() + .filter_map(|n| n.id.rsplit('-').next()) + .filter_map(|n| n.parse::().ok()) + .max() + .unwrap_or(0); + format!("NOM-{:03}", max + 1) + } + + pub fn get(&self, id: &str) -> Option<&Nomination> { + let want = id.to_uppercase(); + self.nominations.iter().find(|n| n.id == want) + } +} + +/// Path to the staging inbox for a given lattice root. +pub fn inbox_path(root: &Path) -> PathBuf { + root.join(INBOX_FILE) +} + +/// Load the inbox, returning an empty one if the file does not exist yet. +pub fn load_inbox(root: &Path) -> Result { + let path = inbox_path(root); + if !path.exists() { + return Ok(Inbox::default()); + } + let content = + std::fs::read_to_string(&path).map_err(|e| format!("failed to read inbox: {}", e))?; + if content.trim().is_empty() { + return Ok(Inbox::default()); + } + serde_yaml::from_str(&content).map_err(|e| format!("failed to parse inbox: {}", e)) +} + +/// Persist the inbox. When it becomes empty the file is removed to keep the tree clean. +pub fn save_inbox(root: &Path, inbox: &Inbox) -> Result<(), String> { + let path = inbox_path(root); + if inbox.nominations.is_empty() { + if path.exists() { + std::fs::remove_file(&path).map_err(|e| format!("failed to remove inbox: {}", e))?; + } + return Ok(()); + } + let content = + serde_yaml::to_string(inbox).map_err(|e| format!("failed to serialize inbox: {}", e))?; + std::fs::write(&path, content).map_err(|e| format!("failed to write inbox: {}", e)) +} + +/// Append a nomination to the inbox, assigning it a fresh id. Returns the stored copy. +/// +/// This writes only to the staging file — it never touches `.lattice/` +/// (the nominate-not-commit guardrail, REQ-CAPTURE-005). +pub fn add_nomination(root: &Path, mut nom: Nomination) -> Result { + let mut inbox = load_inbox(root)?; + nom.id = inbox.next_id(); + inbox.nominations.push(nom.clone()); + save_inbox(root, &inbox)?; + Ok(nom) +} + +/// Remove a nomination by id, returning it if present. +pub fn remove_nomination(root: &Path, id: &str) -> Result, String> { + let mut inbox = load_inbox(root)?; + let want = id.to_uppercase(); + if let Some(pos) = inbox.nominations.iter().position(|n| n.id == want) { + let removed = inbox.nominations.remove(pos); + save_inbox(root, &inbox)?; + Ok(Some(removed)) + } else { + Ok(None) + } +} + +/// One changed file that intersects a tracked implementation binding. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct BindingMatch { + pub file: String, + pub implementation: String, + /// Requirements the implementation satisfies (via `satisfies` edges). + pub requirements: Vec, +} + +/// Result of the deterministic relevance prefilter. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct PrefilterResult { + pub changed_files: Vec, + pub matches: Vec, +} + +impl PrefilterResult { + /// True when at least one changed file touches tracked surface — the signal + /// that escalates to AI nomination. + pub fn tripped(&self) -> bool { + !self.matches.is_empty() + } + + /// Distinct requirement ids implicated across all matches. + pub fn requirement_ids(&self) -> Vec { + let mut ids: Vec = self + .matches + .iter() + .flat_map(|m| m.requirements.iter().cloned()) + .collect(); + ids.sort(); + ids.dedup(); + ids + } +} + +/// Map every bound file path to the implementations that bind it. +pub fn binding_map(nodes: &[&LatticeNode]) -> BTreeMap> { + let mut map: BTreeMap> = BTreeMap::new(); + for node in nodes { + if node.node_type != NodeType::Implementation { + continue; + } + if let Some(NodeMeta::Implementation(meta)) = &node.meta + && let Some(files) = &meta.files + { + for f in files { + map.entry(f.path.clone()).or_default().push(node.id.clone()); + } + } + } + map +} + +/// Deterministic relevance prefilter (REQ-CAPTURE-002). +/// +/// Intersects `changed_files` with implementation binding paths and resolves the +/// affected requirement ids through each implementation's `satisfies` edges. No +/// model call — this is the cheap gate that decides whether AI nomination is +/// even worth invoking. +pub fn prefilter(nodes: &[&LatticeNode], changed_files: &[String]) -> PrefilterResult { + let bindings = binding_map(nodes); + let impl_by_id: BTreeMap<&str, &LatticeNode> = nodes + .iter() + .filter(|n| n.node_type == NodeType::Implementation) + .map(|n| (n.id.as_str(), *n)) + .collect(); + + let mut matches = Vec::new(); + for file in changed_files { + let Some(impl_ids) = bindings.get(file) else { + continue; + }; + for impl_id in impl_ids { + let requirements = impl_by_id + .get(impl_id.as_str()) + .map(|n| { + n.edges + .as_ref() + .and_then(|e| e.satisfies.as_ref()) + .map(|s| s.iter().map(|r| r.target.clone()).collect()) + .unwrap_or_default() + }) + .unwrap_or_default(); + matches.push(BindingMatch { + file: file.clone(), + implementation: impl_id.clone(), + requirements, + }); + } + } + + PrefilterResult { + changed_files: changed_files.to_vec(), + matches, + } +} + +/// Files staged for commit (`git diff --cached --name-only`). +pub fn staged_files(root: &Path) -> Vec { + git_name_only(root, &["diff", "--cached", "--name-only"]) +} + +/// Files changed since a git ref (`git diff --name-only `). +pub fn changed_files_since(root: &Path, git_ref: &str) -> Vec { + git_name_only(root, &["diff", "--name-only", git_ref]) +} + +fn git_name_only(root: &Path, args: &[&str]) -> Vec { + Command::new("git") + .args(args) + .current_dir(root) + .output() + .ok() + .filter(|o| o.status.success()) + .map(|o| { + String::from_utf8_lossy(&o.stdout) + .lines() + .filter(|l| !l.trim().is_empty()) + .map(|s| s.to_string()) + .collect() + }) + .unwrap_or_default() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::types::{ + EdgeReference, Edges, FileRef, ImplementationMeta, LatticeNode, NodeType, Status, + }; + + fn impl_node(id: &str, files: &[&str], satisfies: &[&str]) -> LatticeNode { + LatticeNode { + id: id.to_string(), + node_type: NodeType::Implementation, + title: id.to_string(), + body: String::new(), + status: Status::Active, + version: "1.0.0".to_string(), + created_at: "now".to_string(), + created_by: "test".to_string(), + requested_by: None, + priority: None, + category: None, + tags: None, + acceptance: None, + visibility: None, + resolution: None, + meta: Some(NodeMeta::Implementation(ImplementationMeta { + language: None, + files: Some( + files + .iter() + .map(|p| FileRef { + path: p.to_string(), + functions: None, + }) + .collect(), + ), + test_command: None, + })), + edges: Some(Edges { + satisfies: Some( + satisfies + .iter() + .map(|t| EdgeReference { + target: t.to_string(), + version: Some("1.0.0".to_string()), + rationale: None, + }) + .collect(), + ), + ..Default::default() + }), + } + } + + #[test] + fn prefilter_matches_bound_file_and_resolves_requirements() { + let a = impl_node("IMP-001", &["src/storage.rs"], &["REQ-CORE-001"]); + let nodes = vec![&a]; + let changed = vec!["src/storage.rs".to_string(), "README.md".to_string()]; + let result = prefilter(&nodes, &changed); + assert!(result.tripped()); + assert_eq!(result.matches.len(), 1); + assert_eq!(result.matches[0].implementation, "IMP-001"); + assert_eq!(result.requirement_ids(), vec!["REQ-CORE-001"]); + } + + #[test] + fn prefilter_is_silent_when_no_bound_file_changes() { + let a = impl_node("IMP-001", &["src/storage.rs"], &["REQ-CORE-001"]); + let nodes = vec![&a]; + let changed = vec!["README.md".to_string(), "docs/x.md".to_string()]; + let result = prefilter(&nodes, &changed); + assert!(!result.tripped()); + assert!(result.requirement_ids().is_empty()); + } + + #[test] + fn inbox_assigns_incrementing_ids() { + let mut inbox = Inbox::default(); + assert_eq!(inbox.next_id(), "NOM-001"); + inbox.nominations.push(Nomination { + id: "NOM-001".to_string(), + kind: NominationKind::NewRequirement, + title: "t".to_string(), + body: "b".to_string(), + target: None, + priority: None, + category: None, + rationale: None, + source: None, + created_at: "now".to_string(), + created_by: "test".to_string(), + }); + assert_eq!(inbox.next_id(), "NOM-002"); + } + + #[test] + fn inbox_roundtrip_and_guardrail_stays_outside_lattice() { + let dir = std::env::temp_dir().join(format!("lattice-capture-test-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let nom = Nomination { + id: String::new(), + kind: NominationKind::Challenges, + title: "contradiction spotted".to_string(), + body: "the diff contradicts THX-VERSION-AWARE".to_string(), + target: Some("THX-VERSION-AWARE".to_string()), + priority: None, + category: None, + rationale: None, + source: Some("pre-commit".to_string()), + created_at: "now".to_string(), + created_by: "agent:test".to_string(), + }; + let stored = add_nomination(&dir, nom).unwrap(); + assert_eq!(stored.id, "NOM-001"); + + // The inbox lives outside .lattice/ (guardrail, REQ-CAPTURE-005). + assert!(inbox_path(&dir).ends_with("lattice-inbox.yaml")); + assert!(!inbox_path(&dir).to_string_lossy().contains(".lattice")); + + let loaded = load_inbox(&dir).unwrap(); + assert_eq!(loaded.nominations.len(), 1); + assert_eq!( + loaded.get("nom-001").unwrap().kind, + NominationKind::Challenges + ); + + let removed = remove_nomination(&dir, "NOM-001").unwrap(); + assert!(removed.is_some()); + // File is cleaned up once empty. + assert!(!inbox_path(&dir).exists()); + + std::fs::remove_dir_all(&dir).ok(); + } + + #[test] + fn nomination_kind_maps_to_edge_type() { + assert_eq!(NominationKind::NewRequirement.edge_type(), None); + assert_eq!( + NominationKind::RevealsGapIn.edge_type(), + Some("reveals_gap_in") + ); + assert_eq!( + NominationKind::parse("validates").unwrap(), + NominationKind::Validates + ); + assert!(NominationKind::parse("bogus").is_err()); + } +} diff --git a/src/lib.rs b/src/lib.rs index b04d103..fcaebf6 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -4,6 +4,7 @@ //! interconnected knowledge nodes (sources, theses, requirements, implementations) //! with version-bound edges and drift detection. +pub mod capture; pub mod diff; pub mod export; pub mod graph; @@ -16,6 +17,11 @@ pub mod storage; pub mod types; pub mod update; +pub use capture::{ + BindingMatch, Inbox, Nomination, NominationKind, PrefilterResult, add_nomination, binding_map, + changed_files_since, inbox_path, load_inbox, prefilter, remove_nomination, save_inbox, + staged_files, +}; pub use diff::{ ChangeType, DiffEntry, DiffError, DiffResult, compute_changed_fields, format_diff_markdown, format_entry_text, git_head_sha, lattice_diff, diff --git a/src/main.rs b/src/main.rs index d488892..1b62126 100644 --- a/src/main.rs +++ b/src/main.rs @@ -434,6 +434,12 @@ enum Commands { output: Option, }, + /// Milestone knowledge capture — prefilter, nomination inbox, pre-push gate + Capture { + #[command(subcommand)] + capture_command: CaptureCommands, + }, + // ── Setup ─────────────────────────────────────────────────────── /// Initialize a new lattice in the current directory Init { @@ -751,6 +757,122 @@ enum AddCommands { }, } +#[derive(Subcommand)] +enum CaptureCommands { + /// Run the deterministic relevance prefilter and emit a nomination bundle + Scan { + /// Use staged changes (git diff --cached) — for pre-commit hooks + #[arg(long, conflicts_with = "since")] + staged: bool, + + /// Compare against a git ref instead of the staged index + #[arg(long)] + since: Option, + + /// Output format (text, json) + #[arg(short, long, default_value = "text")] + format: String, + }, + + /// Add a nomination to the staging inbox (does not touch the graph) + Add { + /// Kind: new-requirement, reveals-gap-in, challenges, validates + #[arg(long)] + kind: String, + + /// Short title + #[arg(long)] + title: String, + + /// Body / rationale for the proposed change + #[arg(long)] + body: String, + + /// Target node id (edge kinds), or derives-from thesis (new-requirement) + #[arg(long)] + target: Option, + + /// Proposed priority for a new-requirement (P0, P1, P2) + #[arg(long)] + priority: Option, + + /// Proposed category for a new-requirement + #[arg(long)] + category: Option, + + /// Why this nomination exists + #[arg(long)] + rationale: Option, + + /// Provenance (e.g. pre-commit, a commit sha) + #[arg(long)] + source: Option, + + /// Author (e.g. agent:claude) + #[arg(long, default_value = "agent:claude")] + created_by: String, + + /// Output format (text, json) + #[arg(short, long, default_value = "text")] + format: String, + }, + + /// List pending nominations in the inbox + List { + /// Output format (text, json) + #[arg(short, long, default_value = "text")] + format: String, + }, + + /// Show a single nomination + Show { + /// Nomination id (e.g. NOM-001) + id: String, + + /// Output format (text, json) + #[arg(short, long, default_value = "text")] + format: String, + }, + + /// Accept a nomination — materialize it into the graph and clear it + Accept { + /// Nomination id (e.g. NOM-001) + id: String, + + /// Requirement id to assign (required for new-requirement) + #[arg(long)] + id_assign: Option, + + /// Source node id for edge kinds (edge goes FROM this node TO the target) + #[arg(long)] + from: Option, + + /// Output format (text, json) + #[arg(short, long, default_value = "text")] + format: String, + }, + + /// Reject a nomination — drop it from the inbox + Reject { + /// Nomination id (e.g. NOM-001) + id: String, + }, + + /// Pre-push review — surface unreviewed nominations and drift + Gate { + /// Output format (text, json) + #[arg(short, long, default_value = "text")] + format: String, + }, + + /// Install git pre-commit and pre-push hooks that call capture + InstallHooks { + /// Overwrite existing hooks not managed by lattice + #[arg(long)] + force: bool, + }, +} + #[derive(Subcommand)] enum RemoveCommands { /// Remove an edge between two nodes @@ -1815,10 +1937,388 @@ fn command_to_name(cmd: &Commands) -> &'static str { Commands::Push { .. } => "push", Commands::Migrate => "migrate", Commands::Diff { .. } => "diff", + Commands::Capture { .. } => "capture", Commands::Help { .. } => "help", } } +/// Dispatch `lattice capture` subcommands (milestone knowledge capture). +fn handle_capture(cmd: CaptureCommands) { + use lattice::capture::{ + Nomination, NominationKind, add_nomination, changed_files_since, load_inbox, prefilter, + remove_nomination, staged_files, + }; + + let root = get_lattice_root(); + + match cmd { + CaptureCommands::Scan { + staged, + since, + format, + } => { + let changed = if let Some(git_ref) = &since { + changed_files_since(&root, git_ref) + } else if staged { + staged_files(&root) + } else { + // Default to staged if anything is staged, else working-tree vs HEAD. + let s = staged_files(&root); + if s.is_empty() { + changed_files_since(&root, "HEAD") + } else { + s + } + }; + + let index = build_node_index(&root).unwrap_or_default(); + let nodes: Vec<_> = index.values().collect(); + let result = prefilter(&nodes, &changed); + + if format == "json" { + // Enrich matched requirements with title/body for the agent bundle. + let reqs: Vec = result + .requirement_ids() + .iter() + .map(|id| match index.get(id) { + Some(n) => json!({"id": id, "title": n.title, "body": n.body}), + None => json!({"id": id}), + }) + .collect(); + let bundle = json!({ + "tripped": result.tripped(), + "changed_files": result.changed_files, + "matches": result.matches, + "requirements": reqs, + "instructions": "For each affected requirement, judge whether this change is lattice-worthy: does it satisfy, contradict (challenges), reveal a gap in (reveals-gap-in), or validate a tracked node, or warrant a new requirement? Emit at most 3 high-confidence nominations via 'lattice capture add'. Returning none is valid.", + }); + println!("{}", serde_json::to_string_pretty(&bundle).unwrap()); + return; + } + + if !result.tripped() { + println!( + "{}", + "No tracked surface changed — nothing to nominate.".dimmed() + ); + return; + } + println!("{}", "Change touches tracked surface:".bold()); + for m in &result.matches { + let reqs = if m.requirements.is_empty() { + "(no satisfies edges)".to_string() + } else { + m.requirements.join(", ") + }; + println!(" {} → {} [{}]", m.file.cyan(), m.implementation, reqs); + } + println!( + "\n{}", + "AI: evaluate against the graph and propose with 'lattice capture add'; a human accepts.".dimmed() + ); + } + + CaptureCommands::Add { + kind, + title, + body, + target, + priority, + category, + rationale, + source, + created_by, + format, + } => { + let kind = match NominationKind::parse(&kind) { + Ok(k) => k, + Err(e) => emit_error(&format, "invalid_kind", &e), + }; + let now = chrono::Utc::now().to_rfc3339(); + let nom = Nomination { + id: String::new(), + kind, + title, + body, + target, + priority, + category, + rationale, + source, + created_at: now, + created_by, + }; + match add_nomination(&root, nom) { + Ok(stored) => { + if format == "json" { + println!("{}", serde_json::to_string_pretty(&stored).unwrap()); + } else { + println!( + "{} {} ({})", + "Nominated".green(), + stored.id.bold(), + stored.kind.as_str() + ); + println!( + " Review with 'lattice capture list'; accept with 'lattice capture accept {}'.", + stored.id + ); + } + } + Err(e) => emit_error(&format, "inbox_error", &e), + } + } + + CaptureCommands::List { format } => { + let inbox = load_inbox(&root).unwrap_or_default(); + if format == "json" { + println!( + "{}", + serde_json::to_string_pretty(&inbox.nominations).unwrap() + ); + return; + } + if inbox.nominations.is_empty() { + println!("{}", "Inbox empty — no pending nominations.".dimmed()); + return; + } + println!("{} pending nomination(s):", inbox.nominations.len()); + for n in &inbox.nominations { + let tgt = n + .target + .as_deref() + .map(|t| format!(" → {}", t)) + .unwrap_or_default(); + println!( + " {} [{}] {}{}", + n.id.bold(), + n.kind.as_str().cyan(), + n.title, + tgt + ); + } + } + + CaptureCommands::Show { id, format } => { + let inbox = load_inbox(&root).unwrap_or_default(); + match inbox.get(&id) { + Some(n) => { + if format == "json" { + println!("{}", serde_json::to_string_pretty(n).unwrap()); + } else { + println!("{} [{}]", n.id.bold(), n.kind.as_str().cyan()); + println!("{}", n.title.bold()); + println!("{}", n.body); + if let Some(t) = &n.target { + println!("target: {}", t); + } + if let Some(r) = &n.rationale { + println!("rationale: {}", r); + } + if let Some(s) = &n.source { + println!("source: {}", s); + } + println!("by {} at {}", n.created_by, n.created_at); + } + } + None => emit_error(&format, "not_found", &format!("No nomination '{}'", id)), + } + } + + CaptureCommands::Accept { + id, + id_assign, + from, + format, + } => { + let inbox = load_inbox(&root).unwrap_or_default(); + let Some(nom) = inbox.get(&id).cloned() else { + emit_error(&format, "not_found", &format!("No nomination '{}'", id)); + }; + + let result: Result = match nom.kind { + NominationKind::NewRequirement => { + let Some(req_id) = id_assign else { + emit_error( + &format, + "missing_id", + "new-requirement needs --id-assign REQ-XXX to materialize", + ); + }; + let priority = parse_priority(nom.priority.as_deref().unwrap_or("P1")); + let category = nom + .category + .clone() + .unwrap_or_else(|| "CAPTURE".to_string()); + let opts = AddRequirementOptions { + id: req_id.clone(), + title: nom.title.clone(), + body: nom.body.clone(), + priority, + category, + tags: None, + derives_from: nom.target.clone().map(|t| vec![t]), + depends_on: None, + status: Status::Active, + created_by: nom.created_by.clone(), + }; + add_requirement(&root, opts) + .map(|_| format!("Created requirement {}", req_id)) + .map_err(|e| e.to_string()) + } + _ => { + let edge_type = nom.kind.edge_type().unwrap(); + let Some(from_id) = from else { + emit_error( + &format, + "missing_from", + &format!("{} needs --from (the source node)", nom.kind.as_str()), + ); + }; + let Some(to_id) = nom.target.clone() else { + emit_error( + &format, + "missing_target", + "edge nomination has no target node", + ); + }; + add_edge( + &root, + AddEdgeOptions { + from_id: from_id.clone(), + edge_type: edge_type.to_string(), + to_id: to_id.clone(), + rationale: nom.rationale.clone().or(Some(nom.body.clone())), + }, + ) + .map(|_| format!("Added edge {} --[{}]--> {}", from_id, edge_type, to_id)) + .map_err(|e| e.to_string()) + } + }; + + match result { + Ok(msg) => { + let _ = remove_nomination(&root, &nom.id); + if format == "json" { + println!("{}", json!({"accepted": nom.id, "result": msg})); + } else { + println!("{} {}", "Accepted".green(), nom.id.bold()); + println!(" {}", msg); + } + } + Err(e) => emit_error(&format, "accept_failed", &e), + } + } + + CaptureCommands::Reject { id } => match remove_nomination(&root, &id) { + Ok(Some(n)) => println!("{} {}", "Rejected".yellow(), n.id.bold()), + Ok(None) => { + eprintln!("{}", format!("No nomination '{}'", id).red()); + process::exit(1); + } + Err(e) => { + eprintln!("{}", e.red()); + process::exit(1); + } + }, + + CaptureCommands::Gate { format } => { + let inbox = load_inbox(&root).unwrap_or_default(); + let drift = find_drift(&root).unwrap_or_default(); + let pending = inbox.nominations.len(); + let drift_count = drift.len(); + + if format == "json" { + println!( + "{}", + json!({"pending_nominations": pending, "drift_items": drift_count}) + ); + return; + } + println!("{}", "Pre-push review:".bold()); + println!(" Pending nominations: {}", pending); + println!(" Drift items: {}", drift_count); + if pending > 0 { + println!( + "\n{}", + "Review with 'lattice capture list' — accept, reject, or defer before pushing." + .dimmed() + ); + } + if drift_count > 0 { + println!( + "{}", + "Run 'lattice drift' to inspect version drift.".dimmed() + ); + } + } + + CaptureCommands::InstallHooks { force } => { + install_capture_hooks(&root, force); + } + } +} + +/// Write git pre-commit and pre-push hooks that invoke capture (REQ-CAPTURE-003, 004). +fn install_capture_hooks(root: &std::path::Path, force: bool) { + let marker = "# lattice-capture"; + let hooks_dir = std::process::Command::new("git") + .args(["rev-parse", "--git-path", "hooks"]) + .current_dir(root) + .output() + .ok() + .filter(|o| o.status.success()) + .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string()) + .filter(|s| !s.is_empty()) + .map(|s| root.join(s)) + .unwrap_or_else(|| root.join(".git").join("hooks")); + + if let Err(e) = std::fs::create_dir_all(&hooks_dir) { + eprintln!("{}", format!("Could not create hooks dir: {}", e).red()); + process::exit(1); + } + + let pre_commit = format!( + "#!/bin/sh\n{marker} pre-commit nominator (REQ-CAPTURE-003)\n\ + # Non-blocking: surfaces tracked-surface changes; never fails the commit.\n\ + lattice capture scan --staged || true\nexit 0\n" + ); + let pre_push = format!( + "#!/bin/sh\n{marker} pre-push gate (REQ-CAPTURE-004)\n\ + if ! lattice health --check; then\n\ + \x20 echo 'lattice: health check failed — push blocked (run: lattice health)' >&2\n\ + \x20 exit 1\nfi\nlattice capture gate || true\nexit 0\n" + ); + + for (name, contents) in [("pre-commit", pre_commit), ("pre-push", pre_push)] { + let path = hooks_dir.join(name); + if path.exists() { + let existing = std::fs::read_to_string(&path).unwrap_or_default(); + if !existing.contains(marker) && !force { + eprintln!( + "{}", + format!( + "{} exists and is not managed by lattice — rerun with --force to overwrite.", + path.display() + ) + .yellow() + ); + continue; + } + } + if let Err(e) = std::fs::write(&path, &contents) { + eprintln!("{}", format!("Failed to write {}: {}", name, e).red()); + continue; + } + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let _ = std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)); + } + println!("{} {}", "Installed".green(), path.display()); + } +} + /// Strip a full catalog down to just command signatures (name + parameters). fn compact_catalog(catalog: &serde_json::Value) -> serde_json::Value { let commands = catalog["commands"] @@ -4752,6 +5252,8 @@ fn run_command(command: Commands) { } } + Commands::Capture { capture_command } => handle_capture(capture_command), + Commands::Help { json, compact, From 42a1a6201e9331cc0eb852701beaa77af5165507 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 18 Jul 2026 01:20:06 +0000 Subject: [PATCH 3/5] Simplify capture module (post-review cleanup) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Behavior-preserving cleanups from a 4-angle review of the capture code: - Altitude: move nomination→graph materialization (accept), the agent scan bundle, and git-hook script generation out of the main.rs CLI handler into src/capture.rs, where they are unit-testable and sit with the pipeline they describe. handle_capture is now thin arg-marshalling. - Efficiency: prefilter builds a single path→bindings index in one pass (was two passes + an id round-trip); accept loads/saves the inbox once (was a double read). - Simplification: single Inbox::position() backs both get() and remove_nomination(); hook scripts use plain strings (no \x20 escapes). - Consistency: capture reject routes errors through emit_error and gained --format, matching its sibling subcommands. Adds accept-guard and scan_bundle unit tests. 214 tests pass; clippy -D warnings clean; generated hooks validated with sh -n. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01PoEBdAc9emc4K3k3TfmX1T --- src/capture.rs | 296 ++++++++++++++++++++++++++++++++++++++++++------- src/lib.rs | 6 +- src/main.rs | 198 ++++++++------------------------- 3 files changed, 303 insertions(+), 197 deletions(-) diff --git a/src/capture.rs b/src/capture.rs index d16c6c1..714092c 100644 --- a/src/capture.rs +++ b/src/capture.rs @@ -15,11 +15,14 @@ //! REQ-CAPTURE-004, REQ-CAPTURE-005 use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; use std::collections::BTreeMap; use std::path::{Path, PathBuf}; use std::process::Command; +use std::str::FromStr; -use crate::types::{LatticeNode, NodeMeta, NodeType}; +use crate::storage::{AddEdgeOptions, AddRequirementOptions, add_edge, add_requirement}; +use crate::types::{LatticeNode, NodeIndex, NodeMeta, NodeType, Priority, Status}; /// Staging inbox file. Lives at the repo root, deliberately *outside* `.lattice/` /// so nominations never masquerade as committed graph nodes (REQ-CAPTURE-001, 005). @@ -123,9 +126,15 @@ impl Inbox { format!("NOM-{:03}", max + 1) } - pub fn get(&self, id: &str) -> Option<&Nomination> { + /// Index of the nomination with this id (case-insensitive) — the single + /// source of truth for id matching, shared by `get` and `remove_nomination`. + pub fn position(&self, id: &str) -> Option { let want = id.to_uppercase(); - self.nominations.iter().find(|n| n.id == want) + self.nominations.iter().position(|n| n.id == want) + } + + pub fn get(&self, id: &str) -> Option<&Nomination> { + self.position(id).map(|i| &self.nominations[i]) } } @@ -177,8 +186,7 @@ pub fn add_nomination(root: &Path, mut nom: Nomination) -> Result Result, String> { let mut inbox = load_inbox(root)?; - let want = id.to_uppercase(); - if let Some(pos) = inbox.nominations.iter().position(|n| n.id == want) { + if let Some(pos) = inbox.position(id) { let removed = inbox.nominations.remove(pos); save_inbox(root, &inbox)?; Ok(Some(removed)) @@ -223,22 +231,11 @@ impl PrefilterResult { } } -/// Map every bound file path to the implementations that bind it. -pub fn binding_map(nodes: &[&LatticeNode]) -> BTreeMap> { - let mut map: BTreeMap> = BTreeMap::new(); - for node in nodes { - if node.node_type != NodeType::Implementation { - continue; - } - if let Some(NodeMeta::Implementation(meta)) = &node.meta - && let Some(files) = &meta.files - { - for f in files { - map.entry(f.path.clone()).or_default().push(node.id.clone()); - } - } - } - map +/// An implementation's binding: its id and the requirements it satisfies, +/// resolved once so a match can clone the cached list rather than re-walk edges. +struct Binding { + implementation: String, + requirements: Vec, } /// Deterministic relevance prefilter (REQ-CAPTURE-002). @@ -248,33 +245,41 @@ pub fn binding_map(nodes: &[&LatticeNode]) -> BTreeMap> { /// model call — this is the cheap gate that decides whether AI nomination is /// even worth invoking. pub fn prefilter(nodes: &[&LatticeNode], changed_files: &[String]) -> PrefilterResult { - let bindings = binding_map(nodes); - let impl_by_id: BTreeMap<&str, &LatticeNode> = nodes - .iter() - .filter(|n| n.node_type == NodeType::Implementation) - .map(|n| (n.id.as_str(), *n)) - .collect(); + // Single pass: bound file path -> implementations binding it (id + satisfied + // requirements resolved up front). No second index, no id round-trip. + let mut bindings: BTreeMap<&str, Vec> = BTreeMap::new(); + for node in nodes { + if node.node_type != NodeType::Implementation { + continue; + } + let Some(NodeMeta::Implementation(meta)) = &node.meta else { + continue; + }; + let Some(files) = &meta.files else { continue }; + let requirements: Vec = node + .edges + .as_ref() + .and_then(|e| e.satisfies.as_ref()) + .map(|s| s.iter().map(|r| r.target.clone()).collect()) + .unwrap_or_default(); + for f in files { + bindings.entry(f.path.as_str()).or_default().push(Binding { + implementation: node.id.clone(), + requirements: requirements.clone(), + }); + } + } let mut matches = Vec::new(); for file in changed_files { - let Some(impl_ids) = bindings.get(file) else { + let Some(binds) = bindings.get(file.as_str()) else { continue; }; - for impl_id in impl_ids { - let requirements = impl_by_id - .get(impl_id.as_str()) - .map(|n| { - n.edges - .as_ref() - .and_then(|e| e.satisfies.as_ref()) - .map(|s| s.iter().map(|r| r.target.clone()).collect()) - .unwrap_or_default() - }) - .unwrap_or_default(); + for bind in binds { matches.push(BindingMatch { file: file.clone(), - implementation: impl_id.clone(), - requirements, + implementation: bind.implementation.clone(), + requirements: bind.requirements.clone(), }); } } @@ -312,6 +317,169 @@ fn git_name_only(root: &Path, args: &[&str]) -> Vec { .unwrap_or_default() } +/// The agent-facing nomination bundle for a `scan` (REQ-CAPTURE-003). +/// +/// Pairs the deterministic prefilter result with the affected requirements +/// (enriched with title/body) and the standing instruction for how an agent +/// should nominate. The prompt policy lives here with the pipeline it governs, +/// not in the CLI print path. +pub fn scan_bundle(index: &NodeIndex, result: &PrefilterResult) -> Value { + let requirements: Vec = result + .requirement_ids() + .iter() + .map(|id| match index.get(id) { + Some(n) => json!({"id": id, "title": n.title, "body": n.body}), + None => json!({ "id": id }), + }) + .collect(); + json!({ + "tripped": result.tripped(), + "changed_files": result.changed_files, + "matches": result.matches, + "requirements": requirements, + "instructions": "For each affected requirement, judge whether this change is lattice-worthy: does it satisfy, contradict (challenges), reveal a gap in (reveals-gap-in), or validate a tracked node, or warrant a new requirement? Emit at most 3 high-confidence nominations via 'lattice capture add'. Returning none is valid.", + }) +} + +/// Materialize a nomination into the graph (REQ-CAPTURE-001) — the only path +/// that mutates `.lattice/`, invoked exclusively on explicit human `accept` +/// (the nominate-not-commit guardrail, REQ-CAPTURE-005). Returns a summary of +/// what was created. Does not touch the inbox; the caller clears the nomination. +pub fn accept( + root: &Path, + nom: &Nomination, + id_assign: Option<&str>, + from: Option<&str>, +) -> Result { + match nom.kind { + NominationKind::NewRequirement => { + let req_id = + id_assign.ok_or("new-requirement needs --id-assign REQ-XXX to materialize")?; + let priority = nom + .priority + .as_deref() + .map(Priority::from_str) + .transpose()? + .unwrap_or(Priority::P1); + let opts = AddRequirementOptions { + id: req_id.to_string(), + title: nom.title.clone(), + body: nom.body.clone(), + priority, + category: nom + .category + .clone() + .unwrap_or_else(|| "CAPTURE".to_string()), + tags: None, + derives_from: nom.target.clone().map(|t| vec![t]), + depends_on: None, + status: Status::Active, + created_by: nom.created_by.clone(), + }; + add_requirement(root, opts).map_err(|e| e.to_string())?; + Ok(format!("Created requirement {}", req_id)) + } + _ => { + let edge_type = nom.kind.edge_type().expect("edge kind has an edge type"); + let from_id = from.ok_or_else(|| { + format!("{} needs --from (the source node)", nom.kind.as_str()) + })?; + let to_id = nom + .target + .as_deref() + .ok_or("edge nomination has no target node")?; + add_edge( + root, + AddEdgeOptions { + from_id: from_id.to_string(), + edge_type: edge_type.to_string(), + to_id: to_id.to_string(), + rationale: nom.rationale.clone().or_else(|| Some(nom.body.clone())), + }, + ) + .map_err(|e| e.to_string())?; + Ok(format!( + "Added edge {} --[{}]--> {}", + from_id, edge_type, to_id + )) + } + } +} + +/// Outcome of installing one git hook. +pub enum HookOutcome { + Installed(PathBuf), + SkippedExisting(PathBuf), + Failed(PathBuf, String), +} + +/// Install non-blocking pre-commit and pre-push hooks that drive capture +/// (REQ-CAPTURE-003, REQ-CAPTURE-004). The script bodies — and the policy they +/// encode (pre-commit never blocks; pre-push hard-gates on `health --check`) — +/// live here with the pipeline, not in the CLI. Existing unmanaged hooks are +/// left alone unless `force` is set. +pub fn install_hooks(root: &Path, force: bool) -> Result, String> { + const MARKER: &str = "# lattice-capture"; + let hooks_dir = Command::new("git") + .args(["rev-parse", "--git-path", "hooks"]) + .current_dir(root) + .output() + .ok() + .filter(|o| o.status.success()) + .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string()) + .filter(|s| !s.is_empty()) + .map(|s| root.join(s)) + .unwrap_or_else(|| root.join(".git").join("hooks")); + std::fs::create_dir_all(&hooks_dir) + .map_err(|e| format!("could not create hooks dir: {}", e))?; + + let pre_commit = format!( + "#!/bin/sh\n\ + {MARKER} pre-commit nominator (REQ-CAPTURE-003)\n\ + # Non-blocking: surfaces tracked-surface changes; never fails the commit.\n\ + lattice capture scan --staged || true\n\ + exit 0\n" + ); + let pre_push = format!( + "#!/bin/sh\n\ + {MARKER} pre-push gate (REQ-CAPTURE-004)\n\ + if ! lattice health --check; then\n\ + \techo 'lattice: health check failed — push blocked (run: lattice health)' >&2\n\ + \texit 1\n\ + fi\n\ + lattice capture gate || true\n\ + exit 0\n" + ); + + let mut outcomes = Vec::new(); + for (name, contents) in [("pre-commit", pre_commit), ("pre-push", pre_push)] { + let path = hooks_dir.join(name); + if path.exists() { + let existing = std::fs::read_to_string(&path).unwrap_or_default(); + if !existing.contains(MARKER) && !force { + outcomes.push(HookOutcome::SkippedExisting(path)); + continue; + } + } + match write_hook(&path, &contents) { + Ok(()) => outcomes.push(HookOutcome::Installed(path)), + Err(e) => outcomes.push(HookOutcome::Failed(path, e)), + } + } + Ok(outcomes) +} + +fn write_hook(path: &Path, contents: &str) -> Result<(), String> { + std::fs::write(path, contents).map_err(|e| e.to_string())?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o755)) + .map_err(|e| e.to_string())?; + } + Ok(()) +} + #[cfg(test)] mod tests { use super::*; @@ -459,4 +627,50 @@ mod tests { ); assert!(NominationKind::parse("bogus").is_err()); } + + fn nomination(kind: NominationKind, target: Option<&str>) -> Nomination { + Nomination { + id: "NOM-001".to_string(), + kind, + title: "t".to_string(), + body: "b".to_string(), + target: target.map(|s| s.to_string()), + priority: None, + category: None, + rationale: None, + source: None, + created_at: "now".to_string(), + created_by: "test".to_string(), + } + } + + #[test] + fn accept_requires_id_and_from_before_touching_the_graph() { + let root = std::env::temp_dir(); // never written — guards fire first + let new_req = nomination(NominationKind::NewRequirement, None); + let err = accept(&root, &new_req, None, None).unwrap_err(); + assert!(err.contains("id-assign"), "got: {err}"); + + let edge = nomination(NominationKind::Challenges, Some("THX-X")); + let err = accept(&root, &edge, None, None).unwrap_err(); + assert!(err.contains("--from"), "got: {err}"); + } + + #[test] + fn scan_bundle_reports_matches_and_enriches_requirements() { + let a = impl_node("IMP-001", &["src/storage.rs"], &["REQ-CORE-001"]); + let mut req = impl_node("REQ-CORE-001", &[], &[]); + req.node_type = NodeType::Requirement; + req.title = "Storage layer".to_string(); + let mut index = NodeIndex::new(); + index.insert(a.id.clone(), a.clone()); + index.insert(req.id.clone(), req.clone()); + let nodes: Vec<&LatticeNode> = index.values().collect(); + + let result = prefilter(&nodes, &["src/storage.rs".to_string()]); + let bundle = scan_bundle(&index, &result); + assert_eq!(bundle["tripped"], serde_json::json!(true)); + assert_eq!(bundle["requirements"][0]["id"], "REQ-CORE-001"); + assert_eq!(bundle["requirements"][0]["title"], "Storage layer"); + } } diff --git a/src/lib.rs b/src/lib.rs index fcaebf6..cf4b7d4 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -18,9 +18,9 @@ pub mod types; pub mod update; pub use capture::{ - BindingMatch, Inbox, Nomination, NominationKind, PrefilterResult, add_nomination, binding_map, - changed_files_since, inbox_path, load_inbox, prefilter, remove_nomination, save_inbox, - staged_files, + BindingMatch, HookOutcome, Inbox, Nomination, NominationKind, PrefilterResult, accept, + add_nomination, changed_files_since, inbox_path, install_hooks, load_inbox, prefilter, + remove_nomination, save_inbox, scan_bundle, staged_files, }; pub use diff::{ ChangeType, DiffEntry, DiffError, DiffResult, compute_changed_fields, format_diff_markdown, diff --git a/src/main.rs b/src/main.rs index 1b62126..0a16f84 100644 --- a/src/main.rs +++ b/src/main.rs @@ -856,6 +856,10 @@ enum CaptureCommands { Reject { /// Nomination id (e.g. NOM-001) id: String, + + /// Output format (text, json) + #[arg(short, long, default_value = "text")] + format: String, }, /// Pre-push review — surface unreviewed nominations and drift @@ -1945,8 +1949,9 @@ fn command_to_name(cmd: &Commands) -> &'static str { /// Dispatch `lattice capture` subcommands (milestone knowledge capture). fn handle_capture(cmd: CaptureCommands) { use lattice::capture::{ - Nomination, NominationKind, add_nomination, changed_files_since, load_inbox, prefilter, - remove_nomination, staged_files, + HookOutcome, Nomination, NominationKind, accept, add_nomination, changed_files_since, + install_hooks, load_inbox, prefilter, remove_nomination, save_inbox, scan_bundle, + staged_files, }; let root = get_lattice_root(); @@ -1976,22 +1981,7 @@ fn handle_capture(cmd: CaptureCommands) { let result = prefilter(&nodes, &changed); if format == "json" { - // Enrich matched requirements with title/body for the agent bundle. - let reqs: Vec = result - .requirement_ids() - .iter() - .map(|id| match index.get(id) { - Some(n) => json!({"id": id, "title": n.title, "body": n.body}), - None => json!({"id": id}), - }) - .collect(); - let bundle = json!({ - "tripped": result.tripped(), - "changed_files": result.changed_files, - "matches": result.matches, - "requirements": reqs, - "instructions": "For each affected requirement, judge whether this change is lattice-worthy: does it satisfy, contradict (challenges), reveal a gap in (reveals-gap-in), or validate a tracked node, or warrant a new requirement? Emit at most 3 high-confidence nominations via 'lattice capture add'. Returning none is valid.", - }); + let bundle = scan_bundle(&index, &result); println!("{}", serde_json::to_string_pretty(&bundle).unwrap()); return; } @@ -2131,74 +2121,19 @@ fn handle_capture(cmd: CaptureCommands) { from, format, } => { - let inbox = load_inbox(&root).unwrap_or_default(); - let Some(nom) = inbox.get(&id).cloned() else { + let mut inbox = load_inbox(&root).unwrap_or_default(); + let Some(pos) = inbox.position(&id) else { emit_error(&format, "not_found", &format!("No nomination '{}'", id)); }; + let nom = inbox.nominations[pos].clone(); - let result: Result = match nom.kind { - NominationKind::NewRequirement => { - let Some(req_id) = id_assign else { - emit_error( - &format, - "missing_id", - "new-requirement needs --id-assign REQ-XXX to materialize", - ); - }; - let priority = parse_priority(nom.priority.as_deref().unwrap_or("P1")); - let category = nom - .category - .clone() - .unwrap_or_else(|| "CAPTURE".to_string()); - let opts = AddRequirementOptions { - id: req_id.clone(), - title: nom.title.clone(), - body: nom.body.clone(), - priority, - category, - tags: None, - derives_from: nom.target.clone().map(|t| vec![t]), - depends_on: None, - status: Status::Active, - created_by: nom.created_by.clone(), - }; - add_requirement(&root, opts) - .map(|_| format!("Created requirement {}", req_id)) - .map_err(|e| e.to_string()) - } - _ => { - let edge_type = nom.kind.edge_type().unwrap(); - let Some(from_id) = from else { - emit_error( - &format, - "missing_from", - &format!("{} needs --from (the source node)", nom.kind.as_str()), - ); - }; - let Some(to_id) = nom.target.clone() else { - emit_error( - &format, - "missing_target", - "edge nomination has no target node", - ); - }; - add_edge( - &root, - AddEdgeOptions { - from_id: from_id.clone(), - edge_type: edge_type.to_string(), - to_id: to_id.clone(), - rationale: nom.rationale.clone().or(Some(nom.body.clone())), - }, - ) - .map(|_| format!("Added edge {} --[{}]--> {}", from_id, edge_type, to_id)) - .map_err(|e| e.to_string()) - } - }; - - match result { + match accept(&root, &nom, id_assign.as_deref(), from.as_deref()) { Ok(msg) => { - let _ = remove_nomination(&root, &nom.id); + // Graph mutation succeeded; clear the nomination in one write. + inbox.nominations.remove(pos); + if let Err(e) = save_inbox(&root, &inbox) { + emit_error(&format, "inbox_error", &e); + } if format == "json" { println!("{}", json!({"accepted": nom.id, "result": msg})); } else { @@ -2210,16 +2145,10 @@ fn handle_capture(cmd: CaptureCommands) { } } - CaptureCommands::Reject { id } => match remove_nomination(&root, &id) { + CaptureCommands::Reject { id, format } => match remove_nomination(&root, &id) { Ok(Some(n)) => println!("{} {}", "Rejected".yellow(), n.id.bold()), - Ok(None) => { - eprintln!("{}", format!("No nomination '{}'", id).red()); - process::exit(1); - } - Err(e) => { - eprintln!("{}", e.red()); - process::exit(1); - } + Ok(None) => emit_error(&format, "not_found", &format!("No nomination '{}'", id)), + Err(e) => emit_error(&format, "inbox_error", &e), }, CaptureCommands::Gate { format } => { @@ -2253,69 +2182,32 @@ fn handle_capture(cmd: CaptureCommands) { } } - CaptureCommands::InstallHooks { force } => { - install_capture_hooks(&root, force); - } - } -} - -/// Write git pre-commit and pre-push hooks that invoke capture (REQ-CAPTURE-003, 004). -fn install_capture_hooks(root: &std::path::Path, force: bool) { - let marker = "# lattice-capture"; - let hooks_dir = std::process::Command::new("git") - .args(["rev-parse", "--git-path", "hooks"]) - .current_dir(root) - .output() - .ok() - .filter(|o| o.status.success()) - .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string()) - .filter(|s| !s.is_empty()) - .map(|s| root.join(s)) - .unwrap_or_else(|| root.join(".git").join("hooks")); - - if let Err(e) = std::fs::create_dir_all(&hooks_dir) { - eprintln!("{}", format!("Could not create hooks dir: {}", e).red()); - process::exit(1); - } - - let pre_commit = format!( - "#!/bin/sh\n{marker} pre-commit nominator (REQ-CAPTURE-003)\n\ - # Non-blocking: surfaces tracked-surface changes; never fails the commit.\n\ - lattice capture scan --staged || true\nexit 0\n" - ); - let pre_push = format!( - "#!/bin/sh\n{marker} pre-push gate (REQ-CAPTURE-004)\n\ - if ! lattice health --check; then\n\ - \x20 echo 'lattice: health check failed — push blocked (run: lattice health)' >&2\n\ - \x20 exit 1\nfi\nlattice capture gate || true\nexit 0\n" - ); - - for (name, contents) in [("pre-commit", pre_commit), ("pre-push", pre_push)] { - let path = hooks_dir.join(name); - if path.exists() { - let existing = std::fs::read_to_string(&path).unwrap_or_default(); - if !existing.contains(marker) && !force { - eprintln!( - "{}", - format!( - "{} exists and is not managed by lattice — rerun with --force to overwrite.", - path.display() - ) - .yellow() - ); - continue; + CaptureCommands::InstallHooks { force } => match install_hooks(&root, force) { + Ok(outcomes) => { + for outcome in outcomes { + match outcome { + HookOutcome::Installed(p) => { + println!("{} {}", "Installed".green(), p.display()) + } + HookOutcome::SkippedExisting(p) => eprintln!( + "{}", + format!( + "{} exists and is not managed by lattice — rerun with --force to overwrite.", + p.display() + ) + .yellow() + ), + HookOutcome::Failed(p, e) => { + eprintln!("{}", format!("Failed to write {}: {}", p.display(), e).red()) + } + } + } } - } - if let Err(e) = std::fs::write(&path, &contents) { - eprintln!("{}", format!("Failed to write {}: {}", name, e).red()); - continue; - } - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - let _ = std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)); - } - println!("{} {}", "Installed".green(), path.display()); + Err(e) => { + eprintln!("{}", e.red()); + process::exit(1); + } + }, } } From cb9d9f7e4aeb9cefa18189f0f33ef3d4a03ee926 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 18 Jul 2026 01:22:25 +0000 Subject: [PATCH 4/5] Extract shared run_git_lines git helper Collapses the "spawn git, collect non-empty stdout lines" idiom that was duplicated in capture.rs (git_name_only) and the health command's code-impact block into one public helper in diff.rs, next to the other git helpers. capture's staged_files/changed_files_since and health's changed-file detection now both call diff::run_git_lines. Behavior-preserving; 214 tests pass, clippy -D warnings clean. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01PoEBdAc9emc4K3k3TfmX1T --- src/capture.rs | 22 +++------------------- src/diff.rs | 22 ++++++++++++++++++++++ src/lib.rs | 2 +- src/main.rs | 21 +++++++-------------- 4 files changed, 33 insertions(+), 34 deletions(-) diff --git a/src/capture.rs b/src/capture.rs index 714092c..fd73265 100644 --- a/src/capture.rs +++ b/src/capture.rs @@ -21,6 +21,7 @@ use std::path::{Path, PathBuf}; use std::process::Command; use std::str::FromStr; +use crate::diff::run_git_lines; use crate::storage::{AddEdgeOptions, AddRequirementOptions, add_edge, add_requirement}; use crate::types::{LatticeNode, NodeIndex, NodeMeta, NodeType, Priority, Status}; @@ -292,29 +293,12 @@ pub fn prefilter(nodes: &[&LatticeNode], changed_files: &[String]) -> PrefilterR /// Files staged for commit (`git diff --cached --name-only`). pub fn staged_files(root: &Path) -> Vec { - git_name_only(root, &["diff", "--cached", "--name-only"]) + run_git_lines(root, &["diff", "--cached", "--name-only"]) } /// Files changed since a git ref (`git diff --name-only `). pub fn changed_files_since(root: &Path, git_ref: &str) -> Vec { - git_name_only(root, &["diff", "--name-only", git_ref]) -} - -fn git_name_only(root: &Path, args: &[&str]) -> Vec { - Command::new("git") - .args(args) - .current_dir(root) - .output() - .ok() - .filter(|o| o.status.success()) - .map(|o| { - String::from_utf8_lossy(&o.stdout) - .lines() - .filter(|l| !l.trim().is_empty()) - .map(|s| s.to_string()) - .collect() - }) - .unwrap_or_default() + run_git_lines(root, &["diff", "--name-only", git_ref]) } /// The agent-facing nomination bundle for a `scan` (REQ-CAPTURE-003). diff --git a/src/diff.rs b/src/diff.rs index ace545b..4193d33 100644 --- a/src/diff.rs +++ b/src/diff.rs @@ -79,6 +79,28 @@ pub fn git_head_sha() -> Result { Ok(String::from_utf8_lossy(&output.stdout).trim().to_string()) } +/// Run `git ` in `dir` and return its stdout as non-empty lines. +/// +/// Best-effort change detection: a spawn failure, non-zero exit, or empty +/// output all yield an empty vec, so callers treat "git couldn't tell us" and +/// "nothing changed" the same. Shared by `capture` and `health`. +pub fn run_git_lines(dir: &Path, args: &[&str]) -> Vec { + Command::new("git") + .args(args) + .current_dir(dir) + .output() + .ok() + .filter(|o| o.status.success()) + .map(|o| { + String::from_utf8_lossy(&o.stdout) + .lines() + .filter(|l| !l.trim().is_empty()) + .map(|s| s.to_string()) + .collect() + }) + .unwrap_or_default() +} + /// Compare two nodes field-by-field using serde_json and return the names of changed fields. pub fn compute_changed_fields(old: &LatticeNode, new: &LatticeNode) -> Vec { let old_val = match serde_json::to_value(old) { diff --git a/src/lib.rs b/src/lib.rs index cf4b7d4..77d0d5c 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -24,7 +24,7 @@ pub use capture::{ }; pub use diff::{ ChangeType, DiffEntry, DiffError, DiffResult, compute_changed_fields, format_diff_markdown, - format_entry_text, git_head_sha, lattice_diff, + format_entry_text, git_head_sha, lattice_diff, run_git_lines, }; pub use export::{Audience, ExportOptions, LatticeData, export_narrative}; pub use graph::{ diff --git a/src/main.rs b/src/main.rs index 0a16f84..4048278 100644 --- a/src/main.rs +++ b/src/main.rs @@ -15,7 +15,8 @@ use lattice::{ find_drift, find_lattice_root, find_node_path, fix_issues, format_diff_markdown, format_entry_text, generate_plan, get_git_user, get_github_pages_url, init_lattice, lattice_diff, lint_lattice, load_all_nodes, load_config, load_nodes_by_type, - refine_requirement, remove_edge, replace_edge, resolve_node, split_csv, verify_implementation, + refine_requirement, remove_edge, replace_edge, resolve_node, run_git_lines, split_csv, + verify_implementation, }; use serde_json::json; use std::env; @@ -3694,25 +3695,17 @@ fn run_command(command: Commands) { if lattice_staged { (0, 0, Vec::new()) } else if !lattice_commit.is_empty() { - let output = std::process::Command::new("git") - .args([ + let changed = run_git_lines( + &root, + &[ "diff", "--name-only", &lattice_commit, "--", ".", ":!.lattice/", - ]) - .current_dir(&root) - .output() - .ok() - .map(|o| String::from_utf8_lossy(&o.stdout).to_string()) - .unwrap_or_default(); - let changed: Vec = output - .lines() - .filter(|l| !l.is_empty()) - .map(|s| s.to_string()) - .collect(); + ], + ); let affected: Vec = changed .iter() .filter(|f| bound_files.contains(f.as_str())) From 7204706f21ffc18875d7c52f7836cc18f7b41d99 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 24 Jul 2026 22:23:57 +0000 Subject: [PATCH 5/5] Deduplicate nominations against existing nodes (REQ-CAPTURE-003-A) Entity resolution for the capture pipeline, adapted from knowledge-graph- generation practice (dedup is a required post-processing stage there): at 'capture add' time, score the nomination's title+body against existing nodes by token-overlap (Jaccard) and attach likely-duplicate IDs. list/show surface them and add warns, so restatements of existing nodes reach the reviewer flagged instead of silently entering the inbox. Deterministic first pass; semantic dedup is a natural upgrade under the vector-search feature. - src/capture.rs: find_duplicates + token_set/jaccard; Nomination gains possible_duplicates; unit test for hit + non-match - src/main.rs: add handler computes/attaches duplicates and warns; list/show render them - lattice: REQ-CAPTURE-003-A (refines REQ-CAPTURE-003), IMP-CAPTURE-006, resolved verified; drift clean, health PASS Implements REQ-CAPTURE-003-A Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01PoEBdAc9emc4K3k3TfmX1T --- .lattice/implementations/capture-003.yaml | 4 + .lattice/implementations/capture-006.yaml | 19 +++++ ...e-commit-lattice-worthiness-nominator.yaml | 3 + ...uplicate-nominations-against-existing.yaml | 30 +++++++ src/capture.rs | 79 ++++++++++++++++++- src/main.rs | 35 +++++++- 6 files changed, 165 insertions(+), 5 deletions(-) create mode 100644 .lattice/implementations/capture-006.yaml create mode 100644 .lattice/requirements/capture/a-deduplicate-nominations-against-existing.yaml diff --git a/.lattice/implementations/capture-003.yaml b/.lattice/implementations/capture-003.yaml index 268ef13..0d1b72a 100644 --- a/.lattice/implementations/capture-003.yaml +++ b/.lattice/implementations/capture-003.yaml @@ -16,3 +16,7 @@ edges: satisfies: - target: REQ-CAPTURE-003 version: 1.0.0 + reveals_gap_in: + - target: REQ-CAPTURE-003 + version: 1.0.0 + rationale: 'missing_requirement: Deduplicate nominations against existing nodes before surfacing' diff --git a/.lattice/implementations/capture-006.yaml b/.lattice/implementations/capture-006.yaml new file mode 100644 index 0000000..5749fff --- /dev/null +++ b/.lattice/implementations/capture-006.yaml @@ -0,0 +1,19 @@ +id: IMP-CAPTURE-006 +type: implementation +title: Nomination entity-resolution (dedup) at add time +body: find_duplicates in src/capture.rs scores a nomination's title+body against existing nodes by token-overlap (Jaccard) and returns likely-duplicate IDs; the capture add handler in src/main.rs attaches them to the nomination and warns, and list/show surface them for the reviewer. Deterministic first pass; semantic dedup is a future upgrade under the vector-search feature. +status: active +version: 1.0.0 +created_at: 2026-07-24T22:23:40.942737785+00:00 +created_by: agent:claude +requested_by: Claude +meta: + language: rust + files: + - path: src/capture.rs + - path: src/main.rs + test_command: 'cargo test --lib capture::' +edges: + satisfies: + - target: REQ-CAPTURE-003-A + version: 1.0.0 diff --git a/.lattice/requirements/capture/003-pre-commit-lattice-worthiness-nominator.yaml b/.lattice/requirements/capture/003-pre-commit-lattice-worthiness-nominator.yaml index b25f8dc..472c3ee 100644 --- a/.lattice/requirements/capture/003-pre-commit-lattice-worthiness-nominator.yaml +++ b/.lattice/requirements/capture/003-pre-commit-lattice-worthiness-nominator.yaml @@ -22,3 +22,6 @@ edges: version: 1.0.0 - target: REQ-CAPTURE-002 version: 1.0.0 + - target: REQ-CAPTURE-003-A + version: 1.0.0 + rationale: 'Refinement: missing_requirement' diff --git a/.lattice/requirements/capture/a-deduplicate-nominations-against-existing.yaml b/.lattice/requirements/capture/a-deduplicate-nominations-against-existing.yaml new file mode 100644 index 0000000..626107b --- /dev/null +++ b/.lattice/requirements/capture/a-deduplicate-nominations-against-existing.yaml @@ -0,0 +1,30 @@ +id: REQ-CAPTURE-003-A +type: requirement +title: Deduplicate nominations against existing nodes before surfacing +body: |- + The nominator can propose a new-requirement (or edge) that restates a node already in the graph. Knowledge-graph-generation practice treats entity resolution as a required post-processing stage (Cypher equality, node-similarity, LLM dedup); the capture pipeline currently has no equivalent, so duplicate nominations reach the reviewer unflagged. + + ## Proposed Resolution + + At 'capture add' time, score the nomination's title+body against existing nodes (deterministic token-overlap now; semantic search when vector-search is enabled) and attach likely-duplicate node IDs to the nomination so list/show surface them for the reviewer to resolve. + + --- + _Gap type: missing_requirement_ +status: draft +version: 1.0.0 +created_at: 2026-07-24T22:20:12.692347203+00:00 +created_by: agent:claude-2026-07-24 +requested_by: Claude +priority: P1 +category: CAPTURE +tags: +- refinement +- missing_requirement +resolution: + status: verified + resolved_at: 2026-07-24T22:23:29.584189048+00:00 + resolved_by: agent:claude-2026-07-24 +edges: + derives_from: + - target: THX-MILESTONE-NOMINATION + version: 1.0.0 diff --git a/src/capture.rs b/src/capture.rs index fd73265..70b5059 100644 --- a/src/capture.rs +++ b/src/capture.rs @@ -16,7 +16,7 @@ use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; -use std::collections::BTreeMap; +use std::collections::{BTreeMap, HashSet}; use std::path::{Path, PathBuf}; use std::process::Command; use std::str::FromStr; @@ -103,6 +103,10 @@ pub struct Nomination { /// Provenance, e.g. `pre-commit` or a commit sha — how the nomination arose. #[serde(skip_serializing_if = "Option::is_none")] pub source: Option, + /// Existing node IDs this nomination may duplicate (entity resolution, + /// REQ-CAPTURE-003-A). Surfaced for the reviewer; never auto-resolved. + #[serde(skip_serializing_if = "Option::is_none")] + pub possible_duplicates: Option>, pub created_at: String, pub created_by: String, } @@ -325,6 +329,50 @@ pub fn scan_bundle(index: &NodeIndex, result: &PrefilterResult) -> Value { }) } +/// Existing node IDs whose title+body most resemble the given text, by +/// token-overlap (Jaccard) above a threshold — a deterministic first-pass +/// entity resolution so likely-duplicate nominations are flagged for the +/// reviewer rather than silently added (REQ-CAPTURE-003-A). Semantic dedup is a +/// natural upgrade wherever the vector index is available. +pub fn find_duplicates(index: &NodeIndex, title: &str, body: &str) -> Vec { + const THRESHOLD: f32 = 0.25; + const LIMIT: usize = 3; + let query = token_set(&format!("{title} {body}")); + if query.is_empty() { + return Vec::new(); + } + let mut scored: Vec<(f32, &str)> = index + .values() + .filter_map(|n| { + let score = jaccard(&query, &token_set(&format!("{} {}", n.title, n.body))); + (score >= THRESHOLD).then_some((score, n.id.as_str())) + }) + .collect(); + scored.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap_or(std::cmp::Ordering::Equal)); + scored + .into_iter() + .take(LIMIT) + .map(|(_, id)| id.to_string()) + .collect() +} + +/// Alphanumeric words of length >= 3, lowercased — the token unit for dedup. +fn token_set(text: &str) -> HashSet { + text.split(|c: char| !c.is_alphanumeric()) + .filter(|w| w.len() >= 3) + .map(str::to_lowercase) + .collect() +} + +/// Jaccard similarity of two token sets (0.0 when either is empty). +fn jaccard(a: &HashSet, b: &HashSet) -> f32 { + if a.is_empty() || b.is_empty() { + return 0.0; + } + let inter = a.intersection(b).count() as f32; + inter / a.union(b).count() as f32 +} + /// Materialize a nomination into the graph (REQ-CAPTURE-001) — the only path /// that mutates `.lattice/`, invoked exclusively on explicit human `accept` /// (the nominate-not-commit guardrail, REQ-CAPTURE-005). Returns a summary of @@ -553,6 +601,7 @@ mod tests { category: None, rationale: None, source: None, + possible_duplicates: None, created_at: "now".to_string(), created_by: "test".to_string(), }); @@ -573,6 +622,7 @@ mod tests { category: None, rationale: None, source: Some("pre-commit".to_string()), + possible_duplicates: None, created_at: "now".to_string(), created_by: "agent:test".to_string(), }; @@ -623,6 +673,7 @@ mod tests { category: None, rationale: None, source: None, + possible_duplicates: None, created_at: "now".to_string(), created_by: "test".to_string(), } @@ -640,6 +691,32 @@ mod tests { assert!(err.contains("--from"), "got: {err}"); } + #[test] + fn find_duplicates_flags_similar_nodes_and_ignores_unrelated() { + let mut req = impl_node("REQ-CORE-001", &[], &[]); + req.node_type = NodeType::Requirement; + req.title = "Version-bound edges enable drift detection".to_string(); + req.body = + "Edges record the version of source and target so stale bindings surface.".to_string(); + let mut other = impl_node("REQ-CLI-009", &[], &[]); + other.node_type = NodeType::Requirement; + other.title = "Colorized terminal output".to_string(); + other.body = "The CLI prints results with ANSI colors for readability.".to_string(); + let mut index = NodeIndex::new(); + index.insert(req.id.clone(), req); + index.insert(other.id.clone(), other); + + let dups = find_duplicates( + &index, + "Edges should be version bound for drift detection", + "Record source and target version so stale edge bindings surface as drift.", + ); + assert_eq!(dups, vec!["REQ-CORE-001"]); + + let none = find_duplicates(&index, "Add YAML export format", "Emit nodes as YAML."); + assert!(none.is_empty(), "unrelated text should not match: {none:?}"); + } + #[test] fn scan_bundle_reports_matches_and_enriches_requirements() { let a = impl_node("IMP-001", &["src/storage.rs"], &["REQ-CORE-001"]); diff --git a/src/main.rs b/src/main.rs index 4048278..c984112 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1951,8 +1951,8 @@ fn command_to_name(cmd: &Commands) -> &'static str { fn handle_capture(cmd: CaptureCommands) { use lattice::capture::{ HookOutcome, Nomination, NominationKind, accept, add_nomination, changed_files_since, - install_hooks, load_inbox, prefilter, remove_nomination, save_inbox, scan_bundle, - staged_files, + find_duplicates, install_hooks, load_inbox, prefilter, remove_nomination, save_inbox, + scan_bundle, staged_files, }; let root = get_lattice_root(); @@ -2025,6 +2025,9 @@ fn handle_capture(cmd: CaptureCommands) { Ok(k) => k, Err(e) => emit_error(&format, "invalid_kind", &e), }; + // Entity resolution: flag existing nodes this may duplicate (REQ-CAPTURE-003-A). + let index = build_node_index(&root).unwrap_or_default(); + let dups = find_duplicates(&index, &title, &body); let now = chrono::Utc::now().to_rfc3339(); let nom = Nomination { id: String::new(), @@ -2036,6 +2039,7 @@ fn handle_capture(cmd: CaptureCommands) { category, rationale, source, + possible_duplicates: (!dups.is_empty()).then(|| dups.clone()), created_at: now, created_by, }; @@ -2050,6 +2054,13 @@ fn handle_capture(cmd: CaptureCommands) { stored.id.bold(), stored.kind.as_str() ); + if !dups.is_empty() { + println!( + " {} possible duplicate(s): {}", + "⚠".yellow(), + dups.join(", ").yellow() + ); + } println!( " Review with 'lattice capture list'; accept with 'lattice capture accept {}'.", stored.id @@ -2080,12 +2091,22 @@ fn handle_capture(cmd: CaptureCommands) { .as_deref() .map(|t| format!(" → {}", t)) .unwrap_or_default(); + let dup = n + .possible_duplicates + .as_ref() + .map(|d| { + format!(" {} maybe dup: {}", "⚠".yellow(), d.join(", ")) + .yellow() + .to_string() + }) + .unwrap_or_default(); println!( - " {} [{}] {}{}", + " {} [{}] {}{}{}", n.id.bold(), n.kind.as_str().cyan(), n.title, - tgt + tgt, + dup ); } } @@ -2109,6 +2130,12 @@ fn handle_capture(cmd: CaptureCommands) { if let Some(s) = &n.source { println!("source: {}", s); } + if let Some(d) = &n.possible_duplicates { + println!( + "{}", + format!("possible duplicates: {}", d.join(", ")).yellow() + ); + } println!("by {} at {}", n.created_by, n.created_at); } }