@lamentis/naome 1.3.17 → 1.4.1

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 (55) hide show
  1. package/Cargo.lock +2 -2
  2. package/crates/naome-cli/Cargo.toml +1 -1
  3. package/crates/naome-cli/src/architecture_commands.rs +16 -3
  4. package/crates/naome-cli/src/main.rs +10 -1
  5. package/crates/naome-cli/src/task_commands/common.rs +32 -0
  6. package/crates/naome-cli/src/task_commands/readiness.rs +40 -0
  7. package/crates/naome-cli/src/task_commands/record.rs +134 -0
  8. package/crates/naome-cli/src/task_commands/repair.rs +30 -0
  9. package/crates/naome-cli/src/task_commands/scope_request.rs +24 -0
  10. package/crates/naome-cli/src/task_commands/timeline.rs +71 -0
  11. package/crates/naome-cli/src/task_commands.rs +69 -1
  12. package/crates/naome-cli/tests/task_cli.rs +58 -0
  13. package/crates/naome-cli/tests/task_cli_agent_controls.rs +217 -0
  14. package/crates/naome-cli/tests/task_cli_control.rs +126 -0
  15. package/crates/naome-cli/tests/task_cli_support/mod.rs +150 -0
  16. package/crates/naome-core/Cargo.toml +1 -1
  17. package/crates/naome-core/src/architecture/output.rs +196 -0
  18. package/crates/naome-core/src/architecture/scan/cache.rs +1 -1
  19. package/crates/naome-core/src/architecture.rs +1 -0
  20. package/crates/naome-core/src/lib.rs +15 -9
  21. package/crates/naome-core/src/task_state/mod.rs +10 -0
  22. package/crates/naome-core/src/task_state/status/agent_model.rs +76 -0
  23. package/crates/naome-core/src/task_state/status/control/action.rs +87 -0
  24. package/crates/naome-core/src/task_state/status/control/exit_code.rs +32 -0
  25. package/crates/naome-core/src/task_state/status/control/loop_state.rs +70 -0
  26. package/crates/naome-core/src/task_state/status/control/policy.rs +31 -0
  27. package/crates/naome-core/src/task_state/status/control/proof_recording.rs +25 -0
  28. package/crates/naome-core/src/task_state/status/control/recovery.rs +19 -0
  29. package/crates/naome-core/src/task_state/status/control/repair.rs +125 -0
  30. package/crates/naome-core/src/task_state/status/control/shared.rs +25 -0
  31. package/crates/naome-core/src/task_state/status/control.rs +16 -0
  32. package/crates/naome-core/src/task_state/status/git.rs +133 -0
  33. package/crates/naome-core/src/task_state/status/model.rs +150 -0
  34. package/crates/naome-core/src/task_state/status/proof.rs +167 -0
  35. package/crates/naome-core/src/task_state/status/proof_read.rs +150 -0
  36. package/crates/naome-core/src/task_state/status/report.rs +148 -0
  37. package/crates/naome-core/src/task_state/status/report_context.rs +126 -0
  38. package/crates/naome-core/src/task_state/status/report_support.rs +117 -0
  39. package/crates/naome-core/src/task_state/status/scope.rs +111 -0
  40. package/crates/naome-core/src/task_state/status/transition.rs +73 -0
  41. package/crates/naome-core/src/task_state/status.rs +23 -0
  42. package/crates/naome-core/src/task_state/status_output.rs +103 -0
  43. package/crates/naome-core/tests/architecture_cache.rs +1 -1
  44. package/crates/naome-core/tests/architecture_config.rs +68 -1
  45. package/crates/naome-core/tests/task_state_support/mod.rs +15 -1
  46. package/crates/naome-core/tests/task_state_support/states.rs +4 -0
  47. package/crates/naome-core/tests/task_status.rs +301 -0
  48. package/crates/naome-core/tests/task_status_git.rs +141 -0
  49. package/native/darwin-arm64/naome +0 -0
  50. package/native/linux-x64/naome +0 -0
  51. package/package.json +1 -1
  52. package/templates/naome-root/.naome/bin/check-harness-health.js +1 -1
  53. package/templates/naome-root/.naome/bin/check-task-state.js +1 -1
  54. package/templates/naome-root/.naome/manifest.json +2 -2
  55. package/templates/naome-root/docs/naome/architecture-fitness.md +23 -23
