From 35fa0230c5adf088ece4c5d22e8f343c281ca96d Mon Sep 17 00:00:00 2001 From: Curry Date: Tue, 18 Aug 2026 10:05:31 +0800 Subject: [PATCH 1/2] feat(ux): add guided project status facade --- README.md | 21 ++- .../src/cli/command_catalog/contract.rs | 1 + .../src/cli/command_catalog/mod.rs | 80 ++++++-- .../src/cli/command_catalog/routes/mod.rs | 4 +- .../command_catalog/routes/project_routes.rs | 22 +++ .../src/cli/command_catalog/routes/types.rs | 1 + .../src/cli/command_catalog/tests.rs | 35 +++- crates/code-intel-cli/src/cli/legacy.rs | 1 + .../code-intel-cli/src/cli/project_query.rs | 131 +++++++++++++ crates/code-intel-cli/src/project_context.rs | 177 +++++++++++++++++- .../src/project_context/tests.rs | 99 ++++++++++ .../code-intel-cli/tests/cli_head_parity.rs | 2 +- .../tests/fixtures/cli-head-parity.v2.json | 8 +- crates/code-intel-cli/tests/primary_entry.rs | 46 +++++ 14 files changed, 600 insertions(+), 28 deletions(-) diff --git a/README.md b/README.md index a98d34f1..d8159caa 100644 --- a/README.md +++ b/README.md @@ -122,11 +122,18 @@ echo 'source "$HOME/.config/code-intel/env.sh"' >> ~/.zshrc 重开 shell 后第一次运行: ```bash +code-intel status ~/src/your-repo code-intel ~/src/your-repo code-intel query ~/src/your-repo --kind evidence --json ``` -`code-intel run ` 是第一行主入口的等价命名形式。`query` 只接收仓库路径, +`status` 是低摩擦的项目入口:它把仓库身份、最近一次 committed run 的新鲜度和 +下一步动作放在一起。未分析时返回 `needs_run`,证据与当前 checkout 一致时返回 +`ready`,不一致时返回 `stale`;Agent 可用 `--json` 读取同一个 +`code-intel-project-status.v1` envelope。它只组合现有 authority/freshness 契约, +不会把未提交或过期证据伪装成当前事实。 + +`code-intel run ` 是分析主入口的等价命名形式。`query` 只接收仓库路径, 由 ProjectContext 统一解析仓库键和 artifact root;日常调用不需要再传 `--artifact-root`、`--repo`、run id 或 manifest。低层发布/排障仍可使用 `run execute` 和 `artifact query` 的显式参数接口。 @@ -587,6 +594,18 @@ code-intel audit --operation scope --repo C:\path\to\repo --since ## Agent 工作流 +### 先看状态,再沿下一步工作 + +```powershell +code-intel status +code-intel status --json +``` + +状态输出给出仓库绑定、committed run、新鲜度和一组有界的 `nextActions`:首次分析、 +上下文查询、证据查询、MCP 追踪或 advisory 影响分析。动作清单是可迭代的产品提示, +不改变 artifact、provider 或 gate 的 authority;研究判断和后续 UX 次序仍可通过 +decision/research 文档与 issue 修订。 + ### 先接查询面,全量扫描是深检模式 Agent 平时问单点问题走 MCP,不必为一个问题跑一整轮 pipeline: diff --git a/crates/code-intel-cli/src/cli/command_catalog/contract.rs b/crates/code-intel-cli/src/cli/command_catalog/contract.rs index 0abb45e1..40ee630e 100644 --- a/crates/code-intel-cli/src/cli/command_catalog/contract.rs +++ b/crates/code-intel-cli/src/cli/command_catalog/contract.rs @@ -32,6 +32,7 @@ pub(super) enum CommandAuthority { #[allow(dead_code)] pub(super) enum AuthorityCondition { CommittedOrStaleAdvisory, + CommittedWhenPresent, } /// Mirrors the closed `effect` vocabulary in diff --git a/crates/code-intel-cli/src/cli/command_catalog/mod.rs b/crates/code-intel-cli/src/cli/command_catalog/mod.rs index efa3ca71..3838f947 100644 --- a/crates/code-intel-cli/src/cli/command_catalog/mod.rs +++ b/crates/code-intel-cli/src/cli/command_catalog/mod.rs @@ -20,7 +20,10 @@ use super::legacy::{ use super::primary::{ execute_primary, matches_primary_pattern, parse_primary_args, parse_run_alias_args, PrimaryArgs, }; -use super::project_query::{execute_project_query, parse_project_query_args, ProjectQueryArgs}; +use super::project_query::{ + execute_project_query, execute_project_status, parse_project_query_args, + parse_project_status_args, render_project_status, ProjectQueryArgs, ProjectStatusArgs, +}; mod contract; mod routes; @@ -37,6 +40,7 @@ use routes::{resolve_command_route, resolve_legacy_route, CommandRoute}; pub(super) enum Command { Version(VersionCommand), Primary(PrimaryArgs), + ProjectStatus(ProjectStatusArgs), ProjectQuery(ProjectQueryArgs), Compatibility(CompatibilityCommand), Legacy(LegacyCommand), @@ -142,6 +146,7 @@ pub(super) enum CommandError { message: String, }, Project { + json: bool, kind: &'static str, exit_code: i32, message: String, @@ -187,11 +192,29 @@ pub(super) fn parse_command(raw: &[String]) -> std::result::Result parse_project_status_args(&raw[1..]) + .map(Command::ProjectStatus) + .map_err(|message| { + if raw.iter().any(|argument| argument == "--json") { + CommandError::Project { + json: true, + kind: "usage", + exit_code: 64, + message, + } + } else { + CommandError::Usage { + message, + exit_code: 64, + } + } + }), Some(CommandRoute::ProjectQuery(_)) => parse_project_query_args(&raw[1..]) .map(Command::ProjectQuery) .map_err(|message| { if raw.iter().any(|argument| argument == "--json") { CommandError::Project { + json: true, kind: "usage", exit_code: 64, message, @@ -269,6 +292,20 @@ pub(super) fn execute_command( String::new(), )), Err(error) => Err(CommandError::Project { + json: true, + kind: error.kind(), + exit_code: error.exit_code(), + message: error.message().to_string(), + }), + }, + Command::ProjectStatus(arguments) => match execute_project_status(&arguments) { + Ok(output) => Ok(buffered_outcome( + 0, + render_project_status(&arguments, &output), + String::new(), + )), + Err(error) => Err(CommandError::Project { + json: arguments.json, kind: error.kind(), exit_code: error.exit_code(), message: error.message().to_string(), @@ -321,24 +358,35 @@ pub(super) fn render_outcome( exit_code: 1, }, Err(CommandError::Project { + json, kind, exit_code, message, - }) => RenderedOutcome { - stdout: format!( - "{}\n", - serde_json::to_string(&json!({ - "schema": "code-intel-project-error.v1", - "outcome": "error", - "kind": kind, - "exitCode": exit_code, - "diagnostic": message, - })) - .expect("project error serializes") - ), - stderr: String::new(), - exit_code, - }, + }) => { + if json { + RenderedOutcome { + stdout: format!( + "{}\n", + serde_json::to_string(&json!({ + "schema": "code-intel-project-error.v1", + "outcome": "error", + "kind": kind, + "exitCode": exit_code, + "diagnostic": message, + })) + .expect("project error serializes") + ), + stderr: String::new(), + exit_code, + } + } else { + RenderedOutcome { + stdout: String::new(), + stderr: format!("Code Intel status error: {message}\n"), + exit_code, + } + } + } Err(CommandError::Usage { message, exit_code }) => RenderedOutcome { stdout: String::new(), stderr: format!("{message}\n"), diff --git a/crates/code-intel-cli/src/cli/command_catalog/routes/mod.rs b/crates/code-intel-cli/src/cli/command_catalog/routes/mod.rs index c04ec809..2353a7ef 100644 --- a/crates/code-intel-cli/src/cli/command_catalog/routes/mod.rs +++ b/crates/code-intel-cli/src/cli/command_catalog/routes/mod.rs @@ -749,7 +749,7 @@ pub(super) const COMMAND_ROUTES: &[CommandRoute] = &[ Internal, Internal, &[], - stdout!("text-format:help-quick.v1", "text-format:help-full.v3"), + stdout!("text-format:help-quick.v1", "text-format:help-full.v4"), exits!(0, 1), "retain while this major CLI contract is supported" ), @@ -769,6 +769,7 @@ pub(super) const COMMAND_ROUTES: &[CommandRoute] = &[ ), }, project_routes::RUN_ALIAS, + project_routes::STATUS, project_routes::QUERY, project_routes::PRIMARY, ]; @@ -779,6 +780,7 @@ pub(super) fn resolve_command_route(raw: &[String]) -> Option<&'static CommandRo route.command == command || route.aliases.iter().any(|alias| alias == command) }), CommandRoute::RunAlias(_) => raw.first().is_some_and(|command| command == "run"), + CommandRoute::ProjectStatus(_) => raw.first().is_some_and(|command| command == "status"), CommandRoute::ProjectQuery(_) => raw.first().is_some_and(|command| command == "query"), CommandRoute::Primary(_) => matches_primary_pattern(raw), CommandRoute::Raw(route) => raw.first().is_some_and(|command| { diff --git a/crates/code-intel-cli/src/cli/command_catalog/routes/project_routes.rs b/crates/code-intel-cli/src/cli/command_catalog/routes/project_routes.rs index 11bce771..88eb69e0 100644 --- a/crates/code-intel-cli/src/cli/command_catalog/routes/project_routes.rs +++ b/crates/code-intel-cli/src/cli/command_catalog/routes/project_routes.rs @@ -26,6 +26,28 @@ pub(super) const RUN_ALIAS: super::CommandRoute = retirement_condition: "retain as the named alias of the default production entry", }); +pub(super) const STATUS: super::CommandRoute = + super::CommandRoute::ProjectStatus(super::CommandContract { + stability: super::CommandStability::Public, + controller: super::ControllerOwnership::CommittedEvidence, + authority: super::CommandAuthority::Conditional( + super::AuthorityCondition::CommittedWhenPresent, + ), + effects: &[ + super::CommandEffect::RepoRead, + super::CommandEffect::ProcessSpawn, + ], + output_contract: super::OutputContract::Stdout { + identities: &[ + "code-intel-project-status.v1", + "code-intel-project-error.v1", + "text-format:project-status.v1", + ], + }, + exit_contract: super::ExitContract::Exact(&[0, 64, 65, 74]), + retirement_condition: "retain as the project readiness and next-action entry", + }); + pub(super) const QUERY: super::CommandRoute = super::CommandRoute::ProjectQuery(super::CommandContract { stability: super::CommandStability::Public, diff --git a/crates/code-intel-cli/src/cli/command_catalog/routes/types.rs b/crates/code-intel-cli/src/cli/command_catalog/routes/types.rs index 464ed10c..16878936 100644 --- a/crates/code-intel-cli/src/cli/command_catalog/routes/types.rs +++ b/crates/code-intel-cli/src/cli/command_catalog/routes/types.rs @@ -38,6 +38,7 @@ pub(in crate::cli::command_catalog) struct VersionRoute { pub(in crate::cli::command_catalog) enum CommandRoute { Version(VersionRoute), RunAlias(CommandContract), + ProjectStatus(CommandContract), ProjectQuery(CommandContract), Primary(CommandContract), Raw(RawRoute), diff --git a/crates/code-intel-cli/src/cli/command_catalog/tests.rs b/crates/code-intel-cli/src/cli/command_catalog/tests.rs index 06bf4829..23f70f4c 100644 --- a/crates/code-intel-cli/src/cli/command_catalog/tests.rs +++ b/crates/code-intel-cli/src/cli/command_catalog/tests.rs @@ -50,6 +50,9 @@ fn parser_exposes_typed_commands_and_hides_route_slicing() { ]) .unwrap(); assert!(matches!(project_query, Command::ProjectQuery(_))); + + let project_status = parse_command(&["status".into(), "--json".into()]).unwrap(); + assert!(matches!(project_status, Command::ProjectStatus(_))); } #[test] @@ -197,6 +200,15 @@ fn command_inventory_is_complete_unique_and_dispatchable() { Some(CommandRoute::RunAlias(_)) )); } + CommandRoute::ProjectStatus(contract) => { + contract.assert_complete("status"); + assert!(!spellings.contains(&"status".to_string())); + spellings.push("status".into()); + assert!(matches!( + resolve_command_route(&["status".into()]), + Some(CommandRoute::ProjectStatus(_)) + )); + } CommandRoute::ProjectQuery(contract) => { contract.assert_complete("query"); assert!(!spellings.contains(&"query".to_string())); @@ -248,7 +260,9 @@ fn command_contracts_use_concrete_output_and_exit_types() { for route in COMMAND_ROUTES { let contract = match route { CommandRoute::Version(route) => &route.contract, - CommandRoute::RunAlias(contract) | CommandRoute::ProjectQuery(contract) => contract, + CommandRoute::RunAlias(contract) + | CommandRoute::ProjectStatus(contract) + | CommandRoute::ProjectQuery(contract) => contract, CommandRoute::Primary(contract) => contract, CommandRoute::Raw(route) => &route.contract, CommandRoute::Legacy(route) => &route.contract, @@ -269,6 +283,9 @@ fn unified_route_inventory_owns_version_primary_raw_and_legacy_dispatch() { assert!(COMMAND_ROUTES .iter() .any(|route| matches!(route, CommandRoute::RunAlias(_)))); + assert!(COMMAND_ROUTES + .iter() + .any(|route| matches!(route, CommandRoute::ProjectStatus(_)))); assert!(COMMAND_ROUTES .iter() .any(|route| matches!(route, CommandRoute::ProjectQuery(_)))); @@ -308,6 +325,17 @@ fn command_authority_and_effect_contracts_cover_conditional_and_mutating_routes( raw("change", Some("impact")).contract.authority, CommandAuthority::Conditional(AuthorityCondition::CommittedOrStaleAdvisory) ); + let status = COMMAND_ROUTES + .iter() + .find_map(|route| match route { + CommandRoute::ProjectStatus(contract) => Some(contract), + _ => None, + }) + .expect("registered status command"); + assert_eq!( + status.authority, + CommandAuthority::Conditional(AuthorityCondition::CommittedWhenPresent) + ); assert_eq!( raw("artifact", Some("index")).contract.effects, &[CommandEffect::RepoRead, CommandEffect::LocalWrite] @@ -527,7 +555,8 @@ fn full_help_documents_every_registered_route_alias() { } #[test] -fn full_help_alias_discoverability_has_a_v3_output_contract() { +fn full_help_alias_discoverability_has_a_v4_output_contract() { + assert!(FULL_HELP_TEXT.contains(" status [] [--json]")); let help = COMMAND_ROUTES .iter() .find_map(|route| match route { @@ -539,7 +568,7 @@ fn full_help_alias_discoverability_has_a_v3_output_contract() { assert_eq!( help.contract.output_contract, OutputContract::Stdout { - identities: &["text-format:help-quick.v1", "text-format:help-full.v3"] + identities: &["text-format:help-quick.v1", "text-format:help-full.v4"] } ); } diff --git a/crates/code-intel-cli/src/cli/legacy.rs b/crates/code-intel-cli/src/cli/legacy.rs index 20718165..1309b291 100644 --- a/crates/code-intel-cli/src/cli/legacy.rs +++ b/crates/code-intel-cli/src/cli/legacy.rs @@ -1125,6 +1125,7 @@ Commands: --version|-V [--json] help|--help|-h [--all] run [] [--mode lite|normal|full] [--json] + status [] [--json] query [] --kind evidence [--artifact-schema ] [--type ] [--contains ] [--limit <1..100>] --json report --repo [--artifact-root ] [--json] resume --repo [--artifact-root ] [--json] diff --git a/crates/code-intel-cli/src/cli/project_query.rs b/crates/code-intel-cli/src/cli/project_query.rs index 9d90cb5f..302f7e9d 100644 --- a/crates/code-intel-cli/src/cli/project_query.rs +++ b/crates/code-intel-cli/src/cli/project_query.rs @@ -6,6 +6,7 @@ use serde_json::Value; use crate::project_context::{EvidenceQuery, ProjectContext, ProjectError, ProjectSelector, Query}; const USAGE: &str = "usage: query [] --kind evidence [--artifact-schema ] [--type ] [--contains ] [--limit <1..100>] --json"; +const STATUS_USAGE: &str = "usage: status [] [--json]"; #[derive(Debug)] pub(super) struct ProjectQueryArgs { @@ -13,6 +14,12 @@ pub(super) struct ProjectQueryArgs { query: Query, } +#[derive(Debug)] +pub(super) struct ProjectStatusArgs { + repo: PathBuf, + pub(super) json: bool, +} + pub(super) fn parse_project_query_args(raw: &[String]) -> Result { let mut repo = None; let mut kind = None; @@ -88,6 +95,108 @@ pub(super) fn execute_project_query(arguments: ProjectQueryArgs) -> Result Result { + let mut repo = None; + let mut json = false; + for token in raw { + match token.as_str() { + "--json" if !json => json = true, + "--json" => return Err("duplicate --json".into()), + token if token.starts_with('-') => { + return Err(format!("unknown status argument: {token}\n{STATUS_USAGE}")); + } + token => { + if repo.replace(PathBuf::from(token)).is_some() { + return Err(format!( + "only one repository path may be supplied\n{STATUS_USAGE}" + )); + } + } + } + } + let repo = repo.unwrap_or(env::current_dir().map_err(|error| error.to_string())?); + Ok(ProjectStatusArgs { repo, json }) +} + +pub(super) fn execute_project_status(arguments: &ProjectStatusArgs) -> Result { + let context = ProjectContext::resolve(ProjectSelector::new(arguments.repo.clone()))?; + context.status() +} + +pub(super) fn render_project_status(arguments: &ProjectStatusArgs, status: &Value) -> String { + if arguments.json { + return format!( + "{}\n", + serde_json::to_string(status).expect("project status serializes") + ); + } + + let state = status["status"].as_str().unwrap_or("unknown"); + let project = &status["project"]; + let mut lines = vec![ + format!( + "[{}] {}", + state.to_uppercase(), + project["repo"].as_str().unwrap_or("") + ), + format!(" Path: {}", project["path"].as_str().unwrap_or("")), + format!( + " Freshness: {}", + status["freshness"]["status"] + .as_str() + .unwrap_or("unavailable") + ), + ]; + if let Some(run) = status["committedRun"]["run"].as_str() { + lines.push(format!(" Committed run: {run}")); + lines.push(format!( + " Recorded snapshot: {}", + status["freshness"]["recordedIdentity"] + .as_str() + .unwrap_or("") + )); + lines.push(format!( + " Current snapshot: {}", + status["freshness"]["currentIdentity"] + .as_str() + .unwrap_or("") + )); + } + if let Some(reason) = status["reason"].as_str() { + lines.push(format!(" Reason: {reason}")); + } + lines.push("Next actions:".into()); + for action in status["nextActions"].as_array().into_iter().flatten() { + let id = action["id"].as_str().unwrap_or("next"); + let summary = action["summary"].as_str().unwrap_or(""); + lines.push(format!(" {id}: {summary}")); + if let Some(argv) = action["argv"].as_array() { + lines.push(format!(" {}", render_argv(argv))); + } else if let Some(argv) = action["serverArgv"].as_array() { + lines.push(format!(" start: {}", render_argv(argv))); + lines.push(format!( + " tool: {}", + action["tool"].as_str().unwrap_or("") + )); + } + } + format!("{}\n", lines.join("\n")) +} + +fn render_argv(argv: &[Value]) -> String { + argv.iter() + .filter_map(Value::as_str) + .map(|argument| { + if argument.chars().any(char::is_whitespace) { + format!("\"{}\"", argument.replace('"', "\\\"")) + } else { + argument.to_string() + } + }) + .collect::>() + .join(" ") +} + fn set_once(slot: &mut Option, value: T, name: &str) -> Result<(), String> { if slot.replace(value).is_some() { Err(format!("duplicate {name}")) @@ -137,4 +246,26 @@ mod tests { assert!(error.contains("unknown query argument"), "{flag}: {error}"); } } + + #[test] + fn status_parser_accepts_one_repository_and_an_optional_json_flag() { + let parsed = parse_project_status_args(&argv(&["repository", "--json"])) + .expect("status arguments parse"); + assert_eq!(parsed.repo, PathBuf::from("repository")); + assert!(parsed.json); + } + + #[test] + fn status_parser_rejects_placement_overrides_and_duplicate_inputs() { + for arguments in [ + argv(&["--artifact-root", "elsewhere"]), + argv(&["one", "two"]), + argv(&["--json", "--json"]), + ] { + assert!( + parse_project_status_args(&arguments).is_err(), + "{arguments:?}" + ); + } + } } diff --git a/crates/code-intel-cli/src/project_context.rs b/crates/code-intel-cli/src/project_context.rs index ab026f8b..ee3871b7 100644 --- a/crates/code-intel-cli/src/project_context.rs +++ b/crates/code-intel-cli/src/project_context.rs @@ -6,8 +6,10 @@ use std::time::{SystemTime, UNIX_EPOCH}; use serde_json::{json, Value}; -use crate::committed_evidence::CommittedEvidence; -use crate::committed_evidence_controller::{CommittedAuthority, CommittedEvidenceController}; +use crate::committed_evidence::{CommittedEvidence, EvidenceError}; +use crate::committed_evidence_controller::{ + CommittedAuthority, CommittedEvidenceController, FreshnessRequest, +}; use crate::evidence_query::{EvidenceQueryRequest, MAX_LIMIT}; use crate::{artifacts, authoritative_run, execution_policy}; @@ -195,6 +197,177 @@ impl ProjectContext { } } } + + pub(crate) fn status(&self) -> Result { + if !self.artifact_root.is_dir() { + return Ok(self.status_without_run(format!( + "artifact root is not present: {}", + self.artifact_root.display() + ))); + } + let freshness = match CommittedEvidenceController::freshness(FreshnessRequest { + artifact_root: self.artifact_root.clone(), + repo: self.repo.clone(), + repo_path: Some(self.repo_path.clone()), + }) { + Ok(freshness) => freshness, + Err(error) if is_unindexed(&error) => { + return Ok(self.status_without_run(error_message(&error).to_string())); + } + Err(error) => return Err(ProjectError::from(error)), + }; + Ok(self.status_with_run(freshness.value, &freshness.authority)) + } + + fn status_without_run(&self, reason: String) -> Value { + json!({ + "schema": "code-intel-project-status.v1", + "status": "needs_run", + "project": self.project_identity(), + "freshness": { + "status": "unavailable", + "recordedIdentity": Value::Null, + "currentIdentity": Value::Null, + "workingTreePolicy": Value::Null, + "scope": [], + }, + "committedRun": Value::Null, + "reason": reason, + "nextActions": [self.command_action( + "analyze", + "Analyze this project and publish the first committed run.", + vec!["code-intel".into(), self.repo_path.display().to_string()], + )], + }) + } + + fn status_with_run(&self, freshness: Value, authority: &CommittedAuthority) -> Value { + let receipt = authority.receipt(); + let state = if freshness["status"] == "current" { + "ready" + } else { + "stale" + }; + json!({ + "schema": "code-intel-project-status.v1", + "status": state, + "project": self.project_identity(), + "freshness": freshness, + "committedRun": { + "repo": receipt.repo(), + "run": receipt.run(), + "runIdentity": receipt.run_identity(), + "snapshotIdentity": receipt.snapshot_identity(), + "authority": "committed", + }, + "reason": Value::Null, + "nextActions": self.next_actions(state), + }) + } + + fn project_identity(&self) -> Value { + json!({ + "repo": self.repo, + "path": self.repo_path, + }) + } + + fn next_actions(&self, state: &str) -> Value { + let repo_path = self.repo_path.display().to_string(); + let artifact_root = self.artifact_root.display().to_string(); + let mut actions = Vec::new(); + if state == "stale" { + actions.push(self.command_action( + "refresh", + "Refresh committed evidence before querying it as current.", + vec!["code-intel".into(), repo_path.clone()], + )); + } else { + actions.push(self.command_action( + "context", + "Read the bounded ranked code context for this project.", + vec![ + "code-intel".into(), + "query".into(), + repo_path.clone(), + "--kind".into(), + "evidence".into(), + "--type".into(), + "code_evidence.agent_slice".into(), + "--limit".into(), + "5".into(), + "--json".into(), + ], + )); + actions.push(self.command_action( + "query", + "Query the verified committed artifacts with a bounded result.", + vec![ + "code-intel".into(), + "query".into(), + repo_path.clone(), + "--kind".into(), + "evidence".into(), + "--limit".into(), + "20".into(), + "--json".into(), + ], + )); + actions.push(json!({ + "id": "trace", + "kind": "mcp_tool", + "summary": "Trace one finding through its committed evidence chain.", + "serverArgv": [ + "code-intel", "serve", "--mcp", "--repo-path", repo_path.clone(), + "--repo", self.repo, "--artifact-root", artifact_root.clone(), + ], + "tool": "get_evidence", + })); + } + actions.push(self.command_action( + "impact", + "Estimate blast radius and candidate tests from committed imports as advisory evidence.", + vec![ + "code-intel".into(), + "change".into(), + "impact".into(), + "--artifact-root".into(), + artifact_root, + "--repo".into(), + self.repo.clone(), + "--repo-path".into(), + repo_path, + "--changed".into(), + "".into(), + "--staleness".into(), + "advisory".into(), + ], + )); + Value::Array(actions) + } + + fn command_action(&self, id: &str, summary: &str, argv: Vec) -> Value { + json!({ + "id": id, + "kind": "command", + "summary": summary, + "argv": argv, + }) + } +} + +fn is_unindexed(error: &EvidenceError) -> bool { + matches!( + error, + EvidenceError::Contract(message) + if message.starts_with("no committed authoritative run is indexed for repository:") + ) +} + +fn error_message(error: &EvidenceError) -> &str { + match error { + EvidenceError::Contract(message) | EvidenceError::HostIo(message) => message, + } } #[derive(Clone, Copy, Debug, PartialEq, Eq)] diff --git a/crates/code-intel-cli/src/project_context/tests.rs b/crates/code-intel-cli/src/project_context/tests.rs index 31ad4678..42803d4b 100644 --- a/crates/code-intel-cli/src/project_context/tests.rs +++ b/crates/code-intel-cli/src/project_context/tests.rs @@ -126,3 +126,102 @@ fn explicit_repository_key_cannot_escape_the_artifact_root() { let _ = fs::remove_dir_all(root); } + +#[test] +fn status_without_a_committed_run_guides_the_first_analysis() { + let context = ProjectContext::for_test( + PathBuf::from("repository"), + "repository".into(), + PathBuf::from("missing-artifact-root"), + ); + + let status = context + .status() + .expect("missing authority is a usable state"); + + assert_eq!(status["schema"], "code-intel-project-status.v1"); + assert_eq!(status["status"], "needs_run"); + assert_eq!(status["freshness"]["status"], "unavailable"); + assert!(status["committedRun"].is_null()); + assert_eq!(status["nextActions"][0]["id"], "analyze"); + assert_eq!(status["nextActions"][0]["argv"][0], "code-intel"); +} + +#[test] +fn current_status_exposes_committed_authority_and_the_bounded_workflow() { + let context = ProjectContext::for_test( + PathBuf::from("repository"), + "repository".into(), + PathBuf::from("artifacts"), + ); + let authority = crate::committed_evidence_controller::CommittedAuthority::Receipt( + crate::committed_evidence_controller::CommittedReceipt::for_test( + "repository", + "run-1", + "run-identity", + "snapshot-1", + ), + ); + + let status = context.status_with_run( + serde_json::json!({ + "status": "current", + "recordedIdentity": "snapshot-1", + "currentIdentity": "snapshot-1", + "workingTreePolicy": "head_only", + "scope": ["."], + }), + &authority, + ); + + assert_eq!(status["status"], "ready"); + assert_eq!(status["committedRun"]["authority"], "committed"); + assert_eq!(status["committedRun"]["run"], "run-1"); + let actions = status["nextActions"] + .as_array() + .expect("next actions") + .iter() + .map(|action| action["id"].as_str().expect("action id")) + .collect::>(); + assert_eq!(actions, ["context", "query", "trace", "impact"]); + assert_eq!(status["nextActions"][2]["tool"], "get_evidence"); +} + +#[test] +fn stale_status_preserves_freshness_and_prioritizes_refresh() { + let context = ProjectContext::for_test( + PathBuf::from("repository"), + "repository".into(), + PathBuf::from("artifacts"), + ); + let authority = crate::committed_evidence_controller::CommittedAuthority::Receipt( + crate::committed_evidence_controller::CommittedReceipt::for_test( + "repository", + "run-1", + "run-identity", + "snapshot-old", + ), + ); + + let status = context.status_with_run( + serde_json::json!({ + "status": "stale", + "recordedIdentity": "snapshot-old", + "currentIdentity": "snapshot-new", + "workingTreePolicy": "head_only", + "scope": ["."], + }), + &authority, + ); + + assert_eq!(status["status"], "stale"); + assert_eq!(status["freshness"]["recordedIdentity"], "snapshot-old"); + assert_eq!(status["freshness"]["currentIdentity"], "snapshot-new"); + let actions = status["nextActions"] + .as_array() + .expect("next actions") + .iter() + .map(|action| action["id"].as_str().expect("action id")) + .collect::>(); + assert_eq!(actions, ["refresh", "impact"]); +} diff --git a/crates/code-intel-cli/tests/cli_head_parity.rs b/crates/code-intel-cli/tests/cli_head_parity.rs index 4ea02d7e..3b6eec76 100644 --- a/crates/code-intel-cli/tests/cli_head_parity.rs +++ b/crates/code-intel-cli/tests/cli_head_parity.rs @@ -197,7 +197,7 @@ fn every_legacy_command_spelling_honors_trailing_help() { /// also actually reproduce against a live run, the same guarantee /// `assert_exact_process_result` gives the plain (non-delta) cases. const EXPECTED_INTENTIONAL_DELTAS: &[(&str, &str)] = &[ - ("text-format:help-full.v1", "text-format:help-full.v3"), + ("text-format:help-full.v1", "text-format:help-full.v4"), ("json-format:repin-report.v1", "json-format:repin-report.v2"), ( "text-format:run-namespace-usage.v1", diff --git a/crates/code-intel-cli/tests/fixtures/cli-head-parity.v2.json b/crates/code-intel-cli/tests/fixtures/cli-head-parity.v2.json index b34d7dea..0c0fe555 100644 --- a/crates/code-intel-cli/tests/fixtures/cli-head-parity.v2.json +++ b/crates/code-intel-cli/tests/fixtures/cli-head-parity.v2.json @@ -577,9 +577,9 @@ "--help", "--all" ], - "reason": "Phase 2 acceptance requires full help to expose every registered compatibility alias. The v1 bytes omitted aliases, so v2 resolved that conflict. The stable top-level run/query interface changes the public command model, so this fixture records a reviewed v3 contract instead of silently moving the v2 bytes.", + "reason": "Phase 2 acceptance requires full help to expose every registered compatibility alias. The v1 bytes omitted aliases, so v2 resolved that conflict. The stable top-level run/query interface changed the public command model in v3; v4 adds the project status and next-action entry without silently moving those bytes.", "oldContractId": "text-format:help-full.v1", - "newContractId": "text-format:help-full.v3", + "newContractId": "text-format:help-full.v4", "old": { "exitCode": 0, "stdoutUtf8": "code-intel [options]\n\nCommands:\n resume --repo [--artifact-root ] [--json]\n classify --report [--json]\n sentrux-normalize --steps [--out ]\n sentrux-debt-register --failures [--repo ] [--out ]\n doctor [--artifact-root ] [--json]\n doctor bootstrap [--repo ] [--repo-path ] [--config ] [--platform auto|windows|macos|linux] [--no-require-repowise] [--require-understand] [--json]\n graph --repo [--language zh] [--full] [--write] [--json]\n provider [--action List|Plan|Validate|Invoke] [--provider repowise|understand] [--operation ] [--repo ] [--language zh] [--write] [--json]\n provider repowise-adapt --request --artifact-root --evaluated-at --max-age-seconds \n provider graph-adapt --request --artifact-root --evaluated-at --max-age-seconds \n provider sentrux-adapt --request --artifact-root --evaluated-at --max-age-seconds \n provider session-adapt --repo --trace [--hotspots ] [--out ] [--working-tree-policy head_only|explicit_overlay]\n provider codenexus-adapt --request --artifact-root --evaluated-at --max-age-seconds \n provider file-boundary --request --out \n provider runtime-ci-evidence --artifact-root --request --out \n compatibility retirement-ticket lint --ticket --evaluated-at \n route [--action List|Plan|Validate] [--provider repowise|understand] [--operation ] [--repo ] [--json]\n sentrux [--no-ratchet]\n (--no-ratchet: `check` only, skip the .sentrux/baseline.json ratchet)\n capability exec --request --out [--artifact-root ] [--manifest ]\n model inventory-validate --request [--out ]\n model route --request [--out ]\n snapshot identity --repo --working-tree-policy [--scope ]...\n repin [--repo ] [--write] [--json] [--exclude ]...\n evidence validate --request --artifact-root \n repository survival-scan --request --artifact-root \n audit --operation validate|render --repo --report [--format markdown|html]\n audit --operation scope --repo --since \n artifact index --artifact-root [--output ] [--operation rebuild|incremental] [--existing ]\n artifact query --artifact-root --repo [--repo-path ] [--artifact-schema ] [--type ] [--contains ] [--limit <1..100>]\n change impact --artifact-root --repo --repo-path --changed [--changed ]... [--staleness current|advisory]\n change risk [--repo ] [--sample ] [--format json|text] (git-only defect-risk score, no prior run, no index)\n change agenda [--repo ] [--min-cochange ] [--format json|text] (git-only review units clustered by co-change, ranked worst first)\n edit impact --repo-path --changed [--changed ]... [--scope ]... (working tree, no prior run, authority: none)\n decision request-response --request [--response |--cancel ] --now --branch ...\n decision record --resolution --store \n decision replay --query --store \n run execute --repo --out --authority-root --final-name [--profile default|strict|offline] [--manifest ] [--max-concurrency ] [--session-evidence ]\n run dag-coordinate --repo --out [--manifest ] [--max-concurrency ] [--session-evidence ]\n run commit --source-root --authority-root --manifest-ref --final-name \n benchmark orientation --out [--repetitions <2..10>]\n benchmark tools --corpus --runs --artifact-root --out \n governance ponytail-gate --request \n orchestrate [--action Validate|List|Plan] [--repo ] [--mode lite|normal|full] [--capability ] [--manifest ] [--json]\n", @@ -587,7 +587,7 @@ }, "new": { "exitCode": 0, - "stdoutUtf8": "code-intel [options]\n\nCommands:\n --version|-V [--json]\n help|--help|-h [--all]\n run [] [--mode lite|normal|full] [--json]\n query [] --kind evidence [--artifact-schema ] [--type ] [--contains ] [--limit <1..100>] --json\n report --repo [--artifact-root ] [--json]\n resume --repo [--artifact-root ] [--json]\n classify --report [--json]\n sentrux-normalize --steps [--out ]\n sentrux-debt-register --failures [--repo ] [--out ]\n doctor [--artifact-root ] [--json]\n doctor bootstrap [--repo ] [--repo-path ] [--config ] [--platform auto|windows|macos|linux] [--no-require-repowise] [--require-understand] [--json]\n graph|understand --repo [--language zh] [--full] [--write] [--json]\n provider|providers [--action List|Plan|Validate|Invoke] [--provider repowise|understand] [--operation ] [--repo ] [--language zh] [--write] [--json]\n provider repowise-adapt --request --artifact-root --evaluated-at --max-age-seconds \n provider graph-adapt --request --artifact-root --evaluated-at --max-age-seconds \n provider sentrux-adapt --request --artifact-root --evaluated-at --max-age-seconds \n provider session-adapt --repo --trace [--hotspots ] [--out ] [--working-tree-policy head_only|explicit_overlay]\n provider codenexus-adapt --request --artifact-root --evaluated-at --max-age-seconds \n provider file-boundary --request --out \n provider runtime-ci-evidence --artifact-root --request --out \n compatibility retirement-ticket lint --ticket --evaluated-at \n lint hardcoded-paths [] [--json]\n route|routes [--action List|Plan|Validate] [--provider repowise|understand] [--operation ] [--repo ] [--json]\n sentrux [--no-ratchet]\n (--no-ratchet: `check` only, skip the .sentrux/baseline.json ratchet)\n capability exec --request --out [--artifact-root ] [--manifest ]\n model inventory-validate --request [--out ]\n model route --request [--out ]\n snapshot identity --repo --working-tree-policy [--scope ]...\n repin [--repo ] [--write] [--json] [--exclude ]...\n repowise-hooks [--repo ] [--write] (detects/installs the optional repowise post-commit and distill-rewrite hooks; no-op if repowise is not on PATH)\n evidence validate --request --artifact-root \n repository survival-scan --request --artifact-root \n audit --operation validate|render --repo --report [--format markdown|html]\n audit --operation scope --repo --since \n artifact index --artifact-root [--output ] [--operation rebuild|incremental] [--existing ]\n artifact query --artifact-root --repo [--repo-path ] [--artifact-schema ] [--type ] [--contains ] [--limit <1..100>]\n change impact --artifact-root --repo --repo-path --changed [--changed ]... [--staleness current|advisory]\n change risk [--repo ] [--sample ] [--format json|text] (git-only defect-risk score, no prior run, no index)\n change agenda [--repo ] [--min-cochange ] [--format json|text] (git-only review units clustered by co-change, ranked worst first)\n edit impact --repo-path --changed [--changed ]... [--scope ]... (working tree, no prior run, authority: none)\n edit apply --repo-path --file (--span --expect-sha256 --replacement |--replacement-file )... [--out ] [--manifest ] [--envelope] (span-addressed patch; refuses with evidence on digest drift, exit 10)\n decision request-response --request [--response |--cancel ] --now --branch ...\n decision record --resolution --store \n decision replay --query --store \n run execute --repo --out --authority-root --final-name [--profile default|strict|offline] [--manifest ] [--max-concurrency ] [--session-evidence ]\n run dag-coordinate --repo --out [--manifest ] [--max-concurrency ] [--session-evidence ]\n run commit --source-root --authority-root --manifest-ref --final-name \n serve --mcp [--repo-path ] [--repo ] [--artifact-root ] [--manifest ] (stdio MCP query surface over the committed run; read-only, gates nowhere)\n benchmark orientation --out [--repetitions <2..10>]\n benchmark tools --corpus --runs --artifact-root --out \n governance ponytail-gate --request \n orchestrate|orchestration [--action Validate|List|Plan] [--repo ] [--mode lite|normal|full] [--capability ] [--manifest ] [--json]\n language set --language --repo [--json]\n", + "stdoutUtf8": "code-intel [options]\n\nCommands:\n --version|-V [--json]\n help|--help|-h [--all]\n run [] [--mode lite|normal|full] [--json]\n status [] [--json]\n query [] --kind evidence [--artifact-schema ] [--type ] [--contains ] [--limit <1..100>] --json\n report --repo [--artifact-root ] [--json]\n resume --repo [--artifact-root ] [--json]\n classify --report [--json]\n sentrux-normalize --steps [--out ]\n sentrux-debt-register --failures [--repo ] [--out ]\n doctor [--artifact-root ] [--json]\n doctor bootstrap [--repo ] [--repo-path ] [--config ] [--platform auto|windows|macos|linux] [--no-require-repowise] [--require-understand] [--json]\n graph|understand --repo [--language zh] [--full] [--write] [--json]\n provider|providers [--action List|Plan|Validate|Invoke] [--provider repowise|understand] [--operation ] [--repo ] [--language zh] [--write] [--json]\n provider repowise-adapt --request --artifact-root --evaluated-at --max-age-seconds \n provider graph-adapt --request --artifact-root --evaluated-at --max-age-seconds \n provider sentrux-adapt --request --artifact-root --evaluated-at --max-age-seconds \n provider session-adapt --repo --trace [--hotspots ] [--out ] [--working-tree-policy head_only|explicit_overlay]\n provider codenexus-adapt --request --artifact-root --evaluated-at --max-age-seconds \n provider file-boundary --request --out \n provider runtime-ci-evidence --artifact-root --request --out \n compatibility retirement-ticket lint --ticket --evaluated-at \n lint hardcoded-paths [] [--json]\n route|routes [--action List|Plan|Validate] [--provider repowise|understand] [--operation ] [--repo ] [--json]\n sentrux [--no-ratchet]\n (--no-ratchet: `check` only, skip the .sentrux/baseline.json ratchet)\n capability exec --request --out [--artifact-root ] [--manifest ]\n model inventory-validate --request [--out ]\n model route --request [--out ]\n snapshot identity --repo --working-tree-policy [--scope ]...\n repin [--repo ] [--write] [--json] [--exclude ]...\n repowise-hooks [--repo ] [--write] (detects/installs the optional repowise post-commit and distill-rewrite hooks; no-op if repowise is not on PATH)\n evidence validate --request --artifact-root \n repository survival-scan --request --artifact-root \n audit --operation validate|render --repo --report [--format markdown|html]\n audit --operation scope --repo --since \n artifact index --artifact-root [--output ] [--operation rebuild|incremental] [--existing ]\n artifact query --artifact-root --repo [--repo-path ] [--artifact-schema ] [--type ] [--contains ] [--limit <1..100>]\n change impact --artifact-root --repo --repo-path --changed [--changed ]... [--staleness current|advisory]\n change risk [--repo ] [--sample ] [--format json|text] (git-only defect-risk score, no prior run, no index)\n change agenda [--repo ] [--min-cochange ] [--format json|text] (git-only review units clustered by co-change, ranked worst first)\n edit impact --repo-path --changed [--changed ]... [--scope ]... (working tree, no prior run, authority: none)\n edit apply --repo-path --file (--span --expect-sha256 --replacement |--replacement-file )... [--out ] [--manifest ] [--envelope] (span-addressed patch; refuses with evidence on digest drift, exit 10)\n decision request-response --request [--response |--cancel ] --now --branch ...\n decision record --resolution --store \n decision replay --query --store \n run execute --repo --out --authority-root --final-name [--profile default|strict|offline] [--manifest ] [--max-concurrency ] [--session-evidence ]\n run dag-coordinate --repo --out [--manifest ] [--max-concurrency ] [--session-evidence ]\n run commit --source-root --authority-root --manifest-ref --final-name \n serve --mcp [--repo-path ] [--repo ] [--artifact-root ] [--manifest ] (stdio MCP query surface over the committed run; read-only, gates nowhere)\n benchmark orientation --out [--repetitions <2..10>]\n benchmark tools --corpus --runs --artifact-root --out \n governance ponytail-gate --request \n orchestrate|orchestration [--action Validate|List|Plan] [--repo ] [--mode lite|normal|full] [--capability ] [--manifest ] [--json]\n language set --language --repo [--json]\n", "stderrUtf8": "" } }, @@ -634,4 +634,4 @@ } } ] -} \ No newline at end of file +} diff --git a/crates/code-intel-cli/tests/primary_entry.rs b/crates/code-intel-cli/tests/primary_entry.rs index 119f2bc2..faa31198 100644 --- a/crates/code-intel-cli/tests/primary_entry.rs +++ b/crates/code-intel-cli/tests/primary_entry.rs @@ -176,6 +176,52 @@ fn root_entry_names_the_authority_root_path_when_a_file_blocks_it() { let _ = std::fs::remove_dir_all(root); } +#[test] +fn project_status_turns_an_unindexed_project_into_a_guided_first_step() { + let root = std::env::temp_dir().join(format!( + "code-intel-project-status-empty-{}", + std::process::id() + )); + let repo = root.join("fixture-repo"); + let artifacts = root.join("artifacts"); + std::fs::create_dir_all(&repo).expect("create repository fixture"); + std::fs::create_dir_all(&artifacts).expect("create artifact root fixture"); + + let output = common::cli() + .arg("status") + .arg(&repo) + .arg("--json") + .env("CODE_INTEL_ARTIFACT_ROOT", &artifacts) + .output() + .expect("run project status JSON"); + + assert!(output.status.success()); + assert!(output.stderr.is_empty()); + let status: serde_json::Value = + serde_json::from_slice(&output.stdout).expect("project status is JSON"); + assert_eq!(status["schema"], "code-intel-project-status.v1"); + assert_eq!(status["status"], "needs_run"); + assert_eq!(status["freshness"]["status"], "unavailable"); + assert_eq!(status["nextActions"][0]["id"], "analyze"); + assert_eq!(status["nextActions"][0]["argv"][0], "code-intel"); + + let output = common::cli() + .arg("status") + .arg(&repo) + .env("CODE_INTEL_ARTIFACT_ROOT", &artifacts) + .output() + .expect("run human project status"); + + assert!(output.status.success()); + assert!(output.stderr.is_empty()); + let summary = String::from_utf8(output.stdout).expect("status summary is UTF-8"); + assert!(summary.contains("[NEEDS_RUN] fixture-repo"), "{summary}"); + assert!(summary.contains("Next actions:"), "{summary}"); + assert!(summary.contains("analyze:"), "{summary}"); + + let _ = std::fs::remove_dir_all(root); +} + #[test] fn named_commands_are_not_misclassified_as_repository_paths() { let output = common::cli() From 6647970f9a5a61740d4528a5ccb3622fab586651 Mon Sep 17 00:00:00 2001 From: Curry Date: Tue, 18 Aug 2026 10:26:42 +0800 Subject: [PATCH 2/2] refactor(ux): isolate project status presentation --- crates/code-intel-cli/src/project_context.rs | 184 +----------------- .../src/project_context/status.rs | 179 +++++++++++++++++ 2 files changed, 184 insertions(+), 179 deletions(-) create mode 100644 crates/code-intel-cli/src/project_context/status.rs diff --git a/crates/code-intel-cli/src/project_context.rs b/crates/code-intel-cli/src/project_context.rs index ee3871b7..1b9db606 100644 --- a/crates/code-intel-cli/src/project_context.rs +++ b/crates/code-intel-cli/src/project_context.rs @@ -1,20 +1,17 @@ -use std::env; use std::fs; use std::path::{Path, PathBuf}; -use std::process; use std::time::{SystemTime, UNIX_EPOCH}; use serde_json::{json, Value}; -use crate::committed_evidence::{CommittedEvidence, EvidenceError}; -use crate::committed_evidence_controller::{ - CommittedAuthority, CommittedEvidenceController, FreshnessRequest, -}; +use crate::committed_evidence::CommittedEvidence; +use crate::committed_evidence_controller::{CommittedAuthority, CommittedEvidenceController}; use crate::evidence_query::{EvidenceQueryRequest, MAX_LIMIT}; use crate::{artifacts, authoritative_run, execution_policy}; mod error; mod identity; +mod status; #[cfg(test)] mod tests; @@ -151,8 +148,8 @@ impl ProjectContext { .duration_since(UNIX_EPOCH) .map_err(|error| ProjectError::host_io(error.to_string()))? .as_millis(); - let final_name = format!("{nonce}-{}-core", process::id()); - let staging_root = env::temp_dir().join(format!("code-intel-a09-{final_name}")); + let final_name = format!("{nonce}-{}-core", std::process::id()); + let staging_root = std::env::temp_dir().join(format!("code-intel-a09-{final_name}")); let request = authoritative_run::RunRequest::new( self.repo_path.clone(), staging_root, @@ -197,177 +194,6 @@ impl ProjectContext { } } } - - pub(crate) fn status(&self) -> Result { - if !self.artifact_root.is_dir() { - return Ok(self.status_without_run(format!( - "artifact root is not present: {}", - self.artifact_root.display() - ))); - } - let freshness = match CommittedEvidenceController::freshness(FreshnessRequest { - artifact_root: self.artifact_root.clone(), - repo: self.repo.clone(), - repo_path: Some(self.repo_path.clone()), - }) { - Ok(freshness) => freshness, - Err(error) if is_unindexed(&error) => { - return Ok(self.status_without_run(error_message(&error).to_string())); - } - Err(error) => return Err(ProjectError::from(error)), - }; - Ok(self.status_with_run(freshness.value, &freshness.authority)) - } - - fn status_without_run(&self, reason: String) -> Value { - json!({ - "schema": "code-intel-project-status.v1", - "status": "needs_run", - "project": self.project_identity(), - "freshness": { - "status": "unavailable", - "recordedIdentity": Value::Null, - "currentIdentity": Value::Null, - "workingTreePolicy": Value::Null, - "scope": [], - }, - "committedRun": Value::Null, - "reason": reason, - "nextActions": [self.command_action( - "analyze", - "Analyze this project and publish the first committed run.", - vec!["code-intel".into(), self.repo_path.display().to_string()], - )], - }) - } - - fn status_with_run(&self, freshness: Value, authority: &CommittedAuthority) -> Value { - let receipt = authority.receipt(); - let state = if freshness["status"] == "current" { - "ready" - } else { - "stale" - }; - json!({ - "schema": "code-intel-project-status.v1", - "status": state, - "project": self.project_identity(), - "freshness": freshness, - "committedRun": { - "repo": receipt.repo(), - "run": receipt.run(), - "runIdentity": receipt.run_identity(), - "snapshotIdentity": receipt.snapshot_identity(), - "authority": "committed", - }, - "reason": Value::Null, - "nextActions": self.next_actions(state), - }) - } - - fn project_identity(&self) -> Value { - json!({ - "repo": self.repo, - "path": self.repo_path, - }) - } - - fn next_actions(&self, state: &str) -> Value { - let repo_path = self.repo_path.display().to_string(); - let artifact_root = self.artifact_root.display().to_string(); - let mut actions = Vec::new(); - if state == "stale" { - actions.push(self.command_action( - "refresh", - "Refresh committed evidence before querying it as current.", - vec!["code-intel".into(), repo_path.clone()], - )); - } else { - actions.push(self.command_action( - "context", - "Read the bounded ranked code context for this project.", - vec![ - "code-intel".into(), - "query".into(), - repo_path.clone(), - "--kind".into(), - "evidence".into(), - "--type".into(), - "code_evidence.agent_slice".into(), - "--limit".into(), - "5".into(), - "--json".into(), - ], - )); - actions.push(self.command_action( - "query", - "Query the verified committed artifacts with a bounded result.", - vec![ - "code-intel".into(), - "query".into(), - repo_path.clone(), - "--kind".into(), - "evidence".into(), - "--limit".into(), - "20".into(), - "--json".into(), - ], - )); - actions.push(json!({ - "id": "trace", - "kind": "mcp_tool", - "summary": "Trace one finding through its committed evidence chain.", - "serverArgv": [ - "code-intel", "serve", "--mcp", "--repo-path", repo_path.clone(), - "--repo", self.repo, "--artifact-root", artifact_root.clone(), - ], - "tool": "get_evidence", - })); - } - actions.push(self.command_action( - "impact", - "Estimate blast radius and candidate tests from committed imports as advisory evidence.", - vec![ - "code-intel".into(), - "change".into(), - "impact".into(), - "--artifact-root".into(), - artifact_root, - "--repo".into(), - self.repo.clone(), - "--repo-path".into(), - repo_path, - "--changed".into(), - "".into(), - "--staleness".into(), - "advisory".into(), - ], - )); - Value::Array(actions) - } - - fn command_action(&self, id: &str, summary: &str, argv: Vec) -> Value { - json!({ - "id": id, - "kind": "command", - "summary": summary, - "argv": argv, - }) - } -} - -fn is_unindexed(error: &EvidenceError) -> bool { - matches!( - error, - EvidenceError::Contract(message) - if message.starts_with("no committed authoritative run is indexed for repository:") - ) -} - -fn error_message(error: &EvidenceError) -> &str { - match error { - EvidenceError::Contract(message) | EvidenceError::HostIo(message) => message, - } } #[derive(Clone, Copy, Debug, PartialEq, Eq)] diff --git a/crates/code-intel-cli/src/project_context/status.rs b/crates/code-intel-cli/src/project_context/status.rs new file mode 100644 index 00000000..5cd17a15 --- /dev/null +++ b/crates/code-intel-cli/src/project_context/status.rs @@ -0,0 +1,179 @@ +impl super::ProjectContext { + pub(crate) fn status(&self) -> Result { + if !self.artifact_root.is_dir() { + return Ok(self.status_without_run(format!( + "artifact root is not present: {}", + self.artifact_root.display() + ))); + } + let freshness = match super::CommittedEvidenceController::freshness( + crate::committed_evidence_controller::FreshnessRequest { + artifact_root: self.artifact_root.clone(), + repo: self.repo.clone(), + repo_path: Some(self.repo_path.clone()), + }, + ) { + Ok(freshness) => freshness, + Err(error) if is_unindexed(&error) => { + return Ok(self.status_without_run(error_message(&error).to_string())); + } + Err(error) => return Err(super::ProjectError::from(error)), + }; + Ok(self.status_with_run(freshness.value, &freshness.authority)) + } + + fn status_without_run(&self, reason: String) -> serde_json::Value { + serde_json::json!({ + "schema": "code-intel-project-status.v1", + "status": "needs_run", + "project": self.project_identity(), + "freshness": { + "status": "unavailable", + "recordedIdentity": serde_json::Value::Null, + "currentIdentity": serde_json::Value::Null, + "workingTreePolicy": serde_json::Value::Null, + "scope": [], + }, + "committedRun": serde_json::Value::Null, + "reason": reason, + "nextActions": [self.command_action( + "analyze", + "Analyze this project and publish the first committed run.", + vec!["code-intel".into(), self.repo_path.display().to_string()], + )], + }) + } + + pub(super) fn status_with_run( + &self, + freshness: serde_json::Value, + authority: &super::CommittedAuthority, + ) -> serde_json::Value { + let receipt = authority.receipt(); + let state = if freshness["status"] == "current" { + "ready" + } else { + "stale" + }; + serde_json::json!({ + "schema": "code-intel-project-status.v1", + "status": state, + "project": self.project_identity(), + "freshness": freshness, + "committedRun": { + "repo": receipt.repo(), + "run": receipt.run(), + "runIdentity": receipt.run_identity(), + "snapshotIdentity": receipt.snapshot_identity(), + "authority": "committed", + }, + "reason": serde_json::Value::Null, + "nextActions": self.next_actions(state), + }) + } + + fn project_identity(&self) -> serde_json::Value { + serde_json::json!({ + "repo": self.repo, + "path": self.repo_path, + }) + } + + fn next_actions(&self, state: &str) -> serde_json::Value { + let repo_path = self.repo_path.display().to_string(); + let artifact_root = self.artifact_root.display().to_string(); + let mut actions = Vec::new(); + if state == "stale" { + actions.push(self.command_action( + "refresh", + "Refresh committed evidence before querying it as current.", + vec!["code-intel".into(), repo_path.clone()], + )); + } else { + actions.push(self.command_action( + "context", + "Read the bounded ranked code context for this project.", + vec![ + "code-intel".into(), + "query".into(), + repo_path.clone(), + "--kind".into(), + "evidence".into(), + "--type".into(), + "code_evidence.agent_slice".into(), + "--limit".into(), + "5".into(), + "--json".into(), + ], + )); + actions.push(self.command_action( + "query", + "Query the verified committed artifacts with a bounded result.", + vec![ + "code-intel".into(), + "query".into(), + repo_path.clone(), + "--kind".into(), + "evidence".into(), + "--limit".into(), + "20".into(), + "--json".into(), + ], + )); + actions.push(serde_json::json!({ + "id": "trace", + "kind": "mcp_tool", + "summary": "Trace one finding through its committed evidence chain.", + "serverArgv": [ + "code-intel", "serve", "--mcp", "--repo-path", repo_path.clone(), + "--repo", self.repo, "--artifact-root", artifact_root.clone(), + ], + "tool": "get_evidence", + })); + } + actions.push(self.command_action( + "impact", + "Estimate blast radius and candidate tests from committed imports as advisory evidence.", + vec![ + "code-intel".into(), + "change".into(), + "impact".into(), + "--artifact-root".into(), + artifact_root, + "--repo".into(), + self.repo.clone(), + "--repo-path".into(), + repo_path, + "--changed".into(), + "".into(), + "--staleness".into(), + "advisory".into(), + ], + )); + serde_json::Value::Array(actions) + } + + fn command_action(&self, id: &str, summary: &str, argv: Vec) -> serde_json::Value { + serde_json::json!({ + "id": id, + "kind": "command", + "summary": summary, + "argv": argv, + }) + } +} + +fn is_unindexed(error: &crate::committed_evidence::EvidenceError) -> bool { + matches!( + error, + crate::committed_evidence::EvidenceError::Contract(message) + if message.starts_with("no committed authoritative run is indexed for repository:") + ) +} + +fn error_message(error: &crate::committed_evidence::EvidenceError) -> &str { + match error { + crate::committed_evidence::EvidenceError::Contract(message) + | crate::committed_evidence::EvidenceError::HostIo(message) => message, + } +}