@lamentis/naome 1.4.1 → 1.4.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) 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 +9 -5
  5. package/crates/naome-cli/src/task_commands/can_edit.rs +116 -0
  6. package/crates/naome-cli/src/task_commands/check_run/output.rs +34 -0
  7. package/crates/naome-cli/src/task_commands/check_run/receipts.rs +155 -0
  8. package/crates/naome-cli/src/task_commands/check_run/verification.rs +165 -0
  9. package/crates/naome-cli/src/task_commands/check_run.rs +192 -0
  10. package/crates/naome-cli/src/task_commands/common.rs +39 -1
  11. package/crates/naome-cli/src/task_commands/complete.rs +43 -0
  12. package/crates/naome-cli/src/task_commands/loop_control.rs +55 -0
  13. package/crates/naome-cli/src/task_commands/readiness.rs +14 -10
  14. package/crates/naome-cli/src/task_commands/record.rs +139 -37
  15. package/crates/naome-cli/src/task_commands/repair.rs +58 -11
  16. package/crates/naome-cli/src/task_commands.rs +14 -3
  17. package/crates/naome-cli/tests/task_cli_agent_controls.rs +9 -16
  18. package/crates/naome-cli/tests/task_cli_loop.rs +383 -0
  19. package/crates/naome-cli/tests/task_cli_loop_edit.rs +144 -0
  20. package/crates/naome-cli/tests/task_cli_support/mod.rs +28 -0
  21. package/crates/naome-core/Cargo.toml +1 -1
  22. package/crates/naome-core/src/lib.rs +7 -7
  23. package/crates/naome-core/src/task_state/evidence_fingerprint.rs +47 -0
  24. package/crates/naome-core/src/task_state/mod.rs +2 -0
  25. package/crates/naome-core/src/task_state/status/control/repair.rs +2 -2
  26. package/crates/naome-core/src/task_state/status/model.rs +2 -0
  27. package/crates/naome-core/src/task_state/status/proof.rs +59 -9
  28. package/crates/naome-core/src/task_state/status/proof_read.rs +14 -0
  29. package/crates/naome-core/src/task_state/status/report_context.rs +23 -1
  30. package/crates/naome-core/src/task_state/status/transition.rs +29 -1
  31. package/crates/naome-core/tests/task_status.rs +122 -0
  32. package/installer/context.js +1 -1
  33. package/installer/harness-verification.js +2 -6
  34. package/installer/manifest-state.js +2 -2
  35. package/installer/native.js +3 -31
  36. package/native/darwin-arm64/naome +0 -0
  37. package/native/linux-x64/naome +0 -0
  38. package/package.json +1 -1
  39. package/templates/naome-root/.naome/bin/check-harness-health.js +2 -2
  40. package/templates/naome-root/.naome/bin/check-task-state.js +4 -39
  41. package/templates/naome-root/.naome/bin/naome.js +2 -30
  42. package/templates/naome-root/.naome/manifest.json +2 -2