@@ -0,0 +1,73 @@
1
+ use super::control::agent_loop;
2
+ use super::model::{finding, TaskProofStatus, TaskStatusFinding, TransitionReadinessReport};
3
+ use super::report_context::TaskStatusContext;
4
+
5
+ pub(super) fn transition_report(
6
+ context: TaskStatusContext,
7
+ target_state: &str,
8
+ ) -> TransitionReadinessReport {
9
+ let agent_loop = agent_loop(
10
+ &context.state,
11
+ &context.proof,
12
+ &context.findings,
13
+ &context.scope,
14
+ );
15
+ let blocking_findings = transition_blockers(&context.state, &context.findings, &context.proof);
16
+ let required_before_transition = blocking_findings
17
+ .iter()
18
+ .map(|finding| finding.suggested_fix.clone())
19
+ .collect::<Vec<_>>();
20
+ TransitionReadinessReport {
21
+ schema: "naome.task.transition-readiness.v1".to_string(),
22
+ target_state: target_state.to_string(),
23
+ allowed: blocking_findings.is_empty(),
24
+ blocking_findings,
25
+ required_before_transition,
26
+ agent_loop,
27
+ }
28
+ }
29
+
30
+ fn transition_blockers(
31
+ state: &str,
32
+ findings: &[TaskStatusFinding],
33
+ proof: &TaskProofStatus,
34
+ ) -> Vec<TaskStatusFinding> {
35
+ let mut blockers = findings
36
+ .iter()
37
+ .filter(|finding| {
38
+ finding.severity == "error" || finding.id == "task.state.completed_task_has_diff"
39
+ })
40
+ .cloned()
41
+ .collect::<Vec<_>>();
42
+ if matches!(state, "idle" | "missing") {
43
+ blockers.push(finding(
44
+ "task.transition.no_active_task",
45
+ "error",
46
+ "No active task can transition to complete.",
47
+ None,
48
+ "Create or recover an active task before completing.",
49
+ "Do not complete an idle or missing task.",
50
+ ));
51
+ }
52
+ if matches!(state, "blocked" | "needs_human_review") {
53
+ blockers.push(finding(
54
+ "task.transition.blocked_state",
55
+ "error",
56
+ format!("Task state {state} cannot transition to complete."),
57
+ None,
58
+ "Resolve the blocker or required human review before completing the task.",
59
+ "Do not complete a blocked task state.",
60
+ ));
61
+ }
62
+ if !proof.stale_checks.is_empty() {
63
+ blockers.push(finding(
64
+ "task.transition.stale_proof",
65
+ "error",
66
+ "Task has stale proof.",
67
+ None,
68
+ "Rerun stale checks and record fresh proof.",
69
+ "Do not complete a task with stale proof.",
70
+ ));
71
+ }
72
+ blockers
73
+ }
@@ -0,0 +1,23 @@
1
+ mod agent_model;
2
+ mod control;
3
+ mod git;
4
+ mod model;
5
+ mod proof;
6
+ mod proof_read;
7
+ mod report;
8
+ mod report_context;
9
+ mod report_support;
10
+ mod scope;
11
+ mod transition;
12
+
13
+ pub use agent_model::{
14
+ AgentLoop, NextActionV2, PolicyHints, ProofRecording, ProofRecordingAfterSuccess,
15
+ RecoveryGuidance, RepairPlanItem,
16
+ };
17
+ pub use control::task_status_exit_code;
18
+ pub use model::{
19
+ TaskFeedback, TaskGitStatus, TaskModeStatus, TaskProofPlanReport, TaskProofStatus,
20
+ TaskRecommendedCommand, TaskScopeStatus, TaskStatusFinding, TaskStatusReportV1,
21
+ TransitionReadinessReport,
22
+ };
23
+ pub use report::{task_proof_plan, task_status_report, task_transition_readiness};
@@ -0,0 +1,103 @@
1
+ use super::status::{TaskProofPlanReport, TaskProofStatus, TaskStatusFinding, TaskStatusReportV1};
2
+
3
+ pub fn format_task_status(report: &TaskStatusReportV1) -> String {
4
+ let mut lines = vec![
5
+ format!("NAOME task status {}", report.state),
6
+ format!("agent loop: {}", report.agent_loop.state),
7
+ format!("next machine action: {}", report.next_action_v2.action_type),
8
+ format!("task: {}", report.task_id.as_deref().unwrap_or("<none>")),
9
+ format!("request: {}", report.request.as_deref().unwrap_or("<none>")),
10
+ format!(
11
+ "git head: {}",
12
+ report.git.head.as_deref().unwrap_or("<none>")
13
+ ),
14
+ format!(
15
+ "admission head: {}",
16
+ report.git.admission_head.as_deref().unwrap_or("<none>")
17
+ ),
18
+ format!(
19
+ "admission reachable: {}",
20
+ report.git.admission_head_reachable
21
+ ),
22
+ format!("changed files: {}", report.scope.changed_paths.len()),
23
+ format!(
24
+ "in scope: {}",
25
+ display_list(&report.scope.in_scope_changed_paths)
26
+ ),
27
+ format!(
28
+ "out of scope: {}",
29
+ display_list(&report.scope.out_of_scope_changed_paths)
30
+ ),
31
+ ];
32
+ append_proof_summary(&mut lines, &report.proof);
33
+ append_findings(&mut lines, &report.findings);
34
+ lines.push(format!("next action: {}", report.next_action));
35
+ lines.push(String::new());
36
+ lines.join("\n")
37
+ }
38
+
39
+ pub fn format_task_proof_plan(report: &TaskProofPlanReport) -> String {
40
+ let mut lines = vec![
41
+ format!(
42
+ "NAOME task proof plan {}",
43
+ report.task_id.as_deref().unwrap_or("<none>")
44
+ ),
45
+ format!("agent loop: {}", report.agent_loop.state),
46
+ format!("next machine action: {}", report.next_action_v2.action_type),
47
+ ];
48
+ append_proof_summary(&mut lines, &report.proof);
49
+ if !report.recommended_commands.is_empty() {
50
+ lines.push("recommended commands:".to_string());
51
+ for command in &report.recommended_commands {
52
+ lines.push(format!(
53
+ "- {}: {} (cwd {})",
54
+ command.check_id, command.command, command.cwd
55
+ ));
56
+ }
57
+ }
58
+ append_findings(&mut lines, &report.findings);
59
+ lines.push(format!("next action: {}", report.next_action));
60
+ lines.push(String::new());
61
+ lines.join("\n")
62
+ }
63
+
64
+ fn append_proof_summary(lines: &mut Vec<String>, proof: &TaskProofStatus) {
65
+ lines.push(format!(
66
+ "required checks: {}",
67
+ display_list(&proof.required_checks)
68
+ ));
69
+ lines.push(format!(
70
+ "passed checks: {}",
71
+ display_list(&proof.passed_checks)
72
+ ));
73
+ lines.push(format!(
74
+ "missing checks: {}",
75
+ display_list(&proof.missing_checks)
76
+ ));
77
+ lines.push(format!(
78
+ "stale checks: {}",
79
+ display_list(&proof.stale_checks)
80
+ ));
81
+ }
82
+
83
+ fn append_findings(lines: &mut Vec<String>, findings: &[TaskStatusFinding]) {
84
+ if findings.is_empty() {
85
+ return;
86
+ }
87
+ lines.push("findings:".to_string());
88
+ for finding in findings {
89
+ lines.push(format!(
90
+ "- {} {} {}",
91
+ finding.severity, finding.id, finding.message
92
+ ));
93
+ lines.push(format!(" fix: {}", finding.suggested_fix));
94
+ }
95
+ }
96
+
97
+ fn display_list(values: &[String]) -> String {
98
+ if values.is_empty() {
99
+ "<none>".to_string()
100
+ } else {
101
+ values.join(", ")
102
+ }
103
+ }
@@ -210,7 +210,7 @@ fn changed_only_degrades_when_cache_extractor_version_is_stale() {
210
210
  let cache_path = repo.path().join(".naome/cache/architecture/cache.json");
211
211
  let stale_cache = std::fs::read_to_string(&cache_path)
212
212
  .unwrap()
213
- .replace("architecture-cache-v1.3.17", "architecture-cache-v1.3.14");
213
+ .replace("architecture-cache-v1.4.0", "architecture-cache-v1.3.17");
214
214
  std::fs::write(cache_path, stale_cache).unwrap();
215
215
 
216
216
  let report = validate_architecture(repo.path(), changed_only()).unwrap();
@@ -1,4 +1,7 @@
1
- use naome_core::{format_architecture_validation, validate_architecture, ArchitectureScanOptions};
1
+ use naome_core::{
2
+ architecture_validation_sarif_with_root, format_architecture_validation, validate_architecture,
3
+ ArchitectureScanOptions,
4
+ };
2
5
 
3
6
  mod architecture_support;
4
7
 
@@ -152,3 +155,67 @@ fn validation_reports_unresolved_repo_absolute_imports() {
152
155
  .any(|item| item.contains("unresolved imports"))
153
156
  }));
