@lamentis/naome 1.4.0 → 1.4.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/Cargo.lock +2 -2
  2. package/README.md +17 -122
  3. package/crates/naome-cli/Cargo.toml +1 -1
  4. package/crates/naome-cli/src/main.rs +13 -0
  5. package/crates/naome-cli/src/task_commands/can_edit.rs +116 -0
  6. package/crates/naome-cli/src/task_commands/check_run/output.rs +34 -0
  7. package/crates/naome-cli/src/task_commands/check_run/receipts.rs +155 -0
  8. package/crates/naome-cli/src/task_commands/check_run/verification.rs +165 -0
  9. package/crates/naome-cli/src/task_commands/check_run.rs +192 -0
  10. package/crates/naome-cli/src/task_commands/common.rs +70 -0
  11. package/crates/naome-cli/src/task_commands/complete.rs +43 -0
  12. package/crates/naome-cli/src/task_commands/loop_control.rs +55 -0
  13. package/crates/naome-cli/src/task_commands/readiness.rs +44 -0
  14. package/crates/naome-cli/src/task_commands/record.rs +236 -0
  15. package/crates/naome-cli/src/task_commands/repair.rs +77 -0
  16. package/crates/naome-cli/src/task_commands/scope_request.rs +24 -0
  17. package/crates/naome-cli/src/task_commands/timeline.rs +71 -0
  18. package/crates/naome-cli/src/task_commands.rs +80 -1
  19. package/crates/naome-cli/tests/task_cli.rs +58 -0
  20. package/crates/naome-cli/tests/task_cli_agent_controls.rs +210 -0
  21. package/crates/naome-cli/tests/task_cli_control.rs +126 -0
  22. package/crates/naome-cli/tests/task_cli_loop.rs +383 -0
  23. package/crates/naome-cli/tests/task_cli_loop_edit.rs +144 -0
  24. package/crates/naome-cli/tests/task_cli_support/mod.rs +178 -0
  25. package/crates/naome-core/Cargo.toml +1 -1
  26. package/crates/naome-core/src/lib.rs +7 -2
  27. package/crates/naome-core/src/task_state/evidence_fingerprint.rs +47 -0
  28. package/crates/naome-core/src/task_state/mod.rs +12 -0
  29. package/crates/naome-core/src/task_state/status/agent_model.rs +76 -0
  30. package/crates/naome-core/src/task_state/status/control/action.rs +87 -0
  31. package/crates/naome-core/src/task_state/status/control/exit_code.rs +32 -0
  32. package/crates/naome-core/src/task_state/status/control/loop_state.rs +70 -0
  33. package/crates/naome-core/src/task_state/status/control/policy.rs +31 -0
  34. package/crates/naome-core/src/task_state/status/control/proof_recording.rs +25 -0
  35. package/crates/naome-core/src/task_state/status/control/recovery.rs +19 -0
  36. package/crates/naome-core/src/task_state/status/control/repair.rs +125 -0
  37. package/crates/naome-core/src/task_state/status/control/shared.rs +25 -0
  38. package/crates/naome-core/src/task_state/status/control.rs +16 -0
  39. package/crates/naome-core/src/task_state/status/git.rs +133 -0
  40. package/crates/naome-core/src/task_state/status/model.rs +152 -0
  41. package/crates/naome-core/src/task_state/status/proof.rs +217 -0
  42. package/crates/naome-core/src/task_state/status/proof_read.rs +164 -0
  43. package/crates/naome-core/src/task_state/status/report.rs +148 -0
  44. package/crates/naome-core/src/task_state/status/report_context.rs +148 -0
  45. package/crates/naome-core/src/task_state/status/report_support.rs +117 -0
  46. package/crates/naome-core/src/task_state/status/scope.rs +111 -0
  47. package/crates/naome-core/src/task_state/status/transition.rs +101 -0
  48. package/crates/naome-core/src/task_state/status.rs +23 -0
  49. package/crates/naome-core/src/task_state/status_output.rs +103 -0
  50. package/crates/naome-core/tests/task_state_support/mod.rs +15 -1
  51. package/crates/naome-core/tests/task_state_support/states.rs +4 -0
  52. package/crates/naome-core/tests/task_status.rs +423 -0
  53. package/crates/naome-core/tests/task_status_git.rs +141 -0
  54. package/installer/context.js +1 -1
  55. package/installer/harness-verification.js +2 -6
  56. package/installer/manifest-state.js +2 -2
  57. package/installer/native.js +3 -31
  58. package/native/darwin-arm64/naome +0 -0
  59. package/native/linux-x64/naome +0 -0
  60. package/package.json +1 -1
  61. package/templates/naome-root/.naome/bin/check-harness-health.js +2 -2
  62. package/templates/naome-root/.naome/bin/check-task-state.js +4 -39
  63. package/templates/naome-root/.naome/bin/naome.js +2 -30
  64. package/templates/naome-root/.naome/manifest.json +2 -2
