@lamentis/naome 1.4.1 → 1.4.3

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 (52) 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 +14 -5
  5. package/crates/naome-cli/src/task_commands/agent_snapshot.rs +173 -0
  6. package/crates/naome-cli/src/task_commands/can_edit.rs +64 -0
  7. package/crates/naome-cli/src/task_commands/check_run/output.rs +34 -0
  8. package/crates/naome-cli/src/task_commands/check_run/receipts.rs +163 -0
  9. package/crates/naome-cli/src/task_commands/check_run/verification.rs +165 -0
  10. package/crates/naome-cli/src/task_commands/check_run.rs +196 -0
  11. package/crates/naome-cli/src/task_commands/commit_preflight.rs +89 -0
  12. package/crates/naome-cli/src/task_commands/common.rs +39 -1
  13. package/crates/naome-cli/src/task_commands/compact_proof.rs +69 -0
  14. package/crates/naome-cli/src/task_commands/complete.rs +43 -0
  15. package/crates/naome-cli/src/task_commands/loop_control.rs +73 -0
  16. package/crates/naome-cli/src/task_commands/path_policy.rs +57 -0
  17. package/crates/naome-cli/src/task_commands/planner/checks.rs +166 -0
  18. package/crates/naome-cli/src/task_commands/planner/impact.rs +35 -0
  19. package/crates/naome-cli/src/task_commands/planner/mod.rs +24 -0
  20. package/crates/naome-cli/src/task_commands/preflight.rs +208 -0
  21. package/crates/naome-cli/src/task_commands/readiness.rs +14 -10
  22. package/crates/naome-cli/src/task_commands/record.rs +176 -37
  23. package/crates/naome-cli/src/task_commands/repair.rs +58 -11
  24. package/crates/naome-cli/src/task_commands/scope_suggestions.rs +109 -0
  25. package/crates/naome-cli/src/task_commands.rs +26 -3
  26. package/crates/naome-cli/tests/task_cli_agent_controls.rs +9 -16
  27. package/crates/naome-cli/tests/task_cli_fast_flow.rs +290 -0
  28. package/crates/naome-cli/tests/task_cli_loop.rs +383 -0
  29. package/crates/naome-cli/tests/task_cli_loop_edit.rs +144 -0
  30. package/crates/naome-cli/tests/task_cli_support/mod.rs +28 -0
  31. package/crates/naome-core/Cargo.toml +1 -1
  32. package/crates/naome-core/src/lib.rs +7 -7
  33. package/crates/naome-core/src/task_state/evidence_fingerprint.rs +47 -0
  34. package/crates/naome-core/src/task_state/mod.rs +2 -0
  35. package/crates/naome-core/src/task_state/status/control/repair.rs +2 -2
  36. package/crates/naome-core/src/task_state/status/model.rs +2 -0
  37. package/crates/naome-core/src/task_state/status/proof.rs +59 -9
  38. package/crates/naome-core/src/task_state/status/proof_read.rs +14 -0
  39. package/crates/naome-core/src/task_state/status/report_context.rs +23 -1
  40. package/crates/naome-core/src/task_state/status/transition.rs +29 -1
  41. package/crates/naome-core/tests/task_status.rs +122 -0
  42. package/installer/context.js +1 -1
  43. package/installer/harness-verification.js +2 -6
  44. package/installer/manifest-state.js +2 -2
  45. package/installer/native.js +3 -31
  46. package/native/darwin-arm64/naome +0 -0
  47. package/native/linux-x64/naome +0 -0
  48. package/package.json +1 -1
  49. package/templates/naome-root/.naome/bin/check-harness-health.js +2 -2
  50. package/templates/naome-root/.naome/bin/check-task-state.js +4 -39
  51. package/templates/naome-root/.naome/bin/naome.js +2 -30
  52. package/templates/naome-root/.naome/manifest.json +2 -2