154
157
  }
158
+
159
+ #[test]
160
+ fn validation_sarif_includes_violations_and_config_findings() {
161
+ let repo = FixtureRepo::new();
162
+ repo.write(
163
+ "naome.arch.yaml",
164
+ r#"
165
+ layers:
166
+ domain:
167
+ paths:
168
+ - "src/domain/**"
169
+ infrastructure:
170
+ paths:
171
+ - "src/infrastructure/**"
172
+ unused:
173
+ paths:
174
+ - "src/unused/**"
175
+ allowed_dependencies:
176
+ domain:
177
+ infrastructure:
178
+ rules:
179
+ no_forbidden_layer_dependencies:
180
+ enabled: true
181
+ severity: error
182
+ "#,
183
+ );
184
+ repo.write(
185
+ "src/domain/event.ts",
186
+ "import { db } from '../infrastructure/db';\nimport missing from './missing';\n",
187
+ );
188
+ repo.write("src/infrastructure/db.ts", "export const db = 1;\n");
189
+
190
+ let report = validate_architecture(repo.path(), ArchitectureScanOptions::default()).unwrap();
191
+ let sarif = architecture_validation_sarif_with_root(&report, repo.path());
192
+ let results = sarif["runs"][0]["results"].as_array().unwrap();
193
+
194
+ assert_eq!(sarif["version"], "2.1.0");
195
+ assert!(sarif["runs"][0]["originalUriBaseIds"]["REPO_ROOT"]["uri"]
196
+ .as_str()
197
+ .is_some_and(|uri| uri.starts_with("file://") && uri.ends_with('/')));
198
+ assert!(results.iter().any(|result| {
199
+ result["ruleId"] == "arch.no_forbidden_layer_dependencies"
200
+ && result["level"] == "error"
201
+ && result["locations"][0]["physicalLocation"]["artifactLocation"]["uri"]
202
+ == "src/domain/event.ts"
203
+ && result["locations"][0]["physicalLocation"]["artifactLocation"]["uriBaseId"]
204
+ == "REPO_ROOT"
205
+ }));
206
+ assert!(results.iter().any(|result| {
207
+ result["ruleId"] == "arch.import.unresolved"
208
+ && result["level"] == "warning"
209
+ && result["properties"]["agentInstruction"]
210
+ .as_str()
211
+ .is_some_and(|instruction| instruction.contains("./missing"))
212
+ }));
213
+ assert!(results.iter().any(|result| {
214
+ result["ruleId"] == "arch.config.layer_matches_no_files"
215
+ && result["level"] == "warning"
216
+ && result["locations"][0]["physicalLocation"]["artifactLocation"]["uri"]
217
+ == "naome.arch.yaml"
218
+ && result["locations"][0]["physicalLocation"]["artifactLocation"]["uriBaseId"]
219
+ == "REPO_ROOT"
220
+ }));
221
+ }
@@ -1,3 +1,5 @@
1
+ #![allow(dead_code, unused_imports)]
2
+
1
3
  mod states;