@@ -0,0 +1,192 @@
1
+ use std::path::Path;
2
+ use std::process::Command;
3
+ use std::time::Instant;
4
+
5
+ use naome_core::task_status_report;
6
+ use serde_json::{json, Value};
7
+
8
+ mod output;
9
+ mod receipts;
10
+ mod verification;
11
+
12
+ pub(super) use receipts::{evidence_fingerprint, successful_receipts, CheckRunReceipt};
13
+ pub(super) use verification::read_verification_check;
14
+
15
+ use super::common::{agent_session, print_json_with_session, value_after};
16
+ use output::{finding, run_check_response};
17
+ use receipts::{append_receipt, changed_paths};
18
+ use verification::safe_command;
19
+
20
+ pub(super) fn run_check_command(
21
+ root: &Path,
22
+ args: &[String],
23
+ ) -> Result<(), Box<dyn std::error::Error>> {
24
+ let session = agent_session(args)?;
25
+ let Some(check_id) = value_after(args, "--check") else {
26
+ return Err("naome task run-check requires --check <check-id>".into());
27
+ };
28
+ let record = args.iter().any(|arg| arg == "--record-proof");
29
+ let result = run_check_by_id(root, check_id, record, session.as_deref())?;
30
+ print_json_with_session(result, session.as_deref())
31
+ }
32
+
33
+ pub(super) fn run_check_by_id(
34
+ root: &Path,
35
+ check_id: &str,
36
+ record: bool,
37
+ session: Option<&str>,
38
+ ) -> Result<Value, Box<dyn std::error::Error>> {
39
+ let Some(check) = read_verification_check(root, check_id)? else {
40
+ return Ok(rejected_check(
41
+ check_id,
42
+ "task.check.unknown",
43
+ format!("Unknown verification check id: {check_id}."),
44
+ "Check id is not declared in .naome/verification.json.",
45
+ session,
46
+ ));
47
+ };
48
+ let Some(command) = safe_command(root, &check)? else {
49
+ return Ok(rejected_check(
50
+ check_id,
51
+ "task.check.unsafe_command",
52
+ format!("Check {check_id} is not in the autonomous safe command allowlist."),
53
+ "NAOME refused to execute this check automatically.",
54
+ session,
55
+ ));
56
+ };
57
+
58
+ let before = changed_paths(root)?;
59
+ let start = Instant::now();
60
+ let output = Command::new(&command.program)
61
+ .args(&command.args)
62
+ .current_dir(root.join(&check.cwd))
63
+ .output()?;
64
+ let mut stdout = output.stdout;
65
+ let mut stderr = output.stderr;
66
+ let mut exit_code = output.status.code().unwrap_or(1);
67
+ if check.command == "git diff --check" {
68
+ let cached = Command::new("git")
69
+ .args(["diff", "--cached", "--check"])
70
+ .current_dir(root.join(&check.cwd))
71
+ .output()?;
72
+ if !stdout.is_empty() && !cached.stdout.is_empty() {
73
+ stdout.push(b'\n');
74
+ }
75
+ stdout.extend(cached.stdout);
76
+ if !stderr.is_empty() && !cached.stderr.is_empty() {
77
+ stderr.push(b'\n');
78
+ }
79
+ stderr.extend(cached.stderr);
80
+ if !cached.status.success() {
81
+ exit_code = cached.status.code().unwrap_or(1);
82
+ }
83
+ }
84
+ let duration_ms = start.elapsed().as_millis();
85
+ let after = changed_paths(root)?;
86
+ let evidence_paths = task_status_report(root)?.scope.in_scope_changed_paths;
87
+ let receipt = CheckRunReceipt {
88
+ check_id: check.id.clone(),
89
+ command: check.command.clone(),
90
+ cwd: check.cwd.clone(),
91
+ exit_code,
92
+ checked_at: checked_at(),
93
+ evidence_fingerprint: evidence_fingerprint(root, &evidence_paths)?,
94
+ evidence_paths,
95
+ stdout_summary: bounded_summary(&stdout),
96
+ stderr_summary: bounded_summary(&stderr),
97
+ duration_ms,
98
+ agent_session: session.map(ToString::to_string),
99
+ };
100
+ append_receipt(root, &receipt)?;
101
+
102
+ let (recorded_proof, findings) =
103
+ maybe_record_proof(root, record, exit_code, &check.id, session)?;
104
+ let mut response = run_check_response(
105
+ check_id,
106
+ true,
107
+ Some(exit_code),
108
+ recorded_proof,
109
+ findings,
110
+ if exit_code == 0 {
111
+ "Check executed successfully."
112
+ } else {
113
+ "Check executed and failed; inspect summaries before continuing."
114
+ },
115
+ session,
116
+ );
117
+ response["command"] = json!(check.command);
118
+ response["cwd"] = json!(check.cwd);
119
+ response["durationMs"] = json!(duration_ms);
120
+ response["changedPathsBefore"] = json!(before);
121
+ response["changedPathsAfter"] = json!(after);
122
+ response["stdoutSummary"] = json!(bounded_summary(&stdout));
123
+ response["stderrSummary"] = json!(bounded_summary(&stderr));
124
+ Ok(response)
125
+ }
126
+
127
+ fn maybe_record_proof(
128
+ root: &Path,
129
+ record: bool,
130
+ exit_code: i32,
131
+ check_id: &str,
132
+ session: Option<&str>,
133
+ ) -> Result<(bool, Vec<Value>), Box<dyn std::error::Error>> {
134
+ let mut findings = Vec::new();
135
+ if !record {
136
+ return Ok((false, findings));
137
+ }
138
+ if exit_code != 0 {
139
+ findings.push(finding(
140
+ "task.check.failed",
141
+ "Check did not exit 0; proof was not recorded.",
142
+ ));
143
+ return Ok((false, findings));
144
+ }
145
+
146
+ let status = task_status_report(root)?;
147
+ if !status.scope.out_of_scope_changed_paths.is_empty() {
148
+ findings.push(finding(
149
+ "task.scope.out_of_scope_change",
150
+ "Changed paths are outside task scope; proof was not recorded.",
151
+ ));
152
+ return Ok((false, findings));
153
+ }
154
+
155
+ let recorded = super::record::record_receipts_for_checks(
156
+ root,
157
+ &[check_id.to_string()],
158
+ &status.scope.in_scope_changed_paths,
159
+ session,
160
+ )?;
161
+ Ok((recorded, findings))
162
+ }
163
+
164
+ fn rejected_check(
165
+ check_id: &str,
166
+ finding_id: &str,
167
+ message: String,
168
+ instruction: &str,
169
+ session: Option<&str>,
170
+ ) -> Value {
171
+ run_check_response(
172
+ check_id,
173
+ false,
174
+ None,
175
+ false,
176
+ vec![finding(finding_id, message)],
177
+ instruction,
178
+ session,
179
+ )
180
+ }
181
+
182
+ fn bounded_summary(bytes: &[u8]) -> String {
183
+ String::from_utf8_lossy(bytes)
184
+ .lines()
185
+ .take(8)
186
+ .collect::<Vec<_>>()
187
+ .join("\n")
188
+ }
189
+
190
+ fn checked_at() -> String {
191
+ "1970-01-01T00:00:00.000Z".to_string()
192
+ }
@@ -1,7 +1,7 @@
1
1
  use std::fs;