@@ -0,0 +1,144 @@
1
+ use std::process::Command;
2
+
3
+ mod task_cli_support;
4
+
5
+ use serde_json::json;
6
+
7
+ use task_cli_support::{
8
+ active_task, fixture_root, init_git, run_json, task_state, task_state_with_active_task,
9
+ write_fixture_file,
10
+ };
11
+
12
+ #[test]
13
+ fn can_edit_allows_in_scope_and_blocks_unsafe_paths() {
14
+ let root = fixture_root(task_state());
15
+ init_git(&root);
16
+
17
+ let allowed = run_json(&root, ["task", "can-edit", "--path", "README.md", "--json"]);
18
+ assert_eq!(allowed["schema"], "naome.task.can-edit.v1");
19
+ assert_eq!(allowed["path"], "README.md");
20
+ assert_eq!(allowed["allowed"], true);
21
+
22
+ let outside = run_json(
23
+ &root,
24
+ ["task", "can-edit", "--path", "src/lib.rs", "--json"],
25
+ );
26
+ assert_eq!(outside["allowed"], false);
27
+ assert_eq!(outside["findings"][0]["id"], "task.edit.out_of_scope");
28
+
29
+ let traversal = run_json(
30
+ &root,
31
+ ["task", "can-edit", "--path", "../README.md", "--json"],
32
+ );
33
+ assert_eq!(traversal["allowed"], false);
34
+ assert_eq!(traversal["findings"][0]["id"], "task.edit.unsafe_path");
35
+
36
+ let backslash_traversal = run_json(
37
+ &root,
38
+ ["task", "can-edit", "--path", "..\\README.md", "--json"],
39
+ );
40
+ assert_eq!(backslash_traversal["allowed"], false);
41
+ assert_eq!(
42
+ backslash_traversal["findings"][0]["id"],
43
+ "task.edit.unsafe_path"
44
+ );
45
+
46
+ let absolute = run_json(
47
+ &root,
48
+ ["task", "can-edit", "--path", "/tmp/file.rs", "--json"],
49
+ );
50
+ assert_eq!(absolute["allowed"], false);
51
+ assert_eq!(absolute["findings"][0]["id"], "task.edit.unsafe_path");
52
+
53
+ let control = run_json(
54
+ &root,
55
+ [
56
+ "task",
57
+ "can-edit",
58
+ "--path",
59
+ ".naome/task-state.json",
60
+ "--json",
61
+ ],
62
+ );
63
+ assert_eq!(control["allowed"], false);
64
+ assert_eq!(control["findings"][0]["id"], "task.edit.control_path");
65
+
66
+ let ignore_control = run_json(
67
+ &root,
68
+ ["task", "can-edit", "--path", ".naomeignore", "--json"],
69
+ );
70
+ assert_eq!(ignore_control["allowed"], false);
71
+ assert_eq!(
72
+ ignore_control["findings"][0]["id"],
73
+ "task.edit.control_path"
74
+ );
75
+ }
76
+
77
+ #[test]
78
+ fn can_edit_honors_ignore_directories_wildcards_and_blocked_states() {
79
+ let root = fixture_root(task_state_with_active_task(active_task(json!({
80
+ "allowedPaths": ["**"]
81
+ }))));
82
+ std::fs::write(
83
+ root.join(".naomeignore"),
84
+ ".naome/archive/\n.naome/tasks/\ndist/\n",
85
+ )
86
+ .unwrap();
87
+ init_git(&root);
88
+ write_fixture_file(&root, "dist/bundle.js", "generated\n");
89
+
90
+ let ignored = run_json(
91
+ &root,
92
+ ["task", "can-edit", "--path", "dist/bundle.js", "--json"],
93
+ );
94
+ assert_eq!(ignored["allowed"], false);
95
+ assert_eq!(ignored["findings"][0]["id"], "task.edit.ignored_path");
96
+
97
+ let wildcard_root = fixture_root(task_state_with_active_task(active_task(json!({
98
+ "allowedPaths": ["scripts/*.js"]
99
+ }))));
100
+ init_git(&wildcard_root);
101
+ let wildcard = run_json(
102
+ &wildcard_root,
103
+ ["task", "can-edit", "--path", "scripts/check.js", "--json"],
104
+ );
105
+ assert_eq!(wildcard["allowed"], true);
106
+
107
+ let blocked_root = fixture_root(task_state_with_active_task(active_task(json!({
108
+ "allowedPaths": ["README.md"]
109
+ }))));
110
+ let mut state: serde_json::Value = serde_json::from_str(
111
+ &std::fs::read_to_string(blocked_root.join(".naome/task-state.json")).unwrap(),
112
+ )
113
+ .unwrap();
114
+ state["status"] = json!("blocked");
115
+ state["activeTask"]["status"] = json!("blocked");
116
+ task_cli_support::write_json(&blocked_root, ".naome/task-state.json", &state);
117
+ init_git(&blocked_root);
118
+ let blocked = run_json(
119
+ &blocked_root,
120
+ ["task", "can-edit", "--path", "README.md", "--json"],
121
+ );
122
+ assert_eq!(blocked["allowed"], false);
123
+ assert_eq!(blocked["findings"][0]["id"], "task.edit.no_active_task");
124
+ }
125
+
126
+ #[test]
127
+ fn agent_session_is_validated_and_reflected_in_json() {
128
+ let root = fixture_root(task_state());
129
+ init_git(&root);
130
+
131
+ let status = run_json(
132
+ &root,
133
+ ["task", "status", "--json", "--agent-session", "agent-42"],
134
+ );
135
+ assert_eq!(status["agentSession"], "agent-42");
136
+
137
+ let rejected = Command::new(env!("CARGO_BIN_EXE_naome"))
138
+ .args(["task", "status", "--json", "--agent-session", "../bad"])
139
+ .current_dir(root)
140
+ .output()
141
+ .unwrap();
142
+ assert!(!rejected.status.success());
143
+ assert!(String::from_utf8_lossy(&rejected.stderr).contains("agent-session"));
144
+ }
@@ -131,6 +131,34 @@ pub fn write_json(root: &std::path::Path, path: &str, value: &Value) {
131
131
  );
