diff --git a/README.md b/README.md index 909ee9f..b0de9b8 100644 --- a/README.md +++ b/README.md @@ -1,24 +1,190 @@ -# codectx +# Codectx -`codectx` is a Rust CLI/runtime experiment for bounded AI coding sessions. +**A deterministic repository context compiler for humans and coding tools.** -It models a development session as a small inspectable system: +Codectx observes a Git repository, combines explicit task focus with project commands and imported external signals, and emits a focused context packet for implementation, review, or handoff. -- task goal -- allowed file scope -- current Git/worktree state -- runtime session metadata -- required evidence before exit -- out-of-scope changes and explained net moves +Git remains repository authority. Codectx does not resolve external obligations, run autonomous coding loops, or silently modify implementation files. -The goal is not to replace coding agents. The goal is to give agents and humans a lightweight context surface for understanding what changed, what is allowed, and what proof is still missing. +## Current capabilities -## Status +- human HUD through `codectx`; +- deterministic JSON through `codectx --json`; +- Git root, branch, HEAD, dirty-file, and baseline-relative observation; +- explicit focus from `.codectx/focus.toml` or `--focus PATH`; +- expected, supporting, unexpected, and unclassified path handling; +- discovery of public `justfile` recipes and definition hashes; +- expanded review output through `codectx review`; +- managed handoff output through `codectx handoff`; +- imported Runseal records retained as external claims with unknown freshness; +- the earlier session runtime retained as `codectx-legacy`. -Early public foundation. Interfaces may change. +Codectx does not currently provide closure evaluation, proof freshness, orchestration, repository indexing, a daemon, or a hosted service. -## Build +## Install -```sh -cargo fmt --all -cargo test --workspace +```bash +cargo install --path crates/codectx-cli --locked --force +``` + +This installs both `codectx` and `codectx-legacy`. + +For repository-local development: + +```bash +just codectx --help +just codectx-bins +``` + +## Product walkthrough + +Run the committed fixture demonstration: + +```bash +just demo +``` + +The script creates a temporary Git repository from `fixtures/context-demo/repository`, declares task focus, commits a baseline, and applies: + +- one expected source change; +- one supporting documentation change; +- one unexpected operations file. + +It renders and verifies the HUD, review, deterministic JSON, discovered commands, attention signal, and managed handoff. + +### Human output + +```text +CODECTX · context + +Location + demo · + +Focus + DEMO-001 · Add request tracing + Keep request tracing changes focused while preserving project commands. + +Changes + expected 1 · supporting 1 · unexpected 1 · unclassified 0 + +Attention + - unexpected_changes: 1 changed path(s) do not match the declared focus. + +Next + Inspect unexpected changes + Changed paths fall outside the declared expected and supporting paths. + run: git diff --stat +``` + +### Review output + +```text +Changed paths + - README.md [Supporting] + - ops/emergency.toml [Unexpected] + - src/lib.rs [Expected] + +Constraints + - keep the public API stable + +Available commands + - just check + - just format +``` + +### JSON excerpt + +```json +{ + "schema_version": 1, + "repository": { + "root": "", + "branch": "demo", + "head": "" + }, + "focus": { + "id": "DEMO-001", + "title": "Add request tracing" + }, + "changes": { + "changed_files": [ + { "path": "README.md", "relevance": "supporting" }, + { "path": "ops/emergency.toml", "relevance": "unexpected" }, + { "path": "src/lib.rs", "relevance": "expected" } + ] + }, + "runnables": [ + { "id": "check", "command": "just check", "visibility": "public" }, + { "id": "format", "command": "just format", "visibility": "public" } + ], + "attention": [ + { "code": "unexpected_changes", "level": "warning" } + ] +} +``` + +The handoff command writes the same packet to: + +```text +.codectx/handoffs/latest.json +``` + +## Use Codectx in a repository + +Create `.codectx/focus.toml`: + +```toml +id = "CX-0001" +title = "Add deterministic context packet output" +summary = "Expose focused repository state to implementers and reviewers." +expected_paths = ["crates/codectx-core/**", "crates/codectx-cli/**"] +supporting_paths = ["README.md", "docs/**", ".github/**"] +constraints = [ + "preserve Git as repository authority", + "retain source authority labels", + "derive human and JSON output from one packet", +] +``` + +Primary surfaces: + +```bash +codectx +codectx --json +codectx review +codectx handoff +``` + +## Packet contract + +The packet contains repository, focus, changes, runnables, external signals, attention items, actions, and source references. Human and review outputs are projections of the same packet used for JSON and handoff. + +See [`docs/context-packet-v1.md`](docs/context-packet-v1.md) for the schema and authority rules. + +## Runseal boundary + +Runseal owns persistent obligations, evidence semantics, resolution, and regression. Codectx may display a Runseal record: + +```bash +codectx --runseal-closure ../runseal/closures/RS-0001.toml +``` + +Imported records remain external claims with unknown freshness. Codectx reports the record; it does not independently validate its current truth. + +## Legacy compatibility + +The previous bounded-session implementation remains available: + +```bash +codectx-legacy --help +``` + +## Proof + +Run the accepted repository proof: + +```bash +just check +``` + +It covers formatting, Clippy with denied warnings, workspace tests, binary help, and the fixture-backed product walkthrough. diff --git a/crates/codectx-cli/Cargo.toml b/crates/codectx-cli/Cargo.toml index 7799c25..f7d2b30 100644 --- a/crates/codectx-cli/Cargo.toml +++ b/crates/codectx-cli/Cargo.toml @@ -7,6 +7,10 @@ repository.workspace = true [[bin]] name = "codectx" +path = "src/context_main.rs" + +[[bin]] +name = "codectx-legacy" path = "src/main.rs" [dependencies] diff --git a/crates/codectx-cli/src/context_main.rs b/crates/codectx-cli/src/context_main.rs new file mode 100644 index 0000000..c9b062e --- /dev/null +++ b/crates/codectx-cli/src/context_main.rs @@ -0,0 +1,152 @@ +use anyhow::{anyhow, bail, Result}; +use camino::Utf8PathBuf; +use codectx_core::{render_context_hud, render_review, write_context_packet, ContextPacketBuilder}; +use std::{env, path::PathBuf}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Mode { + Context, + Review, + Handoff, +} + +#[derive(Debug)] +struct ParsedArgs { + mode: Mode, + json: bool, + focus: Option, + baseline: Option, + runseal_closures: Vec, +} + +fn main() -> Result<()> { + let args: Vec = env::args().skip(1).collect(); + if args.iter().any(|arg| arg == "--help" || arg == "-h") { + print_help(); + return Ok(()); + } + + let parsed = parse_args(args)?; + let mut builder = ContextPacketBuilder::new(); + if let Some(baseline) = parsed.baseline { + builder = builder.baseline(baseline); + } + if let Some(focus) = parsed.focus { + builder = builder.focus_path(focus); + } + for closure in parsed.runseal_closures { + builder = builder.runseal_closure(closure); + } + + let packet = builder.build_current(env::current_dir()?)?; + match parsed.mode { + Mode::Context => { + if parsed.json { + print!("{}", packet.to_pretty_json()?); + } else { + print!("{}", render_context_hud(&packet)); + } + } + Mode::Review => { + if parsed.json { + print!("{}", packet.to_pretty_json()?); + } else { + print!("{}", render_review(&packet)); + } + } + Mode::Handoff => { + let output = packet.repository.root.join(".codectx/handoffs/latest.json"); + write_context_packet(&output, &packet)?; + if parsed.json { + print!("{}", packet.to_pretty_json()?); + } else { + println!("handoff written: {output}"); + print!("{}", render_context_hud(&packet)); + } + } + } + + Ok(()) +} + +fn parse_args(args: Vec) -> Result { + let mut mode = Mode::Context; + let mut mode_was_set = false; + let mut json = false; + let mut focus = None; + let mut baseline = None; + let mut runseal_closures = Vec::new(); + let mut index = 0usize; + + while index < args.len() { + match args[index].as_str() { + "packet" => set_mode(&mut mode, &mut mode_was_set, Mode::Context)?, + "review" => set_mode(&mut mode, &mut mode_was_set, Mode::Review)?, + "handoff" => set_mode(&mut mode, &mut mode_was_set, Mode::Handoff)?, + "--json" => json = true, + "--focus" => { + index += 1; + focus = Some(next_path(&args, index, "--focus")?); + } + "--baseline" => { + index += 1; + baseline = Some(next_value(&args, index, "--baseline")?); + } + "--runseal-closure" => { + index += 1; + runseal_closures.push(next_path(&args, index, "--runseal-closure")?); + } + unknown => bail!("unknown Codectx context option: {unknown}"), + } + index += 1; + } + + Ok(ParsedArgs { + mode, + json, + focus, + baseline, + runseal_closures, + }) +} + +fn set_mode(mode: &mut Mode, was_set: &mut bool, next: Mode) -> Result<()> { + if *was_set && *mode != next { + bail!("choose only one of `packet`, `review`, or `handoff`"); + } + *mode = next; + *was_set = true; + Ok(()) +} + +fn next_value(args: &[String], index: usize, option: &str) -> Result { + args.get(index) + .cloned() + .ok_or_else(|| anyhow!("{option} requires a value")) +} + +fn next_path(args: &[String], index: usize, option: &str) -> Result { + let value = next_value(args, index, option)?; + Utf8PathBuf::from_path_buf(PathBuf::from(value)) + .map_err(|path| anyhow!("{option} path is not valid UTF-8: {}", path.display())) +} + +fn print_help() { + println!( + "codectx - compile repository signals into focused context\n\ +\nUSAGE:\n\ + codectx [--json] [--focus PATH] [--baseline REV] [--runseal-closure PATH]...\n\ + codectx review [OPTIONS]\n\ + codectx handoff [OPTIONS]\n\ +\nCOMMANDS:\n\ + packet Render the default context packet\n\ + review Render paths, constraints, commands, and attention items\n\ + handoff Write .codectx/handoffs/latest.json\n\ +\nOPTIONS:\n\ + --json Emit deterministic JSON\n\ + --focus PATH Load a focus TOML file\n\ + --baseline REV Compare changes with a Git revision\n\ + --runseal-closure PATH Import a Runseal record as an external claim\n\ +\nThe earlier session runtime remains available as codectx-legacy." + ); +} diff --git a/crates/codectx-cli/tests/cli_command_contract.rs b/crates/codectx-cli/tests/cli_command_contract.rs index 1bb912c..00d58bc 100644 --- a/crates/codectx-cli/tests/cli_command_contract.rs +++ b/crates/codectx-cli/tests/cli_command_contract.rs @@ -207,7 +207,7 @@ where S: AsRef, { CommandOutput { - output: Command::new(env!("CARGO_BIN_EXE_codectx")) + output: Command::new(env!("CARGO_BIN_EXE_codectx-legacy")) .args(args) .current_dir(repo.path()) .output() diff --git a/crates/codectx-core/src/lib.rs b/crates/codectx-core/src/lib.rs index 9dbf000..cc9536d 100644 --- a/crates/codectx-core/src/lib.rs +++ b/crates/codectx-core/src/lib.rs @@ -4,6 +4,7 @@ pub mod event_store; pub mod git; pub mod hash; pub mod lens; +pub mod packet; pub mod path_rule; pub mod repo; pub mod runtime; @@ -30,6 +31,12 @@ pub use event_store::EventStore; pub use git::{CommandGitProbe, GitProbe, GitState}; pub use hash::{sha256_bytes, sha256_file}; pub use lens::{enter_task, read_context, IncludedFile, LensStatus, ProofCommand, TaskLens}; +pub use packet::{ + classify_changes, render_context_hud, render_review, write_context_packet, AttentionItem, + AttentionLevel, ChangeContext, ChangeRelevance, ClassifiedChange, ContextAction, ContextPacket, + ContextPacketBuilder, EpistemicStatus, ExternalSignal, ExternalSignalKind, FocusSpec, Freshness, + RepositoryContext, RunnableContext, SourceReference, CONTEXT_PACKET_SCHEMA_VERSION, +}; pub use path_rule::PathRule; pub use repo::{find_repo_root, relative_to_repo}; pub use runtime::{ diff --git a/crates/codectx-core/src/packet.rs b/crates/codectx-core/src/packet.rs new file mode 100644 index 0000000..071d381 --- /dev/null +++ b/crates/codectx-core/src/packet.rs @@ -0,0 +1,11 @@ +mod builder; +mod model; +mod render; + +pub use builder::{classify_changes, write_context_packet, ContextPacketBuilder}; +pub use model::{ + AttentionItem, AttentionLevel, ChangeContext, ChangeRelevance, ClassifiedChange, ContextAction, + ContextPacket, EpistemicStatus, ExternalSignal, ExternalSignalKind, FocusSpec, Freshness, + RepositoryContext, RunnableContext, SourceReference, CONTEXT_PACKET_SCHEMA_VERSION, +}; +pub use render::{render_context_hud, render_review}; diff --git a/crates/codectx-core/src/packet/builder.rs b/crates/codectx-core/src/packet/builder.rs new file mode 100644 index 0000000..25ac276 --- /dev/null +++ b/crates/codectx-core/src/packet/builder.rs @@ -0,0 +1,331 @@ +use super::model::{ + AttentionItem, AttentionLevel, ChangeContext, ChangeRelevance, ClassifiedChange, ContextAction, + ContextPacket, EpistemicStatus, ExternalSignal, ExternalSignalKind, FocusSpec, Freshness, + RepositoryContext, RunnableContext, SourceReference, CONTEXT_PACKET_SCHEMA_VERSION, +}; +use crate::{ + discover_just_runnables, find_repo_root, CommandGitProbe, GitProbe, PathRule, Result, + RunnableVisibility, +}; +use camino::{Utf8Path, Utf8PathBuf}; +use serde::Deserialize; +use std::{fs, path::Path}; + +#[derive(Debug, Clone, Default)] +pub struct ContextPacketBuilder { + baseline: Option, + focus_path: Option, + runseal_closure_paths: Vec, +} + +impl ContextPacketBuilder { + pub fn new() -> Self { + Self::default() + } + + pub fn baseline(mut self, baseline: impl Into) -> Self { + self.baseline = Some(baseline.into()); + self + } + + pub fn focus_path(mut self, path: impl Into) -> Self { + self.focus_path = Some(path.into()); + self + } + + pub fn runseal_closure(mut self, path: impl Into) -> Self { + self.runseal_closure_paths.push(path.into()); + self + } + + pub fn build_current(&self, start: impl AsRef) -> Result { + let repo_root = find_repo_root(start)?; + let git = CommandGitProbe.read_state(&repo_root, self.baseline.as_deref())?; + let focus_source = self + .focus_path + .clone() + .unwrap_or_else(|| Utf8PathBuf::from(".codectx/focus.toml")); + let focus = load_focus(&repo_root, &focus_source)?; + let changes = ChangeContext { + dirty_files: git.dirty_files.clone(), + changed_files: classify_changes(&git.changed_files, focus.as_ref()), + }; + + let mut runnables: Vec = discover_just_runnables(&repo_root)? + .into_iter() + .map(|runnable| RunnableContext { + id: runnable.id, + command: runnable.command, + definition_hash: runnable.definition_hash.0, + visibility: runnable.visibility, + }) + .collect(); + runnables.sort_by(|left, right| left.id.cmp(&right.id)); + + let mut external_signals = Vec::new(); + for path in &self.runseal_closure_paths { + external_signals.push(load_runseal_closure(&repo_root, path)?); + } + external_signals.sort_by(|left, right| { + left.kind + .as_sort_key() + .cmp(right.kind.as_sort_key()) + .then_with(|| left.id.cmp(&right.id)) + .then_with(|| left.source_path.cmp(&right.source_path)) + }); + + let attention = derive_attention(focus.as_ref(), &changes, &external_signals); + let actions = derive_actions(focus.as_ref(), &changes, &runnables, &external_signals); + let mut sources = vec![SourceReference { + kind: "git".to_string(), + path: Utf8PathBuf::from(".git"), + authority: "observed_repository_state".to_string(), + }]; + if focus.is_some() { + sources.push(SourceReference { + kind: "focus".to_string(), + path: relative_or_original(&repo_root, &focus_source), + authority: "declared_local_context".to_string(), + }); + } + sources.extend(external_signals.iter().map(|signal| SourceReference { + kind: "runseal_closure".to_string(), + path: signal.source_path.clone(), + authority: "external_signal".to_string(), + })); + sources.sort_by(|left, right| { + left.kind + .cmp(&right.kind) + .then_with(|| left.path.cmp(&right.path)) + }); + + Ok(ContextPacket { + schema_version: CONTEXT_PACKET_SCHEMA_VERSION, + repository: RepositoryContext { + root: git.root, + branch: git.branch, + head: git.head, + }, + focus, + changes, + runnables, + external_signals, + attention, + actions, + sources, + }) + } +} + +pub fn classify_changes(paths: &[Utf8PathBuf], focus: Option<&FocusSpec>) -> Vec { + let expected_rules = rules(focus.map(|focus| focus.expected_paths.as_slice())); + let supporting_rules = rules(focus.map(|focus| focus.supporting_paths.as_slice())); + + let mut classified: Vec = paths + .iter() + .cloned() + .map(|path| { + let relevance = if focus.is_none() { + ChangeRelevance::Unclassified + } else if expected_rules.iter().any(|rule| rule.matches(&path)) { + ChangeRelevance::Expected + } else if supporting_rules.iter().any(|rule| rule.matches(&path)) { + ChangeRelevance::Supporting + } else { + ChangeRelevance::Unexpected + }; + ClassifiedChange { path, relevance } + }) + .collect(); + classified.sort_by(|left, right| left.path.cmp(&right.path)); + classified +} + +pub fn write_context_packet(path: &Utf8Path, packet: &ContextPacket) -> Result<()> { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + fs::write(path, packet.to_pretty_json()?)?; + Ok(()) +} + +fn rules(values: Option<&[String]>) -> Vec { + values + .unwrap_or_default() + .iter() + .cloned() + .map(PathRule::new) + .collect() +} + +fn load_focus(repo_root: &Utf8Path, source: &Utf8Path) -> Result> { + let path = resolve_path(repo_root, source); + if !path.exists() { + return Ok(None); + } + let contents = fs::read_to_string(path)?; + Ok(Some(toml::from_str(&contents)?)) +} + +fn load_runseal_closure(repo_root: &Utf8Path, source: &Utf8Path) -> Result { + let path = resolve_path(repo_root, source); + let contents = fs::read_to_string(path)?; + let record: RunsealClosureRecord = toml::from_str(&contents)?; + Ok(ExternalSignal { + kind: ExternalSignalKind::RunsealClosure, + source_path: relative_or_original(repo_root, source), + id: record.id, + title: record.title, + status: record.status, + epistemic_status: EpistemicStatus::ExternalClaim, + freshness: Freshness::Unknown, + requirements: record.requirements, + proof_commands: record.proof_commands, + }) +} + +#[derive(Debug, Deserialize)] +struct RunsealClosureRecord { + id: String, + title: String, + status: String, + #[serde(default)] + requirements: Vec, + #[serde(default)] + proof_commands: Vec, +} + +fn derive_attention( + focus: Option<&FocusSpec>, + changes: &ChangeContext, + signals: &[ExternalSignal], +) -> Vec { + let mut attention = Vec::new(); + if focus.is_none() { + attention.push(AttentionItem { + code: "focus_missing".to_string(), + level: AttentionLevel::Info, + message: "No .codectx/focus.toml was found; changed paths remain unclassified." + .to_string(), + source: None, + }); + } + + let unexpected = changes + .changed_files + .iter() + .filter(|change| change.relevance == ChangeRelevance::Unexpected) + .count(); + if unexpected > 0 { + attention.push(AttentionItem { + code: "unexpected_changes".to_string(), + level: AttentionLevel::Warning, + message: format!("{unexpected} changed path(s) do not match the declared focus."), + source: Some("focus".to_string()), + }); + } + + for signal in signals { + if signal.status != "resolved" { + attention.push(AttentionItem { + code: "external_obligation_open".to_string(), + level: AttentionLevel::Warning, + message: format!( + "Runseal closure {} externally reports status {}.", + signal.id, signal.status + ), + source: Some(signal.source_path.to_string()), + }); + } + } + + attention.sort_by(|left, right| { + left.code + .cmp(&right.code) + .then_with(|| left.message.cmp(&right.message)) + }); + attention +} + +fn derive_actions( + focus: Option<&FocusSpec>, + changes: &ChangeContext, + runnables: &[RunnableContext], + signals: &[ExternalSignal], +) -> Vec { + let mut actions = Vec::new(); + if changes + .changed_files + .iter() + .any(|change| change.relevance == ChangeRelevance::Unexpected) + { + actions.push(ContextAction { + id: "inspect_unexpected_changes".to_string(), + label: "Inspect unexpected changes".to_string(), + reason: "Changed paths fall outside the declared expected and supporting paths." + .to_string(), + command: Some("git diff --stat".to_string()), + }); + } + + if focus.is_none() { + actions.push(ContextAction { + id: "declare_focus".to_string(), + label: "Declare the current focus".to_string(), + reason: + "A focus file lets Codectx classify repository changes without inventing intent." + .to_string(), + command: Some("cp .codectx/focus.example.toml .codectx/focus.toml".to_string()), + }); + } + + if let Some(signal) = signals.iter().find(|signal| signal.status != "resolved") { + actions.push(ContextAction { + id: "inspect_runseal_signal".to_string(), + label: format!("Inspect Runseal closure {}", signal.id), + reason: + "The imported status is an external claim and must be interpreted at its source." + .to_string(), + command: None, + }); + } + + if let Some(runnable) = runnables + .iter() + .find(|runnable| runnable.visibility == RunnableVisibility::Public) + { + actions.push(ContextAction { + id: format!("run_{}", runnable.id), + label: format!("Run {}", runnable.id), + reason: "This project command is available from the repository justfile.".to_string(), + command: Some(runnable.command.clone()), + }); + } + + if actions.is_empty() { + actions.push(ContextAction { + id: "review_repository_state".to_string(), + label: "Review repository state".to_string(), + reason: "No stronger action was derived from the current context packet.".to_string(), + command: Some("git status --short".to_string()), + }); + } + + actions +} + +fn resolve_path(repo_root: &Utf8Path, path: &Utf8Path) -> Utf8PathBuf { + if path.is_absolute() { + path.to_path_buf() + } else { + repo_root.join(path) + } +} + +fn relative_or_original(repo_root: &Utf8Path, path: &Utf8Path) -> Utf8PathBuf { + let resolved = resolve_path(repo_root, path); + match resolved.strip_prefix(repo_root) { + Ok(relative) => relative.to_path_buf(), + Err(_) => resolved, + } +} diff --git a/crates/codectx-core/src/packet/model.rs b/crates/codectx-core/src/packet/model.rs new file mode 100644 index 0000000..07a8cd6 --- /dev/null +++ b/crates/codectx-core/src/packet/model.rs @@ -0,0 +1,157 @@ +use crate::{Result, RunnableVisibility}; +use camino::Utf8PathBuf; +use serde::{Deserialize, Serialize}; + +pub const CONTEXT_PACKET_SCHEMA_VERSION: u32 = 1; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct ContextPacket { + pub schema_version: u32, + pub repository: RepositoryContext, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub focus: Option, + pub changes: ChangeContext, + pub runnables: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub external_signals: Vec, + pub attention: Vec, + pub actions: Vec, + pub sources: Vec, +} + +impl ContextPacket { + pub fn to_pretty_json(&self) -> Result { + let mut output = serde_json::to_string_pretty(self)?; + output.push('\n'); + Ok(output) + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct RepositoryContext { + pub root: Utf8PathBuf, + pub branch: String, + pub head: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct FocusSpec { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub id: Option, + pub title: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub summary: Option, + #[serde(default)] + pub expected_paths: Vec, + #[serde(default)] + pub supporting_paths: Vec, + #[serde(default)] + pub constraints: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct ChangeContext { + pub dirty_files: Vec, + pub changed_files: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct ClassifiedChange { + pub path: Utf8PathBuf, + pub relevance: ChangeRelevance, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum ChangeRelevance { + Expected, + Supporting, + Unexpected, + Unclassified, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct RunnableContext { + pub id: String, + pub command: String, + pub definition_hash: String, + pub visibility: RunnableVisibility, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct ExternalSignal { + pub kind: ExternalSignalKind, + pub source_path: Utf8PathBuf, + pub id: String, + pub title: String, + pub status: String, + pub epistemic_status: EpistemicStatus, + pub freshness: Freshness, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub requirements: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub proof_commands: Vec, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum ExternalSignalKind { + RunsealClosure, +} + +impl ExternalSignalKind { + pub(crate) fn as_sort_key(self) -> &'static str { + match self { + Self::RunsealClosure => "runseal_closure", + } + } +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum EpistemicStatus { + Observed, + Derived, + ExternalClaim, + Unknown, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum Freshness { + Current, + Stale, + Unknown, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct AttentionItem { + pub code: String, + pub level: AttentionLevel, + pub message: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub source: Option, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum AttentionLevel { + Info, + Warning, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct ContextAction { + pub id: String, + pub label: String, + pub reason: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub command: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct SourceReference { + pub kind: String, + pub path: Utf8PathBuf, + pub authority: String, +} diff --git a/crates/codectx-core/src/packet/render.rs b/crates/codectx-core/src/packet/render.rs new file mode 100644 index 0000000..934737e --- /dev/null +++ b/crates/codectx-core/src/packet/render.rs @@ -0,0 +1,140 @@ +use super::model::{ChangeRelevance, ContextPacket, RunnableContext}; +use crate::RunnableVisibility; +use std::fmt::Write as _; + +pub fn render_context_hud(packet: &ContextPacket) -> String { + let mut output = String::new(); + writeln!(&mut output, "CODECTX · context").unwrap(); + writeln!(&mut output).unwrap(); + writeln!(&mut output, "Location").unwrap(); + writeln!( + &mut output, + " {} · {}", + packet.repository.branch, + short_head(&packet.repository.head) + ) + .unwrap(); + + writeln!(&mut output).unwrap(); + writeln!(&mut output, "Focus").unwrap(); + match &packet.focus { + Some(focus) => { + if let Some(id) = &focus.id { + writeln!(&mut output, " {id} · {}", focus.title).unwrap(); + } else { + writeln!(&mut output, " {}", focus.title).unwrap(); + } + if let Some(summary) = &focus.summary { + writeln!(&mut output, " {summary}").unwrap(); + } + } + None => writeln!(&mut output, " not declared").unwrap(), + } + + writeln!(&mut output).unwrap(); + writeln!(&mut output, "Changes").unwrap(); + writeln!( + &mut output, + " expected {} · supporting {} · unexpected {} · unclassified {}", + count_relevance(packet, ChangeRelevance::Expected), + count_relevance(packet, ChangeRelevance::Supporting), + count_relevance(packet, ChangeRelevance::Unexpected), + count_relevance(packet, ChangeRelevance::Unclassified), + ) + .unwrap(); + + if !packet.external_signals.is_empty() { + writeln!(&mut output).unwrap(); + writeln!(&mut output, "External signals").unwrap(); + for signal in &packet.external_signals { + writeln!( + &mut output, + " {} · {} · {} [external claim / {:?}]", + signal.id, signal.title, signal.status, signal.freshness + ) + .unwrap(); + } + } + + writeln!(&mut output).unwrap(); + writeln!(&mut output, "Attention").unwrap(); + if packet.attention.is_empty() { + writeln!(&mut output, " none").unwrap(); + } else { + for item in &packet.attention { + writeln!(&mut output, " - {}: {}", item.code, item.message).unwrap(); + } + } + + writeln!(&mut output).unwrap(); + writeln!(&mut output, "Next").unwrap(); + if let Some(action) = packet.actions.first() { + writeln!(&mut output, " {}", action.label).unwrap(); + writeln!(&mut output, " {}", action.reason).unwrap(); + if let Some(command) = &action.command { + writeln!(&mut output, " run: {command}").unwrap(); + } + } else { + writeln!(&mut output, " review the current repository state").unwrap(); + } + + output +} + +pub fn render_review(packet: &ContextPacket) -> String { + let mut output = render_context_hud(packet); + writeln!(&mut output).unwrap(); + writeln!(&mut output, "Changed paths").unwrap(); + if packet.changes.changed_files.is_empty() { + writeln!(&mut output, " none").unwrap(); + } else { + for change in &packet.changes.changed_files { + writeln!(&mut output, " - {} [{:?}]", change.path, change.relevance).unwrap(); + } + } + + if let Some(focus) = &packet.focus { + writeln!(&mut output).unwrap(); + writeln!(&mut output, "Constraints").unwrap(); + if focus.constraints.is_empty() { + writeln!(&mut output, " none declared").unwrap(); + } else { + for constraint in &focus.constraints { + writeln!(&mut output, " - {constraint}").unwrap(); + } + } + } + + writeln!(&mut output).unwrap(); + writeln!(&mut output, "Available commands").unwrap(); + let public = public_runnables(&packet.runnables); + if public.is_empty() { + writeln!(&mut output, " none discovered").unwrap(); + } else { + for runnable in public { + writeln!(&mut output, " - {}", runnable.command).unwrap(); + } + } + + output +} + +fn public_runnables(runnables: &[RunnableContext]) -> Vec<&RunnableContext> { + runnables + .iter() + .filter(|runnable| runnable.visibility == RunnableVisibility::Public) + .collect() +} + +fn count_relevance(packet: &ContextPacket, relevance: ChangeRelevance) -> usize { + packet + .changes + .changed_files + .iter() + .filter(|change| change.relevance == relevance) + .count() +} + +fn short_head(head: &str) -> &str { + head.get(..7).unwrap_or(head) +} diff --git a/docs/context-packet-v1.md b/docs/context-packet-v1.md new file mode 100644 index 0000000..1227561 --- /dev/null +++ b/docs/context-packet-v1.md @@ -0,0 +1,9 @@ +# Context packet v1 + +The packet contains repository state, explicit focus, classified changes, discovered commands, imported signals, attention items, suggested actions, and source references. + +Collections use stable ordering. For the same checkout state, `codectx --json` and `.codectx/handoffs/latest.json` contain the same bytes. + +Changed paths are classified as `expected`, `supporting`, `unexpected`, or `unclassified`. Rules may be exact paths or directory prefixes ending in `/**`. + +Codectx distinguishes observed repository state, declared local context, derived suggestions, and imported external claims. Attention items and actions are not authorization, proof, or completion status. diff --git a/fixtures/context-demo/repository/justfile b/fixtures/context-demo/repository/justfile new file mode 100644 index 0000000..235ac77 --- /dev/null +++ b/fixtures/context-demo/repository/justfile @@ -0,0 +1,5 @@ +check: + echo check + +format: + echo format diff --git a/fixtures/context-demo/repository/src/lib.rs b/fixtures/context-demo/repository/src/lib.rs new file mode 100644 index 0000000..3924e0e --- /dev/null +++ b/fixtures/context-demo/repository/src/lib.rs @@ -0,0 +1,3 @@ +pub fn trace_request(request_id: &str) -> String { + format!("request={request_id}") +} diff --git a/justfile b/justfile index bddaec5..8a6052f 100644 --- a/justfile +++ b/justfile @@ -1,4 +1,21 @@ check: - cargo fmt --check + cargo fmt --all --check + cargo clippy --workspace --all-targets -- -D warnings cargo test --workspace - cargo run -p codectx-cli -- --help + cargo run -p codectx-cli --bin codectx -- --help + bash scripts/demo.sh + +demo: + bash scripts/demo.sh + +install-user: + cargo install --path crates/codectx-cli --locked --force + +codectx *args: + cargo run -p codectx-cli --bin codectx -- {{args}} + +codectx-legacy *args: + cargo run -p codectx-cli --bin codectx-legacy -- {{args}} + +codectx-bins: + cargo build -p codectx-cli --bins diff --git a/scripts/demo.sh b/scripts/demo.sh new file mode 100755 index 0000000..b13e389 --- /dev/null +++ b/scripts/demo.sh @@ -0,0 +1,91 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +fixture_root="$repo_root/fixtures/context-demo/repository" +binary="$repo_root/target/debug/codectx" +worktree="$(mktemp -d)" + +cleanup() { + rm -rf -- "$worktree" +} +trap cleanup EXIT + +cargo build --quiet -p codectx-cli --bin codectx +cp -R "$fixture_root/." "$worktree/" + +mkdir -p "$worktree/.codectx" +cat > "$worktree/.codectx/focus.toml" <<'EOF' +id = "DEMO-001" +title = "Add request tracing" +summary = "Keep request tracing changes focused while preserving project commands." +expected_paths = ["src/**"] +supporting_paths = ["README.md"] +constraints = ["keep the public API stable"] +EOF + +cat > "$worktree/README.md" <<'EOF' +# Demo repository + +Baseline documentation. +EOF + +git -C "$worktree" init -q -b demo +git -C "$worktree" config user.name "Codectx Demo" +git -C "$worktree" config user.email "demo@codectx.invalid" +git -C "$worktree" add . +GIT_AUTHOR_DATE="2026-01-01T00:00:00Z" GIT_COMMITTER_DATE="2026-01-01T00:00:00Z" git -C "$worktree" commit -qm "fixture baseline" + +cat > "$worktree/src/lib.rs" <<'EOF' +pub fn trace_request(request_id: &str) -> String { + format!("trace.request_id={request_id}") +} +EOF +printf '\nThe tracing behavior is now part of the current slice.\n' >> "$worktree/README.md" +mkdir -p "$worktree/ops" +printf '%s\n' 'enabled = true' > "$worktree/ops/emergency.toml" + +human_output="$(cd "$worktree" && "$binary")" +review_output="$(cd "$worktree" && "$binary" review)" +json_output="$(cd "$worktree" && "$binary" --json)" +handoff_output="$(cd "$worktree" && "$binary" handoff)" + +printf '%s\n' "=== Human HUD ===" "$human_output" +printf '%s\n' "=== Review ===" "$review_output" +printf '%s\n' "=== JSON packet ===" "$json_output" +printf '%s\n' "=== Handoff ===" "$handoff_output" + +grep -Fq "DEMO-001 · Add request tracing" <<<"$human_output" +grep -Fq "expected 1 · supporting 1 · unexpected 1 · unclassified 0" <<<"$human_output" +grep -Fq "README.md [Supporting]" <<<"$review_output" +grep -Fq "ops/emergency.toml [Unexpected]" <<<"$review_output" +grep -Fq "src/lib.rs [Expected]" <<<"$review_output" +grep -Fq "just check" <<<"$review_output" +grep -Fq "just format" <<<"$review_output" + +JSON_PACKET="$json_output" python3 <<'PY' +import json +import os + +packet = json.loads(os.environ["JSON_PACKET"]) +assert packet["schema_version"] == 1 +assert packet["repository"]["branch"] == "demo" +assert packet["focus"]["id"] == "DEMO-001" +assert { + item["path"]: item["relevance"] + for item in packet["changes"]["changed_files"] +} == { + "README.md": "supporting", + "ops/emergency.toml": "unexpected", + "src/lib.rs": "expected", +} +assert sorted( + item["command"] + for item in packet["runnables"] + if item["visibility"] == "public" +) == ["just check", "just format"] +assert any(item["code"] == "unexpected_changes" for item in packet["attention"]) +PY + +printf '%s\n' "$json_output" | cmp -s - "$worktree/.codectx/handoffs/latest.json" +printf '%s\n' "demo: HUD, review, JSON, commands, attention, and handoff verified"