Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,9 @@ cargo run -p decodex --bin decodex -- status --live
cargo run -p decodex --bin decodex -- diagnose --json
cargo run -p decodex --bin decodex -- maintenance prune --dry-run
cargo run -p decodex --bin decodex -- lane steer <ISSUE> --run-id <RUN_ID> --expected-turn-id <TURN_ID> --message <TEXT>
cargo run -p decodex --bin decodex -- research compile --intent "research X"
cargo run -p decodex --bin decodex -- research compile --input research-design-run.json
cargo run -p decodex --bin decodex -- research promote <CONTRACT_ID>
cargo run -p decodex --bin decodex -- radar refresh-upstream-queue
cargo run -p decodex --bin decodex -- radar refresh-release-delta
cargo run -p decodex --bin decodex -- radar validate
Expand All @@ -136,6 +139,15 @@ publishes snapshots every 15 seconds, and Linear-backed queue/status scans run a
most every 5 minutes per project unless an operator or agent requests an explicit
scan with `POST /api/linear-scan`.

`decodex research compile` is the native Decodex research/design entrypoint. It
accepts minimal natural-language intake or a structured research/design JSON packet,
then persists a `decodex.decision_contract/1` payload in local runtime SQLite. Compile
outcomes distinguish `decision_ready`, `not_decision_ready`, `blocked`, and
`needs_human_decision`. A compiled contract is latent and cannot queue work, mutate
tracker state, set goals, or authorize implementation. `decodex research promote`
records explicit acceptance for a stored contract; only promoted contracts may later
feed issue shaping or internal Execution Program readiness.

### Install from Source

