@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,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
+ }
@@ -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,423 @@
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_marks_mutable_npm_scripts_not_safe_to_execute() {
139
+ let repo = TaskFixture::new(task_state_with_status(
140
+ "implementing",
141
+ active_task(json!({
142
+ "allowedPaths": ["package.json"],
143
+ "requiredCheckIds": ["package-dry-run"],
144
+ "proofResults": []
145
+ })),
146
+ ));
147
+ repo.write_json(
148
+ ".naome/verification.json",
149
+ json!({
150
+ "checks": [{
151
+ "id": "package-dry-run",
152
+ "command": "npm run pack:dry-run",
153
+ "cwd": ".",
154
+ "purpose": "Validate package output.",
155
+ "cost": "medium",
156
+ "source": "npm",
157
+ "evidence": ["package.json"]
158
+ }]
159
+ }),
160
+ );
161
+ repo.init_git();
162
+ repo.write_json(
163
+ "package.json",
164
+ json!({ "scripts": { "pack:dry-run": "echo changed" } }),
165
+ );
166
+
167
+ let plan = task_proof_plan(repo.path()).unwrap();
168
+
169
+ assert!(plan.recommended_commands.iter().any(|command| {
170
+ command.check_id == "package-dry-run"
171
+ && command.command == "npm run pack:dry-run"
172
+ && !command.safe_to_execute
173
+ }));
174
+ }
175
+
176
+ #[test]
177
+ fn proof_plan_next_action_reports_missing_proof_without_command_metadata() {
178
+ let repo = TaskFixture::new(task_state_with_status(
179
+ "implementing",
180
+ active_task(json!({
181
+ "allowedPaths": ["README.md"],
182
+ "requiredCheckIds": ["unknown-check"],
183
+ "proofResults": []
184
+ })),
185
+ ));
186
+ repo.write_json(
187
+ ".naome/verification.json",
188
+ json!({
189
+ "schema": "naome.verification.v1",
190
+ "version": 1,
191
+ "status": "ready",
192
+ "checks": []
193
+ }),
194
+ );
195
+ repo.init_git();
196
+ repo.write("README.md", "changed\n");
197
+
198
+ let plan = task_proof_plan(repo.path()).unwrap();
199
+
200
+ assert!(plan.recommended_commands.is_empty());
201
+ assert_eq!(plan.proof.missing_checks, vec!["unknown-check"]);
202
+ assert!(plan.next_action.contains("Recover verification metadata"));
203
+ }
204
+
205
+ #[test]
206
+ fn transition_readiness_blocks_missing_proof_and_allows_fresh_proof() {
207
+ let repo = TaskFixture::new(task_state_with_status(
208
+ "implementing",
209
+ active_task(json!({
210
+ "allowedPaths": ["README.md"],
211
+ "requiredCheckIds": ["diff-check"],
212
+ "proofResults": []
213
+ })),
214
+ ));
215
+ repo.init_git();
216
+ repo.write("README.md", "changed\n");
217
+
218
+ let blocked = task_transition_readiness(repo.path(), "complete").unwrap();
219
+ assert!(!blocked.allowed);
220
+ assert!(blocked
221
+ .blocking_findings
222
+ .iter()
223
+ .any(|finding| finding.id == "task.proof.missing_check"));
224
+
225
+ let mut state: Value = serde_json::from_str(
226
+ &fs::read_to_string(repo.path().join(".naome/task-state.json")).unwrap(),
227
+ )
228
+ .unwrap();
229
+ state["activeTask"]["proofResults"] =
230
+ json!([successful_proof(json!({ "evidence": ["README.md"] }))]);
231
+ fs::write(
232
+ repo.path().join(".naome/task-state.json"),
233
+ format!("{}\n", serde_json::to_string_pretty(&state).unwrap()),
234
+ )
235
+ .unwrap();
236
+
237
+ let allowed = task_transition_readiness(repo.path(), "complete").unwrap();
238
+ assert!(allowed.allowed);
239
+ assert_eq!(allowed.agent_loop.state, "ready_to_commit");
240
+ }
241
+
242
+ #[test]
243
+ fn transition_readiness_treats_mismatched_proof_fingerprint_as_stale() {
244
+ let repo = TaskFixture::new(task_state_with_status(
245
+ "implementing",
246
+ active_task(json!({
247
+ "allowedPaths": ["README.md"],
248
+ "requiredCheckIds": ["diff-check"],
249
+ "proofPathSets": {
250
+ "current": ["README.md"]
251
+ },
252
+ "proofBatches": [{
253
+ "id": "old-proof",
254
+ "checkedAt": "2026-05-04T12:00:00.000Z",
255
+ "evidencePathSet": "current",
256
+ "proofs": [{
257
+ "checkId": "diff-check",
258
+ "exitCode": 0,
259
+ "evidenceFingerprint": "fnv64:0000000000000000"
260
+ }]
261
+ }]
262
+ })),
263
+ ));
264
+ repo.init_git();
265
+ repo.write("README.md", "changed after proof\n");
266
+
267
+ let transition = task_transition_readiness(repo.path(), "complete").unwrap();
268
+
269
+ assert!(!transition.allowed);
270
+ assert!(transition
271
+ .blocking_findings
272
+ .iter()
273
+ .any(|finding| finding.id == "task.transition.stale_proof"));
274
+ }
275
+
276
+ #[test]
277
+ fn transition_readiness_blocks_pending_human_review_and_stale_blockers() {
278
+ let human_review = TaskFixture::new(task_state_with_status(
279
+ "implementing",
280
+ active_task(json!({
281
+ "requiredCheckIds": ["diff-check"],
282
+ "proofResults": [successful_proof(json!({ "evidence": ["README.md"] }))],
283
+ "humanReview": {
284
+ "required": true,
285
+ "approved": false,
286
+ "reason": "Security-sensitive change."
287
+ }
288
+ })),
289
+ ));
290
+ human_review.init_git();
291
+
292
+ let transition = task_transition_readiness(human_review.path(), "complete").unwrap();
293
+
294
+ assert!(!transition.allowed);
295
+ assert!(transition
296
+ .blocking_findings
297
+ .iter()
298
+ .any(|finding| finding.id == "task.transition.human_review_required"));
299
+
300
+ let mut blocked_state = task_state_with_status(
301
+ "implementing",
302
+ active_task(json!({
303
+ "requiredCheckIds": ["diff-check"],
304
+ "proofResults": [successful_proof(json!({ "evidence": ["README.md"] }))]
305
+ })),
306
+ );
307
+ blocked_state["blocker"] = json!({
308
+ "type": "scope_drift",
309
+ "message": "Resolve stale blocker before completion.",
310
+ "paths": ["README.md"],
311
+ "humanOptions": ["review_task_state"]
312
+ });
313
+ let blocker = TaskFixture::new(blocked_state);
314
+ blocker.init_git();
315
+
316
+ let transition = task_transition_readiness(blocker.path(), "complete").unwrap();
317
+
318
+ assert!(!transition.allowed);
319
+ assert!(transition
320
+ .blocking_findings
321
+ .iter()
322
+ .any(|finding| finding.id == "task.transition.blocker_present"));
323
+ }
324
+
325
+ #[test]
326
+ fn legacy_proof_results_remain_readable_when_fresh() {
327
+ let repo = TaskFixture::new(task_state_with_status(
328
+ "implementing",
329
+ active_task(json!({
330
+ "allowedPaths": ["README.md"],
331
+ "requiredCheckIds": ["diff-check"],
332
+ "proofResults": [successful_proof(json!({ "evidence": ["README.md"] }))]
333
+ })),
334
+ ));
335
+ repo.init_git();
336
+ repo.write("README.md", "covered\n");
337
+
338
+ let plan = task_proof_plan(repo.path()).unwrap();
339
+
340
+ assert_eq!(plan.proof.passed_checks, vec!["diff-check"]);
341
+ assert!(plan.proof.missing_checks.is_empty());
342
+ assert!(plan.proof.stale_checks.is_empty());
343
+ }
344
+
345
+ #[test]
346
+ fn task_status_reports_no_active_task_and_control_state_only_changes() {
347
+ let repo = TaskFixture::new(task_state_with_status("idle", Value::Null));
348
+ repo.init_git();
349
+ repo.write("README.md", "unowned\n");
350
+
351
+ let status = task_status_report(repo.path()).unwrap();
352
+ assert!(status
353
+ .findings
354
+ .iter()
355
+ .all(|finding| finding.id != "task.scope.out_of_scope_change"));
356
+ assert!(status
357
+ .findings
358
+ .iter()
359
+ .any(|finding| finding.id == "task.state.no_active_task_with_diff"));
360
+ assert_eq!(status.next_action_v2.action_type, "create_task");
361
+ assert_eq!(task_status_exit_code(&status.findings, &status.proof), 50);
362
+
363
+ fs::remove_file(repo.path().join("README.md")).unwrap();
364
+ repo.write(".naome/task-state.json", "{\n \"status\": \"idle\"\n}\n");
365
+ let status = task_status_report(repo.path()).unwrap();
366
+ assert!(status
367
+ .findings
368
+ .iter()
369
+ .any(|finding| finding.id == "task.scope.control_state_only_change"));
370
+ }
371
+
372
+ #[test]
373
+ fn malformed_task_state_reports_deterministic_status_finding() {
374
+ let repo = TaskFixture::new(task_state_with_status(
375
+ "implementing",
376
+ active_task(json!({})),
377
+ ));
378
+ repo.init_git();
379
+ repo.write(".naome/task-state.json", "{ invalid json\n");
380
+
381
+ let status = task_status_report(repo.path()).unwrap();
382
+
383
+ assert!(status
384
+ .findings
385
+ .iter()
386
+ .any(|finding| finding.id == "task.state.invalid_json"));
387
+ assert_eq!(task_status_exit_code(&status.findings, &status.proof), 40);
388
+ }
389
+
390
+ #[test]
391
+ fn active_state_without_active_task_is_corrupt_and_cannot_complete() {
392
+ let repo = TaskFixture::new(task_state_with_status("implementing", Value::Null));
393
+ repo.init_git();
394
+
395
+ let status = task_status_report(repo.path()).unwrap();
396
+ let transition = task_transition_readiness(repo.path(), "complete").unwrap();
397
+
398
+ assert!(status
399
+ .findings
400
+ .iter()
401
+ .any(|finding| finding.id == "task.state.active_task_missing"));
402
+ assert!(!transition.allowed);
403
+ }
404
+
405
+ #[test]
406
+ fn blocked_task_states_cannot_transition_to_complete() {
407
+ let repo = TaskFixture::new(task_state_with_status(
408
+ "blocked",
409
+ active_task(json!({
410
+ "requiredCheckIds": ["diff-check"],
411
+ "proofResults": [successful_proof(json!({ "evidence": ["README.md"] }))]
412
+ })),
413
+ ));
414
+ repo.init_git();
415
+
416
+ let transition = task_transition_readiness(repo.path(), "complete").unwrap();
417
+
418
+ assert!(!transition.allowed);
419
+ assert!(transition
420
+ .blocking_findings
421
+ .iter()
422
+ .any(|finding| finding.id == "task.transition.blocked_state"));
423
+ }