@@ -0,0 +1,77 @@
1
+ use std::path::Path;
2
+
3
+ use naome_core::task_status_report;
4
+ use serde_json::json;
5
+
6
+ use super::common::{agent_session, print_json_with_session, value_after};
7
+
8
+ pub(super) fn repair_preview(
9
+ root: &Path,
10
+ args: &[String],
11
+ ) -> Result<(), Box<dyn std::error::Error>> {
12
+ let session = agent_session(args)?;
13
+ let dry_run = args.iter().any(|arg| arg == "--dry-run");
14
+ let execute_safe = args.iter().any(|arg| arg == "--execute-safe");
15
+ if dry_run && execute_safe {
16
+ return Err("naome task repair accepts only one of --dry-run or --execute-safe".into());
17
+ }
18
+ if !dry_run && !execute_safe {
19
+ return Err("naome task repair requires --dry-run or --execute-safe".into());
20
+ }
21
+ let plan_id = value_after(args, "--plan").ok_or("naome task repair requires --plan <id>")?;
22
+ let status = task_status_report(root)?;
23
+ let plan = status
24
+ .repair_plan
25
+ .iter()
26
+ .find(|item| item.id == plan_id)
27
+ .cloned();
28
+ let mut steps = Vec::new();
29
+ let mut executed = false;
30
+ let mut requires_user_approval = false;
31
+ if execute_safe {
32
+ if let Some(plan) = &plan {
33
+ if !can_execute_safe(plan) {
34
+ requires_user_approval = true;
35
+ } else {
36
+ match plan.kind.as_str() {
37
+ "rerun_check" if plan.check_ids.len() == 1 => {
38
+ steps.push(super::check_run::run_check_by_id(
39
+ root,
40
+ &plan.check_ids[0],
41
+ false,
42
+ session.as_deref(),
43
+ )?);
44
+ executed = true;
45
+ }
46
+ "record_proof" => {
47
+ steps.push(super::record::record_proof_value(root, session.as_deref())?);
48
+ executed = true;
49
+ }
50
+ _ => {
51
+ requires_user_approval = true;
52
+ }
53
+ }
54
+ }
55
+ }
56
+ }
57
+ print_json_with_session(
58
+ json!({
59
+ "schema": if dry_run { "naome.task.repair-preview.v1" } else { "naome.task.repair-execute.v1" },
60
+ "planId": plan_id,
61
+ "found": plan.is_some(),
62
+ "wouldExecute": dry_run && plan.as_ref().is_some_and(can_execute_safe),
63
+ "executed": executed,
64
+ "requiresUserApproval": requires_user_approval || plan.as_ref().is_some_and(|item| item.requires_user_approval),
65
+ "plan": plan,
66
+ "steps": steps,
67
+ "agentInstruction": if executed { "Executed only NAOME safe check/proof repair steps." } else { "Review this output; unsafe repair plans require human approval." }
68
+ }),
69
+ session.as_deref(),
70
+ )
71
+ }
72
+
73
+ fn can_execute_safe(item: &naome_core::RepairPlanItem) -> bool {
74
+ item.safe_to_execute
75
+ && !item.requires_user_approval
76
+ && matches!(item.kind.as_str(), "rerun_check" | "record_proof")
77
+ }
@@ -0,0 +1,24 @@
1
+ use std::path::Path;
2
+
3
+ use serde_json::json;
4
+
5
+ use super::common::{print_json, repeated_values, value_after};
6
+
7
+ pub(super) fn request_scope(
8
+ _root: &Path,
9
+ args: &[String],
10
+ ) -> Result<(), Box<dyn std::error::Error>> {
11
+ let requested_paths = repeated_values(args, "--path");
12
+ if requested_paths.is_empty() {
13
+ return Err("naome task request-scope requires at least one --path".into());
14
+ }
15
+ let reason = value_after(args, "--reason").unwrap_or("No reason provided.");
16
+ print_json(json!({
17
+ "schema": "naome.task.scope-request.v1",
18
+ "wouldMutate": false,
19
+ "requestedPaths": requested_paths,
20
+ "reason": reason,
21
+ "suggestedFix": "Ask for an explicit task-scope revision before editing these paths.",
22
+ "agentInstruction": "Do not edit requested paths until a human or deterministic task revision admits them."
23
+ }))
24
+ }
@@ -0,0 +1,71 @@
1
+ use std::path::Path;
2
+
3
+ use naome_core::task_status_report;
4
+ use serde_json::{json, Value};
5
+
6
+ use super::common::{print_json, read_task_state};
7
+
8
+ pub(super) fn timeline(root: &Path, _args: &[String]) -> Result<(), Box<dyn std::error::Error>> {
9
+ let status = task_status_report(root)?;
10
+ let state = read_task_state(root)?;
11
+ let active = state.get("activeTask").unwrap_or(&Value::Null);
12
+ let mut events = admission_events(active);
13
+ events.extend(proof_events(active));
14
+ events.push(json!({ "type": "current_diff", "paths": status.scope.changed_paths }));
15
+ print_json(json!({
16
+ "schema": "naome.task.timeline.v1",
17
+ "state": status.state,
18
+ "taskId": status.task_id,
19
+ "events": events
20
+ }))
21
+ }
22
+
23
+ pub(super) fn loop_snapshot(
24
+ root: &Path,
25
+ _args: &[String],
26
+ ) -> Result<(), Box<dyn std::error::Error>> {
27
+ let status = task_status_report(root)?;
28
+ print_json(json!({
29
+ "schema": "naome.task.loop-snapshot.v1",
30
+ "taskId": status.task_id,
31
+ "state": status.state,
32
+ "status": {
33
+ "agentLoop": status.agent_loop,
34
+ "nextActionV2": status.next_action_v2,
35
+ "findings": status.findings,
36
+ "repairPlan": status.repair_plan,
37
+ "policyHints": status.policy_hints,
38
+ "recoveryGuidance": status.recovery_guidance
39
+ },
40
+ "proof": status.proof
41
+ }))
42
+ }
43
+
44
+ fn admission_events(active: &Value) -> Vec<Value> {
45
+ active
46
+ .get("admission")
47
+ .map(|admission| {
48
+ vec![json!({
49
+ "type": "admission",
50
+ "gitHead": admission.get("gitHead").cloned().unwrap_or(Value::Null),
51
+ "checkedAt": admission.get("checkedAt").cloned().unwrap_or(Value::Null)
52
+ })]
53
+ })
54
+ .unwrap_or_default()
55
+ }
56
+
57
+ fn proof_events(active: &Value) -> Vec<Value> {
58
+ active
59
+ .get("proofBatches")
60
+ .and_then(Value::as_array)
61
+ .into_iter()
62
+ .flatten()
63
+ .map(|batch| {
64
+ json!({
65
+ "type": "proof_batch",
66
+ "id": batch.get("id").cloned().unwrap_or(Value::Null),
67
+ "checkedAt": batch.get("checkedAt").cloned().unwrap_or(Value::Null)
68
+ })
69
+ })
70
+ .collect()
71
+ }
@@ -1,15 +1,94 @@
1
1
  use std::path::Path;
