From 51fee21460a427bb096e459a1c54d4426b469fa0 Mon Sep 17 00:00:00 2001 From: Yvette Carlisle Date: Fri, 22 May 2026 16:58:27 +0800 Subject: [PATCH] {"schema":"decodex/commit/1","summary":"Clean up CLI argument ownership","authority":"manual"} --- README.md | 13 +- apps/decodex-app/README.md | 8 +- apps/decodex/src/archive_hygiene.rs | 2 +- apps/decodex/src/cli.rs | 452 +++++++++++-------- apps/decodex/src/manual.rs | 2 +- apps/decodex/src/orchestrator/daemon.rs | 4 +- apps/decodex/src/orchestrator/entrypoints.rs | 8 +- apps/decodex/src/recovery.rs | 2 +- apps/decodex/src/runtime.rs | 2 +- docs/runbook/recover-review-handoff.md | 2 +- docs/runbook/self-dogfood-pilot.md | 8 +- plugins/decodex/skills/decodex/SKILL.md | 2 +- plugins/decodex/skills/labels/SKILL.md | 4 +- plugins/decodex/skills/manual-cli/SKILL.md | 4 +- 14 files changed, 294 insertions(+), 219 deletions(-) diff --git a/README.md b/README.md index 41d6e37dd..e8c24720a 100644 --- a/README.md +++ b/README.md @@ -85,9 +85,9 @@ without publishing every low-level or skipped item to the static site or Git his - The Decodex runtime contract is Unix-only: macOS and Linux. - Windows is outside the runtime contract. - The public site is static and deploys through GitHub Pages. -- Starting `decodex serve` without `--config` loads enabled projects from the explicit - registry only. It does not scan Codex history, repo-local config files, or currently - open worktrees to infer projects. +- Starting `decodex serve` without its `--config` option loads enabled projects from + the explicit registry only. It does not scan Codex history, repo-local config files, + or currently open worktrees to infer projects. ## Usage @@ -106,6 +106,9 @@ cargo run -p decodex --bin decodex -- run --dry-run cargo run -p decodex --bin decodex -- serve --interval 60s --listen-address 127.0.0.1:8912 ``` +Project-scoped commands accept `--config ` after the subcommand when the +operator wants to override registry-based project resolution for that command. + ### Install from Source ```sh @@ -147,8 +150,8 @@ matching `accounts.jsonl` entry. `~/.codex/decodex/agent-evidence//` and prints the same handoff index for repair agents. -`decodex maintenance prune --dry-run` reports local Decodex storage retention -candidates without applying retention changes. Add `--apply` to rotate +`decodex maintenance prune` defaults to the same read-only report as +`decodex maintenance prune --dry-run`. Add `--apply` to rotate oversized local logs and agent-evidence event streams, prune old backup files, compact old terminal-run protocol events after preserving their summary, and checkpoint the SQLite WAL. `decodex serve` also runs the auto-safe subset at startup and periodically diff --git a/apps/decodex-app/README.md b/apps/decodex-app/README.md index adc9311b2..55e9639f5 100644 --- a/apps/decodex-app/README.md +++ b/apps/decodex-app/README.md @@ -13,10 +13,10 @@ The first Decodex App release manages the shared Codex account pool through the bundled Rust app helper so account UI stays on the same CLI-owned files even when a long-running local `decodex serve` is older than the app bundle. On launch the app also connects to an existing `decodex serve` on the default local endpoint when one is -available; otherwise it starts the bundled `decodex serve --api-only` binary for -operator snapshot and WebUI routes. App-started servers do not poll registered projects -or dispatch Linear work. The helper owns account operations and interactive login flows -that need streamed command output: +available; otherwise it starts the bundled Decodex binary in its hidden API-only +operator endpoint mode for operator snapshot and WebUI routes. App-started servers do +not poll registered projects or dispatch Linear work. The helper owns account +operations and interactive login flows that need streamed command output: - list accounts without printing token material - pin future Decodex runs to one account diff --git a/apps/decodex/src/archive_hygiene.rs b/apps/decodex/src/archive_hygiene.rs index dc4af931b..3871b8daa 100644 --- a/apps/decodex/src/archive_hygiene.rs +++ b/apps/decodex/src/archive_hygiene.rs @@ -47,7 +47,7 @@ pub(crate) fn run(config_path: Option<&Path>, request: &ArchiveHygieneRequest) - let state_store = runtime::open_runtime_store()?; let Some(config_path) = resolve_config_path(config_path, &state_store)? else { eyre::bail!( - "No Decodex project config found. Pass --config or register one with `decodex project add `." + "No Decodex project config found. Pass this command's --config or register one with `decodex project add `." ); }; let config = ServiceConfig::from_path(&config_path)?; diff --git a/apps/decodex/src/cli.rs b/apps/decodex/src/cli.rs index ef410b6b4..ad03daece 100644 --- a/apps/decodex/src/cli.rs +++ b/apps/decodex/src/cli.rs @@ -43,63 +43,29 @@ use crate::{ styles = styles(), )] pub(crate) struct Cli { - /// Override the Decodex project directory or its `project.toml` path. - #[arg(short = 'c', long, global = true, value_name = "PROJECT_DIR")] - config: Option, #[command(subcommand)] command: Command, } impl Cli { pub(crate) fn run(&self) -> crate::prelude::Result<()> { - let config_path = self.config.as_deref(); - match &self.command { - Command::Commit(args) => args.run(config_path), - Command::Land(args) => args.run(config_path), - Command::Run(args) => args.run(config_path), - Command::Serve(args) => args.run(config_path), + Command::Commit(args) => args.run(), + Command::Land(args) => args.run(), + Command::Run(args) => args.run(), + Command::Serve(args) => args.run(), Command::Project(args) => args.run(), - Command::Status(args) => args.run(config_path), - Command::Diagnose(args) => args.run(config_path), - Command::Recover(args) => args.run(config_path), - Command::ArchiveLinear(args) => args.run(config_path), + Command::Status(args) => args.run(), + Command::Diagnose(args) => args.run(), + Command::Recover(args) => args.run(), + Command::ArchiveLinear(args) => args.run(), Command::Maintenance(args) => args.run(), Command::Account(args) => args.run(), Command::Probe(args) => args.run(), - Command::Attempt(args) => args.run(config_path), + Command::Attempt(args) => args.run(), } } } -#[derive(Clone, Copy, Debug, Eq, PartialEq, Deserialize, Serialize)] -#[serde(rename_all = "kebab-case")] -pub(crate) enum AttemptDispatchMode { - Normal, - Retry, - ReviewRepair, - Closeout, -} -impl From for AttemptDispatchMode { - fn from(value: IssueDispatchMode) -> Self { - match value { - IssueDispatchMode::Normal => Self::Normal, - IssueDispatchMode::Retry => Self::Retry, - IssueDispatchMode::ReviewRepair => Self::ReviewRepair, - IssueDispatchMode::Closeout => Self::Closeout, - } - } -} - -impl From for IssueDispatchMode { - fn from(value: AttemptDispatchMode) -> Self { - match value { - AttemptDispatchMode::Normal => Self::Normal, - AttemptDispatchMode::Retry => Self::Retry, - AttemptDispatchMode::ReviewRepair => Self::ReviewRepair, - AttemptDispatchMode::Closeout => Self::Closeout, - } - } -} #[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)] pub(crate) struct AttemptRequest { #[serde(default)] @@ -119,35 +85,17 @@ pub(crate) struct AttemptRequest { pub(crate) workflow_snapshot: String, } -#[derive(Debug, Subcommand)] -enum Command { - /// Create a signed local commit with a `decodex/commit/1` message. - Commit(CommitCommand), - /// Land the current reviewed lane with a GitHub admin merge commit. - Land(LandCommand), - /// Run one orchestration pass. - Run(RunCommand), - /// Run the local multi-project Decodex control plane. - Serve(ServeCommand), - /// Manage the local Decodex project registry. - Project(ProjectCommand), - /// Inspect the current local runtime state for one configured project. - Status(StatusCommand), - /// Write and print the agent-readable local evidence index. - Diagnose(DiagnoseCommand), - /// Diagnose or explicitly repair supported retained-lane recovery cases. - Recover(RecoverCommand), - /// Dry-run or archive old terminal Linear issues by repo label. - ArchiveLinear(ArchiveLinearCommand), - /// Maintain local Decodex logs, evidence, backups, and runtime storage. - Maintenance(MaintenanceCommand), - /// Manage the global Decodex Codex account pool. - Account(AccountCommand), - /// Validate the local app-server integration boundary. - Probe(ProbeCommand), - /// Run one daemon-planned attempt from a structured request. - #[command(name = "_attempt", hide = true)] - Attempt(AttemptCommand), +#[derive(Debug, Args)] +struct ProjectConfigArgs { + /// Use this Decodex project directory or `project.toml` instead of resolving from the + /// registered current checkout. + #[arg(short = 'c', long, value_name = "PROJECT_DIR")] + config: Option, +} +impl ProjectConfigArgs { + fn as_path(&self) -> Option<&Path> { + self.config.as_deref() + } } #[derive(Debug, Args)] @@ -182,27 +130,9 @@ impl AccountCommand { } } -#[derive(Debug, Subcommand)] -enum AccountSubcommand { - /// List configured Codex accounts without printing token material. - List(AccountListCommand), - /// Pin new Decodex runs to one account. - Select(AccountSelectCommand), - /// Return new Decodex runs to balanced account selection. - Clear(AccountClearCommand), - /// Remove one account from the Decodex account pool. - Logout(AccountLogoutCommand), - /// Import an existing Codex `auth.json` into the Decodex account pool. - ImportAuth(AccountImportCommand), - /// Force Codex to use one stored account by overwriting its `auth.json`. - Use(AccountUseCommand), - /// Run Codex device login in an isolated temporary home, then import it. - Login(AccountLoginCommand), -} - #[derive(Debug, Args)] struct AccountListCommand { - /// Print the account list as JSON for app integrations. + /// Emit structured JSON instead of human-readable text. #[arg(long)] json: bool, } @@ -211,14 +141,14 @@ struct AccountListCommand { struct AccountSelectCommand { /// Email, full account id, or redacted fingerprint to pin. selector: String, - /// Print the updated account list as JSON for app integrations. + /// Emit structured JSON instead of human-readable text. #[arg(long)] json: bool, } #[derive(Debug, Args)] struct AccountClearCommand { - /// Print the updated account list as JSON for app integrations. + /// Emit structured JSON instead of human-readable text. #[arg(long)] json: bool, } @@ -227,7 +157,7 @@ struct AccountClearCommand { struct AccountLogoutCommand { /// Email, full account id, or redacted fingerprint to remove. selector: String, - /// Print the updated account list as JSON for app integrations. + /// Emit structured JSON instead of human-readable text. #[arg(long)] json: bool, } @@ -237,7 +167,7 @@ struct AccountImportCommand { /// Path to a Codex `auth.json` file to import. #[arg(value_name = "AUTH_JSON")] auth_json: PathBuf, - /// Print the updated account list as JSON for app integrations. + /// Emit structured JSON instead of human-readable text. #[arg(long)] json: bool, } @@ -250,7 +180,7 @@ struct AccountUseCommand { /// or `~/.codex/auth.json`. #[arg(long, value_name = "AUTH_JSON")] auth_json: Option, - /// Print the updated Codex auth selection as JSON for app integrations. + /// Emit structured JSON instead of human-readable text. #[arg(long)] json: bool, } @@ -261,12 +191,14 @@ struct AccountLoginCommand { #[arg(long, default_value = "codex")] codex_bin: String, /// Keep the temporary Codex home after login for manual inspection. - #[arg(long)] + #[arg(long, hide = true)] keep_temp_home: bool, } #[derive(Debug, Args)] struct CommitCommand { + #[command(flatten)] + project_config: ProjectConfigArgs, /// Tree-change summary for the new commit message. #[arg(value_name = "SUMMARY")] summary: String, @@ -284,9 +216,9 @@ struct CommitCommand { breaking: bool, } impl CommitCommand { - fn run(&self, config_path: Option<&Path>) -> crate::prelude::Result<()> { + fn run(&self) -> crate::prelude::Result<()> { manual::run_commit( - config_path, + self.project_config.as_path(), &ManualCommitRequest { summary: self.summary.clone(), authority: self.authority.clone(), @@ -300,6 +232,8 @@ impl CommitCommand { #[derive(Debug, Args)] struct LandCommand { + #[command(flatten)] + project_config: ProjectConfigArgs, /// Tree-change summary for the landed change record. #[arg(value_name = "SUMMARY")] summary: String, @@ -307,10 +241,11 @@ struct LandCommand { /// name. #[arg(long, value_name = "ISSUE", conflicts_with = "manual_authority")] authority: Option, - /// Use reserved authority `manual` instead of a Linear issue. - #[arg(long, conflicts_with = "authority")] + /// Use reserved authority `manual` instead of a Linear issue; requires `--pr`. + #[arg(long, conflicts_with = "authority", requires = "pr")] manual_authority: bool, - /// Pull request URL to land. Defaults to the current review handoff marker. + /// Pull request URL to land. Required with `--manual-authority`; otherwise defaults to the + /// current review handoff marker. #[arg(long, value_name = "URL")] pr: Option, /// Additional related issues for the landed change record. @@ -321,9 +256,9 @@ struct LandCommand { breaking: bool, } impl LandCommand { - fn run(&self, config_path: Option<&Path>) -> crate::prelude::Result<()> { + fn run(&self) -> crate::prelude::Result<()> { manual::run_land( - config_path, + self.project_config.as_path(), &ManualLandRequest { summary: self.summary.clone(), authority: self.authority.clone(), @@ -338,26 +273,22 @@ impl LandCommand { #[derive(Debug, Args)] struct RunCommand { + #[command(flatten)] + project_config: ProjectConfigArgs, /// Run a specific leased or queued issue by Linear identifier or tracker issue id. #[arg(value_name = "ISSUE")] issue: Option, - /// Skip external side effects where the later implementation allows it. + /// Validate project loading, queue eligibility, and lane planning without tracker mutation. #[arg(long)] dry_run: bool, /// Explain current queued candidates without preparing or dispatching a lane. - #[arg(long, requires = "dry_run")] + #[arg(long, requires = "dry_run", conflicts_with = "issue")] explain: bool, } impl RunCommand { - fn run(&self, config_path: Option<&Path>) -> crate::prelude::Result<()> { - if self.explain && self.issue.is_some() { - eyre::bail!( - "`decodex run --dry-run --explain` explains the project queue and does not accept a positional issue." - ); - } - + fn run(&self) -> crate::prelude::Result<()> { orchestrator::run_once(RunOnceRequest { - config_path, + config_path: self.project_config.as_path(), dry_run: self.dry_run, explain_queue: self.explain, preferred_issue_id: self.issue.as_deref(), @@ -378,19 +309,20 @@ impl RunCommand { #[derive(Debug, Args)] struct ServeCommand { - /// Poll interval between control-plane ticks, for example `60s` or `5m`; unavailable with - /// `--api-only`. + #[command(flatten)] + project_config: ProjectConfigArgs, + /// Poll interval between control-plane ticks, for example `60s` or `5m`. #[arg(long, value_name = "INTERVAL", value_parser = parse_duration_arg)] interval: Option, /// Operator UI listen address. #[arg(long, value_name = "ADDR", default_value = "127.0.0.1:8912")] listen_address: String, /// Serve only local operator HTTP/API endpoints without polling or dispatching projects. - #[arg(long)] + #[arg(long, hide = true)] api_only: bool, } impl ServeCommand { - fn run(&self, config_path: Option<&Path>) -> crate::prelude::Result<()> { + fn run(&self) -> crate::prelude::Result<()> { if self.api_only && self.interval.is_some() { eyre::bail!( "serve --api-only does not accept --interval because API-only mode does not poll projects." @@ -398,7 +330,7 @@ impl ServeCommand { } orchestrator::run_control_plane(ServeRequest { - config_path, + config_path: self.project_config.as_path(), poll_interval: if self.api_only { None } else { @@ -464,18 +396,6 @@ impl ProjectCommand { } } -#[derive(Debug, Subcommand)] -enum ProjectSubcommand { - /// Register or refresh one Decodex project config. - Add(ProjectAddCommand), - /// List registered local projects. - List, - /// Enable one registered project for `decodex serve`. - Enable(ProjectToggleCommand), - /// Disable one registered project for `decodex serve`. - Disable(ProjectToggleCommand), -} - #[derive(Debug, Args)] struct ProjectAddCommand { /// Path to a Decodex project directory containing `project.toml` and `WORKFLOW.md`. @@ -492,6 +412,8 @@ struct ProjectToggleCommand { #[derive(Debug, Args)] struct StatusCommand { + #[command(flatten)] + project_config: ProjectConfigArgs, /// Emit structured JSON instead of human-readable text. #[arg(long)] json: bool, @@ -500,13 +422,15 @@ struct StatusCommand { limit: usize, } impl StatusCommand { - fn run(&self, config_path: Option<&Path>) -> crate::prelude::Result<()> { - orchestrator::print_status(config_path, self.json, self.limit) + fn run(&self) -> crate::prelude::Result<()> { + orchestrator::print_status(self.project_config.as_path(), self.json, self.limit) } } #[derive(Debug, Args)] struct DiagnoseCommand { + #[command(flatten)] + project_config: ProjectConfigArgs, /// Emit the agent handoff index JSON instead of a one-line path summary. #[arg(long)] json: bool, @@ -515,9 +439,9 @@ struct DiagnoseCommand { limit: usize, } impl DiagnoseCommand { - fn run(&self, config_path: Option<&Path>) -> crate::prelude::Result<()> { + fn run(&self) -> crate::prelude::Result<()> { orchestrator::run_diagnose(DiagnoseRequest { - config_path, + config_path: self.project_config.as_path(), json: self.json, limit: self.limit, }) @@ -526,23 +450,19 @@ impl DiagnoseCommand { #[derive(Debug, Args)] struct RecoverCommand { + #[command(flatten)] + project_config: ProjectConfigArgs, #[command(subcommand)] command: RecoverSubcommand, } impl RecoverCommand { - fn run(&self, config_path: Option<&Path>) -> crate::prelude::Result<()> { + fn run(&self) -> crate::prelude::Result<()> { match &self.command { - RecoverSubcommand::ReviewHandoff(args) => args.run(config_path), + RecoverSubcommand::ReviewHandoff(args) => args.run(self.project_config.as_path()), } } } -#[derive(Debug, Subcommand)] -enum RecoverSubcommand { - /// Recover retained review lanes whose handoff marker is missing. - ReviewHandoff(ReviewHandoffRecoveryCommand), -} - #[derive(Debug, Args)] struct ReviewHandoffRecoveryCommand { #[command(subcommand)] @@ -568,14 +488,6 @@ impl ReviewHandoffRecoveryCommand { } } -#[derive(Debug, Subcommand)] -enum ReviewHandoffRecoverySubcommand { - /// Read-only diagnosis for orphaned retained review lanes. - Diagnose(ReviewHandoffDiagnoseCommand), - /// Explicitly bind a validated PR URL to one retained review lane. - Rebind(ReviewHandoffRebindCommand), -} - #[derive(Debug, Args)] struct ReviewHandoffDiagnoseCommand { /// Issue identifier to inspect. Omit to inspect all retained review worktrees. @@ -601,6 +513,8 @@ struct ReviewHandoffRebindCommand { #[derive(Debug, Args)] struct ArchiveLinearCommand { + #[command(flatten)] + project_config: ProjectConfigArgs, /// Repo label scope to inspect, for example `repo:decodex`. #[arg(long = "repo-label", value_name = "LABEL", required = true)] repo_labels: Vec, @@ -612,9 +526,9 @@ struct ArchiveLinearCommand { execute: bool, } impl ArchiveLinearCommand { - fn run(&self, config_path: Option<&Path>) -> crate::prelude::Result<()> { + fn run(&self) -> crate::prelude::Result<()> { archive_hygiene::run( - config_path, + self.project_config.as_path(), &ArchiveHygieneRequest { repo_labels: self.repo_labels.clone(), older_than_days: self.older_than_days, @@ -637,15 +551,9 @@ impl MaintenanceCommand { } } -#[derive(Debug, Subcommand)] -enum MaintenanceSubcommand { - /// Inspect or apply local Decodex storage retention. - Prune(MaintenancePruneCommand), -} - #[derive(Debug, Args)] struct MaintenancePruneCommand { - /// Report candidates without applying retention changes. + /// Report candidates without applying retention changes. This is the default mode. #[arg(long, conflicts_with = "apply")] dry_run: bool, /// Apply safe file retention, state-aware runtime compaction, and WAL checkpointing. @@ -696,16 +604,18 @@ impl ProbeCommand { #[derive(Debug, Args)] struct AttemptCommand { + #[command(flatten)] + project_config: ProjectConfigArgs, /// Structured request file path, or `-` to read the request from stdin. #[arg(value_name = "REQUEST", default_value = "-")] request: String, } impl AttemptCommand { - fn run(&self, config_path: Option<&Path>) -> crate::prelude::Result<()> { + fn run(&self) -> crate::prelude::Result<()> { let request = read_attempt_request(&self.request)?; orchestrator::run_once(RunOnceRequest { - config_path, + config_path: self.project_config.as_path(), dry_run: request.dry_run, explain_queue: false, preferred_issue_id: Some(request.issue_id.as_str()), @@ -724,6 +634,117 @@ impl AttemptCommand { } } +impl From for IssueDispatchMode { + fn from(value: AttemptDispatchMode) -> Self { + match value { + AttemptDispatchMode::Normal => Self::Normal, + AttemptDispatchMode::Retry => Self::Retry, + AttemptDispatchMode::ReviewRepair => Self::ReviewRepair, + AttemptDispatchMode::Closeout => Self::Closeout, + } + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "kebab-case")] +pub(crate) enum AttemptDispatchMode { + Normal, + Retry, + ReviewRepair, + Closeout, +} +impl From for AttemptDispatchMode { + fn from(value: IssueDispatchMode) -> Self { + match value { + IssueDispatchMode::Normal => Self::Normal, + IssueDispatchMode::Retry => Self::Retry, + IssueDispatchMode::ReviewRepair => Self::ReviewRepair, + IssueDispatchMode::Closeout => Self::Closeout, + } + } +} + +#[derive(Debug, Subcommand)] +enum Command { + /// Create a signed local commit with a `decodex/commit/1` message. + Commit(CommitCommand), + /// Land the current reviewed lane with a GitHub admin merge commit. + Land(LandCommand), + /// Run one orchestration pass. + Run(RunCommand), + /// Run the local multi-project Decodex control plane. + Serve(ServeCommand), + /// Manage the local Decodex project registry. + Project(ProjectCommand), + /// Inspect the current local runtime state for one configured project. + Status(StatusCommand), + /// Write and print the agent-readable local evidence index. + Diagnose(DiagnoseCommand), + /// Diagnose or explicitly repair supported retained-lane recovery cases. + Recover(RecoverCommand), + /// Dry-run or archive old terminal Linear issues by repo label. + ArchiveLinear(ArchiveLinearCommand), + /// Maintain local Decodex logs, evidence, backups, and runtime storage. + Maintenance(MaintenanceCommand), + /// Manage the global Decodex Codex account pool. + Account(AccountCommand), + /// Validate the local app-server integration boundary. + Probe(ProbeCommand), + /// Run one daemon-planned attempt from a structured request. + #[command(name = "_attempt", hide = true)] + Attempt(AttemptCommand), +} + +#[derive(Debug, Subcommand)] +enum AccountSubcommand { + /// List configured Codex accounts without printing token material. + List(AccountListCommand), + /// Pin new Decodex runs to one account. + Select(AccountSelectCommand), + /// Return new Decodex runs to balanced account selection. + Clear(AccountClearCommand), + /// Remove one account from the Decodex account pool. + Logout(AccountLogoutCommand), + /// Import an existing Codex `auth.json` into the Decodex account pool. + ImportAuth(AccountImportCommand), + /// Force Codex to use one stored account by overwriting its `auth.json`. + Use(AccountUseCommand), + /// Run Codex device login in an isolated temporary home, then import it. + Login(AccountLoginCommand), +} + +#[derive(Debug, Subcommand)] +enum ProjectSubcommand { + /// Register or refresh one Decodex project config. + Add(ProjectAddCommand), + /// List registered local projects. + List, + /// Enable one registered project for `decodex serve`. + Enable(ProjectToggleCommand), + /// Disable one registered project for `decodex serve`. + Disable(ProjectToggleCommand), +} + +#[derive(Debug, Subcommand)] +enum RecoverSubcommand { + /// Recover retained review lanes whose handoff marker is missing. + ReviewHandoff(ReviewHandoffRecoveryCommand), +} + +#[derive(Debug, Subcommand)] +enum ReviewHandoffRecoverySubcommand { + /// Read-only diagnosis for orphaned retained review lanes. + Diagnose(ReviewHandoffDiagnoseCommand), + /// Explicitly bind a validated PR URL to one retained review lane. + Rebind(ReviewHandoffRebindCommand), +} + +#[derive(Debug, Subcommand)] +enum MaintenanceSubcommand { + /// Inspect or apply local Decodex storage retention. + Prune(MaintenancePruneCommand), +} + fn parse_duration_arg(raw: &str) -> std::result::Result { let (number, unit) = raw .strip_suffix('s') @@ -780,16 +801,16 @@ fn styles() -> Styles { #[cfg(test)] mod tests { - use std::{path::PathBuf, time::Duration}; + use std::{path::Path, time::Duration}; use clap::Parser; use crate::cli::{ AccountCommand, AccountSubcommand, AccountUseCommand, AttemptCommand, Cli, Command, CommitCommand, DiagnoseCommand, LandCommand, ProbeCommand, ProjectCommand, - ProjectSubcommand, RecoverCommand, RecoverSubcommand, ReviewHandoffDiagnoseCommand, - ReviewHandoffRebindCommand, ReviewHandoffRecoveryCommand, ReviewHandoffRecoverySubcommand, - RunCommand, ServeCommand, StatusCommand, + ProjectConfigArgs, ProjectSubcommand, RecoverCommand, RecoverSubcommand, + ReviewHandoffDiagnoseCommand, ReviewHandoffRebindCommand, ReviewHandoffRecoveryCommand, + ReviewHandoffRecoverySubcommand, RunCommand, ServeCommand, StatusCommand, }; #[test] @@ -858,6 +879,15 @@ mod tests { )); } + #[test] + fn land_manual_authority_requires_pr() { + let error = Cli::try_parse_from(["decodex", "land", "ship hotfix", "--manual-authority"]) + .expect_err("manual authority land should require an explicit PR"); + + assert!(error.to_string().contains("--manual-authority")); + assert!(error.to_string().contains("--pr")); + } + #[test] fn commit_rejects_authority_and_manual_authority_together() { let error = Cli::try_parse_from([ @@ -880,7 +910,7 @@ mod tests { assert!(matches!( cli.command, - Command::Run(RunCommand { issue: Some(_), dry_run: true, explain: false }) + Command::Run(RunCommand { issue: Some(_), dry_run: true, explain: false, .. }) )); } @@ -890,7 +920,7 @@ mod tests { assert!(matches!( cli.command, - Command::Run(RunCommand { issue: None, dry_run: false, explain: false }) + Command::Run(RunCommand { issue: None, dry_run: false, explain: false, .. }) )); } @@ -900,35 +930,46 @@ mod tests { assert!(matches!( cli.command, - Command::Run(RunCommand { issue: None, dry_run: true, explain: true }) + Command::Run(RunCommand { issue: None, dry_run: true, explain: true, .. }) )); let error = Cli::try_parse_from(["decodex", "run", "--explain"]) .expect_err("explain should require dry-run"); assert!(error.to_string().contains("--dry-run")); + + let error = Cli::try_parse_from(["decodex", "run", "issue-1", "--dry-run", "--explain"]) + .expect_err("explain should reject positional issue"); + + assert!(error.to_string().contains("--explain")); + assert!(error.to_string().contains("[ISSUE]")); } #[test] - fn parses_serve_with_interval_listen_address_and_global_config() { + fn parses_serve_with_interval_listen_address_and_project_config() { let cli = Cli::parse_from([ "decodex", + "serve", "--config", "./project.toml", - "serve", "--interval", "30s", "--listen-address", "127.0.0.1:9000", ]); - assert_eq!(cli.config, Some(PathBuf::from("./project.toml"))); assert!(matches!( cli.command, - Command::Serve(ServeCommand { interval, listen_address, api_only }) + Command::Serve(ServeCommand { + project_config: ProjectConfigArgs { config: Some(config) }, + interval, + listen_address, + api_only, + }) if interval == Some(Duration::from_secs(30)) && listen_address == "127.0.0.1:9000" && !api_only + && config == Path::new("./project.toml") )); } @@ -948,8 +989,7 @@ mod tests { let Command::Serve(command) = cli.command else { panic!("expected serve command"); }; - let error = - command.run(None).expect_err("api-only serve must reject interval configuration"); + let error = command.run().expect_err("api-only serve must reject interval configuration"); let message = error.to_string(); assert!(message.contains("--api-only")); @@ -1000,14 +1040,33 @@ mod tests { )); } + #[test] + fn account_commands_reject_project_config() { + let error = + Cli::try_parse_from(["decodex", "account", "list", "--config", "./project.toml"]) + .expect_err("global account commands should not accept project config"); + + assert!(error.to_string().contains("--config")); + } + + #[test] + fn project_config_must_belong_to_project_scoped_command() { + let error = Cli::try_parse_from(["decodex", "--config", "./project.toml", "status"]) + .expect_err("project config should not be accepted at root"); + + assert!(error.to_string().contains("--config")); + } + #[test] fn parses_hidden_attempt_with_stdin_request() { - let cli = Cli::parse_from(["decodex", "--config", "./project.toml", "_attempt", "-"]); + let cli = Cli::parse_from(["decodex", "_attempt", "--config", "./project.toml", "-"]); - assert_eq!(cli.config, Some(PathBuf::from("./project.toml"))); assert!(matches!( cli.command, - Command::Attempt(AttemptCommand { request }) if request == "-" + Command::Attempt(AttemptCommand { + project_config: ProjectConfigArgs { config: Some(config) }, + request, + }) if request == "-" && config == Path::new("./project.toml") )); } @@ -1022,60 +1081,72 @@ mod tests { } #[test] - fn parses_status_with_json_limit_and_global_config() { + fn parses_status_with_json_limit_and_project_config() { let cli = Cli::parse_from([ "decodex", + "status", "--config", "./project.toml", - "status", "--json", "--limit", "5", ]); - assert_eq!(cli.config, Some(PathBuf::from("./project.toml"))); - assert!(matches!(cli.command, Command::Status(StatusCommand { json: true, limit: 5 }))); + assert!(matches!( + cli.command, + Command::Status(StatusCommand { + project_config: ProjectConfigArgs { config: Some(config) }, + json: true, + limit: 5, + }) if config == Path::new("./project.toml") + )); } #[test] - fn parses_diagnose_with_json_limit_and_global_config() { + fn parses_diagnose_with_json_limit_and_project_config() { let cli = Cli::parse_from([ "decodex", + "diagnose", "--config", "./project.toml", - "diagnose", "--json", "--limit", "5", ]); - assert_eq!(cli.config, Some(PathBuf::from("./project.toml"))); - assert!(matches!(cli.command, Command::Diagnose(DiagnoseCommand { json: true, limit: 5 }))); + assert!(matches!( + cli.command, + Command::Diagnose(DiagnoseCommand { + project_config: ProjectConfigArgs { config: Some(config) }, + json: true, + limit: 5, + }) if config == Path::new("./project.toml") + )); } #[test] fn parses_review_handoff_diagnose_with_issue_and_json() { let cli = Cli::parse_from([ "decodex", + "recover", "--config", "./project.toml", - "recover", "review-handoff", "diagnose", "PUB-718", "--json", ]); - assert_eq!(cli.config, Some(PathBuf::from("./project.toml"))); assert!(matches!( cli.command, Command::Recover(RecoverCommand { + project_config: ProjectConfigArgs { config: Some(config) }, command: RecoverSubcommand::ReviewHandoff(ReviewHandoffRecoveryCommand { command: ReviewHandoffRecoverySubcommand::Diagnose( ReviewHandoffDiagnoseCommand { issue: Some(_), json: true } ) }) - }) + }) if config == Path::new("./project.toml") )); } @@ -1099,7 +1170,8 @@ mod tests { command: ReviewHandoffRecoverySubcommand::Rebind( ReviewHandoffRebindCommand { issue, pr, dry_run: true } ) - }) + }), + .. }) if issue == "PUB-718" && pr == "https://github.com/hack-ink/pubfi-mono-v2/pull/14" )); diff --git a/apps/decodex/src/manual.rs b/apps/decodex/src/manual.rs index 725ec7ac6..888e6d83e 100644 --- a/apps/decodex/src/manual.rs +++ b/apps/decodex/src/manual.rs @@ -551,7 +551,7 @@ fn resolve_manual_config_path(explicit: Option<&Path>, cwd: &Path) -> Result` or register one with `decodex project add `." + "Decodex project config is required for this command. Pass this command's `--config ` or register one with `decodex project add `." ); } diff --git a/apps/decodex/src/orchestrator/daemon.rs b/apps/decodex/src/orchestrator/daemon.rs index b86ba6dc2..25091a38f 100644 --- a/apps/decodex/src/orchestrator/daemon.rs +++ b/apps/decodex/src/orchestrator/daemon.rs @@ -755,9 +755,9 @@ fn spawn_run_once_child(request: SpawnRunOnceChildRequest<'_>) -> Result let mut command = Command::new(executable); command - .args(["--config"]) + .args(["_attempt", "--config"]) .arg(request.config_path) - .args(["_attempt", "-"]) + .arg("-") .stdin(Stdio::piped()) .stdout(Stdio::inherit()) .stderr(Stdio::inherit()); diff --git a/apps/decodex/src/orchestrator/entrypoints.rs b/apps/decodex/src/orchestrator/entrypoints.rs index 4e3104918..f7a062604 100644 --- a/apps/decodex/src/orchestrator/entrypoints.rs +++ b/apps/decodex/src/orchestrator/entrypoints.rs @@ -39,7 +39,7 @@ pub(crate) fn run_once(request: RunOnceRequest<'_>) -> Result<()> { } eyre::bail!( - "No Decodex project config found. Pass --config or register one with `decodex project add `." + "No Decodex project config found. Pass this command's --config or register one with `decodex project add `." ); }; @@ -158,7 +158,7 @@ pub(crate) fn run_control_plane(request: ServeRequest<'_>) -> Result<()> { if let Some(config_path) = request.config_path { let Some(config_path) = resolve_config_path(Some(config_path), &state_store)? else { eyre::bail!( - "No Decodex project config found. Pass --config or register one with `decodex project add `." + "No Decodex project config found. Pass this command's --config or register one with `decodex project add `." ); }; @@ -213,7 +213,7 @@ pub(crate) fn print_status( let state_store = runtime::open_runtime_store()?; let Some(config_path) = resolve_config_path(config_path, &state_store)? else { eyre::bail!( - "No Decodex project config found. Pass --config or register one with `decodex project add `." + "No Decodex project config found. Pass this command's --config or register one with `decodex project add `." ); }; let config = ServiceConfig::from_path(&config_path)?; @@ -271,7 +271,7 @@ pub(crate) fn run_diagnose(request: DiagnoseRequest<'_>) -> Result<()> { let state_store = runtime::open_runtime_store()?; let Some(config_path) = resolve_config_path(request.config_path, &state_store)? else { eyre::bail!( - "No Decodex project config found. Pass --config or register one with `decodex project add `." + "No Decodex project config found. Pass this command's --config or register one with `decodex project add `." ); }; let config = ServiceConfig::from_path(&config_path)?; diff --git a/apps/decodex/src/recovery.rs b/apps/decodex/src/recovery.rs index d5bc2b65d..447e8d5ba 100644 --- a/apps/decodex/src/recovery.rs +++ b/apps/decodex/src/recovery.rs @@ -176,7 +176,7 @@ fn resolve_recovery_config_path( runtime::registered_config_path_for_cwd(state_store, &env::current_dir()?)?.ok_or_else(|| { eyre::eyre!( - "No Decodex project config found. Pass --config or register one with `decodex project add `." + "No Decodex project config found. Pass this command's --config or register one with `decodex project add `." ) }) } diff --git a/apps/decodex/src/runtime.rs b/apps/decodex/src/runtime.rs index 786ee53c2..97e4cb6d1 100644 --- a/apps/decodex/src/runtime.rs +++ b/apps/decodex/src/runtime.rs @@ -203,7 +203,7 @@ pub(crate) fn registered_config_path_for_cwd( if ambiguous { eyre::bail!( - "Current directory `{}` matches multiple registered Decodex projects; pass `--config `.", + "Current directory `{}` matches multiple registered Decodex projects; pass the command's `--config `.", cwd.display() ); } diff --git a/docs/runbook/recover-review-handoff.md b/docs/runbook/recover-review-handoff.md index e27b1f432..58c204248 100644 --- a/docs/runbook/recover-review-handoff.md +++ b/docs/runbook/recover-review-handoff.md @@ -17,7 +17,7 @@ Run from the project repo or pass the registered project directory: ```sh decodex recover review-handoff diagnose -decodex --config "$HOME/.codex/decodex/projects/" recover review-handoff diagnose +decodex recover --config "$HOME/.codex/decodex/projects/" review-handoff diagnose ``` Omit `` to inspect every retained review worktree for the configured project. diff --git a/docs/runbook/self-dogfood-pilot.md b/docs/runbook/self-dogfood-pilot.md index 38ba7804b..8f8bd831c 100644 --- a/docs/runbook/self-dogfood-pilot.md +++ b/docs/runbook/self-dogfood-pilot.md @@ -54,7 +54,7 @@ For the recommended first deployment, collect each project contract under `~/.co `decodex` resolves config in this order: -1. `--config ` +1. `--config ` on project-scoped commands that accept it 2. the global project registry entry whose `repo_root` or `worktree_root` owns the current directory Projects must be registered explicitly. Keep project configs in the canonical @@ -402,9 +402,9 @@ wants to observe the self-bootstrap loop without reading source code. local runtime database. Passing `--config` refreshes that project registration before the scheduler starts. - Pass `--config ` when you want `serve` to refresh one project registration - before it starts. Omit it when the registry already contains the enabled projects - you want the control plane to monitor. + Pass `decodex serve --config ` when you want `serve` to refresh one + project registration before it starts. Omit it when the registry already contains + the enabled projects you want the control plane to monitor. 5. Open the operator dashboard: diff --git a/plugins/decodex/skills/decodex/SKILL.md b/plugins/decodex/skills/decodex/SKILL.md index fb9981dec..9c4d8bdfa 100644 --- a/plugins/decodex/skills/decodex/SKILL.md +++ b/plugins/decodex/skills/decodex/SKILL.md @@ -26,7 +26,7 @@ specs. Decodex has two supported use modes: 3. Read `Makefile.toml` before running repository validation. 4. For automation questions, read the registered project `project.toml` and `WORKFLOW.md` under `~/.codex/decodex/projects//` or the project - directory supplied with `--config`. + directory supplied through a project-scoped command's `--config`. 5. Use the narrow skill for the current action: - `manual-cli` for normal operator CLI use. - `planning` for Decodex-friendly issue splitting, queue shaping, and concurrency. diff --git a/plugins/decodex/skills/labels/SKILL.md b/plugins/decodex/skills/labels/SKILL.md index b86cceb51..ea72fd9a5 100644 --- a/plugins/decodex/skills/labels/SKILL.md +++ b/plugins/decodex/skills/labels/SKILL.md @@ -30,8 +30,8 @@ The project config is usually under: $HOME/.codex/decodex/projects//project.toml ``` -If a command supplies `--config `, read that project's `project.toml` -instead. +If a project-scoped command supplies `--config `, read that project's +`project.toml` instead. ## Human Label Actions diff --git a/plugins/decodex/skills/manual-cli/SKILL.md b/plugins/decodex/skills/manual-cli/SKILL.md index ec95c0dd0..a2d43c4c0 100644 --- a/plugins/decodex/skills/manual-cli/SKILL.md +++ b/plugins/decodex/skills/manual-cli/SKILL.md @@ -70,8 +70,8 @@ Manual commit and landing are separate narrow workflows: - The project directory must contain `project.toml` and `WORKFLOW.md`. - `project.toml` owns `[paths].repo_root`, service identity, and credential env-var names. -- Commands without `--config` resolve through the explicit registry; they do not scan - arbitrary checkouts for repo-local config files. +- Project-scoped commands without `--config` resolve through the explicit registry; + they do not scan arbitrary checkouts for repo-local config files. ## Status and Dry Run