@lamentis/naome 1.4.3 → 1.4.5

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.
package/Cargo.lock CHANGED
@@ -76,7 +76,7 @@ checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
76
76
 
77
77
  [[package]]
78
78
  name = "naome-cli"
79
- version = "1.4.3"
79
+ version = "1.4.5"
80
80
  dependencies = [
81
81
  "naome-core",
82
82
  "serde_json",
@@ -84,7 +84,7 @@ dependencies = [
84
84
 
85
85
  [[package]]
86
86
  name = "naome-core"
87
- version = "1.4.3"
87
+ version = "1.4.5"
88
88
  dependencies = [
89
89
  "serde",
90
90
  "serde_json",
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "naome-cli"
3
- version = "1.4.3"
3
+ version = "1.4.5"
4
4
  edition.workspace = true
5
5
  license.workspace = true
6
6
  repository.workspace = true
@@ -1,3 +1,4 @@
1
+ use std::collections::{BTreeMap, BTreeSet};
1
2
  use std::path::Path;
2
3
 
3
4
  use naome_core::{
@@ -7,6 +8,8 @@ use serde_json::{json, Value};
7
8
 
8
9
  use super::common::{agent_session, print_json_with_session};
9
10
  use super::planner;
11
+ pub(super) use super::single_pass_action::single_pass_next_action;
12
+ use super::single_pass_action_fields::{collect_csv, collect_string_array};
10
13
 
11
14
  pub(super) fn agent_snapshot(
12
15
  root: &Path,
@@ -26,6 +29,21 @@ pub(super) fn agent_snapshot(
26
29
  );
27
30
  let (safe_to_run, deferred) = planner::split_safe_commands(&planned);
28
31
  let can_commit = transition.allowed && status.agent_loop.can_commit;
32
+ let can_record_proof = super::record::can_record_receipts(
33
+ root,
34
+ &proof_plan.proof_recording.checks_to_record,
35
+ &proof_plan.proof_recording.paths,
36
+ )?;
37
+ let next_action = single_pass_next_action(
38
+ &status,
39
+ &proof_plan,
40
+ &transition,
41
+ &planned,
42
+ &safe_to_run,
43
+ &deferred,
44
+ can_commit,
45
+ can_record_proof,
46
+ );
29
47
  let value = json!({
30
48
  "schema": "naome.task.agent-snapshot.v1",
31
49
  "state": snapshot_state(&status),
@@ -62,7 +80,7 @@ pub(super) fn agent_snapshot(
62
80
  "canComplete": transition.allowed,
63
81
  "blockingFindings": transition.blocking_findings
64
82
  },
65
- "nextAction": snapshot_next_action(&status, &proof_plan, can_commit),
83
+ "nextAction": next_action,
66
84
  "agentLoop": status.agent_loop,
67
85
  "repairPlan": status.repair_plan,
68
86
  "findings": status.findings,
@@ -76,24 +94,59 @@ pub(super) fn agent_snapshot(
76
94
  Ok(())
77
95
  }
78
96
 
79
- fn merge_commands(left: Value, right: Vec<Value>) -> Vec<Value> {
80
- let mut commands = left
97
+ pub(super) fn merge_commands(left: Value, right: Vec<Value>) -> Vec<Value> {
98
+ let commands = left
81
99
  .as_array()
82
100
  .cloned()
83
101
  .unwrap_or_default()
84
102
  .into_iter()
85
103
  .chain(right)
86
104
  .collect::<Vec<_>>();
87
- commands.sort_by(|a, b| {
88
- a.get("checkId")
105
+ let mut merged = BTreeMap::<String, Value>::new();
106
+ for command in commands {
107
+ let Some(check_id) = command
108
+ .get("checkId")
89
109
  .and_then(Value::as_str)
90
- .unwrap_or("")
91
- .cmp(b.get("checkId").and_then(Value::as_str).unwrap_or(""))
92
- });
93
- commands.dedup_by(|a, b| {
94
- a.get("checkId").and_then(Value::as_str) == b.get("checkId").and_then(Value::as_str)
95
- });
96
- commands
110
+ .map(ToString::to_string)
111
+ else {
112
+ continue;
113
+ };
114
+ if let Some(existing) = merged.get_mut(&check_id) {
115
+ merge_command(existing, &command);
116
+ } else {
117
+ merged.insert(check_id, command);
118
+ }
119
+ }
120
+ merged.into_values().collect()
121
+ }
122
+
123
+ fn merge_command(existing: &mut Value, incoming: &Value) {
124
+ merge_csv_field(existing, incoming, "reason");
125
+ existing["selectionReason"] = existing["reason"].clone();
126
+ merge_string_array_field(existing, incoming, "impactedPaths");
127
+ let safe = existing
128
+ .get("safeToExecute")
129
+ .and_then(Value::as_bool)
130
+ .unwrap_or(false)
131
+ && incoming
132
+ .get("safeToExecute")
133
+ .and_then(Value::as_bool)
134
+ .unwrap_or(false);
135
+ existing["safeToExecute"] = json!(safe);
136
+ }
137
+
138
+ fn merge_csv_field(existing: &mut Value, incoming: &Value, field: &str) {
139
+ let mut values = BTreeSet::new();
140
+ collect_csv(existing.get(field), &mut values);
141
+ collect_csv(incoming.get(field), &mut values);
142
+ existing[field] = json!(values.into_iter().collect::<Vec<_>>().join(","));
143
+ }
144
+
145
+ fn merge_string_array_field(existing: &mut Value, incoming: &Value, field: &str) {
146
+ let mut values = BTreeSet::new();
147
+ collect_string_array(existing.get(field), &mut values);
148
+ collect_string_array(incoming.get(field), &mut values);
149
+ existing[field] = json!(values.into_iter().collect::<Vec<_>>());
97
150
  }
98
151
 
99
152
  fn snapshot_state(status: &naome_core::TaskStatusReportV1) -> &'static str {
@@ -125,49 +178,3 @@ fn editable_paths(status: &naome_core::TaskStatusReportV1) -> Vec<String> {
125
178
  }
126
179
  status.scope.allowed_paths.clone()
127
180
  }
128
-
129
- fn snapshot_next_action(
130
- status: &naome_core::TaskStatusReportV1,
131
- proof_plan: &naome_core::TaskProofPlanReport,
132
- can_commit: bool,
133
- ) -> Value {
134
- let action_type = if !status.scope.out_of_scope_changed_paths.is_empty() {
135
- "repair_scope"
136
- } else if status
137
- .findings
138
- .iter()
139
- .any(|finding| finding.id.starts_with("task.git."))
140
- {
141
- "recover_git"
142
- } else if !status.proof.missing_checks.is_empty() || !status.proof.stale_checks.is_empty() {
143
- "run_checks"
144
- } else if !proof_plan.proof_recording.checks_to_record.is_empty() {
145
- "record_proof"
146
- } else if can_commit {
147
- "commit_ready"
148
- } else if status.state == "implementing" && status.agent_loop.can_continue_editing {
149
- "edit"
150
- } else {
151
- "none"
152
- };
153
- if can_commit && action_type == "commit_ready" {
154
- return json!({
155
- "type": "commit_ready",
156
- "reason": "Task is complete enough to commit; do not continue editing before commit.",
157
- "commands": [],
158
- "paths": status.scope.in_scope_changed_paths,
159
- "checkIds": [],
160
- "safeToExecute": false,
161
- "requiresUserApproval": true
162
- });
163
- }
164
- json!({
165
- "type": action_type,
166
- "reason": status.next_action_v2.reason,
167
- "commands": status.next_action_v2.commands,
168
- "paths": status.next_action_v2.paths,
169
- "checkIds": status.next_action_v2.check_ids,
170
- "safeToExecute": status.next_action_v2.safe_to_execute,
171
- "requiresUserApproval": status.next_action_v2.requires_user_approval
172
- })
173
- }
@@ -1,10 +1,12 @@
1
1
  use std::path::Path;
2
2
 
3
- use naome_core::{task_status_exit_code, task_status_report, task_transition_readiness};
4
- use naome_core::{TaskStatusFinding, TaskStatusReportV1};
5
- use serde_json::{json, Value};
3
+ use naome_core::{
4
+ task_status_exit_code, task_status_report, task_transition_readiness, TaskStatusReportV1,
5
+ };
6
+ use serde_json::json;
6
7
 
7
8
  use super::common::{agent_session, print_json_with_session};
9
+ use super::{agent_snapshot, planner};
8
10
 
9
11
  pub(super) fn commit_preflight(
10
12
  root: &Path,
@@ -12,8 +14,18 @@ pub(super) fn commit_preflight(
12
14
  ) -> Result<(), Box<dyn std::error::Error>> {
13
15
  let session = agent_session(args)?;
14
16
  let status = task_status_report(root)?;
17
+ let proof_plan = naome_core::task_proof_plan(root)?;
15
18
  let transition = task_transition_readiness(root, "complete")?;
16
19
  let would_pass = transition.allowed && status.agent_loop.can_commit;
20
+ let planned = agent_snapshot::merge_commands(
21
+ serde_json::to_value(&proof_plan.recommended_commands)?,
22
+ planner::planned_commands(
23
+ root,
24
+ &status.scope.in_scope_changed_paths,
25
+ Some(&status.proof),
26
+ ),
27
+ );
28
+ let (safe_to_run, deferred) = planner::split_safe_commands(&planned);
17
29
  let blocking = if would_pass {
18
30
  Vec::new()
19
31
  } else if !transition.blocking_findings.is_empty() {
@@ -28,7 +40,20 @@ pub(super) fn commit_preflight(
28
40
  "wouldPass": would_pass,
29
41
  "commitPaths": status.scope.in_scope_changed_paths,
30
42
  "blockingFindings": blocking,
31
- "nextAction": commit_preflight_next_action(would_pass, &blocking, &status)?,
43
+ "nextAction": agent_snapshot::single_pass_next_action(
44
+ &status,
45
+ &proof_plan,
46
+ &transition,
47
+ &planned,
48
+ &safe_to_run,
49
+ &deferred,
50
+ would_pass,
51
+ super::record::can_record_receipts(
52
+ root,
53
+ &proof_plan.proof_recording.checks_to_record,
54
+ &proof_plan.proof_recording.paths
55
+ )?
56
+ ),
32
57
  "agentInstruction": if would_pass { "Commit gate preflight is clean; use the normal NAOME commit path." } else { "Resolve blocking findings before committing." }
33
58
  }),
34
59
  session.as_deref(),
@@ -50,40 +75,3 @@ fn commit_preflight_exit_code(would_pass: bool, status: &TaskStatusReportV1) ->
50
75
  code
51
76
  }
52
77
  }
53
-
54
- fn commit_preflight_next_action(
55
- would_pass: bool,
56
- blocking: &[TaskStatusFinding],
57
- status: &TaskStatusReportV1,
58
- ) -> Result<Value, Box<dyn std::error::Error>> {
59
- if would_pass {
60
- return Ok(json!({
61
- "type": "commit_ready",
62
- "reason": "Task state, scope, proof, and transition checks are commit-ready.",
63
- "commands": [],
64
- "paths": status.scope.in_scope_changed_paths,
65
- "checkIds": [],
66
- "safeToExecute": false,
67
- "requiresUserApproval": true
68
- }));
69
- }
70
- if has_status_blocker(status) {
71
- return Ok(serde_json::to_value(&status.next_action_v2)?);
72
- }
73
- if let Some(primary) = blocking.first() {
74
- return Ok(json!({
75
- "type": "blocked",
76
- "reason": primary.message,
77
- "commands": [],
78
- "paths": primary.path.as_ref().map(|path| vec![path.clone()]).unwrap_or_default(),
79
- "checkIds": [],
80
- "safeToExecute": false,
81
- "requiresUserApproval": true
82
- }));
83
- }
84
- Ok(serde_json::to_value(&status.next_action_v2)?)
85
- }
86
-
87
- fn has_status_blocker(status: &TaskStatusReportV1) -> bool {
88
- task_status_exit_code(&status.findings, &status.proof) != 0
89
- }
@@ -4,6 +4,7 @@ use naome_core::{task_proof_plan, task_status_report, task_transition_readiness}
4
4
  use serde_json::json;
5
5
 
6
6
  use super::common::{agent_session, print_json_with_session, read_task_state, write_task_state};
7
+ use super::{agent_snapshot, planner};
7
8
 
8
9
  pub(super) fn task_loop(root: &Path, args: &[String]) -> Result<(), Box<dyn std::error::Error>> {
9
10
  let session = agent_session(args)?;
@@ -11,13 +12,19 @@ pub(super) fn task_loop(root: &Path, args: &[String]) -> Result<(), Box<dyn std:
11
12
  let mut executed_steps = Vec::new();
12
13
 
13
14
  if execute_safe {
14
- let plan = task_proof_plan(root)?;
15
- for item in plan
16
- .repair_plan
17
- .iter()
18
- .filter(|item| item.kind == "rerun_check" && item.safe_to_execute)
19
- {
20
- if let Some(check_id) = item.check_ids.first() {
15
+ let status = task_status_report(root)?;
16
+ let proof_plan = task_proof_plan(root)?;
17
+ let planned = agent_snapshot::merge_commands(
18
+ serde_json::to_value(&proof_plan.recommended_commands)?,
19
+ planner::planned_commands(
20
+ root,
21
+ &status.scope.in_scope_changed_paths,
22
+ Some(&status.proof),
23
+ ),
24
+ );
25
+ let (safe_to_run, _deferred) = planner::split_safe_commands(&planned);
26
+ for command in safe_to_run {
27
+ if let Some(check_id) = command.get("checkId").and_then(serde_json::Value::as_str) {
21
28
  let step =
22
29
  super::check_run::run_check_by_id(root, check_id, true, session.as_deref())?;
23
30
  let failed = step
@@ -49,9 +56,33 @@ pub(super) fn task_loop(root: &Path, args: &[String]) -> Result<(), Box<dyn std:
49
56
  let status = task_status_report(root)?;
50
57
  let proof_plan = task_proof_plan(root)?;
51
58
  let transition = task_transition_readiness(root, "complete")?;
59
+ let planned = agent_snapshot::merge_commands(
60
+ serde_json::to_value(&proof_plan.recommended_commands)?,
61
+ planner::planned_commands(
62
+ root,
63
+ &status.scope.in_scope_changed_paths,
64
+ Some(&status.proof),
65
+ ),
66
+ );
67
+ let (safe_to_run, deferred) = planner::split_safe_commands(&planned);
68
+ let commit_ready = transition.allowed && status.agent_loop.can_commit;
69
+ let next_action = agent_snapshot::single_pass_next_action(
70
+ &status,
71
+ &proof_plan,
72
+ &transition,
73
+ &planned,
74
+ &safe_to_run,
75
+ &deferred,
76
+ commit_ready,
77
+ super::record::can_record_receipts(
78
+ root,
79
+ &proof_plan.proof_recording.checks_to_record,
80
+ &proof_plan.proof_recording.paths,
81
+ )?,
82
+ );
52
83
  let can_commit = json!({
53
84
  "schema": "naome.task.commit-readiness.v1",
54
- "allowed": transition.allowed && status.agent_loop.can_commit,
85
+ "allowed": commit_ready,
55
86
  "commitPaths": status.scope.in_scope_changed_paths,
56
87
  "blockingFindings": transition.blocking_findings,
57
88
  "agentLoop": status.agent_loop
@@ -65,7 +96,7 @@ pub(super) fn task_loop(root: &Path, args: &[String]) -> Result<(), Box<dyn std:
65
96
  "canTransition": transition,
66
97
  "canCommit": can_commit,
67
98
  "executedSteps": executed_steps,
68
- "nextAction": status.next_action_v2,
99
+ "nextAction": next_action,
69
100
  "agentInstruction": if execute_safe { "Executed only safe check/proof steps; no edits, git recovery, commit, push, or PR actions were performed." } else { "Read-only loop report; execute only safe plans explicitly marked safe." }
70
101
  }),
71
102
  session.as_deref(),
@@ -119,6 +119,9 @@ fn proof_reason(reasons: &BTreeSet<String>) -> &'static str {
119
119
 
120
120
  fn check_is_impacted(check: &VerificationCheck, path: &str) -> bool {
121
121
  let command = check.command.as_str();
122
+ if command == "git diff --check" {
123
+ return true;
124
+ }
122
125
  match impact_kind(path) {
123
126
  "changed_task_state" => {
124
127
  command.contains("check-task-state") || command.contains("test:task-state")
@@ -137,8 +140,7 @@ fn check_is_impacted(check: &VerificationCheck, path: &str) -> bool {
137
140
  || command.contains("test:")
138
141
  }
139
142
  _ => {
140
- command == "git diff --check"
141
- || command.contains("quality check")
143
+ command.contains("quality check")
142
144
  || command.contains("semantic check")
143
145
  || command.contains("arch validate")
144
146
  }
@@ -29,6 +29,11 @@ pub(super) fn preflight(root: &Path, args: &[String]) -> Result<(), Box<dyn std:
29
29
  let recommended = planner::planned_commands(root, &normalized_paths, Some(&status.proof));
30
30
  let (safe, _deferred) = planner::split_safe_commands(&recommended);
31
31
  let blocked = !findings.is_empty() || !must_not_edit.is_empty();
32
+ let next_action_paths = if blocked {
33
+ must_not_edit.clone()
34
+ } else {
35
+ normalized_paths.clone()
36
+ };
32
37
  print_json_with_session(
33
38
  json!({
34
39
  "schema": "naome.task.preflight.v1",
@@ -40,8 +45,9 @@ pub(super) fn preflight(root: &Path, args: &[String]) -> Result<(), Box<dyn std:
40
45
  "findings": findings,
41
46
  "nextAction": {
42
47
  "type": if blocked { "blocked" } else { "edit" },
43
- "reason": if blocked { "Preflight is blocked until every requested path is explicit and editable." } else { "All requested paths are editable." },
44
- "paths": must_not_edit,
48
+ "actionId": if blocked { "preflight.blocked" } else { "preflight.edit_allowed" },
49
+ "reasonCodes": if blocked { vec!["preflight-blocked"] } else { vec!["preflight-clean"] },
50
+ "paths": next_action_paths,
45
51
  "commands": [],
46
52
  "checkIds": [],
47
53
  "safeToExecute": !blocked,
@@ -98,6 +98,15 @@ pub(super) fn record_receipts_for_checks(
98
98
  Ok(true)
99
99
  }
100
100
 
101
+ pub(super) fn can_record_receipts(
102
+ root: &Path,
103
+ check_ids: &[String],
104
+ paths: &[String],
105
+ ) -> Result<bool, Box<dyn std::error::Error>> {
106
+ let (can_record, _findings, receipts) = recordable_receipts(root, check_ids, paths)?;
107
+ Ok(can_record && !receipts.is_empty())
108
+ }
109
+
101
110
  fn write_proof_batch(
102
111
  root: &Path,
103
112
  path_set_base: &str,
@@ -0,0 +1,215 @@
1
+ use naome_core::{TaskProofPlanReport, TaskStatusReportV1, TransitionReadinessReport};
2
+ use serde_json::{json, Value};
3
+
4
+ use super::single_pass_action_fields::{
5
+ action, check_ids, impacted_paths, primary_finding_id, reason_codes,
6
+ };
7
+
8
+ pub(super) fn single_pass_next_action(
9
+ status: &TaskStatusReportV1,
10
+ proof_plan: &TaskProofPlanReport,
11
+ transition: &TransitionReadinessReport,
12
+ planned: &[Value],
13
+ safe_to_run: &[Value],
14
+ deferred: &[Value],
15
+ can_commit: bool,
16
+ can_record_proof: bool,
17
+ ) -> Value {
18
+ human_review_action(transition)
19
+ .or_else(|| git_recovery_action(status))
20
+ .or_else(|| scope_drift_action(status))
21
+ .or_else(|| record_action(proof_plan, can_record_proof))
22
+ .or_else(|| proof_action(status, safe_to_run, deferred))
23
+ .or_else(|| transition_blocker_action(transition))
24
+ .unwrap_or_else(|| {
25
+ if can_commit {
26
+ commit_action(status)
27
+ } else if status.state == "implementing" && status.agent_loop.can_continue_editing {
28
+ edit_action(status)
29
+ } else {
30
+ no_action(status, planned)
31
+ }
32
+ })
33
+ }
34
+
35
+ fn human_review_action(transition: &TransitionReadinessReport) -> Option<Value> {
36
+ transition
37
+ .blocking_findings
38
+ .iter()
39
+ .find(|finding| finding.id == "task.transition.human_review_required")
40
+ .map(|finding| {
41
+ blocking_action(
42
+ "human_review",
43
+ "human_review.required",
44
+ &["human-review-required"],
45
+ finding.path.iter().cloned().collect(),
46
+ &finding.id,
47
+ )
48
+ })
49
+ }
50
+
51
+ fn git_recovery_action(status: &TaskStatusReportV1) -> Option<Value> {
52
+ status
53
+ .findings
54
+ .iter()
55
+ .any(|finding| finding.id.starts_with("task.git."))
56
+ .then(|| {
57
+ action(
58
+ "recover_git_state",
59
+ "git.recover_state",
60
+ &["git-recovery-required"],
61
+ &[],
62
+ Vec::new(),
63
+ &[],
64
+ false,
65
+ true,
66
+ primary_finding_id(status, "task.git.").as_deref(),
67
+ )
68
+ })
69
+ }
70
+
71
+ fn scope_drift_action(status: &TaskStatusReportV1) -> Option<Value> {
72
+ (!status.scope.out_of_scope_changed_paths.is_empty()).then(|| {
73
+ action(
74
+ "repair_scope",
75
+ "scope.repair_drift",
76
+ &["scope-drift"],
77
+ &[],
78
+ status.scope.out_of_scope_changed_paths.clone(),
79
+ &[],
80
+ false,
81
+ true,
82
+ primary_finding_id(status, "task.scope.out_of_scope_change").as_deref(),
83
+ )
84
+ })
85
+ }
86
+
87
+ fn proof_action(status: &TaskStatusReportV1, safe: &[Value], deferred: &[Value]) -> Option<Value> {
88
+ (!status.proof.missing_checks.is_empty() || !status.proof.stale_checks.is_empty()).then(|| {
89
+ let commands = if safe.is_empty() { deferred } else { safe };
90
+ action(
91
+ "run_checks",
92
+ "proof.run_required_checks",
93
+ &reason_codes(commands, &status.proof),
94
+ commands,
95
+ impacted_paths(commands, &status.scope.in_scope_changed_paths),
96
+ &check_ids(commands, &status.proof),
97
+ !safe.is_empty(),
98
+ safe.is_empty(),
99
+ primary_finding_id(status, "task.proof.")
100
+ .or_else(|| primary_finding_id(status, "task.scope.missing_evidence"))
101
+ .as_deref(),
102
+ )
103
+ })
104
+ }
105
+
106
+ fn record_action(proof_plan: &TaskProofPlanReport, can_record_proof: bool) -> Option<Value> {
107
+ can_record_proof.then(|| {
108
+ action(
109
+ "record_proof",
110
+ "proof.record_from_receipts",
111
+ &["proof-recording-available"],
112
+ &[json!({
113
+ "command": "node .naome/bin/naome.js task record-proof --from-proof-plan --json",
114
+ "cwd": ".",
115
+ "safeToExecute": true
116
+ })],
117
+ proof_plan.proof_recording.paths.clone(),
118
+ &proof_plan.proof_recording.checks_to_record,
119
+ true,
120
+ false,
121
+ None,
122
+ )
123
+ })
124
+ }
125
+
126
+ fn transition_blocker_action(transition: &TransitionReadinessReport) -> Option<Value> {
127
+ transition
128
+ .blocking_findings
129
+ .iter()
130
+ .find(|finding| unhandled_transition_blocker(&finding.id))
131
+ .map(|finding| {
132
+ blocking_action(
133
+ "blocked",
134
+ "transition.blocked",
135
+ &["transition-blocked"],
136
+ finding.path.iter().cloned().collect(),
137
+ &finding.id,
138
+ )
139
+ })
140
+ }
141
+
142
+ fn blocking_action(
143
+ action_type: &str,
144
+ action_id: &str,
145
+ reason_codes: &[&str],
146
+ paths: Vec<String>,
147
+ finding_id: &str,
148
+ ) -> Value {
149
+ action(
150
+ action_type,
151
+ action_id,
152
+ reason_codes,
153
+ &[],
154
+ paths,
155
+ &[],
156
+ false,
157
+ true,
158
+ Some(finding_id),
159
+ )
160
+ }
161
+
162
+ fn unhandled_transition_blocker(id: &str) -> bool {
163
+ !(id == "task.transition.human_review_required"
164
+ || id.starts_with("task.git.")
165
+ || id.starts_with("task.proof.")
166
+ || id == "task.scope.out_of_scope_change"
167
+ || id == "task.scope.missing_evidence"
168
+ || id == "task.transition.stale_proof")
169
+ }
170
+
171
+ fn commit_action(status: &TaskStatusReportV1) -> Value {
172
+ action(
173
+ "commit_ready",
174
+ "commit.ready",
175
+ &["commit-ready"],
176
+ &[],
177
+ status.scope.in_scope_changed_paths.clone(),
178
+ &[],
179
+ false,
180
+ true,
181
+ None,
182
+ )
183
+ }
184
+
185
+ fn edit_action(status: &TaskStatusReportV1) -> Value {
186
+ action(
187
+ "edit",
188
+ "edit.required",
189
+ &["edit-required"],
190
+ &[json!({
191
+ "command": "node .naome/bin/naome.js task preflight --path <path> --json",
192
+ "cwd": ".",
193
+ "safeToExecute": false
194
+ })],
195
+ status.scope.allowed_paths.clone(),
196
+ &[],
197
+ false,
198
+ true,
199
+ None,
200
+ )
201
+ }
202
+
203
+ fn no_action(status: &TaskStatusReportV1, planned: &[Value]) -> Value {
204
+ action(
205
+ "none",
206
+ "task.no_action",
207
+ &["no-action"],
208
+ planned,
209
+ status.scope.in_scope_changed_paths.clone(),
210
+ &[],
211
+ false,
212
+ false,
213
+ None,
214
+ )
215
+ }
@@ -0,0 +1,101 @@
1
+ use std::collections::BTreeSet;
2
+
3
+ use naome_core::{TaskProofStatus, TaskStatusReportV1};
4
+ use serde_json::{json, Value};
5
+
6
+ pub(super) fn action(
7
+ action_type: &str,
8
+ action_id: &str,
9
+ reason_codes: &[impl AsRef<str>],
10
+ commands: &[Value],
11
+ paths: Vec<String>,
12
+ check_ids: &[String],
13
+ safe_to_execute: bool,
14
+ requires_user_approval: bool,
15
+ primary_finding_id: Option<&str>,
16
+ ) -> Value {
17
+ json!({
18
+ "type": action_type,
19
+ "actionId": action_id,
20
+ "reasonCodes": reason_codes.iter().map(|code| code.as_ref()).collect::<Vec<_>>(),
21
+ "primaryFindingId": primary_finding_id,
22
+ "commands": commands,
23
+ "paths": paths,
24
+ "checkIds": check_ids,
25
+ "safeToExecute": safe_to_execute,
26
+ "requiresUserApproval": requires_user_approval
27
+ })
28
+ }
29
+
30
+ pub(super) fn primary_finding_id(status: &TaskStatusReportV1, prefix: &str) -> Option<String> {
31
+ status
32
+ .findings
33
+ .iter()
34
+ .find(|finding| finding.id.starts_with(prefix))
35
+ .map(|finding| finding.id.clone())
36
+ }
37
+
38
+ pub(super) fn reason_codes(commands: &[Value], proof: &TaskProofStatus) -> Vec<String> {
39
+ let mut codes = BTreeSet::new();
40
+ for command in commands {
41
+ collect_csv(command.get("reason"), &mut codes);
42
+ collect_csv(command.get("selectionReason"), &mut codes);
43
+ }
44
+ if !proof.missing_checks.is_empty() {
45
+ codes.insert("missing-proof".to_string());
46
+ }
47
+ if !proof.stale_checks.is_empty() {
48
+ codes.insert("stale-proof".to_string());
49
+ }
50
+ codes.into_iter().collect()
51
+ }
52
+
53
+ pub(super) fn impacted_paths(commands: &[Value], fallback: &[String]) -> Vec<String> {
54
+ let mut paths = BTreeSet::new();
55
+ for command in commands {
56
+ collect_string_array(command.get("impactedPaths"), &mut paths);
57
+ }
58
+ if paths.is_empty() {
59
+ paths.extend(fallback.iter().cloned());
60
+ }
61
+ paths.into_iter().collect()
62
+ }
63
+
64
+ pub(super) fn check_ids(commands: &[Value], proof: &TaskProofStatus) -> Vec<String> {
65
+ let mut ids = BTreeSet::new();
66
+ ids.extend(commands.iter().filter_map(check_id));
67
+ if ids.is_empty() {
68
+ ids.extend(proof.missing_checks.iter().cloned());
69
+ ids.extend(proof.stale_checks.iter().cloned());
70
+ }
71
+ ids.into_iter().collect()
72
+ }
73
+
74
+ fn check_id(command: &Value) -> Option<String> {
75
+ command
76
+ .get("checkId")
77
+ .and_then(Value::as_str)
78
+ .map(ToString::to_string)
79
+ }
80
+
81
+ pub(super) fn collect_csv(value: Option<&Value>, values: &mut BTreeSet<String>) {
82
+ if let Some(raw) = value.and_then(Value::as_str) {
83
+ values.extend(
84
+ raw.split(',')
85
+ .map(str::trim)
86
+ .filter(|part| !part.is_empty())
87
+ .map(ToString::to_string),
88
+ );
89
+ }
90
+ }
91
+
92
+ pub(super) fn collect_string_array(value: Option<&Value>, values: &mut BTreeSet<String>) {
93
+ values.extend(
94
+ value
95
+ .and_then(Value::as_array)
96
+ .into_iter()
97
+ .flatten()
98
+ .filter_map(Value::as_str)
99
+ .map(ToString::to_string),
100
+ );
101
+ }
@@ -16,6 +16,8 @@ mod record;
16
16
  mod repair;
17
17
  mod scope_request;
18
18
  mod scope_suggestions;
19
+ mod single_pass_action;
20
+ mod single_pass_action_fields;
19
21
  mod timeline;
20
22
 
21
23
  use naome_core::{
@@ -7,7 +7,7 @@ mod task_cli_support;
7
7
 
8
8
  use task_cli_support::{
9
9
  active_task, fixture_root, git, init_git, run_json, task_state, task_state_with_active_task,
10
- write_fixture_file, write_json,
10
+ write_fixture_file, write_json, write_verification_checks,
11
11
  };
12
12
 
13
13
  #[test]
@@ -21,9 +21,63 @@ fn agent_snapshot_reports_missing_proof_and_safe_commands() {
21
21
  assert_eq!(snapshot["schema"], "naome.task.agent-snapshot.v1");
22
22
  assert_eq!(snapshot["proof"]["missingChecks"], json!(["diff-check"]));
23
23
  assert_eq!(snapshot["nextAction"]["type"], "run_checks");
24
+ assert_eq!(
25
+ snapshot["nextAction"]["actionId"],
26
+ "proof.run_required_checks"
27
+ );
28
+ assert_eq!(
29
+ snapshot["nextAction"]["reasonCodes"],
30
+ json!(["changed_docs", "missing-proof"])
31
+ );
32
+ assert_eq!(
33
+ snapshot["nextAction"]["commands"][0]["command"],
34
+ "git diff --check"
35
+ );
24
36
  assert_eq!(snapshot["checks"]["safeToRun"][0]["checkId"], "diff-check");
25
37
  }
26
38
 
39
+ #[test]
40
+ fn agent_snapshot_merges_duplicate_check_reasons_from_proof_and_impact_plans() {
41
+ let root = fixture_root(task_state_with_active_task(active_task(json!({
42
+ "allowedPaths": ["src/lib.rs"],
43
+ "requiredCheckIds": ["repository-quality-check"],
44
+ "proofResults": []
45
+ }))));
46
+ write_verification_checks(
47
+ &root,
48
+ json!([{
49
+ "id": "repository-quality-check",
50
+ "command": "node .naome/bin/naome.js quality check --changed",
51
+ "cwd": ".",
52
+ "purpose": "Validate changed-file quality.",
53
+ "cost": "fast",
54
+ "source": "NAOME",
55
+ "evidence": [".naome/repository-quality.json"],
56
+ "lastVerified": null
57
+ }]),
58
+ );
59
+ init_git(&root);
60
+ write_fixture_file(&root, "src/lib.rs", "pub fn changed() {}\n");
61
+
62
+ let snapshot = run_json(&root, ["task", "agent-snapshot", "--json"]);
63
+ let commands = snapshot["checks"]["recommended"].as_array().unwrap();
64
+ let quality_commands = commands
65
+ .iter()
66
+ .filter(|command| command["checkId"] == "repository-quality-check")
67
+ .collect::<Vec<_>>();
68
+
69
+ assert_eq!(quality_commands.len(), 1);
70
+ assert_eq!(
71
+ quality_commands[0]["reason"],
72
+ "changed_source,missing-proof"
73
+ );
74
+ assert_eq!(
75
+ quality_commands[0]["selectionReason"],
76
+ "changed_source,missing-proof"
77
+ );
78
+ assert_eq!(quality_commands[0]["impactedPaths"], json!(["src/lib.rs"]));
79
+ }
80
+
27
81
  #[test]
28
82
  fn preflight_reports_path_policy_and_check_plan() {
29
83
  let root = fixture_root(task_state_with_active_task(active_task(json!({
@@ -43,6 +97,12 @@ fn preflight_reports_path_policy_and_check_plan() {
43
97
  );
44
98
  assert_eq!(allowed["paths"][0]["editable"], true);
45
99
  assert_eq!(allowed["paths"][0]["risk"], "medium");
100
+ assert_eq!(allowed["nextAction"]["actionId"], "preflight.edit_allowed");
101
+ assert_eq!(allowed["nextAction"]["paths"], json!(["scripts/check.js"]));
102
+ assert_eq!(
103
+ allowed["nextAction"]["reasonCodes"],
104
+ json!(["preflight-clean"])
105
+ );
46
106
 
47
107
  let ignored = run_json(
48
108
  &root,
@@ -50,6 +110,12 @@ fn preflight_reports_path_policy_and_check_plan() {
50
110
  );
51
111
  assert_eq!(ignored["paths"][0]["editable"], false);
52
112
  assert_eq!(ignored["findings"][0]["id"], "task.preflight.ignored_path");
113
+ assert_eq!(ignored["nextAction"]["actionId"], "preflight.blocked");
114
+ assert_eq!(ignored["nextAction"]["paths"], json!(["dist/bundle.js"]));
115
+ assert_eq!(
116
+ ignored["nextAction"]["reasonCodes"],
117
+ json!(["preflight-blocked"])
118
+ );
53
119
 
54
120
  let traversal = run_json(
55
121
  &root,
@@ -79,6 +145,7 @@ fn preflight_blocks_when_no_target_paths_are_selected() {
79
145
  "task.preflight.missing_target_paths"
80
146
  );
81
147
  assert_eq!(preflight["nextAction"]["type"], "blocked");
148
+ assert_eq!(preflight["nextAction"]["actionId"], "preflight.blocked");
82
149
  assert_eq!(preflight["nextAction"]["safeToExecute"], false);
83
150
  }
84
151
 
@@ -111,7 +178,11 @@ fn commit_preflight_blocks_missing_proof() {
111
178
  preflight["blockingFindings"][0]["id"],
112
179
  "task.proof.missing_check"
113
180
  );
114
- assert_eq!(preflight["nextAction"]["type"], "rerun_checks");
181
+ assert_eq!(preflight["nextAction"]["type"], "run_checks");
182
+ assert_eq!(
183
+ preflight["nextAction"]["actionId"],
184
+ "proof.run_required_checks"
185
+ );
115
186
  assert_eq!(preflight["nextAction"]["checkIds"], json!(["diff-check"]));
116
187
  }
117
188
 
@@ -138,9 +209,120 @@ fn agent_snapshot_prefers_commit_ready_over_more_editing() {
138
209
 
139
210
  assert_eq!(snapshot["commit"]["canCommit"], true);
140
211
  assert_eq!(snapshot["nextAction"]["type"], "commit_ready");
212
+ assert_eq!(snapshot["nextAction"]["actionId"], "commit.ready");
213
+ assert_eq!(
214
+ snapshot["nextAction"]["reasonCodes"],
215
+ json!(["commit-ready"])
216
+ );
141
217
  assert_eq!(snapshot["nextAction"]["safeToExecute"], false);
142
218
  }
143
219
 
220
+ #[test]
221
+ fn agent_snapshot_reports_stale_proof_as_concrete_check_action() {
222
+ let root = fixture_root(task_state_with_active_task(active_task(json!({
223
+ "allowedPaths": ["README.md", "docs/**"],
224
+ "requiredCheckIds": ["diff-check"],
225
+ "proofPathSets": {
226
+ "old": ["README.md"]
227
+ },
228
+ "proofBatches": [{
229
+ "id": "old-proof",
230
+ "checkedAt": "2026-05-04T12:00:00.000Z",
231
+ "evidencePathSet": "old",
232
+ "proofs": [{ "checkId": "diff-check", "exitCode": 0 }]
233
+ }]
234
+ }))));
235
+ init_git(&root);
236
+ write_fixture_file(&root, "README.md", "covered\n");
237
+ write_fixture_file(&root, "docs/new.md", "not-covered\n");
238
+
239
+ let snapshot = run_json(&root, ["task", "agent-snapshot", "--json"]);
240
+
241
+ assert_eq!(snapshot["nextAction"]["type"], "run_checks");
242
+ assert_eq!(
243
+ snapshot["nextAction"]["actionId"],
244
+ "proof.run_required_checks"
245
+ );
246
+ assert_eq!(
247
+ snapshot["nextAction"]["reasonCodes"],
248
+ json!(["changed_docs", "stale-proof"])
249
+ );
250
+ assert_eq!(snapshot["nextAction"]["checkIds"], json!(["diff-check"]));
251
+ assert_eq!(
252
+ snapshot["nextAction"]["paths"],
253
+ json!(["README.md", "docs/new.md"])
254
+ );
255
+ }
256
+
257
+ #[test]
258
+ fn agent_snapshot_blocks_scope_drift_before_edit_or_commit_paths() {
259
+ let root = fixture_root(task_state());
260
+ init_git(&root);
261
+ write_fixture_file(&root, "README.md", "changed\n");
262
+ write_fixture_file(&root, "src/outside.rs", "pub fn outside() {}\n");
263
+
264
+ let snapshot = run_json(&root, ["task", "agent-snapshot", "--json"]);
265
+
266
+ assert_eq!(snapshot["nextAction"]["type"], "repair_scope");
267
+ assert_eq!(snapshot["nextAction"]["actionId"], "scope.repair_drift");
268
+ assert_eq!(
269
+ snapshot["nextAction"]["reasonCodes"],
270
+ json!(["scope-drift"])
271
+ );
272
+ assert_eq!(snapshot["nextAction"]["paths"], json!(["src/outside.rs"]));
273
+ assert_eq!(snapshot["nextAction"]["safeToExecute"], false);
274
+ assert_eq!(snapshot["nextAction"]["requiresUserApproval"], true);
275
+ }
276
+
277
+ #[test]
278
+ fn task_loop_execute_safe_records_proof_compacts_and_returns_commit_ready() {
279
+ let root = fixture_root(task_state());
280
+ let mut state: Value =
281
+ serde_json::from_str(&fs::read_to_string(root.join(".naome/task-state.json")).unwrap())
282
+ .unwrap();
283
+ state["activeTask"]["proofResults"] = json!([{
284
+ "checkId": "old-check",
285
+ "command": "old",
286
+ "cwd": ".",
287
+ "exitCode": 0,
288
+ "checkedAt": "2026-05-14T00:00:00.000Z",
289
+ "evidence": ["README.md"],
290
+ "stdoutSummary": "verbose",
291
+ "stderrSummary": "verbose",
292
+ "durationMs": 99
293
+ }]);
294
+ write_json(&root, ".naome/task-state.json", &state);
295
+ init_git(&root);
296
+ write_fixture_file(&root, "README.md", "changed\n");
297
+
298
+ let looped = run_json(&root, ["task", "loop", "--execute-safe", "--json"]);
299
+
300
+ assert_eq!(looped["schema"], "naome.task.loop.v1");
301
+ assert_eq!(looped["nextAction"]["type"], "commit_ready");
302
+ assert_eq!(looped["nextAction"]["actionId"], "commit.ready");
303
+ assert_eq!(looped["nextAction"]["reasonCodes"], json!(["commit-ready"]));
304
+ assert!(looped["executedSteps"]
305
+ .as_array()
306
+ .unwrap()
307
+ .iter()
308
+ .any(|step| {
309
+ step["schema"] == "naome.task.run-check.v1"
310
+ && step["checkId"] == "diff-check"
311
+ && step["recordedProof"] == true
312
+ }));
313
+ assert!(looped["executedSteps"]
314
+ .as_array()
315
+ .unwrap()
316
+ .iter()
317
+ .any(|step| {
318
+ step["schema"] == "naome.task.compact-proof.v1" && step["compacted"] == true
319
+ }));
320
+
321
+ let changed = fs::read_to_string(root.join(".naome/task-state.json")).unwrap();
322
+ assert!(!changed.contains("stdoutSummary"));
323
+ assert!(changed.contains("evidenceFingerprint"));
324
+ }
325
+
144
326
  #[test]
145
327
  fn commit_preflight_surfaces_transition_blocker_action_and_exit_code() {
146
328
  let root = fixture_root(task_state_with_active_task(active_task(json!({
@@ -162,7 +344,12 @@ fn commit_preflight_surfaces_transition_blocker_action_and_exit_code() {
162
344
  preflight["blockingFindings"][0]["id"],
163
345
  "task.transition.human_review_required"
164
346
  );
165
- assert_eq!(preflight["nextAction"]["type"], "blocked");
347
+ assert_eq!(preflight["nextAction"]["type"], "human_review");
348
+ assert_eq!(preflight["nextAction"]["actionId"], "human_review.required");
349
+ assert_eq!(
350
+ preflight["nextAction"]["reasonCodes"],
351
+ json!(["human-review-required"])
352
+ );
166
353
  assert_eq!(preflight["nextAction"]["safeToExecute"], false);
167
354
 
168
355
  let output = Command::new(env!("CARGO_BIN_EXE_naome"))
@@ -0,0 +1,107 @@
1
+ use serde_json::json;
2
+
3
+ mod task_cli_support;
4
+
5
+ use task_cli_support::{
6
+ active_task, fixture_root, init_git, run_json, task_state, task_state_with_active_task,
7
+ write_fixture_file, write_verification_checks,
8
+ };
9
+
10
+ #[test]
11
+ fn agent_snapshot_records_existing_receipts_before_rerunning_checks() {
12
+ let root = fixture_root(task_state());
13
+ init_git(&root);
14
+ write_fixture_file(&root, "README.md", "changed\n");
15
+
16
+ let checked = run_json(
17
+ &root,
18
+ ["task", "run-check", "--check", "diff-check", "--json"],
19
+ );
20
+ assert_eq!(checked["exitCode"], 0);
21
+ assert_eq!(checked["recordedProof"], false);
22
+
23
+ let snapshot = run_json(&root, ["task", "agent-snapshot", "--json"]);
24
+
25
+ assert_eq!(snapshot["nextAction"]["type"], "record_proof");
26
+ assert_eq!(
27
+ snapshot["nextAction"]["actionId"],
28
+ "proof.record_from_receipts"
29
+ );
30
+ assert_eq!(snapshot["nextAction"]["checkIds"], json!(["diff-check"]));
31
+ assert_eq!(snapshot["nextAction"]["safeToExecute"], true);
32
+ }
33
+
34
+ #[test]
35
+ fn commit_preflight_surfaces_generic_transition_blocker_action() {
36
+ let mut state = task_state_with_active_task(active_task(json!({
37
+ "requiredCheckIds": [],
38
+ "proofResults": []
39
+ })));
40
+ state["status"] = json!("blocked");
41
+ let root = fixture_root(state);
42
+ init_git(&root);
43
+
44
+ let preflight = run_json(&root, ["task", "commit-preflight", "--json"]);
45
+
46
+ assert_eq!(preflight["wouldPass"], false);
47
+ assert_eq!(
48
+ preflight["blockingFindings"][0]["id"],
49
+ "task.transition.blocked_state"
50
+ );
51
+ assert_eq!(preflight["nextAction"]["type"], "blocked");
52
+ assert_eq!(preflight["nextAction"]["actionId"], "transition.blocked");
53
+ assert_eq!(
54
+ preflight["nextAction"]["reasonCodes"],
55
+ json!(["transition-blocked"])
56
+ );
57
+ assert_eq!(
58
+ preflight["nextAction"]["primaryFindingId"],
59
+ "task.transition.blocked_state"
60
+ );
61
+ }
62
+
63
+ #[test]
64
+ fn task_loop_execute_safe_runs_merged_safe_impact_plan() {
65
+ let root = fixture_root(task_state_with_active_task(active_task(json!({
66
+ "allowedPaths": ["src/lib.rs"],
67
+ "requiredCheckIds": ["diff-check"],
68
+ "proofResults": []
69
+ }))));
70
+ write_verification_checks(
71
+ &root,
72
+ json!([
73
+ {
74
+ "id": "diff-check",
75
+ "command": "git diff --check",
76
+ "cwd": ".",
77
+ "purpose": "Detect whitespace and patch formatting issues.",
78
+ "cost": "fast",
79
+ "source": "git",
80
+ "evidence": ["src/lib.rs"],
81
+ "lastVerified": null
82
+ },
83
+ {
84
+ "id": "repository-quality-check",
85
+ "command": "git diff --check",
86
+ "cwd": ".",
87
+ "purpose": "Exercise an additional safe impact-planned check.",
88
+ "cost": "fast",
89
+ "source": "git",
90
+ "evidence": ["src/lib.rs"],
91
+ "lastVerified": null
92
+ }
93
+ ]),
94
+ );
95
+ init_git(&root);
96
+ write_fixture_file(&root, "src/lib.rs", "pub fn changed() {}\n");
97
+
98
+ let looped = run_json(&root, ["task", "loop", "--execute-safe", "--json"]);
99
+ let executed = looped["executedSteps"].as_array().unwrap();
100
+
101
+ assert!(executed
102
+ .iter()
103
+ .any(|step| step["checkId"] == "diff-check" && step["executed"] == true));
104
+ assert!(executed
105
+ .iter()
106
+ .any(|step| { step["checkId"] == "repository-quality-check" && step["executed"] == true }));
107
+ }
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "naome-core"
3
- version = "1.4.3"
3
+ version = "1.4.5"
4
4
  edition.workspace = true
5
5
  license.workspace = true
6
6
  repository.workspace = true
Binary file
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lamentis/naome",
3
- "version": "1.4.3",
3
+ "version": "1.4.5",
4
4
  "description": "Native-first CLI for the NAOME agent harness.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",