Skip to main content

orchid/
cli.rs

1use std::path::Path;
2
3use clap::{Args, Parser, Subcommand, ValueEnum};
4use serde_json::{Map, Value};
5
6use crate::core::{emit, json_fail, OrchResult, DEFAULT_STALE_AFTER};
7use crate::orchestration::{
8    self, BlockRequest, CleanupRequest, CloseRequest, CompleteRequest, LeaseRequest, NextRequest,
9    PacketRequest, PacketRoleKind, ReportCheckRequest,
10};
11use crate::paths::root_from_arg;
12
13#[derive(Parser)]
14#[command(
15    name = "orchid",
16    about = "Coordinate scoped agent work from repo-local specs",
17    long_about = "Orchid coordinates scoped agent work from repo-local specs. It leases Markdown task files, creates fresh role packets, validates worker reports, checks Git scope, and emits JSON ACKs for orchestrators."
18)]
19struct Cli {
20    #[arg(long, help = "Repository root; defaults to the current directory")]
21    root: Option<String>,
22    #[arg(long, help = "Pretty-print JSON output")]
23    pretty: bool,
24    #[command(subcommand)]
25    command: Command,
26}
27
28#[derive(Subcommand)]
29enum Command {
30    #[command(about = "List ready task files")]
31    Ready(ReadyArgs),
32    #[command(about = "Summarize specs, task states, and active leases")]
33    Status(StatusArgs),
34    #[command(about = "Reserve a task for one scoped worker")]
35    Lease(LeaseArgs),
36    #[command(about = "List active lease runtime files")]
37    Running,
38    #[command(about = "Refresh a lease heartbeat timestamp")]
39    Heartbeat {
40        #[arg(help = "Lease id to heartbeat, for example l_123")]
41        lease: String,
42    },
43    #[command(about = "Find leases with stale heartbeats")]
44    Stale {
45        #[arg(
46            long,
47            default_value = "30m",
48            help = "Minimum lease age, such as 10m, 2h, or 1d"
49        )]
50        older_than: String,
51    },
52    #[command(about = "Release a lease without completing its task")]
53    Release {
54        #[arg(help = "Lease id to release")]
55        lease: String,
56        #[arg(long, default_value = "", help = "Reason recorded in the release ACK")]
57        reason: String,
58    },
59    #[command(about = "Close lease runtime files after handoff")]
60    Close(CloseArgs),
61    #[command(about = "Remove completed or released runtime artifacts")]
62    Cleanup(CleanupArgs),
63    #[command(about = "Decide the next orchestration action")]
64    Next(NextArgs),
65    #[command(
66        name = "research-path",
67        about = "Print or create a spec research workspace"
68    )]
69    ResearchPath(ResearchPathArgs),
70    #[command(name = "research-clean", about = "Delete a spec research workspace")]
71    ResearchClean {
72        #[arg(help = "Spec id or specs/<spec-id> path")]
73        spec: String,
74    },
75    #[command(about = "Generate a worker, validator, reviewer, or loop-runner packet")]
76    Packet(PacketArgs),
77    #[command(
78        name = "report-check",
79        about = "Validate a worker report before completion"
80    )]
81    ReportCheck {
82        #[arg(help = "Report path, usually .orchid/reports/<lease>.md")]
83        report: String,
84    },
85    #[command(name = "git-status", about = "Return compact Git status")]
86    GitStatus,
87    #[command(
88        name = "git-touched",
89        about = "Compare Git changes against a lease scope"
90    )]
91    GitTouched {
92        #[arg(long, help = "Lease id to inspect")]
93        lease: String,
94    },
95    #[command(name = "git-stage-plan", about = "Plan safe Git pathspecs for a lease")]
96    GitStagePlan {
97        #[arg(long, help = "Lease id to plan staging for")]
98        lease: String,
99    },
100    #[command(about = "Record verified work as complete")]
101    Complete(CompleteArgs),
102    #[command(about = "Mark a task blocked with a reason")]
103    Block(BlockArgs),
104    #[command(about = "Validate spec and task-file structure")]
105    Lint,
106}
107
108#[derive(Args)]
109struct ReadyArgs {
110    #[arg(long, action = clap::ArgAction::Append, help = "Limit ready queue to a spec id; repeatable")]
111    spec: Vec<String>,
112    #[arg(long, help = "Select the first open active spec by numerical prefix")]
113    all_open: bool,
114    #[arg(long, help = "Include blocked tasks and selection details")]
115    explain: bool,
116}
117
118#[derive(Args)]
119struct StatusArgs {
120    #[arg(long, action = clap::ArgAction::Append, help = "Limit status to a spec id; repeatable")]
121    spec: Vec<String>,
122    #[arg(
123        long,
124        help = "Show status for the first open active spec by numerical prefix"
125    )]
126    all_open: bool,
127}
128
129#[derive(Args)]
130#[command(group = clap::ArgGroup::new("mode").args(["serial", "allow_parallel"]).multiple(false))]
131struct LeaseArgs {
132    #[arg(
133        help = "Task target: SPEC with TASK_ID, SPEC/TASK, specs/SPEC with TASK_ID, or specs/SPEC/tasks/TASK.md"
134    )]
135    target: String,
136    #[arg(help = "Task id when TARGET is a spec id or specs/SPEC path")]
137    task_id: Option<String>,
138    #[arg(long, help = "Lease owner label, such as worker:agent_123")]
139    owner: String,
140    #[arg(long, help = "Override generated lease id for tests or recovery")]
141    lease_id: Option<String>,
142    #[arg(long, help = "Require no other active leases")]
143    serial: bool,
144    #[arg(long, help = "Allow a disjoint lease while other leases are active")]
145    allow_parallel: bool,
146}
147
148#[derive(Args)]
149struct CloseArgs {
150    #[arg(long, help = "Lease id to close")]
151    lease: String,
152    #[arg(
153        long,
154        help = "Close and delete runtime files even if the lease is active"
155    )]
156    force: bool,
157}
158
159#[derive(Args)]
160struct CleanupArgs {
161    #[arg(
162        long,
163        help = "Delete completed/released lease files, packets, and reports"
164    )]
165    completed: bool,
166}
167
168#[derive(Args)]
169struct NextArgs {
170    #[arg(long, action = clap::ArgAction::Append, help = "Limit next action to a spec id; repeatable")]
171    spec: Vec<String>,
172    #[arg(long, help = "Select the first open active spec by numerical prefix")]
173    all_open: bool,
174    #[arg(long, default_value = DEFAULT_STALE_AFTER, help = "Minimum lease age for recover/stale decisions")]
175    older_than: String,
176    #[arg(long, help = "Include recommended action, queues, and blockers")]
177    explain: bool,
178}
179
180#[derive(Args)]
181struct ResearchPathArgs {
182    #[arg(help = "Spec id or specs/<spec-id> path")]
183    spec: String,
184    #[arg(long, help = "Create the workspace if missing")]
185    create: bool,
186}
187
188#[derive(Copy, Clone, ValueEnum)]
189enum PacketRole {
190    Worker,
191    Validator,
192    Reviewer,
193    #[value(name = "loop-runner")]
194    LoopRunner,
195}
196
197impl From<PacketRole> for PacketRoleKind {
198    fn from(value: PacketRole) -> Self {
199        match value {
200            PacketRole::Worker => PacketRoleKind::Worker,
201            PacketRole::Validator => PacketRoleKind::Validator,
202            PacketRole::Reviewer => PacketRoleKind::Reviewer,
203            PacketRole::LoopRunner => PacketRoleKind::LoopRunner,
204        }
205    }
206}
207
208#[derive(Args)]
209struct PacketArgs {
210    #[arg(long, help = "Lease id to build a role packet for")]
211    lease: String,
212    #[arg(
213        long,
214        value_enum,
215        default_value = "worker",
216        help = "Packet role to generate"
217    )]
218    role: PacketRole,
219}
220
221#[derive(Args)]
222struct CompleteArgs {
223    #[arg(long, help = "Lease id to complete")]
224    lease: String,
225    #[arg(long, help = "Validator or coordinator label that verified the work")]
226    verified_by: String,
227    #[arg(long, default_value = "", help = "Worker label to record on the task")]
228    implemented_by: String,
229    #[arg(long, default_value = "passed", help = "Verification result to record")]
230    verification_status: String,
231    #[arg(
232        long,
233        default_value = "",
234        help = "Report path or summary reference to record"
235    )]
236    report: String,
237    #[arg(
238        long,
239        default_value = "",
240        help = "Commit hash produced by the coordinator"
241    )]
242    commit: String,
243    #[arg(
244        long,
245        default_value = "",
246        help = "Independent review reference for the commit"
247    )]
248    commit_review: String,
249    #[arg(long, help = "Delete .orchid/spec-research/<spec-id> after completion")]
250    clean_spec_research: bool,
251}
252
253#[derive(Args)]
254struct BlockArgs {
255    #[arg(
256        help = "Task target: SPEC with TASK_ID, SPEC/TASK, specs/SPEC with TASK_ID, or specs/SPEC/tasks/TASK.md"
257    )]
258    target: String,
259    #[arg(help = "Task id when TARGET is a spec id or specs/SPEC path")]
260    task_id: Option<String>,
261    #[arg(long, help = "Reason to write to task state")]
262    reason: String,
263}
264
265pub fn run() -> i32 {
266    let cli = Cli::parse();
267    let root = match root_from_arg(cli.root.as_deref()) {
268        Ok(root) => root,
269        Err(error) => {
270            let payload = json_fail(&error.message, Some(&error.code));
271            emit(&payload, cli.pretty);
272            return 1;
273        }
274    };
275
276    let result: OrchResult<Map<String, Value>> = match run_command(&root, &cli.command) {
277        Ok(payload) => Ok(payload),
278        Err(error) => {
279            let mut payload = json_fail(&error.message, Some(&error.code));
280            payload.extend(error.details);
281            Ok(payload)
282        }
283    };
284
285    match result {
286        Ok(payload) => {
287            let ok = payload
288                .get("ok")
289                .and_then(Value::as_bool)
290                .unwrap_or_else(|| !payload.contains_key("error"));
291            emit(&payload, cli.pretty);
292            if ok {
293                0
294            } else {
295                1
296            }
297        }
298        Err(error) => {
299            let mut payload = json_fail(&error.message, Some(&error.code));
300            payload.extend(error.details);
301            emit(&payload, cli.pretty);
302            1
303        }
304    }
305}
306
307fn run_command(root: &Path, command: &Command) -> OrchResult<Map<String, Value>> {
308    match command {
309        Command::Ready(args) => cmd_ready(root, args),
310        Command::Status(args) => cmd_status(root, args),
311        Command::Lease(args) => cmd_lease(root, args),
312        Command::Running => cmd_running(root),
313        Command::Heartbeat { lease } => cmd_heartbeat(root, lease),
314        Command::Stale { older_than } => cmd_stale(root, older_than),
315        Command::Release { lease, reason } => cmd_release(root, lease, reason),
316        Command::Close(args) => cmd_close(root, args),
317        Command::Cleanup(args) => cmd_cleanup(root, args),
318        Command::Next(args) => cmd_next(root, args),
319        Command::ResearchPath(args) => cmd_research_path(root, args),
320        Command::ResearchClean { spec } => cmd_research_clean(root, spec),
321        Command::Packet(args) => cmd_packet(root, args),
322        Command::ReportCheck { report } => cmd_report_check(root, report),
323        Command::GitStatus => cmd_git_status(root),
324        Command::GitTouched { lease } => cmd_git_touched(root, lease),
325        Command::GitStagePlan { lease } => cmd_git_stage_plan(root, lease),
326        Command::Complete(args) => cmd_complete(root, args),
327        Command::Block(args) => cmd_block(root, args),
328        Command::Lint => cmd_lint(root),
329    }
330}
331
332fn cmd_ready(root: &Path, args: &ReadyArgs) -> OrchResult<Map<String, Value>> {
333    orchestration::ready(
334        root,
335        &orchestration::ReadyRequest {
336            specs: args.spec.clone(),
337            all_open: args.all_open,
338            explain: args.explain,
339        },
340    )
341}
342
343fn cmd_status(root: &Path, args: &StatusArgs) -> OrchResult<Map<String, Value>> {
344    orchestration::status(
345        root,
346        &orchestration::StatusRequest {
347            specs: args.spec.clone(),
348            all_open: args.all_open,
349        },
350    )
351}
352
353fn cmd_lease(root: &Path, args: &LeaseArgs) -> OrchResult<Map<String, Value>> {
354    orchestration::lease(
355        root,
356        &LeaseRequest {
357            target: args.target.clone(),
358            task_id: args.task_id.clone(),
359            owner: args.owner.clone(),
360            lease_id: args.lease_id.clone(),
361            serial: args.serial,
362            allow_parallel: args.allow_parallel,
363        },
364    )
365}
366
367fn cmd_running(root: &Path) -> OrchResult<Map<String, Value>> {
368    orchestration::running(root)
369}
370
371fn cmd_heartbeat(root: &Path, lease_id: &str) -> OrchResult<Map<String, Value>> {
372    orchestration::heartbeat(root, lease_id)
373}
374
375fn cmd_stale(root: &Path, older_than: &str) -> OrchResult<Map<String, Value>> {
376    orchestration::stale(root, older_than)
377}
378
379fn cmd_release(root: &Path, lease_id: &str, reason: &str) -> OrchResult<Map<String, Value>> {
380    orchestration::release(root, lease_id, reason)
381}
382
383fn cmd_close(root: &Path, args: &CloseArgs) -> OrchResult<Map<String, Value>> {
384    orchestration::close(
385        root,
386        &CloseRequest {
387            lease: args.lease.clone(),
388            force: args.force,
389        },
390    )
391}
392
393fn cmd_cleanup(root: &Path, args: &CleanupArgs) -> OrchResult<Map<String, Value>> {
394    orchestration::cleanup(
395        root,
396        &CleanupRequest {
397            completed: args.completed,
398        },
399    )
400}
401
402fn cmd_research_path(root: &Path, args: &ResearchPathArgs) -> OrchResult<Map<String, Value>> {
403    orchestration::research_path(
404        root,
405        &orchestration::ResearchPathRequest {
406            spec: args.spec.clone(),
407            create: args.create,
408        },
409    )
410}
411
412fn cmd_research_clean(root: &Path, spec: &str) -> OrchResult<Map<String, Value>> {
413    orchestration::research_clean(root, spec)
414}
415
416fn cmd_next(root: &Path, args: &NextArgs) -> OrchResult<Map<String, Value>> {
417    orchestration::next(
418        root,
419        &NextRequest {
420            specs: args.spec.clone(),
421            all_open: args.all_open,
422            older_than: args.older_than.clone(),
423            explain: args.explain,
424        },
425    )
426}
427
428fn cmd_packet(root: &Path, args: &PacketArgs) -> OrchResult<Map<String, Value>> {
429    orchestration::packet(
430        root,
431        &PacketRequest {
432            lease: args.lease.clone(),
433            role: args.role.into(),
434        },
435    )
436}
437
438fn cmd_report_check(root: &Path, report: &str) -> OrchResult<Map<String, Value>> {
439    orchestration::report_check(
440        root,
441        &ReportCheckRequest {
442            report: report.to_string(),
443        },
444    )
445}
446
447fn cmd_git_status(root: &Path) -> OrchResult<Map<String, Value>> {
448    orchestration::git_status(root)
449}
450
451fn cmd_git_touched(root: &Path, lease_id: &str) -> OrchResult<Map<String, Value>> {
452    orchestration::git_touched(root, lease_id)
453}
454
455fn cmd_git_stage_plan(root: &Path, lease_id: &str) -> OrchResult<Map<String, Value>> {
456    orchestration::git_stage_plan(root, lease_id)
457}
458
459fn cmd_complete(root: &Path, args: &CompleteArgs) -> OrchResult<Map<String, Value>> {
460    orchestration::complete(
461        root,
462        &CompleteRequest {
463            lease: args.lease.clone(),
464            verified_by: args.verified_by.clone(),
465            implemented_by: args.implemented_by.clone(),
466            verification_status: args.verification_status.clone(),
467            report: args.report.clone(),
468            commit: args.commit.clone(),
469            commit_review: args.commit_review.clone(),
470            clean_spec_research: args.clean_spec_research,
471        },
472    )
473}
474
475fn cmd_block(root: &Path, args: &BlockArgs) -> OrchResult<Map<String, Value>> {
476    orchestration::block(
477        root,
478        &BlockRequest {
479            target: args.target.clone(),
480            task_id: args.task_id.clone(),
481            reason: args.reason.clone(),
482        },
483    )
484}
485
486fn cmd_lint(root: &Path) -> OrchResult<Map<String, Value>> {
487    orchestration::lint(root)
488}