2
4
 
3
5
  use std::collections::HashMap;
@@ -12,7 +14,7 @@ use serde_json::{json, Value};
12
14
  use sha2::{Digest, Sha256};
13
15
  pub use states::{
14
16
  active_task, complete_task_state, idle_task_state, ready_empty_verification_fixture,
15
- successful_admission, successful_proof,
17
+ successful_admission, successful_proof, task_state_with_status,
16
18
  };
17
19
 
18
20
  static FIXTURE_COUNTER: AtomicU64 = AtomicU64::new(0);
@@ -88,6 +90,18 @@ impl TaskFixture {
88
90
  self.refresh_admission_head();
89
91
  }
90
92
 
93
+ pub fn set_admission_head(&self, head: &str) {
94
+ let path = self.root.join(".naome/task-state.json");
95
+ let mut task_state: Value =
96
+ serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
97
+ task_state["activeTask"]["admission"]["gitHead"] = json!(head);
98
+ fs::write(
99
+ path,
100
+ format!("{}\n", serde_json::to_string_pretty(&task_state).unwrap()),
101
+ )
102
+ .unwrap();
103
+ }
104
+
91
105
  pub fn git<const N: usize>(&self, args: [&str; N]) -> String {
92
106
  let output = Command::new("git")
93
107
  .args(args)
@@ -12,6 +12,10 @@ pub fn complete_task_state(overrides: Value) -> Value {
12
12
  )
13
13
  }