2
2
  use std::path::Path;
3
3
 
4
- use serde_json::Value;
4
+ use serde_json::{json, Value};
5
5
 
6
6
  pub(super) fn read_task_state(root: &Path) -> Result<Value, Box<dyn std::error::Error>> {
7
7
  Ok(serde_json::from_str(&fs::read_to_string(
@@ -9,11 +9,30 @@ pub(super) fn read_task_state(root: &Path) -> Result<Value, Box<dyn std::error::
9
9
  )?)?)
10
10
  }
11
11
 
12
+ pub(super) fn write_task_state(
13
+ root: &Path,
14
+ state: &Value,
15
+ ) -> Result<(), Box<dyn std::error::Error>> {
16
+ fs::write(
17
+ root.join(".naome/task-state.json"),
18
+ format!("{}\n", serde_json::to_string_pretty(state)?),
19
+ )?;
20
+ Ok(())
21
+ }
22
+
12
23
  pub(super) fn print_json(value: Value) -> Result<(), Box<dyn std::error::Error>> {
13
24
  println!("{}", serde_json::to_string_pretty(&value)?);
14
25
  Ok(())
15
26
  }
16
27
 
28
+ pub(super) fn print_json_with_session(
29
+ mut value: Value,
30
+ session: Option<&str>,
31
+ ) -> Result<(), Box<dyn std::error::Error>> {
32
+ value["agentSession"] = session.map_or(Value::Null, |session| json!(session));
33
+ print_json(value)
34
+ }
35
+
17
36
  pub(super) fn value_after<'a>(args: &'a [String], flag: &str) -> Option<&'a str> {
18
37
  args.windows(2)
19
38
  .find(|window| window[0] == flag)
@@ -30,3 +49,22 @@ pub(super) fn repeated_values(args: &[String], flag: &str) -> Vec<String> {
30
49
  values.dedup();
31
50
  values
32
51
  }