2
2
 
3
- use naome_core::{migrate_task_state_to_ledger, render_task_state_from_ledger};
3
+ mod can_edit;
4
+ mod check_run;
5
+ mod common;
6
+ mod complete;
7
+ mod loop_control;
8
+ mod readiness;
9
+ mod record;
10
+ mod repair;
11
+ mod scope_request;
12
+ mod timeline;
13
+
14
+ use naome_core::{
15
+ format_task_proof_plan, format_task_status, migrate_task_state_to_ledger,
16
+ render_task_state_from_ledger, task_proof_plan, task_status_exit_code, task_status_report,
17
+ task_transition_readiness,
18
+ };
4
19
 
5
20
  pub fn run_task_command(root: &Path, args: &[String]) -> Result<(), Box<dyn std::error::Error>> {
6
21
  match args.get(1).map(String::as_str) {
7
22
  Some("render-state") => render_state(root, args),
8
23
  Some("migrate-ledger") => migrate_ledger(root, args),
24
+ Some("status") => task_status(root, args),
25
+ Some("proof-plan") => proof_plan(root, args),
26
+ Some("can-edit") => can_edit::can_edit(root, args),
27
+ Some("run-check") => check_run::run_check_command(root, args),
28
+ Some("can-transition") => can_transition(root, args),
29
+ Some("repair") => repair::repair_preview(root, args),
30
+ Some("record-proof") => record::record_proof(root, args),
31
+ Some("complete") => complete::complete_task(root, args),
32
+ Some("loop") => loop_control::task_loop(root, args),
33
+ Some("request-scope") => scope_request::request_scope(root, args),
34
+ Some("can-commit") => readiness::can_commit(root, args),
35
+ Some("timeline") => timeline::timeline(root, args),
36
+ Some("loop-snapshot") => timeline::loop_snapshot(root, args),
9
37
  _ => Err("unknown task command".into()),
10
38
  }
11
39
  }
