@lamentis/naome 1.4.2 → 1.4.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/Cargo.lock +2 -2
- package/crates/naome-cli/Cargo.toml +1 -1
- package/crates/naome-cli/src/main.rs +5 -0
- package/crates/naome-cli/src/task_commands/agent_snapshot.rs +173 -0
- package/crates/naome-cli/src/task_commands/can_edit.rs +7 -59
- package/crates/naome-cli/src/task_commands/check_run/receipts.rs +10 -2
- package/crates/naome-cli/src/task_commands/check_run.rs +7 -3
- package/crates/naome-cli/src/task_commands/commit_preflight.rs +89 -0
- package/crates/naome-cli/src/task_commands/compact_proof.rs +69 -0
- package/crates/naome-cli/src/task_commands/loop_control.rs +25 -7
- package/crates/naome-cli/src/task_commands/path_policy.rs +57 -0
- package/crates/naome-cli/src/task_commands/planner/checks.rs +166 -0
- package/crates/naome-cli/src/task_commands/planner/impact.rs +35 -0
- package/crates/naome-cli/src/task_commands/planner/mod.rs +24 -0
- package/crates/naome-cli/src/task_commands/preflight.rs +208 -0
- package/crates/naome-cli/src/task_commands/record.rs +63 -26
- package/crates/naome-cli/src/task_commands/scope_suggestions.rs +109 -0
- package/crates/naome-cli/src/task_commands.rs +12 -0
- package/crates/naome-cli/tests/task_cli_fast_flow.rs +290 -0
- package/crates/naome-cli/tests/task_cli_loop.rs +1 -1
- package/crates/naome-core/Cargo.toml +1 -1
- package/native/darwin-arm64/naome +0 -0
- package/native/linux-x64/naome +0 -0
- package/package.json +1 -1
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
use std::collections::{BTreeMap, BTreeSet};
|
|
2
|
+
use std::fs;
|
|
3
|
+
use std::path::Path;
|
|
4
|
+
|
|
5
|
+
use naome_core::TaskProofStatus;
|
|
6
|
+
use serde_json::{json, Value};
|
|
7
|
+
|
|
8
|
+
use crate::task_commands::check_run;
|
|
9
|
+
|
|
10
|
+
use super::impact::impact_kind;
|
|
11
|
+
|
|
12
|
+
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
13
|
+
pub(super) struct VerificationCheck {
|
|
14
|
+
id: String,
|
|
15
|
+
command: String,
|
|
16
|
+
cwd: String,
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
pub(in crate::task_commands) fn changed_paths(root: &Path) -> Vec<String> {
|
|
20
|
+
check_run::changed_paths(root).unwrap_or_default()
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
fn read_verification_checks(root: &Path) -> Vec<VerificationCheck> {
|
|
24
|
+
let Ok(content) = fs::read_to_string(root.join(".naome/verification.json")) else {
|
|
25
|
+
return Vec::new();
|
|
26
|
+
};
|
|
27
|
+
let Ok(value) = serde_json::from_str::<Value>(&content) else {
|
|
28
|
+
return Vec::new();
|
|
29
|
+
};
|
|
30
|
+
let mut checks = value
|
|
31
|
+
.get("checks")
|
|
32
|
+
.and_then(Value::as_array)
|
|
33
|
+
.into_iter()
|
|
34
|
+
.flatten()
|
|
35
|
+
.filter_map(|check| {
|
|
36
|
+
Some(VerificationCheck {
|
|
37
|
+
id: check.get("id")?.as_str()?.to_string(),
|
|
38
|
+
command: check.get("command")?.as_str()?.to_string(),
|
|
39
|
+
cwd: check.get("cwd")?.as_str()?.to_string(),
|
|
40
|
+
})
|
|
41
|
+
})
|
|
42
|
+
.collect::<Vec<_>>();
|
|
43
|
+
checks.sort_by(|left, right| left.id.cmp(&right.id));
|
|
44
|
+
checks
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
pub(in crate::task_commands) fn planned_commands(
|
|
48
|
+
root: &Path,
|
|
49
|
+
paths: &[String],
|
|
50
|
+
proof: Option<&TaskProofStatus>,
|
|
51
|
+
) -> Vec<Value> {
|
|
52
|
+
let mut selected = BTreeMap::<String, (VerificationCheck, BTreeSet<String>, String)>::new();
|
|
53
|
+
for check in read_verification_checks(root) {
|
|
54
|
+
let reasons = check_reasons(&check, paths, proof);
|
|
55
|
+
if reasons.is_empty() {
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
let proof_reason = proof_reason(&reasons).to_string();
|
|
59
|
+
selected.insert(check.id.clone(), (check, reasons, proof_reason));
|
|
60
|
+
}
|
|
61
|
+
selected
|
|
62
|
+
.into_values()
|
|
63
|
+
.map(|(check, reasons, proof_reason)| command_value(check, reasons, proof_reason, paths))
|
|
64
|
+
.collect()
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
fn check_reasons(
|
|
68
|
+
check: &VerificationCheck,
|
|
69
|
+
paths: &[String],
|
|
70
|
+
proof: Option<&TaskProofStatus>,
|
|
71
|
+
) -> BTreeSet<String> {
|
|
72
|
+
let mut reasons = BTreeSet::new();
|
|
73
|
+
if let Some(proof) = proof {
|
|
74
|
+
if proof.missing_checks.contains(&check.id) {
|
|
75
|
+
reasons.insert("missing-proof".to_string());
|
|
76
|
+
}
|
|
77
|
+
if proof.stale_checks.contains(&check.id) {
|
|
78
|
+
reasons.insert("stale-proof".to_string());
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
for path in paths {
|
|
82
|
+
if check_is_impacted(check, path) {
|
|
83
|
+
reasons.insert(impact_kind(path).to_string());
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
reasons
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
fn command_value(
|
|
90
|
+
check: VerificationCheck,
|
|
91
|
+
reasons: BTreeSet<String>,
|
|
92
|
+
proof_reason: String,
|
|
93
|
+
paths: &[String],
|
|
94
|
+
) -> Value {
|
|
95
|
+
let selection_reason = reasons.into_iter().collect::<Vec<_>>().join(",");
|
|
96
|
+
json!({
|
|
97
|
+
"checkId": check.id,
|
|
98
|
+
"command": check.command,
|
|
99
|
+
"cwd": check.cwd,
|
|
100
|
+
"reason": selection_reason,
|
|
101
|
+
"selectionReason": selection_reason,
|
|
102
|
+
"impactedPaths": paths,
|
|
103
|
+
"proofReason": proof_reason,
|
|
104
|
+
"safeToExecute": safe_to_execute(&check, paths)
|
|
105
|
+
})
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
fn proof_reason(reasons: &BTreeSet<String>) -> &'static str {
|
|
109
|
+
if reasons.contains("missing-proof") {
|
|
110
|
+
"missing"
|
|
111
|
+
} else if reasons.contains("stale-proof") {
|
|
112
|
+
"stale"
|
|
113
|
+
} else if reasons.contains("final_only") || reasons.contains("release_only") {
|
|
114
|
+
"final"
|
|
115
|
+
} else {
|
|
116
|
+
"impacted"
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
fn check_is_impacted(check: &VerificationCheck, path: &str) -> bool {
|
|
121
|
+
let command = check.command.as_str();
|
|
122
|
+
match impact_kind(path) {
|
|
123
|
+
"changed_task_state" => {
|
|
124
|
+
command.contains("check-task-state") || command.contains("test:task-state")
|
|
125
|
+
}
|
|
126
|
+
"changed_architecture_config" => command.contains("arch validate"),
|
|
127
|
+
"changed_package_metadata" => {
|
|
128
|
+
command.contains("pack:dry-run") || command.contains("test:decision-engine")
|
|
129
|
+
}
|
|
130
|
+
"changed_native_binary" | "changed_templates" => {
|
|
131
|
+
command.contains("check-harness-health") || command.contains("pack:dry-run")
|
|
132
|
+
}
|
|
133
|
+
"changed_docs" => command.contains("quality check") || command.contains("semantic check"),
|
|
134
|
+
"changed_tests" => {
|
|
135
|
+
command.contains("quality check")
|
|
136
|
+
|| command.contains("semantic check")
|
|
137
|
+
|| command.contains("test:")
|
|
138
|
+
}
|
|
139
|
+
_ => {
|
|
140
|
+
command == "git diff --check"
|
|
141
|
+
|| command.contains("quality check")
|
|
142
|
+
|| command.contains("semantic check")
|
|
143
|
+
|| command.contains("arch validate")
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
fn safe_to_execute(check: &VerificationCheck, paths: &[String]) -> bool {
|
|
149
|
+
if check.cwd != "." {
|
|
150
|
+
return false;
|
|
151
|
+
}
|
|
152
|
+
match check.command.as_str() {
|
|
153
|
+
"git diff --check"
|
|
154
|
+
| "node .naome/bin/check-harness-health.js"
|
|
155
|
+
| "node .naome/bin/check-task-state.js"
|
|
156
|
+
| "node .naome/bin/naome.js quality check --changed"
|
|
157
|
+
| "node .naome/bin/naome.js semantic check --changed"
|
|
158
|
+
| "node .naome/bin/naome.js arch validate --changed-only" => true,
|
|
159
|
+
"npm run check:task-state" | "npm run test:task-state" | "npm run test:decision-engine" => {
|
|
160
|
+
!paths
|
|
161
|
+
.iter()
|
|
162
|
+
.any(|path| path == "package.json" || path == "packages/naome/package.json")
|
|
163
|
+
}
|
|
164
|
+
_ => false,
|
|
165
|
+
}
|
|
166
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
pub(in crate::task_commands) fn impact_kind(path: &str) -> &'static str {
|
|
2
|
+
if path == ".naome/task-state.json" {
|
|
3
|
+
"changed_task_state"
|
|
4
|
+
} else if path == "naome.arch.yaml" || path.ends_with("/naome.arch.yaml") {
|
|
5
|
+
"changed_architecture_config"
|
|
6
|
+
} else if path == "package.json"
|
|
7
|
+
|| path.ends_with("/package.json")
|
|
8
|
+
|| path.ends_with("Cargo.toml")
|
|
9
|
+
|| path.ends_with("Cargo.lock")
|
|
10
|
+
{
|
|
11
|
+
"changed_package_metadata"
|
|
12
|
+
} else if path.contains("/native/") || path.ends_with(".node") || path.ends_with(".so") {
|
|
13
|
+
"changed_native_binary"
|
|
14
|
+
} else if path.contains("/templates/") || path.starts_with("templates/") {
|
|
15
|
+
"changed_templates"
|
|
16
|
+
} else if path.ends_with(".md") || path.starts_with("docs/") {
|
|
17
|
+
"changed_docs"
|
|
18
|
+
} else if path.contains("test") || path.contains("spec") {
|
|
19
|
+
"changed_tests"
|
|
20
|
+
} else {
|
|
21
|
+
"changed_source"
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
pub(in crate::task_commands) fn path_risk(path: &str) -> &'static str {
|
|
26
|
+
match impact_kind(path) {
|
|
27
|
+
"changed_package_metadata"
|
|
28
|
+
| "changed_native_binary"
|
|
29
|
+
| "changed_templates"
|
|
30
|
+
| "changed_task_state"
|
|
31
|
+
| "changed_architecture_config" => "high",
|
|
32
|
+
"changed_source" | "changed_tests" => "medium",
|
|
33
|
+
_ => "low",
|
|
34
|
+
}
|
|
35
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
use serde_json::Value;
|
|
2
|
+
|
|
3
|
+
mod checks;
|
|
4
|
+
mod impact;
|
|
5
|
+
|
|
6
|
+
pub(super) use checks::{changed_paths, planned_commands};
|
|
7
|
+
pub(super) use impact::path_risk;
|
|
8
|
+
|
|
9
|
+
pub(super) fn split_safe_commands(commands: &[Value]) -> (Vec<Value>, Vec<Value>) {
|
|
10
|
+
let mut safe = Vec::new();
|
|
11
|
+
let mut deferred = Vec::new();
|
|
12
|
+
for command in commands {
|
|
13
|
+
if command
|
|
14
|
+
.get("safeToExecute")
|
|
15
|
+
.and_then(Value::as_bool)
|
|
16
|
+
.unwrap_or(false)
|
|
17
|
+
{
|
|
18
|
+
safe.push(command.clone());
|
|
19
|
+
} else {
|
|
20
|
+
deferred.push(command.clone());
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
(safe, deferred)
|
|
24
|
+
}
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
use std::path::Path;
|
|
2
|
+
|
|
3
|
+
use naome_core::{path_matches_any, task_status_report, TaskStatusReportV1};
|
|
4
|
+
use serde_json::{json, Value};
|
|
5
|
+
|
|
6
|
+
use super::common::{agent_session, print_json_with_session, repeated_values};
|
|
7
|
+
use super::path_policy::{is_control_path, is_ignored, normalize_requested_path};
|
|
8
|
+
use super::planner;
|
|
9
|
+
|
|
10
|
+
pub(super) fn preflight(root: &Path, args: &[String]) -> Result<(), Box<dyn std::error::Error>> {
|
|
11
|
+
let session = agent_session(args)?;
|
|
12
|
+
let paths = requested_paths(root, args);
|
|
13
|
+
let status = task_status_report(root)?;
|
|
14
|
+
let mut findings = target_findings(selector_supplied(args));
|
|
15
|
+
let mut must_not_edit = Vec::new();
|
|
16
|
+
let mut normalized_paths = Vec::new();
|
|
17
|
+
let path_reports = path_reports(
|
|
18
|
+
root,
|
|
19
|
+
&paths,
|
|
20
|
+
&status,
|
|
21
|
+
&mut findings,
|
|
22
|
+
&mut must_not_edit,
|
|
23
|
+
&mut normalized_paths,
|
|
24
|
+
);
|
|
25
|
+
normalized_paths.sort();
|
|
26
|
+
normalized_paths.dedup();
|
|
27
|
+
must_not_edit.sort();
|
|
28
|
+
must_not_edit.dedup();
|
|
29
|
+
let recommended = planner::planned_commands(root, &normalized_paths, Some(&status.proof));
|
|
30
|
+
let (safe, _deferred) = planner::split_safe_commands(&recommended);
|
|
31
|
+
let blocked = !findings.is_empty() || !must_not_edit.is_empty();
|
|
32
|
+
print_json_with_session(
|
|
33
|
+
json!({
|
|
34
|
+
"schema": "naome.task.preflight.v1",
|
|
35
|
+
"paths": path_reports,
|
|
36
|
+
"recommendedCommands": recommended,
|
|
37
|
+
"safeCommands": safe,
|
|
38
|
+
"mustNotEdit": must_not_edit,
|
|
39
|
+
"expectedProofPathSet": normalized_paths,
|
|
40
|
+
"findings": findings,
|
|
41
|
+
"nextAction": {
|
|
42
|
+
"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,
|
|
45
|
+
"commands": [],
|
|
46
|
+
"checkIds": [],
|
|
47
|
+
"safeToExecute": !blocked,
|
|
48
|
+
"requiresUserApproval": blocked
|
|
49
|
+
}
|
|
50
|
+
}),
|
|
51
|
+
session.as_deref(),
|
|
52
|
+
)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
fn requested_paths(root: &Path, args: &[String]) -> Vec<String> {
|
|
56
|
+
let mut paths = if args.iter().any(|arg| arg == "--from-changed") {
|
|
57
|
+
planner::changed_paths(root)
|
|
58
|
+
} else {
|
|
59
|
+
repeated_values(args, "--path")
|
|
60
|
+
};
|
|
61
|
+
paths.sort();
|
|
62
|
+
paths.dedup();
|
|
63
|
+
paths
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
fn selector_supplied(args: &[String]) -> bool {
|
|
67
|
+
args.iter()
|
|
68
|
+
.any(|arg| arg == "--from-changed" || arg == "--path")
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
fn target_findings(selector_supplied: bool) -> Vec<Value> {
|
|
72
|
+
if !selector_supplied {
|
|
73
|
+
vec![finding(
|
|
74
|
+
"task.preflight.missing_target_paths",
|
|
75
|
+
"Preflight requires --from-changed or at least one --path.",
|
|
76
|
+
"",
|
|
77
|
+
)]
|
|
78
|
+
} else {
|
|
79
|
+
Vec::new()
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
fn path_reports(
|
|
84
|
+
root: &Path,
|
|
85
|
+
paths: &[String],
|
|
86
|
+
status: &TaskStatusReportV1,
|
|
87
|
+
findings: &mut Vec<Value>,
|
|
88
|
+
must_not_edit: &mut Vec<String>,
|
|
89
|
+
normalized_paths: &mut Vec<String>,
|
|
90
|
+
) -> Vec<Value> {
|
|
91
|
+
paths
|
|
92
|
+
.iter()
|
|
93
|
+
.map(|raw_path| {
|
|
94
|
+
path_report(
|
|
95
|
+
root,
|
|
96
|
+
raw_path,
|
|
97
|
+
status,
|
|
98
|
+
findings,
|
|
99
|
+
must_not_edit,
|
|
100
|
+
normalized_paths,
|
|
101
|
+
)
|
|
102
|
+
})
|
|
103
|
+
.collect()
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
fn path_report(
|
|
107
|
+
root: &Path,
|
|
108
|
+
raw_path: &str,
|
|
109
|
+
status: &TaskStatusReportV1,
|
|
110
|
+
findings: &mut Vec<Value>,
|
|
111
|
+
must_not_edit: &mut Vec<String>,
|
|
112
|
+
normalized_paths: &mut Vec<String>,
|
|
113
|
+
) -> Value {
|
|
114
|
+
let (path, path_error) = normalize_requested_path(raw_path);
|
|
115
|
+
let ignored = path_error.is_none() && is_ignored(root, &path);
|
|
116
|
+
let control = path_error.is_none() && is_control_path(&path);
|
|
117
|
+
let in_scope = path_error.is_none() && path_matches_any(&path, &status.scope.allowed_paths);
|
|
118
|
+
let editable = path_editable(status, path_error.is_none(), ignored, control, in_scope);
|
|
119
|
+
track_path_report(&path, editable, must_not_edit, normalized_paths);
|
|
120
|
+
add_path_finding(&path, path_error, ignored, control, in_scope, findings);
|
|
121
|
+
let commands =
|
|
122
|
+
planner::planned_commands(root, std::slice::from_ref(&path), Some(&status.proof));
|
|
123
|
+
json!({
|
|
124
|
+
"path": path,
|
|
125
|
+
"editable": editable,
|
|
126
|
+
"reason": if editable { "Path is editable for the active task." } else { "Path is not editable without scope or state repair." },
|
|
127
|
+
"ignored": ignored,
|
|
128
|
+
"controlPath": control,
|
|
129
|
+
"inScope": in_scope,
|
|
130
|
+
"impactedChecks": commands.iter().filter_map(|command| command.get("checkId").and_then(Value::as_str).map(ToString::to_string)).collect::<Vec<_>>(),
|
|
131
|
+
"requiredBeforeCommit": commands,
|
|
132
|
+
"risk": planner::path_risk(raw_path),
|
|
133
|
+
"agentInstruction": if editable { "Agent may edit this path, then rerun task agent-snapshot or task loop." } else { "Do not edit this path in the current task." }
|
|
134
|
+
})
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
fn path_editable(
|
|
138
|
+
status: &TaskStatusReportV1,
|
|
139
|
+
valid_path: bool,
|
|
140
|
+
ignored: bool,
|
|
141
|
+
control: bool,
|
|
142
|
+
in_scope: bool,
|
|
143
|
+
) -> bool {
|
|
144
|
+
valid_path
|
|
145
|
+
&& !ignored
|
|
146
|
+
&& !control
|
|
147
|
+
&& in_scope
|
|
148
|
+
&& status.task_id.is_some()
|
|
149
|
+
&& !matches!(
|
|
150
|
+
status.state.as_str(),
|
|
151
|
+
"idle" | "missing" | "complete" | "blocked" | "needs_human_review"
|
|
152
|
+
)
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
fn track_path_report(
|
|
156
|
+
path: &str,
|
|
157
|
+
editable: bool,
|
|
158
|
+
must_not_edit: &mut Vec<String>,
|
|
159
|
+
normalized_paths: &mut Vec<String>,
|
|
160
|
+
) {
|
|
161
|
+
if editable {
|
|
162
|
+
normalized_paths.push(path.to_string());
|
|
163
|
+
} else {
|
|
164
|
+
must_not_edit.push(path.to_string());
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
fn add_path_finding(
|
|
169
|
+
path: &str,
|
|
170
|
+
path_error: Option<String>,
|
|
171
|
+
ignored: bool,
|
|
172
|
+
control: bool,
|
|
173
|
+
in_scope: bool,
|
|
174
|
+
findings: &mut Vec<Value>,
|
|
175
|
+
) {
|
|
176
|
+
if let Some(message) = path_error {
|
|
177
|
+
findings.push(finding("task.preflight.unsafe_path", &message, path));
|
|
178
|
+
} else if ignored {
|
|
179
|
+
findings.push(finding(
|
|
180
|
+
"task.preflight.ignored_path",
|
|
181
|
+
"Path is ignored by .naomeignore.",
|
|
182
|
+
path,
|
|
183
|
+
));
|
|
184
|
+
} else if control {
|
|
185
|
+
findings.push(finding(
|
|
186
|
+
"task.preflight.control_path",
|
|
187
|
+
"Path is a NAOME control path.",
|
|
188
|
+
path,
|
|
189
|
+
));
|
|
190
|
+
} else if !in_scope {
|
|
191
|
+
findings.push(finding(
|
|
192
|
+
"task.preflight.out_of_scope",
|
|
193
|
+
"Path is outside active task scope.",
|
|
194
|
+
path,
|
|
195
|
+
));
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
fn finding(id: &str, message: &str, path: &str) -> Value {
|
|
200
|
+
json!({
|
|
201
|
+
"id": id,
|
|
202
|
+
"severity": "error",
|
|
203
|
+
"message": message,
|
|
204
|
+
"path": path,
|
|
205
|
+
"suggestedFix": "Use an in-scope non-control path or request explicit scope revision.",
|
|
206
|
+
"agentInstruction": "Do not edit this path unless a human-approved task scope revision allows it."
|
|
207
|
+
})
|
|
208
|
+
}
|
|
@@ -158,9 +158,6 @@ fn proof_batch(
|
|
|
158
158
|
"exitCode": receipt.exit_code,
|
|
159
159
|
"checkedAt": receipt.checked_at,
|
|
160
160
|
"evidenceFingerprint": receipt.evidence_fingerprint,
|
|
161
|
-
"stdoutSummary": receipt.stdout_summary,
|
|
162
|
-
"stderrSummary": receipt.stderr_summary,
|
|
163
|
-
"durationMs": receipt.duration_ms,
|
|
164
161
|
"agentSession": receipt.agent_session
|
|
165
162
|
})
|
|
166
163
|
}).collect::<Vec<_>>()
|
|
@@ -174,42 +171,82 @@ fn recordable_receipts(
|
|
|
174
171
|
) -> Result<(bool, Vec<Value>, Vec<CheckRunReceipt>), Box<dyn std::error::Error>> {
|
|
175
172
|
let receipts = successful_receipts(root)?;
|
|
176
173
|
let current_fingerprint = evidence_fingerprint(root, paths)?;
|
|
174
|
+
let task_id = read_task_state(root)?
|
|
175
|
+
.get("activeTask")
|
|
176
|
+
.and_then(|task| task.get("id"))
|
|
177
|
+
.and_then(Value::as_str)
|
|
178
|
+
.map(ToString::to_string);
|
|
177
179
|
let mut selected = Vec::new();
|
|
178
180
|
let mut findings = Vec::new();
|
|
179
181
|
for check_id in check_ids {
|
|
180
182
|
let expected = super::check_run::read_verification_check(root, check_id)?;
|
|
181
|
-
let
|
|
183
|
+
let matching_check = receipts
|
|
182
184
|
.iter()
|
|
183
185
|
.rev()
|
|
184
|
-
.find(|receipt|
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
186
|
+
.find(|receipt| receipt.check_id == *check_id);
|
|
187
|
+
let Some(receipt) = matching_check else {
|
|
188
|
+
findings.push(no_recent_success(check_id));
|
|
189
|
+
continue;
|
|
190
|
+
};
|
|
191
|
+
if receipt.task_id != task_id {
|
|
192
|
+
findings.push(json!({
|
|
193
|
+
"id": "task.proof.receipt_task_mismatch",
|
|
194
|
+
"severity": "error",
|
|
195
|
+
"message": format!("Recent receipt for {check_id} was produced for a different task."),
|
|
196
|
+
"path": null,
|
|
197
|
+
"suggestedFix": "Rerun the check for the active task before recording proof.",
|
|
198
|
+
"agentInstruction": "Do not record proof from another task."
|
|
199
|
+
}));
|
|
200
|
+
continue;
|
|
201
|
+
}
|
|
202
|
+
if !expected
|
|
203
|
+
.as_ref()
|
|
204
|
+
.is_some_and(|check| receipt.command == check.command && receipt.cwd == check.cwd)
|
|
205
|
+
{
|
|
206
|
+
findings.push(no_recent_success(check_id));
|
|
207
|
+
continue;
|
|
208
|
+
}
|
|
209
|
+
if paths != receipt.evidence_paths {
|
|
210
|
+
findings.push(json!({
|
|
211
|
+
"id": "task.proof.receipt_path_mismatch",
|
|
202
212
|
"severity": "error",
|
|
203
|
-
"message": format!("
|
|
213
|
+
"message": format!("Recent receipt for {check_id} covers a different path set."),
|
|
204
214
|
"path": null,
|
|
205
|
-
"suggestedFix": "
|
|
206
|
-
"agentInstruction": "Do not record proof
|
|
207
|
-
}))
|
|
215
|
+
"suggestedFix": "Rerun the check against the current task-owned changed paths.",
|
|
216
|
+
"agentInstruction": "Do not record proof when receipt paths differ from the current task diff."
|
|
217
|
+
}));
|
|
218
|
+
continue;
|
|
219
|
+
}
|
|
220
|
+
if receipt.evidence_fingerprint != current_fingerprint {
|
|
221
|
+
findings.push(json!({
|
|
222
|
+
"id": "task.proof.stale_receipt_content",
|
|
223
|
+
"severity": "error",
|
|
224
|
+
"message": format!("Recent receipt for {check_id} does not match current file contents."),
|
|
225
|
+
"path": null,
|
|
226
|
+
"suggestedFix": "Rerun the check after the latest edits.",
|
|
227
|
+
"agentInstruction": "Do not record proof from stale check output."
|
|
228
|
+
}));
|
|
229
|
+
continue;
|
|
230
|
+
}
|
|
231
|
+
match matching_check.cloned() {
|
|
232
|
+
Some(receipt) => selected.push(receipt),
|
|
233
|
+
None => findings.push(no_recent_success(check_id)),
|
|
208
234
|
}
|
|
209
235
|
}
|
|
210
236
|
Ok((findings.is_empty(), findings, selected))
|
|
211
237
|
}
|
|
212
238
|
|
|
239
|
+
fn no_recent_success(check_id: &str) -> Value {
|
|
240
|
+
json!({
|
|
241
|
+
"id": "task.proof.no_recent_success",
|
|
242
|
+
"severity": "error",
|
|
243
|
+
"message": format!("No recent successful safe check evidence covers current task paths for {check_id}."),
|
|
244
|
+
"path": null,
|
|
245
|
+
"suggestedFix": "Run naome task run-check --check <id> --record-proof --json or naome task loop --execute-safe --json.",
|
|
246
|
+
"agentInstruction": "Do not record proof until NAOME has just executed the check successfully."
|
|
247
|
+
})
|
|
248
|
+
}
|
|
249
|
+
|
|
213
250
|
fn unique_path_set_id(path_sets: &serde_json::Map<String, Value>, base: &str) -> String {
|
|
214
251
|
unique_id(base, |candidate| path_sets.contains_key(candidate))
|
|
215
252
|
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
use std::path::Path;
|
|
2
|
+
use std::process::Command;
|
|
3
|
+
|
|
4
|
+
use serde_json::json;
|
|
5
|
+
|
|
6
|
+
use super::common::{agent_session, print_json_with_session};
|
|
7
|
+
use super::path_policy::is_ignored;
|
|
8
|
+
use super::planner;
|
|
9
|
+
|
|
10
|
+
pub(super) fn scope_suggestions(
|
|
11
|
+
root: &Path,
|
|
12
|
+
args: &[String],
|
|
13
|
+
) -> Result<(), Box<dyn std::error::Error>> {
|
|
14
|
+
let session = agent_session(args)?;
|
|
15
|
+
if !args.iter().any(|arg| arg == "--from-changed") {
|
|
16
|
+
return Err("naome task scope-suggestions requires --from-changed".into());
|
|
17
|
+
}
|
|
18
|
+
let mut suggestions = rename_suggestions(root)
|
|
19
|
+
.into_iter()
|
|
20
|
+
.chain(loader_suggestions(root))
|
|
21
|
+
.filter(|suggestion| {
|
|
22
|
+
!suggestion
|
|
23
|
+
.get("path")
|
|
24
|
+
.and_then(serde_json::Value::as_str)
|
|
25
|
+
.is_some_and(|path| is_ignored(root, path))
|
|
26
|
+
})
|
|
27
|
+
.collect::<Vec<_>>();
|
|
28
|
+
suggestions.sort_by(|left, right| {
|
|
29
|
+
left.get("path")
|
|
30
|
+
.and_then(serde_json::Value::as_str)
|
|
31
|
+
.unwrap_or("")
|
|
32
|
+
.cmp(
|
|
33
|
+
right
|
|
34
|
+
.get("path")
|
|
35
|
+
.and_then(serde_json::Value::as_str)
|
|
36
|
+
.unwrap_or(""),
|
|
37
|
+
)
|
|
38
|
+
});
|
|
39
|
+
print_json_with_session(
|
|
40
|
+
json!({
|
|
41
|
+
"schema": "naome.task.scope-suggestions.v1",
|
|
42
|
+
"suggestions": suggestions,
|
|
43
|
+
"agentInstruction": "Suggestions are read-only hints; task scope mutation still requires explicit approval."
|
|
44
|
+
}),
|
|
45
|
+
session.as_deref(),
|
|
46
|
+
)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
fn rename_suggestions(root: &Path) -> Vec<serde_json::Value> {
|
|
50
|
+
let Ok(output) = Command::new("git")
|
|
51
|
+
.args(["status", "--porcelain=v1", "-z"])
|
|
52
|
+
.current_dir(root)
|
|
53
|
+
.output()
|
|
54
|
+
else {
|
|
55
|
+
return Vec::new();
|
|
56
|
+
};
|
|
57
|
+
if !output.status.success() {
|
|
58
|
+
return Vec::new();
|
|
59
|
+
}
|
|
60
|
+
let mut suggestions = Vec::new();
|
|
61
|
+
let entries = output.stdout.split(|byte| *byte == 0).collect::<Vec<_>>();
|
|
62
|
+
let mut index = 0;
|
|
63
|
+
while index < entries.len() {
|
|
64
|
+
let entry = entries[index];
|
|
65
|
+
index += 1;
|
|
66
|
+
if entry.len() < 4 {
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
let status = String::from_utf8_lossy(&entry[..2]);
|
|
70
|
+
if status.contains('R') && index < entries.len() {
|
|
71
|
+
let old = String::from_utf8_lossy(entries[index]).replace('\\', "/");
|
|
72
|
+
index += 1;
|
|
73
|
+
let new = String::from_utf8_lossy(&entry[3..]).replace('\\', "/");
|
|
74
|
+
for path in [old, new] {
|
|
75
|
+
suggestions.push(json!({
|
|
76
|
+
"path": path,
|
|
77
|
+
"reason": "paired path for renamed file",
|
|
78
|
+
"confidence": 0.95,
|
|
79
|
+
"requiresUserApproval": true
|
|
80
|
+
}));
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
suggestions
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
fn loader_suggestions(root: &Path) -> Vec<serde_json::Value> {
|
|
88
|
+
planner::changed_paths(root)
|
|
89
|
+
.into_iter()
|
|
90
|
+
.filter(|path| path.ends_with(".test.js") || path.ends_with("_test.rs"))
|
|
91
|
+
.filter_map(|path| {
|
|
92
|
+
let loader = if path.starts_with("scripts/") {
|
|
93
|
+
Some("scripts/naome-installer-support.js".to_string())
|
|
94
|
+
} else if path.contains("/tests/") {
|
|
95
|
+
path.split("/tests/")
|
|
96
|
+
.next()
|
|
97
|
+
.map(|prefix| format!("{prefix}/tests/support/mod.rs"))
|
|
98
|
+
} else {
|
|
99
|
+
None
|
|
100
|
+
}?;
|
|
101
|
+
Some(json!({
|
|
102
|
+
"path": loader,
|
|
103
|
+
"reason": "fixture support helper for changed test",
|
|
104
|
+
"confidence": 0.7,
|
|
105
|
+
"requiresUserApproval": true
|
|
106
|
+
}))
|
|
107
|
+
})
|
|
108
|
+
.collect()
|
|
109
|
+
}
|