52
+
53
+ pub(super) fn agent_session(args: &[String]) -> Result<Option<String>, Box<dyn std::error::Error>> {
54
+ let Some(value) = value_after(args, "--agent-session") else {
55
+ return Ok(None);
56
+ };
57
+ if value.is_empty()
58
+ || value.len() > 80
59
+ || value.contains('/')
60
+ || value.contains('\\')
61
+ || !value.is_ascii()
62
+ || value.chars().any(|character| character.is_control())
63
+ {
64
+ return Err(
65
+ "invalid --agent-session; expected ASCII id up to 80 chars without path separators"
66
+ .into(),
67
+ );
68
+ }
69
+ Ok(Some(value.to_string()))
70
+ }
@@ -0,0 +1,43 @@
1
+ use std::path::Path;
2
+
3
+ use naome_core::task_transition_readiness;
4
+ use serde_json::json;
5
+
6
+ use super::common::{agent_session, print_json_with_session, read_task_state, write_task_state};
7
+
8
+ pub(super) fn complete_task(
9
+ root: &Path,
10
+ args: &[String],
11
+ ) -> Result<(), Box<dyn std::error::Error>> {
12
+ if !args.iter().any(|arg| arg == "--from-can-transition") {
13
+ return Err("naome task complete requires --from-can-transition".into());
14
+ }
15
+ let session = agent_session(args)?;
16
+ let readiness = task_transition_readiness(root, "complete")?;
17
+ if !readiness.allowed {
18
+ return print_json_with_session(
19
+ json!({
20
+ "schema": "naome.task.complete.v1",
21
+ "completed": false,
22
+ "blockingFindings": readiness.blocking_findings,
23
+ "agentLoop": readiness.agent_loop,
24
+ "agentInstruction": "Do not complete the task until can-transition allows completion."
25
+ }),
26
+ session.as_deref(),
27
+ );
28
+ }
29
+ let mut state = read_task_state(root)?;
30
+ state["status"] = json!("complete");
31
+ state["updatedAt"] = json!("1970-01-01T00:00:00.000Z");
32
+ write_task_state(root, &state)?;
33
+ print_json_with_session(
34
+ json!({
35
+ "schema": "naome.task.complete.v1",
36
+ "completed": true,
37
+ "blockingFindings": [],
38
+ "agentLoop": readiness.agent_loop,
39
+ "agentInstruction": "Task completed through guarded can-transition readiness."
40
+ }),
41
+ session.as_deref(),
42
+ )
43
+ }
@@ -0,0 +1,55 @@
1
+ use std::path::Path;
2
+
3
+ use naome_core::{task_proof_plan, task_status_report, task_transition_readiness};
4
+ use serde_json::json;
5
+
6
+ use super::common::{agent_session, print_json_with_session};
7
+
8
+ pub(super) fn task_loop(root: &Path, args: &[String]) -> Result<(), Box<dyn std::error::Error>> {
9
+ let session = agent_session(args)?;
10
+ let execute_safe = args.iter().any(|arg| arg == "--execute-safe");
11
+ let mut executed_steps = Vec::new();
12
+
13
+ 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() {
21
+ executed_steps.push(super::check_run::run_check_by_id(
22
+ root,
23
+ check_id,
24
+ true,
25
+ session.as_deref(),
26
+ )?);
27
+ }
28
+ }
29
+ }
30
+
31
+ let status = task_status_report(root)?;
32
+ let proof_plan = task_proof_plan(root)?;
33
+ let transition = task_transition_readiness(root, "complete")?;
34
+ let can_commit = json!({
35
+ "schema": "naome.task.commit-readiness.v1",
36
+ "allowed": transition.allowed && status.agent_loop.can_commit,
37
+ "commitPaths": status.scope.in_scope_changed_paths,
38
+ "blockingFindings": transition.blocking_findings,
39
+ "agentLoop": status.agent_loop
40
+ });
41
+ print_json_with_session(
42
+ json!({
43
+ "schema": "naome.task.loop.v1",
44
+ "mode": if execute_safe { "execute_safe" } else { "read_only" },
45
+ "status": status,
46
+ "proofPlan": proof_plan,
47
+ "canTransition": transition,
48
+ "canCommit": can_commit,
49
+ "executedSteps": executed_steps,
50
+ "nextAction": status.next_action_v2,
51
+ "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." }
52
+ }),
53
+ session.as_deref(),
54
+ )
55
+ }
@@ -4,20 +4,24 @@ use std::path::Path;
4
4
  use naome_core::{task_status_report, task_transition_readiness};
5
5
  use serde_json::json;
6
6
 
7
- use super::common::print_json;
7
+ use super::common::{agent_session, print_json_with_session};
8
8
 
