1use std::collections::BTreeSet;
2use std::fmt;
3use std::path::{Path, PathBuf};
4use std::str::FromStr;
5
6use serde::{Deserialize, Serialize};
7
8#[derive(Debug, Clone, PartialEq, Eq, Default)]
11pub struct ImplicitWorkflowContractInput {
12 pub objective: String,
13 pub cwd: Option<PathBuf>,
14 pub autonomy_mode: Option<AutonomyMode>,
15 pub workflow_type: Option<WorkflowType>,
16 pub risk_level: Option<RiskLevel>,
17 pub mana_unit_ref: Option<String>,
18}
19
20impl ImplicitWorkflowContractInput {
21 pub fn prompt(objective: impl Into<String>) -> Self {
22 Self {
23 objective: objective.into(),
24 ..Self::default()
25 }
26 }
27
28 pub fn cwd(mut self, cwd: impl Into<PathBuf>) -> Self {
29 self.cwd = Some(cwd.into());
30 self
31 }
32
33 pub fn autonomy_mode(mut self, autonomy_mode: AutonomyMode) -> Self {
34 self.autonomy_mode = Some(autonomy_mode);
35 self
36 }
37
38 pub fn workflow_type(mut self, workflow_type: WorkflowType) -> Self {
39 self.workflow_type = Some(workflow_type);
40 self
41 }
42
43 pub fn risk_level(mut self, risk_level: RiskLevel) -> Self {
44 self.risk_level = Some(risk_level);
45 self
46 }
47
48 pub fn mana_unit_ref(mut self, mana_unit_ref: impl Into<String>) -> Self {
49 self.mana_unit_ref = Some(mana_unit_ref.into());
50 self
51 }
52}
53
54#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
57#[serde(default)]
58pub struct WorkflowContract {
59 pub id: Option<String>,
60 pub title: Option<String>,
61 pub objective: String,
62 pub workflow_type: WorkflowType,
63 pub risk_level: RiskLevel,
64 pub autonomy_mode: AutonomyMode,
65 pub workspace_scope: WorkspaceScope,
66 pub tool_permissions: ToolPermissionSet,
67 pub required_verification: Vec<VerificationRequirement>,
68 pub approval_requirements: Vec<ApprovalRequirement>,
69 pub trust_scope: TrustScope,
70 pub closeout_criteria: CloseoutCriteria,
71 pub mana_unit_ref: Option<String>,
72 pub parent_workflow_ref: Option<String>,
73}
74
75impl WorkflowContract {
76 pub fn implicit(objective: impl Into<String>) -> Self {
77 Self::implicit_from(ImplicitWorkflowContractInput::prompt(objective))
78 }
79
80 pub fn implicit_from(input: ImplicitWorkflowContractInput) -> Self {
81 let workspace_scope = input
82 .cwd
83 .as_deref()
84 .map(workspace_scope_for_cwd)
85 .unwrap_or_default();
86
87 Self {
88 title: title_from_objective(&input.objective),
89 objective: input.objective,
90 workflow_type: input.workflow_type.unwrap_or_default(),
91 risk_level: input.risk_level.unwrap_or_default(),
92 autonomy_mode: input.autonomy_mode.unwrap_or_default(),
93 workspace_scope,
94 mana_unit_ref: input.mana_unit_ref,
95 ..Self::default()
96 }
97 }
98
99 pub fn with_workspace_scope(mut self, workspace_scope: WorkspaceScope) -> Self {
100 self.workspace_scope = workspace_scope;
101 self
102 }
103
104 pub fn with_autonomy_mode(mut self, autonomy_mode: AutonomyMode) -> Self {
105 self.autonomy_mode = autonomy_mode;
106 self
107 }
108
109 pub fn with_mana_unit_ref(mut self, mana_unit_ref: impl Into<String>) -> Self {
110 self.mana_unit_ref = Some(mana_unit_ref.into());
111 self
112 }
113}
114
115impl Default for WorkflowContract {
116 fn default() -> Self {
117 Self {
118 id: None,
119 title: None,
120 objective: String::new(),
121 workflow_type: WorkflowType::AdHoc,
122 risk_level: RiskLevel::Unknown,
123 autonomy_mode: AutonomyMode::Safe,
124 workspace_scope: WorkspaceScope::CurrentDirectory,
125 tool_permissions: ToolPermissionSet::default(),
126 required_verification: Vec::new(),
127 approval_requirements: Vec::new(),
128 trust_scope: TrustScope::default(),
129 closeout_criteria: CloseoutCriteria::default(),
130 mana_unit_ref: None,
131 parent_workflow_ref: None,
132 }
133 }
134}
135
136#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
137#[serde(rename_all = "kebab-case")]
138pub enum WorkflowType {
139 #[default]
140 AdHoc,
141 CodeChange,
142 Investigation,
143 Review,
144 Planning,
145 Documentation,
146 Verification,
147 Orchestration,
148}
149
150#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, Default)]
151#[serde(rename_all = "kebab-case")]
152pub enum RiskLevel {
153 Low,
154 Medium,
155 High,
156 Critical,
157 #[default]
158 Unknown,
159}
160
161#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
162#[serde(rename_all = "kebab-case")]
163pub enum AutonomyMode {
164 Suggest,
165 #[default]
166 Safe,
167 LocalAuto,
168 WorktreeAuto,
169 AllowAllLocal,
170 AllowAll,
171 Ci,
172}
173
174impl AutonomyMode {
175 pub const ALL: [Self; 7] = [
176 Self::Suggest,
177 Self::Safe,
178 Self::LocalAuto,
179 Self::WorktreeAuto,
180 Self::AllowAllLocal,
181 Self::AllowAll,
182 Self::Ci,
183 ];
184
185 pub fn canonical_name(self) -> &'static str {
186 match self {
187 AutonomyMode::Suggest => "suggest",
188 AutonomyMode::Safe => "safe",
189 AutonomyMode::LocalAuto => "local-auto",
190 AutonomyMode::WorktreeAuto => "worktree-auto",
191 AutonomyMode::AllowAllLocal => "allow-all-local",
192 AutonomyMode::AllowAll => "allow-all",
193 AutonomyMode::Ci => "ci",
194 }
195 }
196}
197
198impl fmt::Display for AutonomyMode {
199 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
200 f.write_str(self.canonical_name())
201 }
202}
203
204impl FromStr for AutonomyMode {
205 type Err = ParseAutonomyModeError;
206
207 fn from_str(value: &str) -> Result<Self, Self::Err> {
208 let normalized = value.trim().to_ascii_lowercase().replace('_', "-");
209 match normalized.as_str() {
210 "suggest" | "plan" | "planning" | "review" | "review-only" => Ok(Self::Suggest),
211 "safe" | "default" | "interactive" => Ok(Self::Safe),
212 "local" | "auto-local" | "local-auto" => Ok(Self::LocalAuto),
213 "worktree" | "auto-worktree" | "worktree-auto" => Ok(Self::WorktreeAuto),
214 "allow-all-local" | "all-local" | "local-all" | "yolo-local" => Ok(Self::AllowAllLocal),
215 "allow-all" | "all" | "yolo" => Ok(Self::AllowAll),
216 "ci" | "headless" | "noninteractive" | "non-interactive" => Ok(Self::Ci),
217 _ => Err(ParseAutonomyModeError(value.to_owned())),
218 }
219 }
220}
221
222#[derive(Debug, Clone, PartialEq, Eq)]
223pub struct ParseAutonomyModeError(String);
224
225impl fmt::Display for ParseAutonomyModeError {
226 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
227 write!(f, "unknown autonomy mode `{}`", self.0)
228 }
229}
230
231impl std::error::Error for ParseAutonomyModeError {}
232
233#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
234#[serde(rename_all = "kebab-case")]
235pub enum WorkspaceScope {
236 #[default]
237 CurrentDirectory,
238 Repository {
239 root: PathBuf,
240 },
241 Worktree {
242 path: PathBuf,
243 branch: Option<String>,
244 },
245 Custom {
246 root: PathBuf,
247 },
248}
249
250#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
251#[serde(default)]
252pub struct ToolPermissionSet {
253 pub allowed_tools: BTreeSet<String>,
254 pub denied_tools: BTreeSet<String>,
255}
256
257impl ToolPermissionSet {
258 pub fn allow(mut self, tool: impl Into<String>) -> Self {
259 self.allowed_tools.insert(normalize_tool_name(tool.into()));
260 self
261 }
262
263 pub fn deny(mut self, tool: impl Into<String>) -> Self {
264 self.denied_tools.insert(normalize_tool_name(tool.into()));
265 self
266 }
267
268 pub fn allows_all_by_default(&self) -> bool {
269 self.allowed_tools.is_empty()
270 }
271}
272
273#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
274#[serde(default)]
275pub struct VerificationRequirement {
276 pub name: Option<String>,
277 pub kind: VerificationRequirementKind,
278 pub required: bool,
279}
280
281impl VerificationRequirement {
282 pub fn command(command: impl Into<String>) -> Self {
283 Self {
284 name: None,
285 kind: VerificationRequirementKind::Command {
286 command: command.into(),
287 },
288 required: true,
289 }
290 }
291}
292
293impl Default for VerificationRequirement {
294 fn default() -> Self {
295 Self {
296 name: None,
297 kind: VerificationRequirementKind::Manual,
298 required: true,
299 }
300 }
301}
302
303#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
304#[serde(rename_all = "kebab-case", tag = "kind")]
305pub enum VerificationRequirementKind {
306 Command {
307 command: String,
308 },
309 Diff,
310 Policy,
311 #[default]
312 Manual,
313}
314
315#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
316#[serde(default)]
317pub struct ApprovalRequirement {
318 pub action: ApprovalAction,
319 pub reason: Option<String>,
320 pub required: bool,
321}
322
323impl ApprovalRequirement {
324 pub fn required(action: ApprovalAction, reason: impl Into<String>) -> Self {
325 Self {
326 action,
327 reason: Some(reason.into()),
328 required: true,
329 }
330 }
331}
332
333impl Default for ApprovalRequirement {
334 fn default() -> Self {
335 Self {
336 action: ApprovalAction::HighRiskTool,
337 reason: None,
338 required: true,
339 }
340 }
341}
342
343#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
344#[serde(rename_all = "kebab-case")]
345pub enum ApprovalAction {
346 #[default]
347 HighRiskTool,
348 Network,
349 SecretAccess,
350 OutsideWorkspaceWrite,
351 DestructiveShell,
352 DependencyChange,
353 SchemaMigration,
354 Deployment,
355}
356
357#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
358#[serde(default)]
359pub struct TrustScope {
360 pub allow_external_context: bool,
361 pub allow_durable_memory_writes: bool,
362 pub low_trust_requires_review: bool,
363}
364
365impl Default for TrustScope {
366 fn default() -> Self {
367 Self {
368 allow_external_context: true,
369 allow_durable_memory_writes: true,
370 low_trust_requires_review: true,
371 }
372 }
373}
374
375#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
376#[serde(default)]
377pub struct CloseoutCriteria {
378 pub require_summary: bool,
379 pub require_evidence_packet: bool,
380 pub require_no_unresolved_required_verification: bool,
381 pub criteria: Vec<String>,
382}
383
384impl Default for CloseoutCriteria {
385 fn default() -> Self {
386 Self {
387 require_summary: true,
388 require_evidence_packet: false,
389 require_no_unresolved_required_verification: true,
390 criteria: Vec::new(),
391 }
392 }
393}
394
395fn workspace_scope_for_cwd(cwd: &Path) -> WorkspaceScope {
396 match std::fs::canonicalize(cwd) {
397 Ok(root) => WorkspaceScope::Repository { root },
398 Err(_) => WorkspaceScope::Repository {
399 root: cwd.to_path_buf(),
400 },
401 }
402}
403
404fn normalize_tool_name(tool: String) -> String {
405 tool.trim().to_ascii_lowercase()
406}
407
408fn title_from_objective(objective: &str) -> Option<String> {
409 let title = objective.lines().next().unwrap_or_default().trim();
410 if title.is_empty() {
411 None
412 } else {
413 Some(title.chars().take(80).collect())
414 }
415}
416
417#[cfg(test)]
418mod tests {
419 use super::*;
420
421 #[test]
422 fn workflow_contract_defaults_are_safe_and_lightweight() {
423 let contract = WorkflowContract::implicit("Fix the failing auth test");
424 assert_eq!(contract.objective, "Fix the failing auth test");
425 assert_eq!(contract.title.as_deref(), Some("Fix the failing auth test"));
426 assert_eq!(contract.workflow_type, WorkflowType::AdHoc);
427 assert_eq!(contract.risk_level, RiskLevel::Unknown);
428 assert_eq!(contract.autonomy_mode, AutonomyMode::Safe);
429 assert_eq!(contract.workspace_scope, WorkspaceScope::CurrentDirectory);
430 assert!(contract.tool_permissions.allows_all_by_default());
431 assert!(contract.required_verification.is_empty());
432 assert!(contract.closeout_criteria.require_summary);
433 }
434
435 #[test]
436 fn implicit_workflow_contract_uses_run_context() {
437 let temp = tempfile::TempDir::new().unwrap();
438 let contract = WorkflowContract::implicit_from(
439 ImplicitWorkflowContractInput::prompt("Fix login tests")
440 .cwd(temp.path())
441 .autonomy_mode(AutonomyMode::LocalAuto)
442 .workflow_type(WorkflowType::CodeChange)
443 .risk_level(RiskLevel::Medium),
444 );
445
446 assert_eq!(contract.objective, "Fix login tests");
447 assert_eq!(contract.title.as_deref(), Some("Fix login tests"));
448 assert_eq!(contract.autonomy_mode, AutonomyMode::LocalAuto);
449 assert_eq!(contract.workflow_type, WorkflowType::CodeChange);
450 assert_eq!(contract.risk_level, RiskLevel::Medium);
451 assert!(matches!(
452 contract.workspace_scope,
453 WorkspaceScope::Repository { .. }
454 ));
455 assert!(contract.tool_permissions.allows_all_by_default());
456 assert!(contract.closeout_criteria.require_summary);
457 }
458
459 #[test]
460 fn implicit_workflow_contract_records_mana_unit_ref() {
461 let contract = WorkflowContract::implicit_from(
462 ImplicitWorkflowContractInput::prompt("Implement mana task").mana_unit_ref("394.2.2"),
463 );
464
465 assert_eq!(contract.mana_unit_ref.as_deref(), Some("394.2.2"));
466 assert_eq!(contract.objective, "Implement mana task");
467 assert_eq!(contract.autonomy_mode, AutonomyMode::Safe);
468 assert_eq!(contract.risk_level, RiskLevel::Unknown);
469 }
470
471 #[test]
472 fn workflow_contract_serializes_with_kebab_case_modes() {
473 let contract = WorkflowContract::implicit("Refactor parser")
474 .with_autonomy_mode(AutonomyMode::LocalAuto)
475 .with_workspace_scope(WorkspaceScope::Repository {
476 root: PathBuf::from("/tmp/repo"),
477 });
478
479 let json = serde_json::to_string(&contract).expect("serialize contract");
480 assert!(json.contains("local-auto"));
481 let decoded: WorkflowContract = serde_json::from_str(&json).expect("deserialize contract");
482 assert_eq!(decoded, contract);
483 }
484
485 #[test]
486 fn autonomy_modes_have_canonical_names_and_safe_default() {
487 assert_eq!(AutonomyMode::default(), AutonomyMode::Safe);
488 let names: Vec<_> = AutonomyMode::ALL
489 .iter()
490 .map(|mode| mode.canonical_name())
491 .collect();
492 assert_eq!(
493 names,
494 vec![
495 "suggest",
496 "safe",
497 "local-auto",
498 "worktree-auto",
499 "allow-all-local",
500 "allow-all",
501 "ci"
502 ]
503 );
504 for mode in AutonomyMode::ALL {
505 assert_eq!(mode.to_string(), mode.canonical_name());
506 }
507 }
508
509 #[test]
510 fn autonomy_modes_parse_canonical_names_and_aliases() {
511 let cases = [
512 ("suggest", AutonomyMode::Suggest),
513 ("plan", AutonomyMode::Suggest),
514 ("planning", AutonomyMode::Suggest),
515 ("review-only", AutonomyMode::Suggest),
516 ("safe", AutonomyMode::Safe),
517 ("default", AutonomyMode::Safe),
518 ("interactive", AutonomyMode::Safe),
519 ("local", AutonomyMode::LocalAuto),
520 ("local_auto", AutonomyMode::LocalAuto),
521 ("auto-local", AutonomyMode::LocalAuto),
522 ("worktree", AutonomyMode::WorktreeAuto),
523 ("worktree_auto", AutonomyMode::WorktreeAuto),
524 ("auto-worktree", AutonomyMode::WorktreeAuto),
525 ("allow-all-local", AutonomyMode::AllowAllLocal),
526 ("all-local", AutonomyMode::AllowAllLocal),
527 ("local-all", AutonomyMode::AllowAllLocal),
528 ("yolo-local", AutonomyMode::AllowAllLocal),
529 ("allow-all", AutonomyMode::AllowAll),
530 ("all", AutonomyMode::AllowAll),
531 ("yolo", AutonomyMode::AllowAll),
532 ("ci", AutonomyMode::Ci),
533 ("headless", AutonomyMode::Ci),
534 ("noninteractive", AutonomyMode::Ci),
535 ("non-interactive", AutonomyMode::Ci),
536 ];
537
538 for (input, expected) in cases {
539 assert_eq!(input.parse::<AutonomyMode>().unwrap(), expected, "{input}");
540 assert_eq!(
541 input.to_ascii_uppercase().parse::<AutonomyMode>().unwrap(),
542 expected,
543 "uppercase {input}"
544 );
545 }
546 assert!("dangerous".parse::<AutonomyMode>().is_err());
547 }
548
549 #[test]
550 fn autonomy_modes_serde_roundtrip_canonical_names() {
551 for mode in AutonomyMode::ALL {
552 let json = serde_json::to_string(&mode).unwrap();
553 assert_eq!(json, format!("\"{}\"", mode.canonical_name()));
554 let decoded: AutonomyMode = serde_json::from_str(&json).unwrap();
555 assert_eq!(decoded, mode);
556 }
557 }
558
559 #[test]
560 fn autonomy_mode_parses_canonical_names_and_aliases() {
561 assert_eq!("safe".parse::<AutonomyMode>().unwrap(), AutonomyMode::Safe);
562 assert_eq!(
563 "local".parse::<AutonomyMode>().unwrap(),
564 AutonomyMode::LocalAuto
565 );
566 assert_eq!(
567 "worktree-auto".parse::<AutonomyMode>().unwrap(),
568 AutonomyMode::WorktreeAuto
569 );
570 assert_eq!(
571 "yolo".parse::<AutonomyMode>().unwrap(),
572 AutonomyMode::AllowAll
573 );
574 assert!("nope".parse::<AutonomyMode>().is_err());
575 }
576
577 #[test]
578 fn tool_permission_names_are_normalized() {
579 let perms = ToolPermissionSet::default().allow(" Read ").deny(" BASH ");
580 assert!(perms.allowed_tools.contains("read"));
581 assert!(perms.denied_tools.contains("bash"));
582 }
583
584 #[test]
585 fn verification_and_approval_requirements_round_trip() {
586 let contract = WorkflowContract {
587 required_verification: vec![VerificationRequirement::command("cargo test")],
588 approval_requirements: vec![ApprovalRequirement::required(
589 ApprovalAction::Network,
590 "fetch issue details",
591 )],
592 closeout_criteria: CloseoutCriteria {
593 criteria: vec!["targeted tests pass".to_owned()],
594 ..CloseoutCriteria::default()
595 },
596 ..WorkflowContract::implicit("Implement feature")
597 };
598
599 let value = serde_json::to_value(&contract).expect("serialize");
600 let decoded: WorkflowContract = serde_json::from_value(value).expect("deserialize");
601 assert_eq!(decoded, contract);
602 }
603}