14
14
 
15
+ pub fn task_state_with_status(status: &str, active_task: Value) -> Value {
16
+ task_state_record(status, active_task, json!("2026-05-04T12:00:00.000Z"))
17
+ }
18
+
15
19
  fn task_state_record(status: &str, active_task: Value, updated_at: Value) -> Value {
16
20
  let mut state: Value = serde_json::from_str(include_str!(
17
21
  "../../../../templates/naome-root/.naome/task-state.json"
@@ -0,0 +1,301 @@
1
+ #![allow(dead_code, unused_imports)]
2
+
3
+ use std::fs;
4
+
5
+ use naome_core::{
6
+ task_proof_plan, task_status_exit_code, task_status_report, task_transition_readiness,
7
+ };
8
+ use serde_json::{json, Value};
9
+
10
+ mod task_state_support;
11
+
12
+ use task_state_support::{
13
+ active_task, complete_task_state, idle_task_state, successful_proof, task_state_with_status,
14
+ TaskFixture,
15
+ };
16
+
17
+ #[test]
18
+ fn clean_idle_status_is_actionable_and_stable_json() {
19
+ let repo = TaskFixture::new(idle_task_state());
20
+ repo.init_git();
21
+
22
+ let status = task_status_report(repo.path()).unwrap();
23
+ let value = serde_json::to_value(&status).unwrap();
24
+
25
+ assert_eq!(value["schema"], "naome.task.status.v1");
26
+ assert_eq!(value["state"], "idle");
27
+ assert_eq!(value["blocked"], false);
28
+ assert_eq!(value["scope"]["changedPaths"], json!([]));
29
+ assert!(value["nextAction"]
30
+ .as_str()
31
+ .is_some_and(|action| action.contains("Create a NAOME task")));
32
+ assert_eq!(value["nextActionV2"]["type"], "create_task");
33
+ assert_eq!(value["agentLoop"]["state"], "ready_for_new_task");
34
+ }
35
+
36
+ #[test]
37
+ fn implementing_status_splits_in_scope_and_out_of_scope_changes() {
38
+ let repo = TaskFixture::new(task_state_with_status(
39
+ "implementing",
40
+ active_task(json!({
41
+ "allowedPaths": ["README.md"],
42
+ "requiredCheckIds": ["diff-check"],
43
+ "proofResults": []
44
+ })),
45
+ ));
46
+ repo.init_git();
47
+ repo.write("README.md", "changed\n");
48
+ repo.write("src/lib.rs", "pub fn outside() {}\n");
49
+
50
+ let status = task_status_report(repo.path()).unwrap();
51
+
52
+ assert_eq!(status.scope.in_scope_changed_paths, vec!["README.md"]);
53
+ assert_eq!(status.scope.out_of_scope_changed_paths, vec!["src/lib.rs"]);
54
+ assert!(status.findings.iter().any(|finding| {
55
+ finding.id == "task.scope.out_of_scope_change"
56
+ && finding.path.as_deref() == Some("src/lib.rs")
57
+ && finding.agent_instruction.contains("Do not commit")
58
+ }));
59
+ assert_eq!(status.next_action_v2.action_type, "repair_scope");
60
+ assert_eq!(status.agent_loop.state, "blocked_by_scope_drift");
61
+ assert!(status
62
+ .repair_plan
63
+ .iter()
64
+ .any(|item| item.id == "remove_out_of_scope_change_src_lib_rs"
65
+ && item.paths == vec!["src/lib.rs"]));
66
+ assert!(status.blocked);
67
+ }
68
+
69
+ #[test]
70
+ fn completed_task_with_new_diff_reports_unbaselined_diff() {
71
+ let repo = TaskFixture::new(complete_task_state(json!({
72
+ "allowedPaths": ["README.md"],
73
+ "proofResults": [successful_proof(json!({ "evidence": ["README.md"] }))],
74
+ })));
75
+ repo.init_git();
76
+ repo.write("README.md", "new diff after completion\n");
77
+
78
+ let status = task_status_report(repo.path()).unwrap();
79
+
80
+ assert!(status.findings.iter().any(|finding| {
81
+ finding.id == "task.state.completed_task_has_diff"
82
+ && finding.path.as_deref() == Some("README.md")
83
+ }));
84
+ let transition = task_transition_readiness(repo.path(), "complete").unwrap();
85
+ assert!(!transition.allowed);
86
+ assert!(transition
87
+ .blocking_findings
88
+ .iter()
89
+ .any(|finding| finding.id == "task.state.completed_task_has_diff"));
90
+ }
91
+
92
+ #[test]
93
+ fn proof_plan_reports_missing_and_stale_required_checks() {
94
+ let repo = TaskFixture::new(task_state_with_status(
95
+ "implementing",
96
+ active_task(json!({
97
+ "allowedPaths": ["README.md", "docs/**"],
98
+ "requiredCheckIds": ["diff-check"],
99
+ "proofPathSets": {
100
+ "old": ["README.md"]
101
+ },
102
+ "proofBatches": [{
103
+ "id": "old-proof",
104
+ "checkedAt": "2026-05-04T12:00:00.000Z",
105
+ "evidencePathSet": "old",
106
+ "proofs": [{ "checkId": "diff-check", "exitCode": 0 }]
107
+ }]
108
+ })),
109
+ ));
110
+ repo.init_git();
111
+ repo.write("README.md", "covered\n");
112
+ repo.write("docs/task.md", "not covered\n");
113
+
114
+ let plan = task_proof_plan(repo.path()).unwrap();
115
+
116
+ assert_eq!(plan.proof.missing_checks, Vec::<String>::new());
117
+ assert_eq!(plan.proof.stale_checks, vec!["diff-check"]);
118
+ assert_eq!(plan.next_action_v2.action_type, "rerun_checks");
119
+ assert_eq!(plan.agent_loop.state, "blocked_by_stale_proof");
120
+ assert_eq!(
121
+ plan.proof_recording.paths,
122
+ vec!["README.md", "docs/task.md"]
123
+ );
124
+ assert_eq!(plan.proof_recording.checks_to_record, vec!["diff-check"]);
125
+ assert!(plan
126
+ .repair_plan
127
+ .iter()
128
+ .any(|item| item.kind == "rerun_check" && item.check_ids == vec!["diff-check"]));
129
+ assert!(plan.recommended_commands.iter().any(|command| {
130
+ command.check_id == "diff-check" && command.command == "git diff --check"
131
+ }));
132
+ assert!(plan.task_feedback.iter().any(|feedback| {
133
+ feedback.problem.contains("Proof evidence") && feedback.files == vec!["docs/task.md"]
134
+ }));
135
+ }
136
+
137
+ #[test]
138
+ fn proof_plan_next_action_reports_missing_proof_without_command_metadata() {
139
+ let repo = TaskFixture::new(task_state_with_status(
140
+ "implementing",
141
+ active_task(json!({
142
+ "allowedPaths": ["README.md"],
143
+ "requiredCheckIds": ["unknown-check"],
144
+ "proofResults": []
145
+ })),
146
+ ));
147
+ repo.write_json(
148
+ ".naome/verification.json",
149
+ json!({
150
+ "schema": "naome.verification.v1",
151
+ "version": 1,
152
+ "status": "ready",
153
+ "checks": []
154
+ }),
155
+ );
156
+ repo.init_git();
157
+ repo.write("README.md", "changed\n");
158
+
159
+ let plan = task_proof_plan(repo.path()).unwrap();
160
+
161
+ assert!(plan.recommended_commands.is_empty());
162
+ assert_eq!(plan.proof.missing_checks, vec!["unknown-check"]);
163
+ assert!(plan.next_action.contains("Recover verification metadata"));
164
+ }
165
+
166
+ #[test]
167
+ fn transition_readiness_blocks_missing_proof_and_allows_fresh_proof() {
168
+ let repo = TaskFixture::new(task_state_with_status(
169
+ "implementing",
170
+ active_task(json!({
171
+ "allowedPaths": ["README.md"],
172
+ "requiredCheckIds": ["diff-check"],
173
+ "proofResults": []
174
+ })),
175
+ ));
176
+ repo.init_git();
177
+ repo.write("README.md", "changed\n");
178
+
179
+ let blocked = task_transition_readiness(repo.path(), "complete").unwrap();
180
+ assert!(!blocked.allowed);
181
+ assert!(blocked
182
+ .blocking_findings
183
+ .iter()
184
+ .any(|finding| finding.id == "task.proof.missing_check"));
185
+
186
+ let mut state: Value = serde_json::from_str(
187
+ &fs::read_to_string(repo.path().join(".naome/task-state.json")).unwrap(),
188
+ )
189
+ .unwrap();
190
+ state["activeTask"]["proofResults"] =
191
+ json!([successful_proof(json!({ "evidence": ["README.md"] }))]);
192
+ fs::write(
193
+ repo.path().join(".naome/task-state.json"),
194
+ format!("{}\n", serde_json::to_string_pretty(&state).unwrap()),
195
+ )
196
+ .unwrap();
197
+
198
+ let allowed = task_transition_readiness(repo.path(), "complete").unwrap();
199
+ assert!(allowed.allowed);
200
+ assert_eq!(allowed.agent_loop.state, "ready_to_commit");
201
+ }
202
+
203
+ #[test]
204
+ fn legacy_proof_results_remain_readable_when_fresh() {
205
+ let repo = TaskFixture::new(task_state_with_status(
206
+ "implementing",
207
+ active_task(json!({
208
+ "allowedPaths": ["README.md"],
209
+ "requiredCheckIds": ["diff-check"],
210
+ "proofResults": [successful_proof(json!({ "evidence": ["README.md"] }))]
211
+ })),
212
+ ));
213
+ repo.init_git();
214
+ repo.write("README.md", "covered\n");
215
+
216
+ let plan = task_proof_plan(repo.path()).unwrap();
217
+
218
+ assert_eq!(plan.proof.passed_checks, vec!["diff-check"]);
219
+ assert!(plan.proof.missing_checks.is_empty());
220
+ assert!(plan.proof.stale_checks.is_empty());
221
+ }
222
+
223
+ #[test]
224
+ fn task_status_reports_no_active_task_and_control_state_only_changes() {
225
+ let repo = TaskFixture::new(task_state_with_status("idle", Value::Null));
226
+ repo.init_git();
227
+ repo.write("README.md", "unowned\n");
228
+
229
+ let status = task_status_report(repo.path()).unwrap();
230
+ assert!(status
231
+ .findings
232
+ .iter()
233
+ .all(|finding| finding.id != "task.scope.out_of_scope_change"));
234
+ assert!(status
235
+ .findings
236
+ .iter()
237
+ .any(|finding| finding.id == "task.state.no_active_task_with_diff"));
238
+ assert_eq!(status.next_action_v2.action_type, "create_task");
239
+ assert_eq!(task_status_exit_code(&status.findings, &status.proof), 50);
240
+
241
+ fs::remove_file(repo.path().join("README.md")).unwrap();
242
+ repo.write(".naome/task-state.json", "{\n \"status\": \"idle\"\n}\n");
243
+ let status = task_status_report(repo.path()).unwrap();
244
+ assert!(status
245
+ .findings
246
+ .iter()
247
+ .any(|finding| finding.id == "task.scope.control_state_only_change"));
248
+ }
249
+
250
+ #[test]
251
+ fn malformed_task_state_reports_deterministic_status_finding() {
252
+ let repo = TaskFixture::new(task_state_with_status(
253
+ "implementing",
254
+ active_task(json!({})),
255
+ ));
256
+ repo.init_git();
257
+ repo.write(".naome/task-state.json", "{ invalid json\n");
258
+
259
+ let status = task_status_report(repo.path()).unwrap();
260
+
261
+ assert!(status
262
+ .findings
263
+ .iter()
264
+ .any(|finding| finding.id == "task.state.invalid_json"));
265
+ assert_eq!(task_status_exit_code(&status.findings, &status.proof), 40);
266
+ }
267
+
268
+ #[test]
269
+ fn active_state_without_active_task_is_corrupt_and_cannot_complete() {
270
+ let repo = TaskFixture::new(task_state_with_status("implementing", Value::Null));
271
+ repo.init_git();
272
+
273
+ let status = task_status_report(repo.path()).unwrap();
274
+ let transition = task_transition_readiness(repo.path(), "complete").unwrap();
275
+
276
+ assert!(status
277
+ .findings
278
+ .iter()
279
+ .any(|finding| finding.id == "task.state.active_task_missing"));
280
+ assert!(!transition.allowed);
281
+ }
282
+
283
+ #[test]
284
+ fn blocked_task_states_cannot_transition_to_complete() {
285
+ let repo = TaskFixture::new(task_state_with_status(
286
+ "blocked",
287
+ active_task(json!({
288
+ "requiredCheckIds": ["diff-check"],
289
+ "proofResults": [successful_proof(json!({ "evidence": ["README.md"] }))]
290
+ })),
291
+ ));
292
+ repo.init_git();
293
+
294
+ let transition = task_transition_readiness(repo.path(), "complete").unwrap();
295
+
296
+ assert!(!transition.allowed);
297
+ assert!(transition
298
+ .blocking_findings
299
+ .iter()
300
+ .any(|finding| finding.id == "task.transition.blocked_state"));
301
+ }