```sh
Expand Down
264 changes: 260 additions & 4 deletions apps/decodex/src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ use std::{
};

use clap::{
Args, Parser, Subcommand,
Args, Parser, Subcommand, ValueEnum,
builder::{
Styles,
styling::{AnsiColor, Effects},
Expand Down Expand Up @@ -34,6 +34,10 @@ use crate::{
RadarValidateRequest,
},
recovery::{self, ReviewHandoffDiagnoseRequest, ReviewHandoffRebindRequest},
research_design::{
self, ResearchDesignCompileRequest, ResearchDesignOutcome, ResearchDesignPromoteRequest,
ResearchDesignRunInput,
},
runtime,
};

Expand Down Expand Up @@ -69,6 +73,7 @@ impl Cli {
Command::Status(args) => args.run(),
Command::Diagnose(args) => args.run(),
Command::Evidence(args) => args.run(),
Command::Research(args) => args.run(),
Command::Recover(args) => args.run(),
Command::ArchiveLinear(args) => args.run(),
Command::Maintenance(args) => args.run(),
Expand Down Expand Up @@ -610,6 +615,127 @@ impl EvidenceCommand {
}
}

#[derive(Debug, Args)]
struct ResearchCommand {
#[command(flatten)]
project_config: ProjectConfigArgs,
#[command(subcommand)]
command: ResearchSubcommand,
}
impl ResearchCommand {
fn run(&self) -> Result<()> {
match &self.command {
ResearchSubcommand::Compile(args) => args.run(self.project_config.as_path()),
ResearchSubcommand::Promote(args) => args.run(self.project_config.as_path()),
}
}
}

#[derive(Debug, Args)]
struct ResearchCompileCommand {
/// Structured research/design input JSON. Use `-` to read from stdin.
#[arg(long, value_name = "JSON")]
input: Option<PathBuf>,
/// Natural-language research/design intent for minimal intake.
#[arg(long, value_name = "TEXT", conflicts_with = "input")]
intent: Option<String>,
/// Source tracker issue identifier to link to the contract.
#[arg(long, value_name = "ISSUE")]
source_issue: Option<String>,
/// Outcome for minimal natural-language intake.
#[arg(long, value_enum, default_value = "not-decision-ready")]
outcome: ResearchOutcomeArg,
/// Emit structured JSON instead of human-readable text.
#[arg(long)]
json: bool,
}
impl ResearchCompileCommand {
fn run(&self, config_path: Option<&Path>) -> Result<()> {
let input = self.research_input()?;
let report =
research_design::run_compile(ResearchDesignCompileRequest { config_path, input })?;

if self.json {
println!("{}", serde_json::to_string_pretty(&report)?);
} else {
println!(
"research compile {}: contract={} status={} ready_after_promotion={} authority={}",
research_outcome_label(report.outcome),
report.contract_id,
report.contract_status.as_str(),
report.issue_generation_ready_after_promotion,
report.execution_authority_granted
);
println!("{}", report.feedback);
}

Ok(())
}

fn research_input(&self) -> Result<ResearchDesignRunInput> {
match (&self.input, &self.intent) {
(Some(path), None) => read_research_input(path),
(None, Some(intent)) => Ok(ResearchDesignRunInput::from_intent(
intent.clone(),
self.source_issue.clone(),
self.outcome.into(),
)),
(None, None) =>
eyre::bail!("research compile requires either --input <JSON> or --intent <TEXT>."),
(Some(_), Some(_)) =>
eyre::bail!("research compile accepts --input or --intent, not both."),
}
}
}

#[derive(Debug, Args)]
struct ResearchPromoteCommand {
/// Decision Contract identifier to promote.
#[arg(value_name = "CONTRACT_ID")]
contract_id: String,
/// Actor accepting the contract.
#[arg(long, value_name = "TEXT", default_value = "operator")]
accepted_by: String,
/// Acceptance source, usually conversation or runtime policy.
#[arg(long, value_name = "TEXT", default_value = "conversation")]
acceptance_source: String,
/// RFC3339 acceptance timestamp. Defaults to current UTC time.
#[arg(long, value_name = "RFC3339")]
accepted_at: Option<String>,
/// Optional acceptance reason.
#[arg(long, value_name = "TEXT")]
reason: Option<String>,
/// Emit structured JSON instead of human-readable text.
#[arg(long)]
json: bool,
}
impl ResearchPromoteCommand {
fn run(&self, config_path: Option<&Path>) -> Result<()> {
let report = research_design::run_promote(ResearchDesignPromoteRequest {
config_path,
contract_id: &self.contract_id,
accepted_by: &self.accepted_by,
accepted_at: self.accepted_at.as_deref(),
acceptance_source: &self.acceptance_source,
promotion_reason: self.reason.clone(),
})?;

if self.json {
println!("{}", serde_json::to_string_pretty(&report)?);
} else {
println!(
"research promote: contract={} status={} authority={} ready={}",
report.contract_id,
report.contract_status.as_str(),
report.execution_authority_granted,
report.ready_for_issue_shaping
);
}

Ok(())
}
}

#[derive(Debug, Args)]
struct RecoverCommand {
#[command(flatten)]
Expand Down Expand Up @@ -1319,6 +1445,8 @@ enum Command {
Diagnose(DiagnoseCommand),
/// Inspect local-only private execution evidence for one issue or run.
Evidence(EvidenceCommand),
/// Compile or promote Decodex-native research/design contracts.
Research(ResearchCommand),
/// Diagnose or explicitly repair supported retained-lane recovery cases.
Recover(RecoverCommand),
/// Dry-run or archive old terminal Linear issues by repo label.
Expand Down Expand Up @@ -1378,6 +1506,33 @@ enum LaneSubcommand {
Steer(LaneSteerCommand),
}

#[derive(Debug, Subcommand)]
enum ResearchSubcommand {
/// Compile bounded research/design input into a latent Decision Contract.
Compile(ResearchCompileCommand),
/// Promote an accepted Decision Contract into execution authority.
Promote(ResearchPromoteCommand),
}

#[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)]
#[value(rename_all = "kebab-case")]
enum ResearchOutcomeArg {
DecisionReady,
NotDecisionReady,
Blocked,
NeedsHumanDecision,
}
impl From<ResearchOutcomeArg> for ResearchDesignOutcome {
fn from(value: ResearchOutcomeArg) -> Self {
match value {
ResearchOutcomeArg::DecisionReady => Self::DecisionReady,
ResearchOutcomeArg::NotDecisionReady => Self::NotDecisionReady,
ResearchOutcomeArg::Blocked => Self::Blocked,
ResearchOutcomeArg::NeedsHumanDecision => Self::NeedsHumanDecision,
}
}
}

#[derive(Debug, Subcommand)]
enum RecoverSubcommand {
/// Recover retained review lanes whose handoff marker is missing.
Expand Down Expand Up @@ -1448,6 +1603,15 @@ fn lane_steer_report_is_failure(report: &LaneSteerReport) -> bool {
matches!(report.outcome.as_str(), "rejected" | "failed" | "timed_out" | "fallback")
}

fn research_outcome_label(outcome: ResearchDesignOutcome) -> &'static str {
match outcome {
ResearchDesignOutcome::DecisionReady => "decision-ready",
ResearchDesignOutcome::NotDecisionReady => "not-decision-ready",
ResearchDesignOutcome::Blocked => "blocked",
ResearchDesignOutcome::NeedsHumanDecision => "needs-human-decision",
}
}

fn render_lane_steer_report(report: &LaneSteerReport) -> String {
format!(
"lane steer {}: issue={} run_id={} attempt={} expected_turn_id={} current_turn_id={} response_turn_id={} failure_class={} audit_record_id={} delivery_status={}\n",
Expand All @@ -1464,6 +1628,22 @@ fn render_lane_steer_report(report: &LaneSteerReport) -> String {
)
}

fn read_research_input(path: &Path) -> Result<ResearchDesignRunInput> {
let raw = if path == Path::new("-") {
let mut raw = String::new();

io::stdin().read_to_string(&mut raw)?;

raw
} else {
fs::read_to_string(path)?
};

serde_json::from_str(&raw).map_err(|error| {
eyre::eyre!("Failed to parse research input `{}`: {error}", path.display())
})
}

fn read_attempt_request(request: &str) -> Result<AttemptRequest> {
let raw = if request == "-" {
let mut raw = String::new();
Expand Down Expand Up @@ -1504,9 +1684,10 @@ mod tests {
RadarLedgerIngestExistingCommand, RadarLedgerSubcommand, RadarLedgerSummaryCommand,
RadarRefreshReleaseDeltaCommand, RadarRefreshUpstreamQueueCommand,
RadarRenderSignalCommand, RadarSubcommand, RadarValidateCommand, RecoverCommand,
RecoverSubcommand, ReviewHandoffDiagnoseCommand, ReviewHandoffRebindCommand,
ReviewHandoffRecoveryCommand, ReviewHandoffRecoverySubcommand, RunCommand, ServeCommand,
StatusCommand,
RecoverSubcommand, ResearchCommand, ResearchCompileCommand, ResearchOutcomeArg,
ResearchPromoteCommand, ResearchSubcommand, ReviewHandoffDiagnoseCommand,
ReviewHandoffRebindCommand, ReviewHandoffRecoveryCommand, ReviewHandoffRecoverySubcommand,
RunCommand, ServeCommand, StatusCommand,
};

#[test]
Expand Down Expand Up @@ -2224,6 +2405,81 @@ mod tests {
));
}

#[test]
fn parses_research_compile_with_intent_and_project_config() {
let cli = Cli::parse_from([
"decodex",
"research",
"--config",
"./project.toml",
"compile",
"--intent",
"research X",
"--source-issue",
"XY-860",
"--outcome",
"needs-human-decision",
"--json",
]);

assert!(matches!(
cli.command,
Command::Research(ResearchCommand {
project_config: ProjectConfigArgs { config: Some(config) },
command: ResearchSubcommand::Compile(ResearchCompileCommand {
intent: Some(intent),
source_issue: Some(source_issue),
outcome: ResearchOutcomeArg::NeedsHumanDecision,
json: true,
..
})
}) if config == Path::new("./project.toml")
&& intent == "research X"
&& source_issue == "XY-860"
));
}

#[test]
fn parses_research_promote_with_acceptance_metadata() {
let cli = Cli::parse_from([
"decodex",
"research",
"--config",
"./project.toml",
"promote",
"research-design-contract",
"--accepted-by",
"operator",
"--accepted-at",
"2026-06-10T00:00:00Z",
"--acceptance-source",
"conversation",
"--reason",
"push this forward",
"--json",
]);

assert!(matches!(
cli.command,
Command::Research(ResearchCommand {
project_config: ProjectConfigArgs { config: Some(config) },
command: ResearchSubcommand::Promote(ResearchPromoteCommand {
contract_id,
accepted_by,
accepted_at: Some(accepted_at),
acceptance_source,
reason: Some(reason),
json: true,
})
}) if config == Path::new("./project.toml")
&& contract_id == "research-design-contract"
&& accepted_by == "operator"
&& accepted_at == "2026-06-10T00:00:00Z"
&& acceptance_source == "conversation"
&& reason == "push this forward"
));
}

#[test]
fn parses_diagnose_with_json_limit_and_project_config() {
let cli = Cli::parse_from([
Expand Down
1 change: 1 addition & 0 deletions apps/decodex/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ mod prelude {
}
mod radar;
mod recovery;
mod research_design;
mod run_control;
mod runtime;
mod tracker;
Expand Down
Loading