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..0d1b72a --- /dev/null +++ b/.lattice/implementations/capture-003.yaml @@ -0,0 +1,22 @@ +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 + 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-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/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/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..3abf964 --- /dev/null +++ b/.lattice/requirements/capture/001-nomination-staging-surface-outside-the-g.yaml @@ -0,0 +1,19 @@ +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 +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 + 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..64c8ffc --- /dev/null +++ b/.lattice/requirements/capture/002-deterministic-binding-intersection-relev.yaml @@ -0,0 +1,19 @@ +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 +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 + 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..472c3ee --- /dev/null +++ b/.lattice/requirements/capture/003-pre-commit-lattice-worthiness-nominator.yaml @@ -0,0 +1,27 @@ +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 +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 + version: 1.0.0 + depends_on: + - target: REQ-CAPTURE-001 + 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/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..9ac3640 --- /dev/null +++ b/.lattice/requirements/capture/004-pre-push-consistency-gate-and-nomination.yaml @@ -0,0 +1,22 @@ +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 +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 + 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..ce3b1bb --- /dev/null +++ b/.lattice/requirements/capture/005-nominate-not-commit-guardrail.yaml @@ -0,0 +1,19 @@ +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 +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 + version: 1.0.0 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/.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.' 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..70b5059 --- /dev/null +++ b/src/capture.rs @@ -0,0 +1,737 @@ +//! 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 serde_json::{Value, json}; +use std::collections::{BTreeMap, HashSet}; +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}; + +/// 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, + /// 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, +} + +/// 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) + } + + /// 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().position(|n| n.id == want) + } + + pub fn get(&self, id: &str) -> Option<&Nomination> { + self.position(id).map(|i| &self.nominations[i]) + } +} + +/// 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)?; + if let Some(pos) = inbox.position(id) { + 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 + } +} + +/// 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). +/// +/// 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 { + // 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(binds) = bindings.get(file.as_str()) else { + continue; + }; + for bind in binds { + matches.push(BindingMatch { + file: file.clone(), + implementation: bind.implementation.clone(), + requirements: bind.requirements.clone(), + }); + } + } + + PrefilterResult { + changed_files: changed_files.to_vec(), + matches, + } +} + +/// Files staged for commit (`git diff --cached --name-only`). +pub fn staged_files(root: &Path) -> Vec { + 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 { + run_git_lines(root, &["diff", "--name-only", git_ref]) +} + +/// 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.", + }) +} + +/// 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 +/// 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::*; + 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, + possible_duplicates: 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()), + possible_duplicates: None, + 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()); + } + + 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, + possible_duplicates: 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 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"]); + 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/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 b04d103..77d0d5c 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,9 +17,14 @@ pub mod storage; pub mod types; pub mod update; +pub use capture::{ + 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, - 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 d488892..c984112 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; @@ -434,6 +435,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 +758,126 @@ 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, + + /// Output format (text, json) + #[arg(short, long, default_value = "text")] + format: 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 +1942,303 @@ 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::{ + HookOutcome, Nomination, NominationKind, accept, add_nomination, changed_files_since, + find_duplicates, install_hooks, load_inbox, prefilter, remove_nomination, save_inbox, + scan_bundle, 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" { + let bundle = scan_bundle(&index, &result); + 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), + }; + // 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(), + kind, + title, + body, + target, + priority, + category, + rationale, + source, + possible_duplicates: (!dups.is_empty()).then(|| dups.clone()), + 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() + ); + 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 + ); + } + } + 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(); + 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, + dup + ); + } + } + + 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); + } + if let Some(d) = &n.possible_duplicates { + println!( + "{}", + format!("possible duplicates: {}", d.join(", ")).yellow() + ); + } + 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 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(); + + match accept(&root, &nom, id_assign.as_deref(), from.as_deref()) { + Ok(msg) => { + // 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 { + println!("{} {}", "Accepted".green(), nom.id.bold()); + println!(" {}", msg); + } + } + Err(e) => emit_error(&format, "accept_failed", &e), + } + } + + CaptureCommands::Reject { id, format } => match remove_nomination(&root, &id) { + Ok(Some(n)) => println!("{} {}", "Rejected".yellow(), n.id.bold()), + Ok(None) => emit_error(&format, "not_found", &format!("No nomination '{}'", id)), + Err(e) => emit_error(&format, "inbox_error", &e), + }, + + 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 } => 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()) + } + } + } + } + Err(e) => { + eprintln!("{}", e.red()); + process::exit(1); + } + }, + } +} + /// 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"] @@ -3302,25 +3722,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())) @@ -4752,6 +5164,8 @@ fn run_command(command: Commands) { } } + Commands::Capture { capture_command } => handle_capture(capture_command), + Commands::Help { json, compact,