132
132
  }
133
133
 
134
+ pub fn write_verification_checks(root: &std::path::Path, checks: Value) {
135
+ write_json(
136
+ root,
137
+ ".naome/verification.json",
138
+ &json!({
139
+ "schema": "naome.verification.v1",
140
+ "version": 1,
141
+ "status": "ready",
142
+ "checks": checks
143
+ }),
144
+ );
145
+ }
146
+
147
+ pub fn run_json<const N: usize>(root: &std::path::Path, args: [&str; N]) -> Value {
148
+ let output = Command::new(env!("CARGO_BIN_EXE_naome"))
149
+ .args(args)
150
+ .current_dir(root)
151
+ .output()
152
+ .unwrap();
153
+ assert!(
154
+ output.status.success(),
155
+ "{}{}",
156
+ String::from_utf8_lossy(&output.stdout),
157
+ String::from_utf8_lossy(&output.stderr)
158
+ );
159
+ serde_json::from_slice(&output.stdout).unwrap()
160
+ }
161
+
134
162
  fn verification() -> Value {
135
163
  json!({
136
164
  "schema": "naome.verification.v1",
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "naome-core"
3
- version = "1.4.1"
3
+ version = "1.4.3"
4
4
  edition.workspace = true
5
5
  license.workspace = true
6
6
  repository.workspace = true
@@ -68,13 +68,13 @@ pub use task_ledger::{
68
68
  TaskLedgerProjection, TaskLedgerStatus,
69
69
  };
70
70
  pub use task_state::{
71
- completed_task_commit_paths, format_task_proof_plan, format_task_status, task_proof_plan,
72
- task_status_exit_code, task_status_report, task_transition_readiness, validate_task_state,
73
- AgentLoop, NextActionV2, PolicyHints, ProofRecording, ProofRecordingAfterSuccess,
74
- RecoveryGuidance, RepairPlanItem, TaskFeedback, TaskGitStatus, TaskModeStatus,
75
- TaskProofPlanReport, TaskProofStatus, TaskRecommendedCommand, TaskScopeStatus, TaskStateMode,
76
- TaskStateOptions, TaskStateReport, TaskStatusFinding, TaskStatusReportV1,
77
- TransitionReadinessReport,
71
+ completed_task_commit_paths, format_task_proof_plan, format_task_status,
72
+ task_evidence_fingerprint, task_proof_plan, task_status_exit_code, task_status_report,
73
+ task_transition_readiness, validate_task_state, AgentLoop, NextActionV2, PolicyHints,
74
+ ProofRecording, ProofRecordingAfterSuccess, RecoveryGuidance, RepairPlanItem, TaskFeedback,
75
+ TaskGitStatus, TaskModeStatus, TaskProofPlanReport, TaskProofStatus, TaskRecommendedCommand,
76
+ TaskScopeStatus, TaskStateMode, TaskStateOptions, TaskStateReport, TaskStatusFinding,
77
+ TaskStatusReportV1, TransitionReadinessReport,
78
78
  };
79
79
  pub use verification::seed_builtin_verification_checks;
80
80
  pub use verification_contract::validate_verification_contract;
@@ -0,0 +1,47 @@
1
+ use std::fs;
2
+ use std::path::Path;
3
+
4
+ use crate::models::NaomeError;
5
+
6
+ pub fn task_evidence_fingerprint(root: &Path, paths: &[String]) -> Result<String, NaomeError> {
7
+ let mut paths = paths.to_vec();
8
+ paths.sort();
9
+ let mut hash = Fnv64::new();
10
+ for path in paths {
11
+ hash.update(path.as_bytes());
12
+ hash.update(b"\0");
13
+ match fs::read(root.join(&path)) {
14
+ Ok(content) => {
15
+ hash.update(b"file:");
16
+ hash.update(content.len().to_string().as_bytes());
17
+ hash.update(b":");
18
+ hash.update(&content);
19
+ }
20
+ Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
21
+ hash.update(b"missing");
22
+ }
23
+ Err(error) => return Err(NaomeError::from(error)),
24
+ }
25
+ hash.update(b"\0");
26
+ }
27
+ Ok(format!("fnv64:{:016x}", hash.finish()))
28
+ }
29
+
30
+ struct Fnv64(u64);
31
+
32
+ impl Fnv64 {
33
+ fn new() -> Self {
34
+ Self(0xcbf29ce484222325)
35
+ }
36
+
37
+ fn update(&mut self, bytes: &[u8]) {
38
+ for byte in bytes {
39
+ self.0 ^= u64::from(*byte);
40
+ self.0 = self.0.wrapping_mul(0x100000001b3);
41
+ }
42
+ }
43
+
44
+ fn finish(self) -> u64 {
45
+ self.0
46
+ }
47
+ }
@@ -8,6 +8,7 @@ mod completion;
8
8
  mod deleted_paths;
9
9
  mod diff;
10
10
  mod evidence;
11
+ mod evidence_fingerprint;
11
12
  mod git_io;
12
13
  mod git_parse;
13
14
  mod git_refs;
@@ -32,6 +33,7 @@ mod util;
32
33
 
33
34
  pub use api::validate_task_state;
34
35
  pub use completed_refresh::completed_task_harness_refresh_diff;
36
+ pub use evidence_fingerprint::task_evidence_fingerprint;
35
37
  pub(crate) use proof_model::{canonical_proof_check_ids, canonical_proofs};
36
38
  pub use status::{
37
39
  task_proof_plan, task_status_exit_code, task_status_report, task_transition_readiness,
@@ -42,8 +42,8 @@ fn check_repairs(commands: &[TaskRecommendedCommand]) -> Vec<RepairPlanItem> {
42
42
  check_ids: vec![command.check_id.clone()],
43
43
  commands: vec![command.command.clone()],
44
44
  cwd: Some(command.cwd.clone()),
45
- safe_to_execute: true,
46
- requires_user_approval: false,
45
+ safe_to_execute: command.safe_to_execute,
46
+ requires_user_approval: !command.safe_to_execute,
47
47
  })
48
48
  .collect()
49
49
  }
@@ -127,8 +127,10 @@ pub struct TaskRecommendedCommand {
127
127
  pub command: String,
128
128
  pub cwd: String,
129
129
  pub reason: String,
130
+ pub proof_reason: String,
130
131
  pub selection_reason: String,
131
132
  pub impacted_paths: Vec<String>,
133
+ pub safe_to_execute: bool,
132
134
  }
133
135
 
134
136
  pub(super) fn finding(
@@ -1,20 +1,37 @@
1
1
  use std::collections::{BTreeMap, BTreeSet};
2
+ use std::path::Path;
2
3
 
3
4
  use serde_json::Value;
4
5
 
6
+ use crate::models::NaomeError;
7
+ use crate::task_state::evidence_fingerprint::task_evidence_fingerprint;
5
8
  use crate::task_state::util::string_array;
6
9
 
7
10
  use super::model::{finding, TaskProofStatus, TaskRecommendedCommand, TaskStatusFinding};
8
11
  use super::proof_read::{ProofRecord, VerificationCheck};
9
12
 
13
+ const AUTONOMOUS_SAFE_CHECKS: &[&str] = &[
14
+ "git diff --check",
15
+ "node .naome/bin/check-harness-health.js",
16
+ "node .naome/bin/check-task-state.js",
17
+ "node .naome/bin/naome.js quality check --changed",
18
+ "node .naome/bin/naome.js semantic check --changed",
19
+ "node .naome/bin/naome.js arch validate --changed-only",
20
+ "npm run check:task-state",
21
+ "npm run test:task-state",
22
+ "npm run test:decision-engine",
23
+ ];
24
+
10
25
  pub(super) fn proof_status(
26
+ root: &Path,
11
27
  active_task: Option<&Value>,
12
28
  proofs: &[ProofRecord],
13
29
  current_task_paths: &[String],
14
- ) -> TaskProofStatus {
30
+ ) -> Result<TaskProofStatus, NaomeError> {
15
31
  let required_checks = active_task
16
32
  .and_then(|task| string_array(task.get("requiredCheckIds")))
17
33
  .unwrap_or_default();
34
+ let current_fingerprint = task_evidence_fingerprint(root, current_task_paths)?;
18
35
  let mut passed_checks = Vec::new();
19
36
  let mut missing_checks = Vec::new();
20
37
  let mut stale_checks = Vec::new();
@@ -28,7 +45,7 @@ pub(super) fn proof_status(
28
45
  missing_checks.push(check_id.clone());
29
46
  } else if successful
30
47
  .iter()
31
- .any(|proof| evidence_covers(&proof.evidence_paths, current_task_paths))
48
+ .any(|proof| proof_is_fresh(proof, current_task_paths, &current_fingerprint))
32
49
  {
33
50
  passed_checks.push(check_id.clone());
34
51
  } else {
@@ -36,12 +53,12 @@ pub(super) fn proof_status(
36
53
  }
37
54
  }
38
55
 
39
- TaskProofStatus {
56
+ Ok(TaskProofStatus {
40
57
  required_checks,
41
58
  passed_checks,
42
59
  missing_checks,
43
60
  stale_checks,
44
- }
61
+ })
45
62
  }
46
63
 
47
64
  pub(super) fn add_proof_findings(
@@ -125,28 +142,61 @@ pub(super) fn recommended_commands(
125
142
  .iter()
126
143
  .filter_map(|check_id| {
127
144
  let check = verification.get(check_id)?;
145
+ let reason = if proof.stale_checks.contains(check_id) {
146
+ "stale-proof".to_string()
147
+ } else {
148
+ "missing-proof".to_string()
149
+ };
128
150
  Some(TaskRecommendedCommand {
129
151
  check_id: check_id.clone(),
130
152
  command: check.command.clone(),
131
153
  cwd: check.cwd.clone(),
132
- reason: if proof.stale_checks.contains(check_id) {
133
- "stale-proof".to_string()
134
- } else {
135
- "missing-proof".to_string()
136
- },
154
+ reason: reason.clone(),
155
+ proof_reason: reason,
137
156
  selection_reason: "Required by active task proof state.".to_string(),
138
157
  impacted_paths: current_task_paths.to_vec(),
158
+ safe_to_execute: is_safe_autonomous_command(
159
+ &check.command,
160
+ &check.cwd,
161
+ current_task_paths,
162
+ ),
139
163
  })
140
164
  })
141
165
  .collect()
142
166
  }
143
167
 
168
+ fn is_safe_autonomous_command(command: &str, cwd: &str, current_task_paths: &[String]) -> bool {
169
+ if cwd != "." || !AUTONOMOUS_SAFE_CHECKS.contains(&command) {
170
+ return false;
171
+ }
172
+ if command.starts_with("npm run ")
173
+ && current_task_paths
174
+ .iter()
175
+ .any(|path| path == "package.json" || path == "packages/naome/package.json")
176
+ {
177
+ return false;
178
+ }
179
+ true
180
+ }
181
+
144
182
  fn evidence_covers(evidence_paths: &[String], current_paths: &[String]) -> bool {
145
183
  current_paths
146
184
  .iter()
147
185
  .all(|path| evidence_paths.iter().any(|evidence| evidence == path))
148
186
  }
149
187
 
188
+ fn proof_is_fresh(
189
+ proof: &ProofRecord,
190
+ current_paths: &[String],
191
+ current_fingerprint: &str,
192
+ ) -> bool {
193
+ evidence_covers(&proof.evidence_paths, current_paths)
194
+ && proof
195
+ .evidence_fingerprint
196
+ .as_deref()
197
+ .is_none_or(|fingerprint| fingerprint == current_fingerprint)
198
+ }
199
+
150
200
  fn stale_files_for_check(
151
201
  check_id: &str,
152
202
  current_task_paths: &[String],
@@ -15,6 +15,7 @@ pub(super) struct ProofRecord {
15
15
  pub(super) check_id: String,
16
16
  pub(super) exit_code: i64,
17
17
  pub(super) evidence_paths: Vec<String>,
18
+ pub(super) evidence_fingerprint: Option<String>,
18
19
  }
19
20
 
20
21
  #[derive(Debug, Clone)]
@@ -32,6 +33,10 @@ pub(super) fn read_proofs(active_task: &Value) -> Vec<ProofRecord> {
32
33
  if let Some(batches) = active_task.get("proofBatches").and_then(Value::as_array) {
33
34
  for batch in batches {
34
35
  let batch_evidence = batch_evidence(batch, &path_sets);
36
+ let batch_fingerprint = batch
37
+ .get("evidenceFingerprint")
38
+ .and_then(Value::as_str)
39
+ .map(ToString::to_string);
35
40
  let Some(batch_proofs) = batch.get("proofs").and_then(Value::as_array) else {
36
41
  continue;
37
42
  };
@@ -47,6 +52,11 @@ pub(super) fn read_proofs(active_task: &Value) -> Vec<ProofRecord> {
47
52
  exit_code,
48
53
  evidence_paths: compact_evidence_paths(proof, &path_sets)
49
54
  .unwrap_or_else(|| batch_evidence.clone()),
55
+ evidence_fingerprint: proof
56
+ .get("evidenceFingerprint")
57
+ .and_then(Value::as_str)
58
+ .map(ToString::to_string)
59
+ .or_else(|| batch_fingerprint.clone()),
50
60
  });
51
61
  }
52
62
  }
@@ -123,6 +133,10 @@ fn read_legacy_proof(proof: &Value) -> Option<ProofRecord> {
123
133
  Some(ProofRecord {
124
134
  check_id: proof.get("checkId")?.as_str()?.to_string(),
125
135
  exit_code: proof.get("exitCode")?.as_i64()?,
136
+ evidence_fingerprint: proof
137
+ .get("evidenceFingerprint")
138
+ .and_then(Value::as_str)
139
+ .map(ToString::to_string),
126
140
  evidence_paths: proof
127
141
  .get("evidence")
128
142
  .and_then(Value::as_array)
@@ -27,6 +27,8 @@ pub(super) struct TaskStatusContext {
27
27
  pub(super) proof: TaskProofStatus,
28
28
  pub(super) recommended_commands: Vec<TaskRecommendedCommand>,
29
29
  pub(super) findings: Vec<TaskStatusFinding>,
30
+ pub(super) human_review_pending: bool,
31
+ pub(super) blocker_present: bool,
30
32
  }
31
33
 
32
34
  impl TaskStatusContext {
@@ -57,7 +59,7 @@ impl TaskStatusContext {
57
59
  let verification = read_verification_checks(root, &mut findings)?;
58
60
  let proofs = active_task.map(read_proofs).unwrap_or_default();
59
61
  add_unknown_proof_findings(&proofs, &verification, &mut findings);
60
- let proof = proof_status(active_task, &proofs, &scope.in_scope_changed_paths);
62
+ let proof = proof_status(root, active_task, &proofs, &scope.in_scope_changed_paths)?;
61
63
  add_proof_findings(
62
64
  &proof,
63
65
  &scope.in_scope_changed_paths,
@@ -77,6 +79,10 @@ impl TaskStatusContext {
77
79
  proof,
78
80
  recommended_commands,
79
81
  findings,
82
+ human_review_pending: human_review_pending(active_task),
83
+ blocker_present: task_state
84
+ .and_then(|state| state.get("blocker"))
85
+ .is_some_and(|blocker| !blocker.is_null()),
80
86
  })
81
87
  }
82
88
  }
@@ -105,6 +111,22 @@ fn add_active_task_shape_finding(
105
111
  }
106
112
  }
107
113
 
114
+ fn human_review_pending(active_task: Option<&Value>) -> bool {
115
+ active_task
116
+ .and_then(|task| task.get("humanReview"))
117
+ .and_then(Value::as_object)
118
+ .is_some_and(|review| {
119
+ review
120
+ .get("required")
121
+ .and_then(Value::as_bool)
122
+ .unwrap_or(false)
123
+ && !review
124
+ .get("approved")
125
+ .and_then(Value::as_bool)
126
+ .unwrap_or(false)
127
+ })
128
+ }
129
+
108
130
  fn task_mode(active_task: Option<&Value>) -> TaskModeStatus {
109
131
  let kind = task_text(active_task, "kind").unwrap_or_else(|| "standard".to_string());
110
132
  let declared_review_fix = active_task
@@ -12,7 +12,13 @@ pub(super) fn transition_report(
12
12
  &context.findings,
13
13
  &context.scope,
14
14
  );
15
- let blocking_findings = transition_blockers(&context.state, &context.findings, &context.proof);
15
+ let blocking_findings = transition_blockers(
16
+ &context.state,
17
+ &context.findings,
18
+ &context.proof,
19
+ context.human_review_pending,
20
+ context.blocker_present,
21
+ );
16
22
  let required_before_transition = blocking_findings
17
23
  .iter()
18
24
  .map(|finding| finding.suggested_fix.clone())
@@ -31,6 +37,8 @@ fn transition_blockers(
31
37
  state: &str,
32
38
  findings: &[TaskStatusFinding],
33
39
  proof: &TaskProofStatus,
40
+ human_review_pending: bool,
41
+ blocker_present: bool,
34
42
  ) -> Vec<TaskStatusFinding> {
35
43
  let mut blockers = findings
36
44
  .iter()
@@ -59,6 +67,26 @@ fn transition_blockers(
59
67
  "Do not complete a blocked task state.",
60
68
  ));
61
69
  }
70
+ if human_review_pending {
71
+ blockers.push(finding(
72
+ "task.transition.human_review_required",
73
+ "error",
74
+ "Task requires human review approval before completion.",
75
+ Some(".naome/task-state.json".to_string()),
76
+ "Wait for explicit human review approval before completing the task.",
77
+ "Do not complete a task while humanReview.required is true and approved is false.",
78
+ ));
79
+ }
80
+ if blocker_present && !matches!(state, "blocked" | "needs_human_review") {
81
+ blockers.push(finding(
82
+ "task.transition.blocker_present",
83
+ "error",
84
+ "Task-state has a blocker object that must be resolved before completion.",
85
+ Some(".naome/task-state.json".to_string()),
86
+ "Resolve or clear the blocker through an existing safe task-state path before completing.",
87
+ "Do not complete a task while .naome/task-state.json blocker is still present.",
88
+ ));
89
+ }
62
90
  if !proof.stale_checks.is_empty() {
63
91
  blockers.push(finding(
64
92
  "task.transition.stale_proof",