9
- pub(super) fn can_commit(root: &Path, _args: &[String]) -> Result<(), Box<dyn std::error::Error>> {
9
+ pub(super) fn can_commit(root: &Path, args: &[String]) -> Result<(), Box<dyn std::error::Error>> {
10
+ let session = agent_session(args)?;
10
11
  let status = task_status_report(root)?;
11
12
  let transition = task_transition_readiness(root, "complete")?;
12
13
  let required = commit_requirements(&status, &transition.blocking_findings);
13
- print_json(json!({
14
- "schema": "naome.task.commit-readiness.v1",
15
- "allowed": transition.allowed && status.agent_loop.can_commit && required.is_empty(),
16
- "commitPaths": status.scope.in_scope_changed_paths,
17
- "blockingFindings": transition.blocking_findings,
18
- "requiredBeforeCommit": required,
19
- "agentLoop": status.agent_loop
20
- }))
14
+ print_json_with_session(
15
+ json!({
16
+ "schema": "naome.task.commit-readiness.v1",
17
+ "allowed": transition.allowed && status.agent_loop.can_commit && required.is_empty(),
18
+ "commitPaths": status.scope.in_scope_changed_paths,
19
+ "blockingFindings": transition.blocking_findings,
20
+ "requiredBeforeCommit": required,
21
+ "agentLoop": status.agent_loop
22
+ }),
23
+ session.as_deref(),
24
+ )
21
25
  }
22
26
 
23
27
  fn commit_requirements(
@@ -1,48 +1,65 @@
1
- use std::fs;
2
1
  use std::path::Path;
3
2
 
4
3
  use naome_core::task_proof_plan;
5
4
  use serde_json::{json, Value};
6
5
 
7
- use super::common::{print_json, read_task_state};
6
+ use super::check_run::{evidence_fingerprint, successful_receipts, CheckRunReceipt};
7
+ use super::common::{agent_session, print_json_with_session, read_task_state, write_task_state};
8
8
 
9
9
  pub(super) fn record_proof(root: &Path, args: &[String]) -> Result<(), Box<dyn std::error::Error>> {
10
10
  if !args.iter().any(|arg| arg == "--from-proof-plan") {
11
11
  return Err("naome task record-proof requires --from-proof-plan".into());
12
12
  }
13
- let plan = task_proof_plan(root)?;
13
+ let session = agent_session(args)?;
14
14
  let dry_run = args.iter().any(|arg| arg == "--dry-run");
15
- let recorded = !dry_run && !plan.proof_recording.checks_to_record.is_empty();
15
+ print_json_with_session(
16
+ record_proof_payload(root, dry_run, session.as_deref())?,
17
+ session.as_deref(),
18
+ )
19
+ }
20
+
21
+ pub(super) fn record_proof_value(
22
+ root: &Path,
23
+ session: Option<&str>,
24
+ ) -> Result<Value, Box<dyn std::error::Error>> {
25
+ record_proof_payload(root, false, session)
26
+ }
27
+
28
+ fn record_proof_payload(
29
+ root: &Path,
30
+ dry_run: bool,
31
+ session: Option<&str>,
32
+ ) -> Result<Value, Box<dyn std::error::Error>> {
33
+ let plan = task_proof_plan(root)?;
34
+ let paths = plan.proof_recording.paths.clone();
35
+ let check_ids = plan.proof_recording.checks_to_record.clone();
36
+ let (can_record, findings, receipts) = recordable_receipts(root, &check_ids, &paths)?;
37
+ let recorded = !dry_run && can_record && !check_ids.is_empty();
16
38
  let recorded_ids = if recorded {
17
- write_proof_batch(root, &plan.proof_recording)?
39
+ write_proof_batch(
40
+ root,
41
+ &plan.proof_recording.path_set_id,
42
+ &plan.proof_recording.proof_batch_id,
43
+ &paths,
44
+ &receipts,
45
+ )?
18
46
  } else {
19
47
  RecordedProof {
20
48
  path_set_id: plan.proof_recording.path_set_id.clone(),
21
49
  proof_batch_id: plan.proof_recording.proof_batch_id.clone(),
22
50
  }
23
51
  };
24
- if dry_run && !plan.proof_recording.checks_to_record.is_empty() {
25
- print_json(json!({
52
+ Ok(json!({
26
53
  "schema": "naome.task.record-proof.v1",
27
54
  "dryRun": dry_run,
28
55
  "recorded": recorded,
29
56
  "pathSetId": recorded_ids.path_set_id,
30
- "paths": plan.proof_recording.paths,
57
+ "paths": paths,
31
58
  "proofBatchId": recorded_ids.proof_batch_id,
32
- "checksRecorded": plan.proof_recording.checks_to_record,
33
- "agentInstruction": "Record proof only after the listed checks exited 0 for the current task diff."
34
- }))?;
35
- return Ok(());
36
- }
37
- print_json(json!({
38
- "schema": "naome.task.record-proof.v1",
39
- "dryRun": dry_run,
40
- "recorded": recorded,
41
- "pathSetId": recorded_ids.path_set_id,
42
- "paths": plan.proof_recording.paths,
43
- "proofBatchId": recorded_ids.proof_batch_id,
44
- "checksRecorded": plan.proof_recording.checks_to_record,
45
- "agentInstruction": "Record proof only after the listed checks exited 0 for the current task diff."
59
+ "checksRecorded": if recorded || dry_run && can_record { check_ids } else { Vec::<String>::new() },
60
+ "findings": findings,
61
+ "agentInstruction": if recorded { "Proof recorded from recent successful safe check evidence." } else { "Run task run-check --record-proof or task loop --execute-safe before recording proof." },
62
+ "agentSession": session
46
63
  }))
47
64
  }
48
65
 
@@ -51,17 +68,48 @@ struct RecordedProof {
51
68
  proof_batch_id: String,
52
69
  }
53
70
 
71
+ pub(super) fn record_receipts_for_checks(
72
+ root: &Path,
73
+ check_ids: &[String],
74
+ paths: &[String],
75
+ session: Option<&str>,
76
+ ) -> Result<bool, Box<dyn std::error::Error>> {
77
+ let (can_record, _findings, receipts) = recordable_receipts(root, check_ids, paths)?;
78
+ if !can_record || receipts.is_empty() {
79
+ return Ok(false);
80
+ }
81
+ let task_id = read_task_state(root)?
82
+ .get("activeTask")
83
+ .and_then(|task| task.get("id"))
84
+ .and_then(Value::as_str)
85
+ .unwrap_or("task")
86
+ .to_string();
87
+ let batch_id = format!("{task_id}-proof");
88
+ let receipts = receipts
89
+ .into_iter()
90
+ .map(|mut receipt| {
91
+ if receipt.agent_session.is_none() {
92
+ receipt.agent_session = session.map(ToString::to_string);
93
+ }
94
+ receipt
95
+ })
96
+ .collect::<Vec<_>>();
97
+ write_proof_batch(root, "current-task-diff", &batch_id, paths, &receipts)?;
98
+ Ok(true)
99
+ }
100
+
54
101
  fn write_proof_batch(
55
102
  root: &Path,
56
- recording: &naome_core::ProofRecording,
103
+ path_set_base: &str,
104
+ proof_batch_base: &str,
105
+ paths: &[String],
106
+ receipts: &[CheckRunReceipt],
57
107
  ) -> Result<RecordedProof, Box<dyn std::error::Error>> {
58
- let path = root.join(".naome/task-state.json");
59
108
  let mut state = read_task_state(root)?;
60
- let checked_at = state
61
- .get("updatedAt")
62
- .and_then(Value::as_str)
63
- .unwrap_or("1970-01-01T00:00:00.000Z")
64
- .to_string();
109
+ let checked_at = receipts
110
+ .first()
111
+ .map(|receipt| receipt.checked_at.clone())
112
+ .unwrap_or_else(|| "1970-01-01T00:00:00.000Z".to_string());
65
113
  let active = state
66
114
  .get_mut("activeTask")
67
115
  .and_then(Value::as_object_mut)
@@ -71,21 +119,21 @@ fn write_proof_batch(
71
119
  .or_insert_with(|| json!({}))
72
120
  .as_object_mut()
73
121
  .ok_or("activeTask.proofPathSets must be an object")?;
74
- let path_set_id = unique_path_set_id(path_sets, &recording.path_set_id);
75
- path_sets.insert(path_set_id.clone(), json!(recording.paths));
122
+ let path_set_id = unique_path_set_id(path_sets, path_set_base);
123
+ path_sets.insert(path_set_id.clone(), json!(paths));
76
124
  let batches = active
77
125
  .entry("proofBatches")
78
126
  .or_insert_with(|| json!([]))
79
127
  .as_array_mut()
80
128
  .ok_or("activeTask.proofBatches must be an array")?;
81
- let proof_batch_id = unique_proof_batch_id(batches, &recording.proof_batch_id);
129
+ let proof_batch_id = unique_proof_batch_id(batches, proof_batch_base);
82
130
  batches.push(proof_batch(
83
- recording,
131
+ receipts,
84
132
  &checked_at,
85
133
  &path_set_id,
86
134
  &proof_batch_id,
87
135
  ));
88
- fs::write(path, format!("{}\n", serde_json::to_string_pretty(&state)?))?;
136
+ write_task_state(root, &state)?;
89
137
  Ok(RecordedProof {
90
138
  path_set_id,
91
139
  proof_batch_id,
@@ -93,7 +141,7 @@ fn write_proof_batch(
93
141
  }
94
142
 
95
143
  fn proof_batch(
96
- recording: &naome_core::ProofRecording,
144
+ receipts: &[CheckRunReceipt],
97
145
  checked_at: &str,
98
146
  path_set_id: &str,
99
147
  proof_batch_id: &str,
@@ -102,12 +150,66 @@ fn proof_batch(
102
150
  "id": proof_batch_id,
103
151
  "checkedAt": checked_at,
104
152
  "evidencePathSet": path_set_id,
105
- "proofs": recording.checks_to_record.iter().map(|check_id| {
106
- json!({ "checkId": check_id, "exitCode": 0 })
153
+ "proofs": receipts.iter().map(|receipt| {
154
+ json!({
155
+ "checkId": receipt.check_id,
156
+ "command": receipt.command,
157
+ "cwd": receipt.cwd,
158
+ "exitCode": receipt.exit_code,
159
+ "checkedAt": receipt.checked_at,
160
+ "evidenceFingerprint": receipt.evidence_fingerprint,
161
+ "stdoutSummary": receipt.stdout_summary,
162
+ "stderrSummary": receipt.stderr_summary,
163
+ "durationMs": receipt.duration_ms,
164
+ "agentSession": receipt.agent_session
165
+ })
107
166
  }).collect::<Vec<_>>()
108
167
  })
109
168
  }
110
169
 
170
+ fn recordable_receipts(
171
+ root: &Path,
172
+ check_ids: &[String],
173
+ paths: &[String],
174
+ ) -> Result<(bool, Vec<Value>, Vec<CheckRunReceipt>), Box<dyn std::error::Error>> {
175
+ let receipts = successful_receipts(root)?;
176
+ let current_fingerprint = evidence_fingerprint(root, paths)?;
177
+ let mut selected = Vec::new();
178
+ let mut findings = Vec::new();
179
+ for check_id in check_ids {
180
+ let expected = super::check_run::read_verification_check(root, check_id)?;
181
+ let receipt = receipts
182
+ .iter()
183
+ .rev()
184
+ .find(|receipt| {
185
+ receipt.check_id == *check_id
186
+ && expected.as_ref().is_some_and(|check| {
187
+ receipt.command == check.command && receipt.cwd == check.cwd
188
+ })
189
+ && paths.iter().all(|path| {
190
+ receipt
191
+ .evidence_paths
192
+ .iter()
193
+ .any(|evidence| evidence == path)
194
+ })
195
+ && receipt.evidence_fingerprint == current_fingerprint
196
+ })
197
+ .cloned();
198
+ match receipt {
199
+ Some(receipt) => selected.push(receipt),
200
+ None => findings.push(json!({
201
+ "id": "task.proof.no_recent_success",
202
+ "severity": "error",
203
+ "message": format!("No recent successful safe check evidence covers current task paths for {check_id}."),
204
+ "path": null,
205
+ "suggestedFix": "Run naome task run-check --check <id> --record-proof --json or naome task loop --execute-safe --json.",
206
+ "agentInstruction": "Do not record proof until NAOME has just executed the check successfully."
207
+ })),
208
+ }
209
+ }
210
+ Ok((findings.is_empty(), findings, selected))
211
+ }
212
+
111
213
  fn unique_path_set_id(path_sets: &serde_json::Map<String, Value>, base: &str) -> String {
112
214
  unique_id(base, |candidate| path_sets.contains_key(candidate))
113
215
  }