@lamentis/naome 1.4.4 → 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.4"
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.4"
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.4"
3
+ version = "1.4.5"
4
4
  edition.workspace = true
5
5
  license.workspace = true
6
6
  repository.workspace = true
@@ -8,6 +8,8 @@ use serde_json::{json, Value};
8
8
 
9
9
  use super::common::{agent_session, print_json_with_session};
10
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};
11
13
 
12
14
  pub(super) fn agent_snapshot(
13
15
  root: &Path,
@@ -27,6 +29,21 @@ pub(super) fn agent_snapshot(
27
29
  );
28
30
  let (safe_to_run, deferred) = planner::split_safe_commands(&planned);
29
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
+ );
30
47
  let value = json!({
31
48
  "schema": "naome.task.agent-snapshot.v1",
32
49
  "state": snapshot_state(&status),
@@ -63,7 +80,7 @@ pub(super) fn agent_snapshot(
63
80
  "canComplete": transition.allowed,
64
81
  "blockingFindings": transition.blocking_findings
65
82
  },
66
- "nextAction": snapshot_next_action(&status, &proof_plan, can_commit),
83
+ "nextAction": next_action,
67
84
  "agentLoop": status.agent_loop,
68
85
  "repairPlan": status.repair_plan,
69
86
  "findings": status.findings,
@@ -77,7 +94,7 @@ pub(super) fn agent_snapshot(
77
94
  Ok(())
78
95
  }
79
96
 
80
- fn merge_commands(left: Value, right: Vec<Value>) -> Vec<Value> {
97
+ pub(super) fn merge_commands(left: Value, right: Vec<Value>) -> Vec<Value> {
81
98
  let commands = left
82
99
  .as_array()
83
100
  .cloned()
@@ -125,17 +142,6 @@ fn merge_csv_field(existing: &mut Value, incoming: &Value, field: &str) {
125
142
  existing[field] = json!(values.into_iter().collect::<Vec<_>>().join(","));
126
143
  }
127
144
 
128
- fn collect_csv(value: Option<&Value>, values: &mut BTreeSet<String>) {
129
- if let Some(raw) = value.and_then(Value::as_str) {
130
- values.extend(
131
- raw.split(',')
132
- .map(str::trim)
133
- .filter(|part| !part.is_empty())
134
- .map(ToString::to_string),
135
- );
136
- }
137
- }
138
-
139
145
  fn merge_string_array_field(existing: &mut Value, incoming: &Value, field: &str) {
140
146
  let mut values = BTreeSet::new();
141
147
  collect_string_array(existing.get(field), &mut values);
@@ -143,17 +149,6 @@ fn merge_string_array_field(existing: &mut Value, incoming: &Value, field: &str)
143
149
  existing[field] = json!(values.into_iter().collect::<Vec<_>>());
144
150
  }
145
151
 
146
- fn collect_string_array(value: Option<&Value>, values: &mut BTreeSet<String>) {
147
- values.extend(
148
- value
149
- .and_then(Value::as_array)
150
- .into_iter()
151
- .flatten()
152
- .filter_map(Value::as_str)
153
- .map(ToString::to_string),
154
- );
155
- }
156
-
157
152
  fn snapshot_state(status: &naome_core::TaskStatusReportV1) -> &'static str {
158
153
  if status
159
154
  .findings
@@ -183,49 +178,3 @@ fn editable_paths(status: &naome_core::TaskStatusReportV1) -> Vec<String> {
183
178
  }
184
179
  status.scope.allowed_paths.clone()
185
180
  }
186
-
187
- fn snapshot_next_action(
188
- status: &naome_core::TaskStatusReportV1,
189
- proof_plan: &naome_core::TaskProofPlanReport,
190
- can_commit: bool,
191
- ) -> Value {
192
- let action_type = if !status.scope.out_of_scope_changed_paths.is_empty() {
193
- "repair_scope"
194
- } else if status
195
- .findings
196
- .iter()
197
- .any(|finding| finding.id.starts_with("task.git."))
198
- {
199
- "recover_git"
200
- } else if !status.proof.missing_checks.is_empty() || !status.proof.stale_checks.is_empty() {
201
- "run_checks"
202
- } else if !proof_plan.proof_recording.checks_to_record.is_empty() {
203
- "record_proof"
204
- } else if can_commit {
205
- "commit_ready"
206
- } else if status.state == "implementing" && status.agent_loop.can_continue_editing {
207
- "edit"
208
- } else {
209
- "none"
210
- };
211
- if can_commit && action_type == "commit_ready" {
212
- return json!({
213
- "type": "commit_ready",
214
- "reason": "Task is complete enough to commit; do not continue editing before commit.",
215
- "commands": [],
216
- "paths": status.scope.in_scope_changed_paths,
217
- "checkIds": [],
218
- "safeToExecute": false,
219
- "requiresUserApproval": true
220
- });
221
- }
222
- json!({
223
- "type": action_type,
224
- "reason": status.next_action_v2.reason,
225
- "commands": status.next_action_v2.commands,
226
- "paths": status.next_action_v2.paths,
227
- "checkIds": status.next_action_v2.check_ids,
228
- "safeToExecute": status.next_action_v2.safe_to_execute,
229
- "requiresUserApproval": status.next_action_v2.requires_user_approval
230
- })
231
- }
@@ -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::{
@@ -21,6 +21,18 @@ 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
 
@@ -85,6 +97,12 @@ fn preflight_reports_path_policy_and_check_plan() {
85
97
  );
86
98
  assert_eq!(allowed["paths"][0]["editable"], true);
87
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
+ );
88
106
 
89
107
  let ignored = run_json(
90
108
  &root,
@@ -92,6 +110,12 @@ fn preflight_reports_path_policy_and_check_plan() {
92
110
  );
93
111
  assert_eq!(ignored["paths"][0]["editable"], false);
94
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
+ );
95
119
 
96
120
  let traversal = run_json(
97
121
  &root,
@@ -121,6 +145,7 @@ fn preflight_blocks_when_no_target_paths_are_selected() {
121
145
  "task.preflight.missing_target_paths"
122
146
  );
123
147
  assert_eq!(preflight["nextAction"]["type"], "blocked");
148
+ assert_eq!(preflight["nextAction"]["actionId"], "preflight.blocked");
124
149
  assert_eq!(preflight["nextAction"]["safeToExecute"], false);
125
150
  }
126
151
 
@@ -153,7 +178,11 @@ fn commit_preflight_blocks_missing_proof() {
153
178
  preflight["blockingFindings"][0]["id"],
154
179
  "task.proof.missing_check"
155
180
  );
156
- 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
+ );
157
186
  assert_eq!(preflight["nextAction"]["checkIds"], json!(["diff-check"]));
158
187
  }
159
188
 
@@ -180,9 +209,120 @@ fn agent_snapshot_prefers_commit_ready_over_more_editing() {
180
209
 
181
210
  assert_eq!(snapshot["commit"]["canCommit"], true);
182
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
+ );
183
217
  assert_eq!(snapshot["nextAction"]["safeToExecute"], false);
184
218
  }
185
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
+
186
326
  #[test]
187
327
  fn commit_preflight_surfaces_transition_blocker_action_and_exit_code() {
188
328
  let root = fixture_root(task_state_with_active_task(active_task(json!({
@@ -204,7 +344,12 @@ fn commit_preflight_surfaces_transition_blocker_action_and_exit_code() {
204
344
  preflight["blockingFindings"][0]["id"],
205
345
  "task.transition.human_review_required"
206
346
  );
207
- 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
+ );
208
353
  assert_eq!(preflight["nextAction"]["safeToExecute"], false);
209
354
 
210
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.4"
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.4",
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",