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}