12
40
 
41
+ fn task_status(root: &Path, args: &[String]) -> Result<(), Box<dyn std::error::Error>> {
42
+ let session = common::agent_session(args)?;
43
+ let report = task_status_report(root)?;
44
+ if args.iter().any(|arg| arg == "--json") {
45
+ common::print_json_with_session(serde_json::to_value(&report)?, session.as_deref())?;
46
+ } else {
47
+ print!("{}", format_task_status(&report));
48
+ }
49
+ exit_if_requested(args, task_status_exit_code(&report.findings, &report.proof));
50
+ Ok(())
51
+ }
52
+
53
+ fn proof_plan(root: &Path, args: &[String]) -> Result<(), Box<dyn std::error::Error>> {
54
+ let session = common::agent_session(args)?;
55
+ let report = task_proof_plan(root)?;
56
+ if args.iter().any(|arg| arg == "--json") {
57
+ common::print_json_with_session(serde_json::to_value(&report)?, session.as_deref())?;
58
+ } else {
59
+ print!("{}", format_task_proof_plan(&report));
60
+ }
61
+ exit_if_requested(args, task_status_exit_code(&report.findings, &report.proof));
62
+ Ok(())
63
+ }
64
+
65
+ fn can_transition(root: &Path, args: &[String]) -> Result<(), Box<dyn std::error::Error>> {
66
+ let session = common::agent_session(args)?;
67
+ let Some(target) = args
68
+ .windows(2)
69
+ .find(|window| window[0] == "--to")
70
+ .map(|window| window[1].as_str())
71
+ else {
72
+ return Err("naome task can-transition requires --to complete".into());
73
+ };
74
+ let report = task_transition_readiness(root, target)?;
75
+ if args.iter().any(|arg| arg == "--json") {
76
+ common::print_json_with_session(serde_json::to_value(&report)?, session.as_deref())?;
77
+ } else {
78
+ println!(
79
+ "NAOME task transition {target}: {}",
80
+ if report.allowed { "allowed" } else { "blocked" }
81
+ );
82
+ }
83
+ Ok(())
84
+ }
85
+
86
+ fn exit_if_requested(args: &[String], code: i32) {
87
+ if args.iter().any(|arg| arg == "--exit-code") {
88
+ std::process::exit(code);
89
+ }
90
+ }
91
+
13
92
  fn render_state(root: &Path, args: &[String]) -> Result<(), Box<dyn std::error::Error>> {
14
93
  let write = args.iter().any(|arg| arg == "--write");
15
94
  let json = args.iter().any(|arg| arg == "--json");
@@ -0,0 +1,58 @@
1
+ use std::process::Command;
2
+
3
+ use serde_json::{json, Value};
4
+
5
+ mod task_cli_support;
6
+
7
+ use task_cli_support::{fixture_root, init_git, task_state, write_fixture_file};
8
+
9
+ #[test]
10
+ fn task_status_json_reports_scope_and_next_action() {
11
+ let root = fixture_root(task_state());
12
+ init_git(&root);
13
+ write_fixture_file(&root, "README.md", "changed\n");
14
+
15
+ let output = Command::new(env!("CARGO_BIN_EXE_naome"))
16
+ .args(["task", "status", "--json"])
17
+ .current_dir(&root)
18
+ .output()
19
+ .unwrap();
20
+
21
+ assert_success(&output);
22
+ let payload: Value = serde_json::from_slice(&output.stdout).unwrap();
23
+ assert_eq!(payload["schema"], "naome.task.status.v1");
24
+ assert_eq!(
25
+ payload["scope"]["inScopeChangedPaths"],
26
+ json!(["README.md"])
27
+ );
28
+ assert_eq!(payload["nextActionV2"]["type"], "rerun_checks");
29
+ assert_eq!(payload["agentLoop"]["state"], "blocked_by_missing_proof");
30
+ }
31
+
32
+ #[test]
33
+ fn task_proof_plan_human_output_lists_recommended_commands() {
34
+ let root = fixture_root(task_state());
35
+ init_git(&root);
36
+ write_fixture_file(&root, "README.md", "changed\n");
37
+
38
+ let output = Command::new(env!("CARGO_BIN_EXE_naome"))
39
+ .args(["task", "proof-plan"])
40
+ .current_dir(root)
41
+ .output()
42
+ .unwrap();
43
+
44
+ assert_success(&output);
45
+ let stdout = String::from_utf8_lossy(&output.stdout);
46
+ assert!(stdout.contains("agent loop: blocked_by_missing_proof"));
47
+ assert!(stdout.contains("recommended commands:"));
48
+ assert!(stdout.contains("diff-check: git diff --check"));
49
+ }
50
+
51
+ fn assert_success(output: &std::process::Output) {
52
+ assert!(
53
+ output.status.success(),
54
+ "{}{}",
55
+ String::from_utf8_lossy(&output.stdout),
56
+ String::from_utf8_lossy(&output.stderr)
57
+ );
58
+ }
@@ -0,0 +1,210 @@
1
+ use std::fs;
2
+
3
+ use serde_json::{json, Value};
4
+
5
+ mod task_cli_support;
6
+
7
+ use task_cli_support::{fixture_root, init_git, run_json, task_state, write_fixture_file};
8
+
9
+ #[test]
10
+ fn status_json_exposes_policy_hints_and_recovery_guidance() {
11
+ let root = fixture_root(task_state());
12
+ init_git(&root);
13
+ write_fixture_file(&root, "README.md", "changed\n");
14
+
15
+ let status = run_json(&root, ["task", "status", "--json"]);
16
+
17
+ assert_eq!(status["policyHints"]["mayEdit"], json!(["README.md"]));
18
+ assert_eq!(status["taskMode"]["kind"], "standard");
19
+ assert_eq!(
20
+ status["policyHints"]["mustRunAfterEdit"],
21
+ json!(["diff-check"])
22
+ );
23
+ assert!(status["policyHints"]["forbiddenActions"]
24
+ .as_array()
25
+ .unwrap()
26
+ .iter()
27
+ .any(|item| item == "Do not bypass the NAOME commit gate."));
28
+ assert!(status["recoveryGuidance"].as_array().unwrap().is_empty());
29
+ }
30
+
31
+ #[test]
32
+ fn repair_dry_run_previews_selected_safe_plan() {
33
+ let root = fixture_root(task_state());
34
+ init_git(&root);
35
+ write_fixture_file(&root, "src/lib.rs", "outside\n");
36
+ write_fixture_file(&root, "docs/outside.md", "outside\n");
37
+
38
+ let status = run_json(&root, ["task", "status", "--json"]);
39
+ let repair_ids = status["repairPlan"]
40
+ .as_array()
41
+ .unwrap()
42
+ .iter()
43
+ .filter_map(|item| item["id"].as_str())
44
+ .collect::<Vec<_>>();
45
+ assert!(repair_ids.contains(&"remove_out_of_scope_change_src_lib_rs"));
46
+ assert!(repair_ids.contains(&"remove_out_of_scope_change_docs_outside_md"));
47
+
48
+ let preview = run_json(
49
+ &root,
50
+ [
51
+ "task",
52
+ "repair",
53
+ "--plan",
54
+ "remove_out_of_scope_change_src_lib_rs",
55
+ "--dry-run",
56
+ "--json",
57
+ ],
58
+ );
59
+
60
+ assert_eq!(preview["schema"], "naome.task.repair-preview.v1");
61
+ assert_eq!(preview["found"], true);
62
+ assert_eq!(preview["wouldExecute"], false);
63
+ assert_eq!(preview["plan"]["kind"], "git_revert_path");
64
+ assert_eq!(preview["plan"]["paths"], json!(["src/lib.rs"]));
65
+ }
66
+
67
+ #[test]
68
+ fn record_proof_from_plan_writes_compact_batch() {
69
+ let root = fixture_root(task_cli_support::task_state_with_active_task(
70
+ task_cli_support::active_task(json!({
71
+ "proofPathSets": {
72
+ "current-task-diff": ["OLD.md"]
73
+ },
74
+ "proofBatches": [{
75
+ "id": "cli-task-proof",
76
+ "checkedAt": "2026-05-04T12:00:00.000Z",
77
+ "evidencePathSet": "current-task-diff",
78
+ "proofs": [{ "checkId": "diff-check", "exitCode": 0 }]
79
+ }]
80
+ })),
81
+ ));
82
+ init_git(&root);
83
+ write_fixture_file(&root, "README.md", "changed\n");
84
+
85
+ let check = run_json(
86
+ &root,
87
+ ["task", "run-check", "--check", "diff-check", "--json"],
88
+ );
89
+ assert_eq!(check["schema"], "naome.task.run-check.v1");
90
+ assert_eq!(check["executed"], true);
91
+ assert_eq!(check["recordedProof"], false);
92
+
93
+ let recorded = run_json(
94
+ &root,
95
+ ["task", "record-proof", "--from-proof-plan", "--json"],
96
+ );
97
+
98
+ assert_eq!(recorded["schema"], "naome.task.record-proof.v1");
99
+ assert_eq!(recorded["recorded"], true);
100
+ assert_eq!(recorded["checksRecorded"], json!(["diff-check"]));
101
+ assert_eq!(recorded["pathSetId"], "current-task-diff-2");
102
+ assert_eq!(recorded["proofBatchId"], "cli-task-proof-2");
103
+
104
+ let task_state: Value =
105
+ serde_json::from_str(&fs::read_to_string(root.join(".naome/task-state.json")).unwrap())
106
+ .unwrap();
107
+ assert_eq!(
108
+ task_state["activeTask"]["proofPathSets"]["current-task-diff"],
109
+ json!(["OLD.md"])
110
+ );
111
+ assert_eq!(
112
+ task_state["activeTask"]["proofPathSets"]["current-task-diff-2"],
113
+ json!(["README.md"])
114
+ );
115
+ assert_eq!(
116
+ task_state["activeTask"]["proofBatches"][1]["id"],
117
+ "cli-task-proof-2"
118
+ );
119
+ assert_eq!(
120
+ task_state["activeTask"]["proofBatches"][1]["proofs"][0]["checkId"],
121
+ "diff-check"
122
+ );
123
+ }
124
+
125
+ #[test]
126
+ fn rerun_check_repair_preserves_configured_cwd() {
127
+ let root = fixture_root(task_state());
128
+ init_git(&root);
129
+ let mut verification: Value =
130
+ serde_json::from_str(&fs::read_to_string(root.join(".naome/verification.json")).unwrap())
131
+ .unwrap();
132
+ verification["checks"][0]["cwd"] = json!("checks");
133
+ task_cli_support::write_json(&root, ".naome/verification.json", &verification);
134
+ write_fixture_file(&root, "README.md", "changed\n");
135
+
136
+ let plan = run_json(&root, ["task", "proof-plan", "--json"]);
137
+ let rerun = plan["repairPlan"]
138
+ .as_array()
139
+ .unwrap()
140
+ .iter()
141
+ .find(|item| item["id"] == "rerun_diff-check")
142
+ .unwrap();
143
+
144
+ assert_eq!(rerun["cwd"], "checks");
145
+ assert_eq!(rerun["commands"], json!(["git diff --check"]));
146
+ }
147
+
148
+ #[test]
149
+ fn agent_control_commands_emit_stable_json_contracts() {
150
+ let root = fixture_root(task_state());
151
+ init_git(&root);
152
+ write_fixture_file(&root, "README.md", "changed\n");
153
+
154
+ let scope = run_json(
155
+ &root,
156
+ [
157
+ "task",
158
+ "request-scope",
159
+ "--path",
160
+ "src/lib.rs",
161
+ "--reason",
162
+ "Need to update implementation.",
163
+ "--json",
164
+ ],
165
+ );
166
+ assert_eq!(scope["schema"], "naome.task.scope-request.v1");
167
+ assert_eq!(scope["wouldMutate"], false);
168
+ assert_eq!(scope["requestedPaths"], json!(["src/lib.rs"]));
169
+
170
+ let can_commit = run_json(&root, ["task", "can-commit", "--json"]);
171
+ assert_eq!(can_commit["schema"], "naome.task.commit-readiness.v1");
172
+ assert_eq!(can_commit["allowed"], false);
173
+ assert!(can_commit["requiredBeforeCommit"]
174
+ .as_array()
175
+ .unwrap()
176
+ .iter()
177
+ .any(|item| item.as_str().unwrap().contains("required check")));
178
+
179
+ let timeline = run_json(&root, ["task", "timeline", "--json"]);
180
+ assert_eq!(timeline["schema"], "naome.task.timeline.v1");
181
+ assert!(timeline["events"].as_array().unwrap().len() >= 2);
182
+
183
+ let snapshot = run_json(&root, ["task", "loop-snapshot", "--json"]);
184
+ assert_eq!(snapshot["schema"], "naome.task.loop-snapshot.v1");
185
+ assert_eq!(
186
+ snapshot["status"]["agentLoop"]["state"],
187
+ "blocked_by_missing_proof"
188
+ );
189
+ }
190
+
191
+ #[test]
192
+ fn review_fix_mode_is_structured_not_inferred_from_prompt_text() {
193
+ let root = fixture_root(task_cli_support::task_state_with_active_task(
194
+ task_cli_support::active_task(json!({
195
+ "kind": "review_fix",
196
+ "declaredChangeTypes": ["review-fix"],
197
+ "proofResults": []
198
+ })),
199
+ ));
200
+ init_git(&root);
201
+
202
+ let status = run_json(&root, ["task", "status", "--json"]);
203
+
204
+ assert_eq!(status["taskMode"]["kind"], "review_fix");
205
+ assert_eq!(status["taskMode"]["reviewFix"], true);
206
+ assert!(status["taskMode"]["scopePolicy"]
207
+ .as_str()
208
+ .unwrap()
209
+ .contains("explicit allowedPaths"));
210
+ }
@@ -0,0 +1,126 @@
1
+ use std::fs;
2
+ use std::process::Command;
3
+
4
+ use serde_json::{json, Value};
5
+
6
+ mod task_cli_support;
7
+
8
+ use task_cli_support::{
9
+ active_task, fixture_root, init_git, successful_proof, task_state, task_state_with_active_task,
10
+ write_fixture_file, write_json,
11
+ };
12
+
13
+ #[test]
14
+ fn exit_code_reports_missing_stale_scope_git_and_conflict_states() {
15
+ assert_exit_code(
16
+ task_state(),
17
+ |root| write_fixture_file(root, "README.md", "changed\n"),
18
+ 10,
19
+ );
20
+
21
+ assert_exit_code(
22
+ stale_task_state(),
23
+ |root| {
24
+ write_fixture_file(root, "README.md", "covered\n");
25
+ write_fixture_file(root, "docs/task.md", "stale\n");
26
+ },
27
+ 11,
28
+ );
29
+
30
+ assert_exit_code(
31
+ task_state(),
32
+ |root| write_fixture_file(root, "src/lib.rs", "x\n"),
33
+ 20,
34
+ );
35
+
36
+ assert_exit_code(
37
+ task_state(),
38
+ |root| {
39
+ set_admission_head(root, "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa");
40
+ },
41
+ 30,
42
+ );
43
+
44
+ assert_exit_code(
45
+ task_state(),
46
+ |root| {
47
+ fs::write(
48
+ root.join(".naome/task-state.json"),
49
+ "<<<<<<< HEAD\n{}\n=======\n{}\n>>>>>>> branch\n",
50
+ )
51
+ .unwrap();
52
+ },
53
+ 40,
54
+ );
55
+ }
56
+
57
+ #[test]
58
+ fn can_transition_blocks_missing_proof_and_allows_fresh_proof() {
59
+ let blocked = run_transition(task_state(), |root| {
60
+ write_fixture_file(root, "README.md", "changed\n");
61
+ });
62
+ assert_eq!(blocked["schema"], "naome.task.transition-readiness.v1");
63
+ assert_eq!(blocked["allowed"], false);
64
+ assert!(blocked["blockingFindings"]
65
+ .as_array()
66
+ .unwrap()
67
+ .iter()
68
+ .any(|finding| { finding["id"] == "task.proof.missing_check" }));
69
+
70
+ let allowed = run_transition(fresh_task_state(), |root| {
71
+ write_fixture_file(root, "README.md", "changed\n");
72
+ });
73
+ assert_eq!(allowed["allowed"], true);
74
+ assert_eq!(allowed["agentLoop"]["state"], "ready_to_commit");
75
+ }
76
+
77
+ fn assert_exit_code(state: Value, mutate: impl FnOnce(&std::path::Path), expected: i32) {
78
+ let root = fixture_root(state);
79
+ init_git(&root);
80
+ mutate(&root);
81
+ let output = Command::new(env!("CARGO_BIN_EXE_naome"))
82
+ .args(["task", "status", "--json", "--exit-code"])
83
+ .current_dir(root)
84
+ .output()
85
+ .unwrap();
86
+ assert_eq!(output.status.code(), Some(expected));
87
+ }
88
+
89
+ fn run_transition(state: Value, mutate: impl FnOnce(&std::path::Path)) -> Value {
90
+ let root = fixture_root(state);
91
+ init_git(&root);
92
+ mutate(&root);
93
+ let output = Command::new(env!("CARGO_BIN_EXE_naome"))
94
+ .args(["task", "can-transition", "--to", "complete", "--json"])
95
+ .current_dir(root)
96
+ .output()
97
+ .unwrap();
98
+ assert!(output.status.success());
99
+ serde_json::from_slice(&output.stdout).unwrap()
100
+ }
101
+
102
+ fn stale_task_state() -> Value {
103
+ task_state_with_active_task(active_task(json!({
104
+ "allowedPaths": ["README.md", "docs/**"],
105
+ "proofPathSets": { "old": ["README.md"] },
106
+ "proofBatches": [{
107
+ "id": "old-proof",
108
+ "checkedAt": "2026-05-04T12:00:00.000Z",
109
+ "evidencePathSet": "old",
110
+ "proofs": [{ "checkId": "diff-check", "exitCode": 0 }]
111
+ }]
112
+ })))
113
+ }
114
+
115
+ fn fresh_task_state() -> Value {
116
+ task_state_with_active_task(active_task(json!({
117
+ "proofResults": [successful_proof(json!(["README.md"]))]
118
+ })))
119
+ }
120
+
121
+ fn set_admission_head(root: &std::path::Path, head: &str) {
122
+ let path = root.join(".naome/task-state.json");
123
+ let mut state: Value = serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
124
+ state["activeTask"]["admission"]["gitHead"] = json!(head);
125
+ write_json(root, ".naome/task-state.json", &state);
126
+ }