@lamentis/naome 1.1.2 → 1.2.0

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 (125) hide show
  1. package/Cargo.lock +2 -2
  2. package/Cargo.toml +1 -1
  3. package/LICENSE +180 -21
  4. package/README.md +49 -6
  5. package/bin/naome.js +54 -16
  6. package/crates/naome-cli/Cargo.toml +1 -1
  7. package/crates/naome-cli/src/check_commands.rs +135 -0
  8. package/crates/naome-cli/src/cli_args.rs +5 -0
  9. package/crates/naome-cli/src/dispatcher.rs +36 -0
  10. package/crates/naome-cli/src/install_bridge.rs +83 -0
  11. package/crates/naome-cli/src/main.rs +57 -341
  12. package/crates/naome-cli/src/prompt_commands.rs +68 -0
  13. package/crates/naome-cli/src/quality_commands.rs +141 -0
  14. package/crates/naome-cli/src/simple_commands.rs +53 -0
  15. package/crates/naome-cli/src/workflow_commands.rs +153 -0
  16. package/crates/naome-core/Cargo.toml +1 -1
  17. package/crates/naome-core/src/harness_health/integrity.rs +96 -0
  18. package/crates/naome-core/src/harness_health.rs +14 -126
  19. package/crates/naome-core/src/install_plan.rs +3 -0
  20. package/crates/naome-core/src/intent/classifier.rs +171 -0
  21. package/crates/naome-core/src/intent/envelope.rs +108 -0
  22. package/crates/naome-core/src/intent/legacy.rs +138 -0
  23. package/crates/naome-core/src/intent/legacy_response.rs +76 -0
  24. package/crates/naome-core/src/intent/model.rs +71 -0
  25. package/crates/naome-core/src/intent/patterns.rs +170 -0
  26. package/crates/naome-core/src/intent/resolver.rs +162 -0
  27. package/crates/naome-core/src/intent/resolver_active.rs +17 -0
  28. package/crates/naome-core/src/intent/resolver_baseline.rs +55 -0
  29. package/crates/naome-core/src/intent/resolver_catalog.rs +167 -0
  30. package/crates/naome-core/src/intent/resolver_policy.rs +72 -0
  31. package/crates/naome-core/src/intent/resolver_shared.rs +55 -0
  32. package/crates/naome-core/src/intent/risk.rs +40 -0
  33. package/crates/naome-core/src/intent/segment.rs +170 -0
  34. package/crates/naome-core/src/intent.rs +64 -879
  35. package/crates/naome-core/src/journal.rs +9 -20
  36. package/crates/naome-core/src/lib.rs +13 -0
  37. package/crates/naome-core/src/quality/adapters.rs +178 -0
  38. package/crates/naome-core/src/quality/baseline.rs +75 -0
  39. package/crates/naome-core/src/quality/checks/duplicate_blocks.rs +175 -0
  40. package/crates/naome-core/src/quality/checks/near_duplicates.rs +130 -0
  41. package/crates/naome-core/src/quality/checks.rs +228 -0
  42. package/crates/naome-core/src/quality/cleanup.rs +72 -0
  43. package/crates/naome-core/src/quality/config.rs +109 -0
  44. package/crates/naome-core/src/quality/mod.rs +90 -0
  45. package/crates/naome-core/src/quality/scanner/repo_paths.rs +103 -0
  46. package/crates/naome-core/src/quality/scanner.rs +367 -0
  47. package/crates/naome-core/src/quality/types.rs +289 -0
  48. package/crates/naome-core/src/route.rs +62 -0
  49. package/crates/naome-core/src/task_state/admission.rs +63 -0
  50. package/crates/naome-core/src/task_state/admission_proof.rs +72 -0
  51. package/crates/naome-core/src/task_state/api.rs +130 -0
  52. package/crates/naome-core/src/task_state/commit_gate.rs +138 -0
  53. package/crates/naome-core/src/task_state/compact_proof.rs +160 -0
  54. package/crates/naome-core/src/task_state/completed_refresh.rs +89 -0
  55. package/crates/naome-core/src/task_state/completion.rs +72 -0
  56. package/crates/naome-core/src/task_state/deleted_paths.rs +47 -0
  57. package/crates/naome-core/src/task_state/diff.rs +95 -0
  58. package/crates/naome-core/src/task_state/evidence.rs +154 -0
  59. package/crates/naome-core/src/task_state/git_io.rs +86 -0
  60. package/crates/naome-core/src/task_state/git_parse.rs +86 -0
  61. package/crates/naome-core/src/task_state/git_refs.rs +37 -0
  62. package/crates/naome-core/src/task_state/human_review_state.rs +31 -0
  63. package/crates/naome-core/src/task_state/mod.rs +38 -0
  64. package/crates/naome-core/src/task_state/process_guard.rs +40 -0
  65. package/crates/naome-core/src/task_state/progress.rs +123 -0
  66. package/crates/naome-core/src/task_state/proof.rs +139 -0
  67. package/crates/naome-core/src/task_state/proof_entry.rs +66 -0
  68. package/crates/naome-core/src/task_state/proof_model.rs +70 -0
  69. package/crates/naome-core/src/task_state/proof_sources.rs +76 -0
  70. package/crates/naome-core/src/task_state/push_gate.rs +49 -0
  71. package/crates/naome-core/src/task_state/reconcile.rs +7 -0
  72. package/crates/naome-core/src/task_state/repair.rs +168 -0
  73. package/crates/naome-core/src/task_state/shape.rs +117 -0
  74. package/crates/naome-core/src/task_state/task_diff_api.rs +170 -0
  75. package/crates/naome-core/src/task_state/task_records.rs +131 -0
  76. package/crates/naome-core/src/task_state/task_references.rs +126 -0
  77. package/crates/naome-core/src/task_state/types.rs +87 -0
  78. package/crates/naome-core/src/task_state/util.rs +137 -0
  79. package/crates/naome-core/src/verification/render.rs +122 -0
  80. package/crates/naome-core/src/verification.rs +176 -58
  81. package/crates/naome-core/src/verification_contract.rs +49 -21
  82. package/crates/naome-core/src/workflow/integrity.rs +123 -0
  83. package/crates/naome-core/src/workflow/integrity_normalize.rs +7 -0
  84. package/crates/naome-core/src/workflow/integrity_support.rs +110 -0
  85. package/crates/naome-core/src/workflow/mod.rs +18 -0
  86. package/crates/naome-core/src/workflow/mutation.rs +68 -0
  87. package/crates/naome-core/src/workflow/output.rs +111 -0
  88. package/crates/naome-core/src/workflow/phase_inference.rs +73 -0
  89. package/crates/naome-core/src/workflow/phases.rs +169 -0
  90. package/crates/naome-core/src/workflow/policy.rs +156 -0
  91. package/crates/naome-core/src/workflow/processes.rs +91 -0
  92. package/crates/naome-core/src/workflow/types.rs +42 -0
  93. package/crates/naome-core/tests/harness_health.rs +3 -0
  94. package/crates/naome-core/tests/intent.rs +97 -792
  95. package/crates/naome-core/tests/intent_support/mod.rs +133 -0
  96. package/crates/naome-core/tests/intent_v2.rs +90 -0
  97. package/crates/naome-core/tests/quality.rs +425 -0
  98. package/crates/naome-core/tests/route.rs +88 -188
  99. package/crates/naome-core/tests/task_state.rs +3 -0
  100. package/crates/naome-core/tests/task_state_compact.rs +110 -0
  101. package/crates/naome-core/tests/task_state_compact_support/mod.rs +5 -0
  102. package/crates/naome-core/tests/task_state_compact_support/repo.rs +130 -0
  103. package/crates/naome-core/tests/task_state_compact_support/states.rs +151 -0
  104. package/crates/naome-core/tests/workflow_integrity.rs +85 -0
  105. package/crates/naome-core/tests/workflow_policy.rs +139 -0
  106. package/crates/naome-core/tests/workflow_support/mod.rs +194 -0
  107. package/native/darwin-arm64/naome +0 -0
  108. package/native/linux-x64/naome +0 -0
  109. package/package.json +2 -2
  110. package/templates/naome-root/.naome/bin/check-harness-health.js +66 -85
  111. package/templates/naome-root/.naome/bin/check-task-state.js +9 -10
  112. package/templates/naome-root/.naome/bin/naome.js +34 -63
  113. package/templates/naome-root/.naome/manifest.json +20 -18
  114. package/templates/naome-root/.naome/repository-quality-baseline.json +5 -0
  115. package/templates/naome-root/.naome/repository-quality.json +24 -0
  116. package/templates/naome-root/.naome/task-contract.schema.json +93 -11
  117. package/templates/naome-root/.naome/upgrade-state.json +1 -1
  118. package/templates/naome-root/.naome/verification.json +37 -0
  119. package/templates/naome-root/AGENTS.md +3 -0
  120. package/templates/naome-root/docs/naome/agent-workflow.md +25 -12
  121. package/templates/naome-root/docs/naome/execution.md +25 -21
  122. package/templates/naome-root/docs/naome/index.md +4 -3
  123. package/templates/naome-root/docs/naome/repository-quality.md +43 -0
  124. package/templates/naome-root/docs/naome/testing.md +12 -0
  125. package/crates/naome-core/src/task_state.rs +0 -2210
@@ -1,2210 +0,0 @@
1
- use std::collections::{HashMap, HashSet};
2
- use std::fs;
3
- use std::path::{Path, PathBuf};
4
- use std::process::Command;
5
-
6
- use serde_json::Value;
7
-
8
- use crate::harness_health::{validate_harness_health, HarnessHealthOptions};
9
- use crate::install_plan::MACHINE_OWNED_PATHS;
10
- use crate::models::NaomeError;
11
-
12
- const CONTROL_STATE_PATH: &str = ".naome/task-state.json";
13
- const ALLOWED_STATUS: &[&str] = &[
14
- "idle",
15
- "planning",
16
- "implementing",
17
- "revising",
18
- "verifying",
19
- "needs_human_review",
20
- "blocked",
21
- "complete",
22
- ];
23
- const BLOCKING_STATUS: &[&str] = &[
24
- "planning",
25
- "implementing",
26
- "revising",
27
- "verifying",
28
- "needs_human_review",
29
- "blocked",
30
- ];
31
- const ALLOWED_EVIDENCE_STATUS: &[&str] = &["added", "modified", "deleted", "renamed"];
32
-
33
- #[derive(Debug, Clone, Copy, PartialEq, Eq)]
34
- pub enum TaskStateMode {
35
- State,
36
- Admission,
37
- Progress,
38
- CommitGate,
39
- PushGate,
40
- }
41
-
42
- #[derive(Debug, Clone)]
43
- pub struct TaskStateOptions {
44
- pub mode: TaskStateMode,
45
- pub harness_health: Option<HarnessHealthOptions>,
46
- }
47
-
48
- impl Default for TaskStateOptions {
49
- fn default() -> Self {
50
- Self {
51
- mode: TaskStateMode::State,
52
- harness_health: None,
53
- }
54
- }
55
- }
56
-
57
- #[derive(Debug, Clone, PartialEq, Eq)]
58
- pub struct TaskStateReport {
59
- pub errors: Vec<String>,
60
- pub notices: Vec<String>,
61
- }
62
-
63
- #[derive(Debug, Clone)]
64
- struct ChangedEntry {
65
- path: String,
66
- status: String,
67
- }
68
-
69
- #[derive(Debug, Clone, PartialEq, Eq)]
70
- pub struct CompletedTaskHarnessRefreshDiff {
71
- pub harness_paths: Vec<String>,
72
- pub task_paths: Vec<String>,
73
- }
74
-
75
- #[derive(Debug, Clone, PartialEq, Eq)]
76
- pub struct HarnessRefreshWithUnrelatedDiff {
77
- pub harness_paths: Vec<String>,
78
- pub unrelated_paths: Vec<String>,
79
- }
80
-
81
- #[derive(Debug, Clone, PartialEq, Eq)]
82
- pub struct HarnessRefreshDiff {
83
- pub harness_paths: Vec<String>,
84
- pub unrelated_paths: Vec<String>,
85
- }
86
-
87
- #[derive(Debug, Clone, PartialEq, Eq)]
88
- pub struct CompletedTaskCommitDiff {
89
- pub task_paths: Vec<String>,
90
- pub unrelated_paths: Vec<String>,
91
- }
92
-
93
- pub fn validate_task_state(
94
- root: &Path,
95
- options: TaskStateOptions,
96
- ) -> Result<TaskStateReport, NaomeError> {
97
- let mut report = TaskStateReport {
98
- errors: Vec::new(),
99
- notices: Vec::new(),
100
- };
101
- let Some(task_state) = read_json(root, ".naome/task-state.json", &mut report.errors)? else {
102
- return Ok(report);
103
- };
104
-
105
- validate_task_state_shape(&task_state, &mut report.errors);
106
- let status = task_state
107
- .get("status")
108
- .and_then(Value::as_str)
109
- .unwrap_or("invalid");
110
- if !ALLOWED_STATUS.contains(&status) {
111
- return Ok(report);
112
- }
113
-
114
- validate_harness_health_gate(root, &options, &mut report.errors)?;
115
- if !report.errors.is_empty() {
116
- return Ok(report);
117
- }
118
-
119
- match options.mode {
120
- TaskStateMode::Admission => {
121
- validate_admission(&task_state, root, &mut report.errors)?;
122
- return Ok(report);
123
- }
124
- TaskStateMode::Progress => {
125
- validate_progress(&task_state, root, &mut report.errors)?;
126
- return Ok(report);
127
- }
128
- TaskStateMode::CommitGate => {
129
- validate_commit_gate(&task_state, root, &mut report.errors, &mut report.notices)?;
130
- return Ok(report);
131
- }
132
- TaskStateMode::PushGate => {
133
- validate_push_gate(&task_state, &mut report.errors);
134
- return Ok(report);
135
- }
136
- TaskStateMode::State => {}
137
- }
138
-
139
- if status == "idle" {
140
- validate_idle_state(&task_state, &mut report.errors);
141
- return Ok(report);
142
- }
143
-
144
- let active_error_start = report.errors.len();
145
- validate_active_task(task_state.get("activeTask"), &mut report.errors);
146
- validate_pending_upgrade(&task_state, root, &mut report.errors)?;
147
- validate_active_task_references(
148
- task_state.get("activeTask"),
149
- root,
150
- &mut report.errors,
151
- Some(status),
152
- )?;
153
-
154
- if status == "needs_human_review" {
155
- validate_blocker(task_state.get("blocker"), &mut report.errors);
156
- validate_human_review_blocker_paths(
157
- task_state.get("activeTask"),
158
- task_state.get("blocker"),
159
- root,
160
- &mut report.errors,
161
- )?;
162
- validate_proof_evidence_covers_changed_paths(
163
- task_state.get("activeTask"),
164
- root,
165
- &mut report.errors,
166
- )?;
167
- if report.errors.len() > active_error_start {
168
- report.errors.push("needs_human_review task state is invalid; fix blocker paths and proof evidence before asking for a human decision.".to_string());
169
- } else {
170
- report.errors.push(format_blocker(
171
- "Human review required",
172
- task_state.get("blocker"),
173
- ));
174
- }
175
- return Ok(report);
176
- }
177
-
178
- if status == "blocked" {
179
- validate_blocker(task_state.get("blocker"), &mut report.errors);
180
- report
181
- .errors
182
- .push(format_blocker("Task is blocked", task_state.get("blocker")));
183
- return Ok(report);
184
- }
185
-
186
- if BLOCKING_STATUS.contains(&status) {
187
- report.errors.push(format!(
188
- "Task is still {status}; new work must wait until the active task is complete or resolved."
189
- ));
190
- return Ok(report);
191
- }
192
-
193
- validate_complete_task(
194
- task_state.get("activeTask"),
195
- task_state.get("blocker"),
196
- root,
197
- &mut report.errors,
198
- &mut report.notices,
199
- )?;
200
- Ok(report)
201
- }
202
-
203
- pub fn completed_task_commit_paths(root: &Path) -> Result<Vec<String>, NaomeError> {
204
- Ok(completed_task_commit_diff(root)?
205
- .map(|diff| diff.task_paths)
206
- .unwrap_or_default())
207
- }
208
-
209
- pub fn completed_task_commit_diff(
210
- root: &Path,
211
- ) -> Result<Option<CompletedTaskCommitDiff>, NaomeError> {
212
- let mut read_errors = Vec::new();
213
- let Some(task_state) = read_json(root, ".naome/task-state.json", &mut read_errors)? else {
214
- return Ok(None);
215
- };
216
- if !read_errors.is_empty()
217
- || task_state.get("status").and_then(Value::as_str) != Some("complete")
218
- {
219
- return Ok(None);
220
- }
221
- let Some(active_task) = task_state.get("activeTask") else {
222
- return Ok(None);
223
- };
224
-
225
- let allowed_paths = string_array(active_task.get("allowedPaths")).unwrap_or_default();
226
- let mut task_entries = Vec::new();
227
- let mut unrelated_paths = Vec::new();
228
- for entry in read_git_changed_entries(root)? {
229
- if entry.path == CONTROL_STATE_PATH || matches_any_pattern(&entry.path, &allowed_paths) {
230
- task_entries.push(entry);
231
- } else {
232
- unrelated_paths.push(entry.path);
233
- }
234
- }
235
-
236
- if task_entries.is_empty() {
237
- return Ok(None);
238
- }
239
-
240
- let mut errors = Vec::new();
241
- validate_task_state_shape(&task_state, &mut errors);
242
- validate_active_task(Some(active_task), &mut errors);
243
- validate_pending_upgrade(&task_state, root, &mut errors)?;
244
- validate_active_task_references(Some(active_task), root, &mut errors, Some("complete"))?;
245
- if !task_state.get("blocker").is_some_and(Value::is_null) {
246
- errors.push("complete task state must have blocker set to null.".to_string());
247
- }
248
- let check_ids = read_verification_check_ids(root, &mut errors)?;
249
- validate_required_check_ids(active_task, &check_ids, &mut errors);
250
- validate_complete_task_against_entries(
251
- active_task,
252
- root,
253
- &check_ids,
254
- &task_entries,
255
- &mut errors,
256
- )?;
257
- if !errors.is_empty() {
258
- return Ok(None);
259
- }
260
-
261
- let mut task_paths: Vec<String> = task_entries
262
- .into_iter()
263
- .map(|entry| entry.path)
264
- .collect::<HashSet<_>>()
265
- .into_iter()
266
- .collect();
267
- task_paths.sort();
268
- unrelated_paths.sort();
269
-
270
- Ok(Some(CompletedTaskCommitDiff {
271
- task_paths,
272
- unrelated_paths,
273
- }))
274
- }
275
-
276
- pub fn harness_refresh_diff(root: &Path) -> Result<Option<HarnessRefreshDiff>, NaomeError> {
277
- let changed_paths = read_git_changed_paths(root)?;
278
- let has_repair_signal = changed_paths
279
- .iter()
280
- .any(|path| is_packaged_machine_owned_path(path) || is_repair_archive_path(path));
281
- if !has_repair_signal {
282
- return Ok(None);
283
- }
284
-
285
- let mut harness_paths = Vec::new();
286
- let mut unrelated_paths = Vec::new();
287
-
288
- for path in changed_paths {
289
- if is_safe_harness_refresh_path(&path) {
290
- harness_paths.push(path);
291
- } else {
292
- unrelated_paths.push(path);
293
- }
294
- }
295
-
296
- if harness_paths.is_empty() {
297
- return Ok(None);
298
- }
299
-
300
- harness_paths.sort();
301
- unrelated_paths.sort();
302
-
303
- Ok(Some(HarnessRefreshDiff {
304
- harness_paths,
305
- unrelated_paths,
306
- }))
307
- }
308
-
309
- pub fn harness_refresh_with_unrelated_diff(
310
- root: &Path,
311
- ) -> Result<Option<HarnessRefreshWithUnrelatedDiff>, NaomeError> {
312
- let Some(diff) = harness_refresh_diff(root)? else {
313
- return Ok(None);
314
- };
315
- if diff.unrelated_paths.is_empty() {
316
- return Ok(None);
317
- }
318
-
319
- Ok(Some(HarnessRefreshWithUnrelatedDiff {
320
- harness_paths: diff.harness_paths,
321
- unrelated_paths: diff.unrelated_paths,
322
- }))
323
- }
324
-
325
- fn validate_harness_health_gate(
326
- root: &Path,
327
- options: &TaskStateOptions,
328
- errors: &mut Vec<String>,
329
- ) -> Result<(), NaomeError> {
330
- let Some(health_options) = options.harness_health.clone() else {
331
- return Ok(());
332
- };
333
-
334
- let health_errors = validate_harness_health(root, health_options)?;
335
- if health_errors.is_empty() {
336
- return Ok(());
337
- }
338
-
339
- errors.push(
340
- "Harness health failed; normal NAOME task work is repair-only until machine-owned harness files are healthy. Human options: repair_harness, review_harness_health."
341
- .to_string(),
342
- );
343
- errors.extend(
344
- health_errors
345
- .into_iter()
346
- .map(|error| format!("Harness health: {error}")),
347
- );
348
- Ok(())
349
- }
350
-
351
- fn validate_task_state_shape(task_state: &Value, errors: &mut Vec<String>) {
352
- let Some(object) = task_state.as_object() else {
353
- errors.push(".naome/task-state.json must be a JSON object.".to_string());
354
- return;
355
- };
356
-
357
- if object.get("schema").and_then(Value::as_str) != Some("naome.task-state.v1") {
358
- errors.push(".naome/task-state.json schema must be naome.task-state.v1.".to_string());
359
- }
360
-
361
- if object.get("version").and_then(Value::as_i64) != Some(1) {
362
- errors.push(".naome/task-state.json version must be 1.".to_string());
363
- }
364
-
365
- let status = object.get("status").and_then(Value::as_str);
366
- if !status.is_some_and(|value| ALLOWED_STATUS.contains(&value)) {
367
- errors.push(format!(
368
- ".naome/task-state.json status must be one of: {}.",
369
- ALLOWED_STATUS.join(", ")
370
- ));
371
- }
372
-
373
- if let Some(updated_at) = object.get("updatedAt") {
374
- if !updated_at.is_null() && !updated_at.as_str().is_some_and(is_iso_datetime) {
375
- errors.push(
376
- ".naome/task-state.json updatedAt must be an ISO timestamp or null.".to_string(),
377
- );
378
- }
379
- }
380
- }
381
-
382
- fn validate_idle_state(task_state: &Value, errors: &mut Vec<String>) {
383
- if !task_state.get("activeTask").is_some_and(Value::is_null) {
384
- errors.push("idle task state must have activeTask set to null.".to_string());
385
- }
386
-
387
- if !task_state.get("blocker").is_some_and(Value::is_null) {
388
- errors.push("idle task state must have blocker set to null.".to_string());
389
- }
390
- }
391
-
392
- fn validate_active_task(active_task: Option<&Value>, errors: &mut Vec<String>) {
393
- let Some(active_task) = active_task.and_then(Value::as_object) else {
394
- errors.push("activeTask must be an object for active task states.".to_string());
395
- return;
396
- };
397
-
398
- if !active_task
399
- .get("id")
400
- .and_then(Value::as_str)
401
- .is_some_and(is_id)
402
- {
403
- errors.push("activeTask.id must be kebab-case lowercase.".to_string());
404
- }
405
-
406
- require_string(active_task.get("request"), "activeTask.request", errors);
407
- validate_prompt_record(
408
- active_task.get("userPrompt"),
409
- "activeTask.userPrompt",
410
- errors,
411
- );
412
- require_string_array(
413
- active_task.get("allowedPaths"),
414
- "activeTask.allowedPaths",
415
- errors,
416
- );
417
- require_string_array(
418
- active_task.get("declaredChangeTypes"),
419
- "activeTask.declaredChangeTypes",
420
- errors,
421
- );
422
- require_string_array_allow_empty(
423
- active_task.get("requiredCheckIds"),
424
- "activeTask.requiredCheckIds",
425
- errors,
426
- );
427
- validate_control_state_patterns(
428
- active_task.get("allowedPaths"),
429
- "activeTask.allowedPaths",
430
- errors,
431
- );
432
-
433
- if !active_task.get("proofResults").is_some_and(Value::is_array) {
434
- errors.push("activeTask.proofResults must be an array.".to_string());
435
- }
436
-
437
- validate_revisions(active_task.get("revisions"), errors);
438
- validate_human_review(active_task.get("humanReview"), errors);
439
- }
440
-
441
- fn validate_revisions(revisions: Option<&Value>, errors: &mut Vec<String>) {
442
- let Some(revisions) = revisions else {
443
- return;
444
- };
445
- let Some(revisions) = revisions.as_array() else {
446
- errors.push("activeTask.revisions must be an array when present.".to_string());
447
- return;
448
- };
449
-
450
- for (index, revision) in revisions.iter().enumerate() {
451
- let prefix = format!("activeTask.revisions[{index}]");
452
- let Some(object) = revision.as_object() else {
453
- errors.push(format!("{prefix} must be an object."));
454
- continue;
455
- };
456
-
457
- require_string(object.get("request"), &format!("{prefix}.request"), errors);
458
- validate_prompt_record(
459
- object.get("userPrompt"),
460
- &format!("{prefix}.userPrompt"),
461
- errors,
462
- );
463
-
464
- if !object
465
- .get("requestedAt")
466
- .and_then(Value::as_str)
467
- .is_some_and(is_iso_datetime)
468
- {
469
- errors.push(format!("{prefix}.requestedAt must be an ISO timestamp."));
470
- }
471
-
472
- if let Some(proof_stale) = object.get("proofStale") {
473
- if !proof_stale.is_boolean() {
474
- errors.push(format!("{prefix}.proofStale must be boolean when present."));
475
- }
476
- }
477
- }
478
- }
479
-
480
- fn validate_prompt_record(
481
- prompt_record: Option<&Value>,
482
- field_name: &str,
483
- errors: &mut Vec<String>,
484
- ) {
485
- let Some(object) = prompt_record.and_then(Value::as_object) else {
486
- errors.push(format!(
487
- "{field_name} must be an object with receivedAt and text."
488
- ));
489
- return;
490
- };
491
-
492
- if !object
493
- .get("receivedAt")
494
- .and_then(Value::as_str)
495
- .is_some_and(is_iso_datetime)
496
- {
497
- errors.push(format!("{field_name}.receivedAt must be an ISO timestamp."));
498
- }
499
-
500
- require_string(object.get("text"), &format!("{field_name}.text"), errors);
501
- }
502
-
503
- fn validate_human_review(human_review: Option<&Value>, errors: &mut Vec<String>) {
504
- let Some(object) = human_review.and_then(Value::as_object) else {
505
- errors.push("activeTask.humanReview must be an object.".to_string());
506
- return;
507
- };
508
-
509
- if !object.get("required").is_some_and(Value::is_boolean) {
510
- errors.push("activeTask.humanReview.required must be boolean.".to_string());
511
- }
512
-
513
- if !object.get("approved").is_some_and(Value::is_boolean) {
514
- errors.push("activeTask.humanReview.approved must be boolean.".to_string());
515
- }
516
-
517
- if let Some(reason) = object.get("reason") {
518
- if !reason.is_null() && !reason.as_str().is_some_and(is_non_empty_string) {
519
- errors.push("activeTask.humanReview.reason must be a string or null.".to_string());
520
- }
521
- }
522
- }
523
-
524
- fn validate_blocker(blocker: Option<&Value>, errors: &mut Vec<String>) {
525
- let Some(object) = blocker.and_then(Value::as_object) else {
526
- errors.push(
527
- "blocker must be an object when task state is blocked or needs human review."
528
- .to_string(),
529
- );
530
- return;
531
- };
532
-
533
- require_string(object.get("type"), "blocker.type", errors);
534
- require_string(object.get("message"), "blocker.message", errors);
535
- require_string_array_allow_empty(object.get("paths"), "blocker.paths", errors);
536
- require_string_array(object.get("humanOptions"), "blocker.humanOptions", errors);
537
- }
538
-
539
- fn format_blocker(prefix: &str, blocker: Option<&Value>) -> String {
540
- let Some(object) = blocker.and_then(Value::as_object) else {
541
- return format!("{prefix}.");
542
- };
543
-
544
- let mut parts = vec![format!(
545
- "{prefix}: {}",
546
- object
547
- .get("message")
548
- .and_then(Value::as_str)
549
- .unwrap_or("No message recorded.")
550
- )];
551
-
552
- if let Some(paths) = string_array(object.get("paths")) {
553
- if !paths.is_empty() {
554
- parts.push(format!("Paths: {}", paths.join(", ")));
555
- }
556
- }
557
-
558
- if let Some(options) = string_array(object.get("humanOptions")) {
559
- if !options.is_empty() {
560
- parts.push(format!("Human options: {}", options.join(", ")));
561
- }
562
- }
563
-
564
- parts.join(" ")
565
- }
566
-
567
- fn validate_pending_upgrade(
568
- _task_state: &Value,
569
- root: &Path,
570
- errors: &mut Vec<String>,
571
- ) -> Result<(), NaomeError> {
572
- if !root.join(".naome/upgrade-state.json").exists() {
573
- return Ok(());
574
- }
575
-
576
- let Some(upgrade_state) = read_json(root, ".naome/upgrade-state.json", errors)? else {
577
- return Ok(());
578
- };
579
-
580
- if upgrade_state.get("status").and_then(Value::as_str) == Some("needs_agent_upgrade") {
581
- let pending = upgrade_state
582
- .get("pending")
583
- .and_then(Value::as_array)
584
- .map(|values| {
585
- values
586
- .iter()
587
- .filter_map(Value::as_str)
588
- .collect::<Vec<_>>()
589
- .join(", ")
590
- })
591
- .unwrap_or_else(|| "unknown".to_string());
592
- errors.push(format!(
593
- "NAOME upgrade is pending. Finish docs/naome/upgrade.md before feature work. Pending: {pending}"
594
- ));
595
- }
596
-
597
- Ok(())
598
- }
599
-
600
- fn validate_active_task_references(
601
- active_task: Option<&Value>,
602
- root: &Path,
603
- errors: &mut Vec<String>,
604
- status: Option<&str>,
605
- ) -> Result<(), NaomeError> {
606
- let Some(active_task) = active_task else {
607
- return Ok(());
608
- };
609
-
610
- validate_admission_proof(active_task.get("admission"), root, errors)?;
611
- validate_external_git_reconciliation(active_task, status, root, errors)?;
612
- let check_ids = read_verification_check_ids(root, errors)?;
613
- validate_required_check_ids(active_task, &check_ids, errors);
614
- validate_proof_result_entries(active_task, &check_ids, root, errors)?;
615
- Ok(())
616
- }
617
-
618
- fn validate_admission_proof(
619
- admission: Option<&Value>,
620
- root: &Path,
621
- errors: &mut Vec<String>,
622
- ) -> Result<(), NaomeError> {
623
- let Some(object) = admission.and_then(Value::as_object) else {
624
- errors.push(
625
- "activeTask.admission must be an object recorded from a passed admission check."
626
- .to_string(),
627
- );
628
- return Ok(());
629
- };
630
- let prefix = "activeTask.admission";
631
-
632
- require_string(object.get("command"), &format!("{prefix}.command"), errors);
633
- require_string(object.get("cwd"), &format!("{prefix}.cwd"), errors);
634
- require_string_array_allow_empty(
635
- object.get("changedPaths"),
636
- &format!("{prefix}.changedPaths"),
637
- errors,
638
- );
639
- require_string(object.get("gitHead"), &format!("{prefix}.gitHead"), errors);
640
-
641
- if object.get("command").and_then(Value::as_str)
642
- != Some("node .naome/bin/check-task-state.js --admission")
643
- {
644
- errors.push(format!(
645
- "{prefix}.command must be node .naome/bin/check-task-state.js --admission."
646
- ));
647
- }
648
-
649
- if object.get("cwd").and_then(Value::as_str) != Some(".") {
650
- errors.push(format!("{prefix}.cwd must be \".\"."));
651
- }
652
-
653
- match object.get("exitCode").and_then(Value::as_i64) {
654
- Some(0) => {}
655
- Some(_) => errors.push(format!("{prefix}.exitCode must be 0.")),
656
- None => errors.push(format!("{prefix}.exitCode must be an integer.")),
657
- }
658
-
659
- if !object
660
- .get("checkedAt")
661
- .and_then(Value::as_str)
662
- .is_some_and(is_iso_datetime)
663
- {
664
- errors.push(format!("{prefix}.checkedAt must be an ISO timestamp."));
665
- }
666
-
667
- if let Some(changed_paths) = object.get("changedPaths").and_then(Value::as_array) {
668
- if !changed_paths.is_empty() {
669
- errors.push(format!("{prefix}.changedPaths must be empty because task admission requires a clean git diff."));
670
- }
671
- }
672
-
673
- if let Some(git_head) = object.get("gitHead").and_then(Value::as_str) {
674
- if !git_head.trim().is_empty() && !git_commit_exists(root, git_head)? {
675
- errors.push(format!("{prefix}.gitHead must be an existing git commit."));
676
- }
677
- }
678
-
679
- Ok(())
680
- }
681
-
682
- fn validate_external_git_reconciliation(
683
- active_task: &Value,
684
- status: Option<&str>,
685
- root: &Path,
686
- errors: &mut Vec<String>,
687
- ) -> Result<(), NaomeError> {
688
- if status == Some("complete") {
689
- return Ok(());
690
- }
691
-
692
- let Some(admission_head) = active_task
693
- .get("admission")
694
- .and_then(|admission| admission.get("gitHead"))
695
- .and_then(Value::as_str)
696
- .filter(|head| !head.trim().is_empty())
697
- else {
698
- return Ok(());
699
- };
700
-
701
- let Some(current_head) = read_git_head(root)? else {
702
- return Ok(());
703
- };
704
-
705
- if current_head != admission_head {
706
- errors.push(format!("Task git HEAD changed after admission from {admission_head} to {current_head}. Reconcile external git work before continuing. Human options: mark_task_complete_from_git, reopen_task_revision, recover_current_diff, cancel_task_state."));
707
- }
708
-
709
- Ok(())
710
- }
711
-
712
- fn read_verification_check_ids(
713
- root: &Path,
714
- errors: &mut Vec<String>,
715
- ) -> Result<HashSet<String>, NaomeError> {
716
- let mut check_ids = HashSet::new();
717
- let Some(verification) = read_json(root, ".naome/verification.json", errors)? else {
718
- return Ok(check_ids);
719
- };
720
-
721
- if let Some(checks) = verification.get("checks").and_then(Value::as_array) {
722
- for check in checks {
723
- if let Some(id) = check.get("id").and_then(Value::as_str) {
724
- if !id.trim().is_empty() {
725
- check_ids.insert(id.to_string());
726
- }
727
- }
728
- }
729
- }
730
-
731
- Ok(check_ids)
732
- }
733
-
734
- fn validate_required_check_ids(
735
- active_task: &Value,
736
- check_ids: &HashSet<String>,
737
- errors: &mut Vec<String>,
738
- ) {
739
- let Some(required_check_ids) = active_task
740
- .get("requiredCheckIds")
741
- .and_then(Value::as_array)
742
- else {
743
- return;
744
- };
745
-
746
- for check_id in required_check_ids {
747
- if let Some(check_id) = check_id.as_str().filter(|value| !value.trim().is_empty()) {
748
- if !check_ids.contains(check_id) {
749
- errors.push(format!(
750
- "activeTask.requiredCheckIds unknown check id: {check_id}"
751
- ));
752
- }
753
- }
754
- }
755
- }
756
-
757
- fn validate_proof_result_entries(
758
- active_task: &Value,
759
- check_ids: &HashSet<String>,
760
- root: &Path,
761
- errors: &mut Vec<String>,
762
- ) -> Result<(), NaomeError> {
763
- let Some(proofs) = active_task.get("proofResults").and_then(Value::as_array) else {
764
- return Ok(());
765
- };
766
-
767
- for (index, proof) in proofs.iter().enumerate() {
768
- validate_proof_result(proof, index, check_ids, root, errors, active_task)?;
769
- }
770
-
771
- Ok(())
772
- }
773
-
774
- fn validate_proof_results(
775
- active_task: &Value,
776
- check_ids: &HashSet<String>,
777
- root: &Path,
778
- errors: &mut Vec<String>,
779
- ) -> Result<(), NaomeError> {
780
- let Some(required_check_ids) = active_task
781
- .get("requiredCheckIds")
782
- .and_then(Value::as_array)
783
- else {
784
- return Ok(());
785
- };
786
- let Some(proofs) = active_task.get("proofResults").and_then(Value::as_array) else {
787
- return Ok(());
788
- };
789
-
790
- for check_id in required_check_ids {
791
- let Some(check_id) = check_id.as_str().filter(|value| !value.trim().is_empty()) else {
792
- continue;
793
- };
794
-
795
- match proofs
796
- .iter()
797
- .find(|proof| proof.get("checkId").and_then(Value::as_str) == Some(check_id))
798
- {
799
- Some(proof) if proof.get("exitCode").and_then(Value::as_i64) == Some(0) => {}
800
- Some(_) => errors.push(format!(
801
- "activeTask.proofResults failed proof result: {check_id}"
802
- )),
803
- None => errors.push(format!(
804
- "activeTask.proofResults missing proof result: {check_id}"
805
- )),
806
- }
807
- }
808
-
809
- validate_proof_result_entries(active_task, check_ids, root, errors)
810
- }
811
-
812
- fn validate_proof_evidence_covers_changed_paths(
813
- active_task: Option<&Value>,
814
- root: &Path,
815
- errors: &mut Vec<String>,
816
- ) -> Result<(), NaomeError> {
817
- let Some(active_task) = active_task else {
818
- return Ok(());
819
- };
820
- let Some(proofs) = active_task.get("proofResults").and_then(Value::as_array) else {
821
- return Ok(());
822
- };
823
- if proofs.is_empty() {
824
- return Ok(());
825
- }
826
-
827
- let entries = read_git_changed_entries(root)?;
828
- validate_proof_evidence_covers_changed_entries(active_task, &entries, errors)
829
- }
830
-
831
- fn validate_proof_evidence_covers_changed_entries(
832
- active_task: &Value,
833
- entries: &[ChangedEntry],
834
- errors: &mut Vec<String>,
835
- ) -> Result<(), NaomeError> {
836
- let Some(proofs) = active_task.get("proofResults").and_then(Value::as_array) else {
837
- return Ok(());
838
- };
839
- if proofs.is_empty() {
840
- return Ok(());
841
- }
842
-
843
- let changed_paths = task_diff_from_entries(active_task, entries);
844
- let evidence_paths: HashSet<String> = proofs
845
- .iter()
846
- .flat_map(|proof| {
847
- proof
848
- .get("evidence")
849
- .and_then(Value::as_array)
850
- .cloned()
851
- .unwrap_or_default()
852
- })
853
- .filter_map(|entry| evidence_entry_path(&entry).map(normalize_path))
854
- .collect();
855
-
856
- let allowed_paths = string_array(active_task.get("allowedPaths")).unwrap_or_default();
857
- let changed_allowed_paths: Vec<String> = changed_paths
858
- .diff_paths
859
- .into_iter()
860
- .filter(|path| matches_any_pattern(path, &allowed_paths))
861
- .collect();
862
- let missing_paths: Vec<String> = changed_allowed_paths
863
- .into_iter()
864
- .filter(|path| !evidence_paths.contains(path))
865
- .collect();
866
-
867
- if !missing_paths.is_empty() {
868
- errors.push(format!(
869
- "activeTask.proofResults evidence missing changed allowed paths: {}",
870
- missing_paths.join(", ")
871
- ));
872
- }
873
-
874
- Ok(())
875
- }
876
-
877
- fn validate_proof_result(
878
- proof: &Value,
879
- index: usize,
880
- check_ids: &HashSet<String>,
881
- root: &Path,
882
- errors: &mut Vec<String>,
883
- active_task: &Value,
884
- ) -> Result<(), NaomeError> {
885
- let prefix = format!("activeTask.proofResults[{index}]");
886
- let Some(object) = proof.as_object() else {
887
- errors.push(format!("{prefix} must be an object."));
888
- return Ok(());
889
- };
890
-
891
- require_string(object.get("checkId"), &format!("{prefix}.checkId"), errors);
892
- require_string(object.get("command"), &format!("{prefix}.command"), errors);
893
- require_string(object.get("cwd"), &format!("{prefix}.cwd"), errors);
894
- validate_evidence_array(
895
- object.get("evidence"),
896
- &format!("{prefix}.evidence"),
897
- errors,
898
- );
899
- validate_control_state_paths(
900
- object.get("evidence"),
901
- &format!("{prefix}.evidence"),
902
- errors,
903
- );
904
-
905
- if object.get("exitCode").and_then(Value::as_i64).is_none() {
906
- errors.push(format!("{prefix}.exitCode must be an integer."));
907
- }
908
-
909
- if !object
910
- .get("checkedAt")
911
- .and_then(Value::as_str)
912
- .is_some_and(is_iso_datetime)
913
- {
914
- errors.push(format!("{prefix}.checkedAt must be an ISO timestamp."));
915
- }
916
-
917
- if let Some(check_id) = object.get("checkId").and_then(Value::as_str) {
918
- if !check_id.trim().is_empty() && !check_ids.contains(check_id) {
919
- errors.push(format!("{prefix}.checkId unknown check id: {check_id}"));
920
- }
921
- }
922
-
923
- validate_evidence_paths(
924
- object.get("evidence"),
925
- &format!("{prefix}.evidence"),
926
- root,
927
- errors,
928
- active_task,
929
- )
930
- }
931
-
932
- fn validate_evidence_array(evidence: Option<&Value>, field_name: &str, errors: &mut Vec<String>) {
933
- let Some(evidence) = evidence.and_then(Value::as_array) else {
934
- errors.push(format!("{field_name} must be an evidence array."));
935
- return;
936
- };
937
-
938
- for (index, entry) in evidence.iter().enumerate() {
939
- let prefix = format!("{field_name}[{index}]");
940
- if entry.as_str().is_some_and(is_non_empty_string) {
941
- continue;
942
- }
943
-
944
- let Some(object) = entry.as_object() else {
945
- errors.push(format!(
946
- "{prefix} must be a non-empty string path or an evidence object."
947
- ));
948
- continue;
949
- };
950
-
951
- require_string(object.get("path"), &format!("{prefix}.path"), errors);
952
-
953
- if let Some(status) = object.get("status").and_then(Value::as_str) {
954
- if !ALLOWED_EVIDENCE_STATUS.contains(&status) {
955
- errors.push(format!(
956
- "{prefix}.status must be one of: {}.",
957
- ALLOWED_EVIDENCE_STATUS.join(", ")
958
- ));
959
- }
960
- }
961
-
962
- if object.contains_key("fromPath")
963
- && !object
964
- .get("fromPath")
965
- .and_then(Value::as_str)
966
- .is_some_and(is_non_empty_string)
967
- {
968
- errors.push(format!(
969
- "{prefix}.fromPath must be a non-empty string when present."
970
- ));
971
- }
972
- }
973
- }
974
-
975
- fn validate_control_state_patterns(
976
- patterns: Option<&Value>,
977
- field_name: &str,
978
- errors: &mut Vec<String>,
979
- ) {
980
- let Some(patterns) = string_array(patterns) else {
981
- return;
982
- };
983
-
984
- for pattern in patterns {
985
- if matches_path_pattern(CONTROL_STATE_PATH, &pattern) {
986
- errors.push(format!(
987
- "{field_name} cannot include NAOME control state: {pattern}"
988
- ));
989
- }
990
- }
991
- }
992
-
993
- fn validate_control_state_paths(paths: Option<&Value>, field_name: &str, errors: &mut Vec<String>) {
994
- let Some(paths) = paths.and_then(Value::as_array) else {
995
- return;
996
- };
997
-
998
- for entry in paths {
999
- let Some(path) = evidence_entry_path(entry) else {
1000
- continue;
1001
- };
1002
- if normalize_path(&path) == CONTROL_STATE_PATH {
1003
- errors.push(format!(
1004
- "{field_name} cannot include NAOME control state: {path}"
1005
- ));
1006
- }
1007
- }
1008
- }
1009
-
1010
- fn validate_evidence_paths(
1011
- evidence: Option<&Value>,
1012
- field_name: &str,
1013
- root: &Path,
1014
- errors: &mut Vec<String>,
1015
- active_task: &Value,
1016
- ) -> Result<(), NaomeError> {
1017
- let Some(evidence) = evidence.and_then(Value::as_array) else {
1018
- return Ok(());
1019
- };
1020
-
1021
- let mut deleted_paths: HashSet<String> = read_git_changed_entries(root)?
1022
- .into_iter()
1023
- .filter(|entry| entry.status == "deleted")
1024
- .map(|entry| entry.path)
1025
- .collect();
1026
- for path in read_historical_deleted_paths(active_task, root)? {
1027
- deleted_paths.insert(path);
1028
- }
1029
-
1030
- for entry in evidence {
1031
- let Some(evidence_path) = evidence_entry_path(entry) else {
1032
- continue;
1033
- };
1034
- let normalized_path = normalize_path(&evidence_path);
1035
- if Path::new(&evidence_path).is_absolute()
1036
- || normalized_path.split('/').any(|part| part == "..")
1037
- {
1038
- errors.push(format!("{field_name} unsafe path: {evidence_path}"));
1039
- continue;
1040
- }
1041
-
1042
- if !root.join(&normalized_path).exists() && !deleted_paths.contains(&normalized_path) {
1043
- errors.push(format!(
1044
- "{field_name} path does not exist or is not deleted in git diff: {evidence_path}"
1045
- ));
1046
- }
1047
- }
1048
-
1049
- Ok(())
1050
- }
1051
-
1052
- fn read_historical_deleted_paths(
1053
- active_task: &Value,
1054
- root: &Path,
1055
- ) -> Result<Vec<String>, NaomeError> {
1056
- let Some(admission_head) = active_task
1057
- .get("admission")
1058
- .and_then(|admission| admission.get("gitHead"))
1059
- .and_then(Value::as_str)
1060
- .filter(|head| !head.trim().is_empty())
1061
- else {
1062
- return Ok(Vec::new());
1063
- };
1064
-
1065
- if !git_commit_exists(root, admission_head)? {
1066
- return Ok(Vec::new());
1067
- }
1068
-
1069
- let Some(current_head) = read_git_head(root)? else {
1070
- return Ok(Vec::new());
1071
- };
1072
-
1073
- if current_head == admission_head {
1074
- return Ok(Vec::new());
1075
- }
1076
-
1077
- let output = run_git(
1078
- root,
1079
- ["diff", "--name-status", "-z", admission_head, &current_head],
1080
- )?;
1081
- if !output.status.success() {
1082
- return Ok(Vec::new());
1083
- }
1084
-
1085
- Ok(parse_name_status_output(&output.stdout)
1086
- .into_iter()
1087
- .filter(|entry| entry.status == "deleted")
1088
- .map(|entry| entry.path)
1089
- .collect())
1090
- }
1091
-
1092
- fn evidence_entry_path(entry: &Value) -> Option<String> {
1093
- entry
1094
- .as_str()
1095
- .filter(|value| is_non_empty_string(value))
1096
- .map(ToString::to_string)
1097
- .or_else(|| {
1098
- entry
1099
- .get("path")
1100
- .and_then(Value::as_str)
1101
- .filter(|value| is_non_empty_string(value))
1102
- .map(ToString::to_string)
1103
- })
1104
- }
1105
-
1106
- fn validate_changed_paths(
1107
- active_task: &Value,
1108
- root: &Path,
1109
- errors: &mut Vec<String>,
1110
- ) -> Result<(), NaomeError> {
1111
- let entries = read_git_changed_entries(root)?;
1112
- validate_changed_entries(active_task, &entries, errors)
1113
- }
1114
-
1115
- fn validate_changed_entries(
1116
- active_task: &Value,
1117
- entries: &[ChangedEntry],
1118
- errors: &mut Vec<String>,
1119
- ) -> Result<(), NaomeError> {
1120
- let diff = task_diff_from_entries(active_task, entries);
1121
- if !diff.outside_paths.is_empty() {
1122
- errors.push(format!(
1123
- "Changed files outside allowedPaths: {}",
1124
- diff.outside_paths.join(", ")
1125
- ));
1126
- }
1127
- Ok(())
1128
- }
1129
-
1130
- fn validate_human_review_blocker_paths(
1131
- active_task: Option<&Value>,
1132
- blocker: Option<&Value>,
1133
- root: &Path,
1134
- errors: &mut Vec<String>,
1135
- ) -> Result<(), NaomeError> {
1136
- let (Some(active_task), Some(blocker)) = (active_task, blocker) else {
1137
- return Ok(());
1138
- };
1139
- let diff = read_task_diff(active_task, root)?;
1140
- if diff.outside_paths.is_empty() {
1141
- return Ok(());
1142
- }
1143
-
1144
- let blocker_paths: HashSet<String> = blocker
1145
- .get("paths")
1146
- .and_then(Value::as_array)
1147
- .into_iter()
1148
- .flatten()
1149
- .filter_map(Value::as_str)
1150
- .map(normalize_path)
1151
- .collect();
1152
- let missing_paths: Vec<String> = diff
1153
- .outside_paths
1154
- .into_iter()
1155
- .filter(|path| !blocker_paths.contains(path))
1156
- .collect();
1157
-
1158
- if !missing_paths.is_empty() {
1159
- errors.push(format!(
1160
- "blocker.paths missing actual scope violations: {}",
1161
- missing_paths.join(", ")
1162
- ));
1163
- }
1164
-
1165
- Ok(())
1166
- }
1167
-
1168
- struct TaskDiff {
1169
- diff_paths: Vec<String>,
1170
- outside_paths: Vec<String>,
1171
- }
1172
-
1173
- fn read_task_diff(active_task: &Value, root: &Path) -> Result<TaskDiff, NaomeError> {
1174
- let entries = read_git_changed_entries(root)?;
1175
- Ok(task_diff_from_entries(active_task, &entries))
1176
- }
1177
-
1178
- fn task_diff_from_entries(active_task: &Value, entries: &[ChangedEntry]) -> TaskDiff {
1179
- let allowed_paths = string_array(active_task.get("allowedPaths")).unwrap_or_default();
1180
- let diff_paths: Vec<String> = entries
1181
- .iter()
1182
- .map(|entry| entry.path.clone())
1183
- .filter(|path| path != CONTROL_STATE_PATH)
1184
- .collect();
1185
- let outside_paths = diff_paths
1186
- .iter()
1187
- .filter(|path| !matches_any_pattern(path, &allowed_paths))
1188
- .cloned()
1189
- .collect();
1190
-
1191
- TaskDiff {
1192
- diff_paths,
1193
- outside_paths,
1194
- }
1195
- }
1196
-
1197
- fn validate_complete_task(
1198
- active_task: Option<&Value>,
1199
- blocker: Option<&Value>,
1200
- root: &Path,
1201
- errors: &mut Vec<String>,
1202
- notices: &mut Vec<String>,
1203
- ) -> Result<(), NaomeError> {
1204
- let error_start = errors.len();
1205
-
1206
- if !blocker.is_some_and(Value::is_null) {
1207
- errors.push("complete task state must have blocker set to null.".to_string());
1208
- }
1209
-
1210
- let Some(active_task) = active_task else {
1211
- return Ok(());
1212
- };
1213
-
1214
- if active_task
1215
- .get("humanReview")
1216
- .and_then(|review| review.get("required"))
1217
- .and_then(Value::as_bool)
1218
- == Some(true)
1219
- && active_task
1220
- .get("humanReview")
1221
- .and_then(|review| review.get("approved"))
1222
- .and_then(Value::as_bool)
1223
- != Some(true)
1224
- {
1225
- errors.push("complete task requires human review approval before completion.".to_string());
1226
- }
1227
-
1228
- let check_ids = read_verification_check_ids(root, errors)?;
1229
- validate_required_check_ids(active_task, &check_ids, errors);
1230
- let entries = read_git_changed_entries(root)?;
1231
- validate_complete_task_against_entries(active_task, root, &check_ids, &entries, errors)?;
1232
-
1233
- if errors.len() == error_start {
1234
- add_completed_task_diff_notice(root, notices)?;
1235
- }
1236
-
1237
- Ok(())
1238
- }
1239
-
1240
- fn validate_complete_task_against_entries(
1241
- active_task: &Value,
1242
- root: &Path,
1243
- check_ids: &HashSet<String>,
1244
- entries: &[ChangedEntry],
1245
- errors: &mut Vec<String>,
1246
- ) -> Result<(), NaomeError> {
1247
- validate_proof_results(active_task, check_ids, root, errors)?;
1248
- validate_changed_entries(active_task, entries, errors)?;
1249
- validate_proof_evidence_covers_changed_entries(active_task, entries, errors)?;
1250
- Ok(())
1251
- }
1252
-
1253
- fn validate_progress(
1254
- task_state: &Value,
1255
- root: &Path,
1256
- errors: &mut Vec<String>,
1257
- ) -> Result<(), NaomeError> {
1258
- validate_init_complete(root, errors)?;
1259
- validate_upgrade_complete(root, errors)?;
1260
-
1261
- let status = task_state
1262
- .get("status")
1263
- .and_then(Value::as_str)
1264
- .unwrap_or("invalid");
1265
-
1266
- match status {
1267
- "planning" | "implementing" | "revising" | "verifying" => {
1268
- validate_active_task(task_state.get("activeTask"), errors);
1269
- validate_pending_upgrade(task_state, root, errors)?;
1270
- validate_active_task_references(
1271
- task_state.get("activeTask"),
1272
- root,
1273
- errors,
1274
- Some(status),
1275
- )?;
1276
- if let Some(active_task) = task_state.get("activeTask") {
1277
- validate_changed_paths(active_task, root, errors)?;
1278
- }
1279
- }
1280
- "needs_human_review" => {
1281
- validate_active_task(task_state.get("activeTask"), errors);
1282
- validate_active_task_references(
1283
- task_state.get("activeTask"),
1284
- root,
1285
- errors,
1286
- Some(status),
1287
- )?;
1288
- validate_blocker(task_state.get("blocker"), errors);
1289
- validate_human_review_blocker_paths(
1290
- task_state.get("activeTask"),
1291
- task_state.get("blocker"),
1292
- root,
1293
- errors,
1294
- )?;
1295
- validate_proof_evidence_covers_changed_paths(
1296
- task_state.get("activeTask"),
1297
- root,
1298
- errors,
1299
- )?;
1300
- errors.push(format_blocker(
1301
- "Human review required",
1302
- task_state.get("blocker"),
1303
- ));
1304
- }
1305
- "blocked" => {
1306
- validate_active_task(task_state.get("activeTask"), errors);
1307
- validate_blocker(task_state.get("blocker"), errors);
1308
- errors.push(format_blocker("Task is blocked", task_state.get("blocker")));
1309
- }
1310
- "complete" => errors.push(
1311
- "Task is complete; use node .naome/bin/check-task-state.js for completion validation."
1312
- .to_string(),
1313
- ),
1314
- "idle" => errors.push(
1315
- "No active task is in progress; use --admission before starting feature work."
1316
- .to_string(),
1317
- ),
1318
- _ => {}
1319
- }
1320
-
1321
- Ok(())
1322
- }
1323
-
1324
- fn validate_admission(
1325
- task_state: &Value,
1326
- root: &Path,
1327
- errors: &mut Vec<String>,
1328
- ) -> Result<(), NaomeError> {
1329
- validate_init_complete(root, errors)?;
1330
- validate_upgrade_complete(root, errors)?;
1331
-
1332
- let status = task_state
1333
- .get("status")
1334
- .and_then(Value::as_str)
1335
- .unwrap_or("invalid");
1336
- match status {
1337
- "idle" => validate_idle_state(task_state, errors),
1338
- "complete" => {
1339
- validate_active_task(task_state.get("activeTask"), errors);
1340
- validate_active_task_references(
1341
- task_state.get("activeTask"),
1342
- root,
1343
- errors,
1344
- Some(status),
1345
- )?;
1346
- validate_complete_task(
1347
- task_state.get("activeTask"),
1348
- task_state.get("blocker"),
1349
- root,
1350
- errors,
1351
- &mut Vec::new(),
1352
- )?;
1353
- }
1354
- "needs_human_review" => {
1355
- let start = errors.len();
1356
- validate_active_task(task_state.get("activeTask"), errors);
1357
- validate_active_task_references(
1358
- task_state.get("activeTask"),
1359
- root,
1360
- errors,
1361
- Some(status),
1362
- )?;
1363
- validate_blocker(task_state.get("blocker"), errors);
1364
- validate_human_review_blocker_paths(
1365
- task_state.get("activeTask"),
1366
- task_state.get("blocker"),
1367
- root,
1368
- errors,
1369
- )?;
1370
- validate_proof_evidence_covers_changed_paths(
1371
- task_state.get("activeTask"),
1372
- root,
1373
- errors,
1374
- )?;
1375
- if errors.len() > start {
1376
- errors.push("Task admission is blocked because needs_human_review state is invalid; fix blocker paths and proof evidence before asking for a human decision.".to_string());
1377
- } else {
1378
- errors.push(format_blocker(
1379
- "Task admission is blocked",
1380
- task_state.get("blocker"),
1381
- ));
1382
- }
1383
- }
1384
- "blocked" => {
1385
- validate_blocker(task_state.get("blocker"), errors);
1386
- errors.push(format_blocker(
1387
- "Task admission is blocked",
1388
- task_state.get("blocker"),
1389
- ));
1390
- }
1391
- other => errors.push(format!(
1392
- "Task admission is blocked because task state is {other}."
1393
- )),
1394
- }
1395
-
1396
- validate_clean_git_diff(task_state, root, errors)
1397
- }
1398
-
1399
- fn validate_upgrade_complete(root: &Path, errors: &mut Vec<String>) -> Result<(), NaomeError> {
1400
- let Some(upgrade_state) = read_json(root, ".naome/upgrade-state.json", errors)? else {
1401
- return Ok(());
1402
- };
1403
-
1404
- if upgrade_state.get("status").and_then(Value::as_str) != Some("complete") {
1405
- let pending = upgrade_state
1406
- .get("pending")
1407
- .and_then(Value::as_array)
1408
- .map(|values| {
1409
- values
1410
- .iter()
1411
- .filter_map(Value::as_str)
1412
- .collect::<Vec<_>>()
1413
- .join(", ")
1414
- })
1415
- .unwrap_or_else(|| "unknown".to_string());
1416
- errors.push(format!("NAOME upgrade is not complete. Finish docs/naome/upgrade.md before feature work. Pending: {pending}"));
1417
- }
1418
- Ok(())
1419
- }
1420
-
1421
- fn validate_init_complete(root: &Path, errors: &mut Vec<String>) -> Result<(), NaomeError> {
1422
- let Some(init_state) = read_json(root, ".naome/init-state.json", errors)? else {
1423
- return Ok(());
1424
- };
1425
-
1426
- if init_state.get("initialized").and_then(Value::as_bool) != Some(true)
1427
- || init_state.get("intakeStatus").and_then(Value::as_str) != Some("complete")
1428
- {
1429
- errors.push(
1430
- "NAOME intake is not complete. Finish docs/naome/first-run.md before feature work."
1431
- .to_string(),
1432
- );
1433
- }
1434
- Ok(())
1435
- }
1436
-
1437
- fn validate_clean_git_diff(
1438
- task_state: &Value,
1439
- root: &Path,
1440
- errors: &mut Vec<String>,
1441
- ) -> Result<(), NaomeError> {
1442
- let changed_paths = read_git_changed_paths(root)?;
1443
- if !changed_paths.is_empty() {
1444
- errors.push(format_dirty_diff_admission_blocker(
1445
- task_state,
1446
- root,
1447
- &changed_paths,
1448
- )?);
1449
- }
1450
- Ok(())
1451
- }
1452
-
1453
- fn validate_commit_gate(
1454
- task_state: &Value,
1455
- root: &Path,
1456
- errors: &mut Vec<String>,
1457
- notices: &mut Vec<String>,
1458
- ) -> Result<(), NaomeError> {
1459
- let staged_entries = read_git_staged_changed_entries(root)?;
1460
- let changed_paths: Vec<String> = staged_entries
1461
- .iter()
1462
- .map(|entry| entry.path.clone())
1463
- .collect();
1464
- if changed_paths.is_empty() {
1465
- return Ok(());
1466
- }
1467
-
1468
- let status = task_state
1469
- .get("status")
1470
- .and_then(Value::as_str)
1471
- .unwrap_or("invalid");
1472
- if status == "complete" && is_deterministic_harness_refresh_diff(&changed_paths) {
1473
- validate_pending_upgrade(task_state, root, errors)?;
1474
- validate_completed_task_for_harness_refresh(task_state, root, &staged_entries, errors)?;
1475
- return Ok(());
1476
- }
1477
-
1478
- if status == "complete" {
1479
- validate_active_task(task_state.get("activeTask"), errors);
1480
- validate_active_task_references(task_state.get("activeTask"), root, errors, Some(status))?;
1481
- if !task_state.get("blocker").is_some_and(Value::is_null) {
1482
- errors.push("complete task state must have blocker set to null.".to_string());
1483
- }
1484
- if let Some(active_task) = task_state.get("activeTask") {
1485
- let check_ids = read_verification_check_ids(root, errors)?;
1486
- validate_required_check_ids(active_task, &check_ids, errors);
1487
- validate_complete_task_against_entries(
1488
- active_task,
1489
- root,
1490
- &check_ids,
1491
- &staged_entries,
1492
- errors,
1493
- )?;
1494
- if errors.is_empty() {
1495
- notices.push(format!(
1496
- "Commit gate accepted task-owned staged paths: {}.",
1497
- changed_paths.join(", ")
1498
- ));
1499
- }
1500
- }
1501
- return Ok(());
1502
- }
1503
-
1504
- if status == "idle" && is_install_or_upgrade_baseline_diff(root, &changed_paths)? {
1505
- return Ok(());
1506
- }
1507
-
1508
- if status == "idle" && is_harness_repair_diff(root, &changed_paths)? {
1509
- return Ok(());
1510
- }
1511
-
1512
- validate_init_complete(root, errors)?;
1513
- validate_upgrade_complete(root, errors)?;
1514
-
1515
- if status == "idle" {
1516
- errors.push(format!("NAOME commit gate blocked: changed paths are not owned by a completed task state. Changed paths: {}. Finish a NAOME task and use naome commit, or reconcile the diff before committing.", changed_paths.join(", ")));
1517
- return Ok(());
1518
- }
1519
-
1520
- if status == "blocked" || status == "needs_human_review" {
1521
- validate_blocker(task_state.get("blocker"), errors);
1522
- }
1523
-
1524
- errors.push(format!("NAOME commit gate blocked because task state is {status}. Finish or revise the active task, set it to complete with fresh proof, then use naome commit. Human options: continue_current_task, request_task_changes, mark_task_blocked, cancel_task_state."));
1525
- Ok(())
1526
- }
1527
-
1528
- fn validate_completed_task_for_harness_refresh(
1529
- task_state: &Value,
1530
- root: &Path,
1531
- staged_entries: &[ChangedEntry],
1532
- errors: &mut Vec<String>,
1533
- ) -> Result<(), NaomeError> {
1534
- validate_active_task(task_state.get("activeTask"), errors);
1535
- validate_active_task_references(task_state.get("activeTask"), root, errors, Some("complete"))?;
1536
- if !task_state.get("blocker").is_some_and(Value::is_null) {
1537
- errors.push("complete task state must have blocker set to null.".to_string());
1538
- }
1539
-
1540
- let Some(active_task) = task_state.get("activeTask") else {
1541
- return Ok(());
1542
- };
1543
-
1544
- let check_ids = read_verification_check_ids(root, errors)?;
1545
- validate_required_check_ids(active_task, &check_ids, errors);
1546
-
1547
- let mut validation_errors = Vec::new();
1548
- validate_complete_task_against_entries(
1549
- active_task,
1550
- root,
1551
- &check_ids,
1552
- staged_entries,
1553
- &mut validation_errors,
1554
- )?;
1555
-
1556
- let staged_harness_paths = task_diff_from_entries(active_task, staged_entries).outside_paths;
1557
- let allowed_scope_error = format!(
1558
- "Changed files outside allowedPaths: {}",
1559
- staged_harness_paths.join(", ")
1560
- );
1561
-
1562
- errors.extend(
1563
- validation_errors
1564
- .into_iter()
1565
- .filter(|error| error != &allowed_scope_error),
1566
- );
1567
-
1568
- Ok(())
1569
- }
1570
-
1571
- fn validate_push_gate(task_state: &Value, errors: &mut Vec<String>) {
1572
- let status = task_state
1573
- .get("status")
1574
- .and_then(Value::as_str)
1575
- .unwrap_or("invalid");
1576
- if !BLOCKING_STATUS.contains(&status) {
1577
- return;
1578
- }
1579
-
1580
- if status == "blocked" || status == "needs_human_review" {
1581
- validate_blocker(task_state.get("blocker"), errors);
1582
- }
1583
-
1584
- errors.push(format!("NAOME push gate blocked because task state is {status}. Resolve the active task before pushing. Human options: continue_current_task, request_task_changes, mark_task_blocked, cancel_task_state."));
1585
- }
1586
-
1587
- fn format_dirty_diff_admission_blocker(
1588
- task_state: &Value,
1589
- root: &Path,
1590
- changed_paths: &[String],
1591
- ) -> Result<String, NaomeError> {
1592
- let prefix = format!(
1593
- "Task admission requires a clean git diff. Changed paths: {}.",
1594
- changed_paths.join(", ")
1595
- );
1596
-
1597
- if is_harness_repair_diff(root, changed_paths)? {
1598
- return Ok(format!("{prefix} These look like completed Harness Repair changes. Run NAOME intent for the next natural-language request before deciding whether to baseline, review, or cancel the repair diff."));
1599
- }
1600
-
1601
- if is_completed_task_diff(task_state, changed_paths) {
1602
- return Ok(format!("{prefix} These look like completed task changes. Run NAOME intent for the next natural-language request; deterministic policy can baseline a valid completed task before creating the next task."));
1603
- }
1604
-
1605
- if is_naome_baseline_diff(changed_paths) {
1606
- return Ok(format!("{prefix} These look like completed NAOME install or upgrade changes. Run NAOME intent for the next natural-language request; deterministic policy can baseline setup before creating the next task."));
1607
- }
1608
-
1609
- Ok(format!("{prefix} Ask the user to choose exactly one: review_task_diff, request_task_changes, cancel_task_changes. Do not start new feature work or commit without explicit user selection."))
1610
- }
1611
-
1612
- fn is_harness_repair_diff(root: &Path, changed_paths: &[String]) -> Result<bool, NaomeError> {
1613
- let machine_owned_paths = read_machine_owned_paths(root)?;
1614
- if machine_owned_paths.is_empty() {
1615
- return Ok(false);
1616
- }
1617
-
1618
- let has_repair_signal = changed_paths
1619
- .iter()
1620
- .any(|path| machine_owned_paths.contains(path) || is_repair_archive_path(path));
1621
- if !has_repair_signal {
1622
- return Ok(false);
1623
- }
1624
-
1625
- Ok(changed_paths
1626
- .iter()
1627
- .all(|path| machine_owned_paths.contains(path) || is_repair_support_path(path)))
1628
- }
1629
-
1630
- fn is_repair_support_path(path: &str) -> bool {
1631
- path == ".naome/manifest.json"
1632
- || path == ".naome/upgrade-state.json"
1633
- || is_repair_archive_path(path)
1634
- }
1635
-
1636
- fn is_packaged_machine_owned_path(path: &str) -> bool {
1637
- MACHINE_OWNED_PATHS.iter().any(|owned| *owned == path)
1638
- }
1639
-
1640
- fn is_safe_harness_refresh_path(path: &str) -> bool {
1641
- is_packaged_machine_owned_path(path) || is_repair_support_path(path)
1642
- }
1643
-
1644
- fn is_deterministic_harness_refresh_diff(changed_paths: &[String]) -> bool {
1645
- !changed_paths.is_empty()
1646
- && changed_paths
1647
- .iter()
1648
- .any(|path| is_packaged_machine_owned_path(path) || is_repair_archive_path(path))
1649
- && changed_paths
1650
- .iter()
1651
- .all(|path| is_safe_harness_refresh_path(path))
1652
- }
1653
-
1654
- fn is_repair_archive_path(path: &str) -> bool {
1655
- path.starts_with(".naome/archive/repair-")
1656
- }
1657
-
1658
- fn is_completed_task_diff(task_state: &Value, changed_paths: &[String]) -> bool {
1659
- if task_state.get("status").and_then(Value::as_str) != Some("complete") {
1660
- return false;
1661
- }
1662
- let Some(active_task) = task_state.get("activeTask") else {
1663
- return false;
1664
- };
1665
- let allowed_paths = string_array(active_task.get("allowedPaths")).unwrap_or_default();
1666
- let task_paths: Vec<&String> = changed_paths
1667
- .iter()
1668
- .filter(|path| path.as_str() != CONTROL_STATE_PATH)
1669
- .collect();
1670
- !task_paths.is_empty()
1671
- && task_paths
1672
- .iter()
1673
- .all(|path| matches_any_pattern(path, &allowed_paths))
1674
- }
1675
-
1676
- fn is_naome_baseline_diff(changed_paths: &[String]) -> bool {
1677
- changed_paths.iter().all(|path| {
1678
- path == "AGENTS.md"
1679
- || path == ".gitignore"
1680
- || path == ".naomeignore"
1681
- || path.starts_with(".naome/")
1682
- || path.starts_with("docs/naome/")
1683
- })
1684
- }
1685
-
1686
- fn is_install_or_upgrade_baseline_diff(
1687
- root: &Path,
1688
- changed_paths: &[String],
1689
- ) -> Result<bool, NaomeError> {
1690
- if !is_naome_baseline_diff(changed_paths) {
1691
- return Ok(false);
1692
- }
1693
-
1694
- let has_setup_signal = changed_paths.iter().any(|path| {
1695
- matches!(
1696
- path.as_str(),
1697
- "AGENTS.md"
1698
- | ".gitignore"
1699
- | ".naomeignore"
1700
- | ".naome/init-state.json"
1701
- | ".naome/manifest.json"
1702
- | ".naome/package.json"
1703
- | ".naome/task-contract.schema.json"
1704
- | ".naome/upgrade-state.json"
1705
- )
1706
- });
1707
- if !has_setup_signal {
1708
- return Ok(false);
1709
- }
1710
-
1711
- if read_init_incomplete(root)? || read_upgrade_baseline_signal(root)? {
1712
- return Ok(true);
1713
- }
1714
-
1715
- Ok(changed_paths.iter().any(|path| {
1716
- matches!(
1717
- path.as_str(),
1718
- ".naome/init-state.json" | ".naome/manifest.json" | ".naome/upgrade-state.json"
1719
- )
1720
- }))
1721
- }
1722
-
1723
- fn read_init_incomplete(root: &Path) -> Result<bool, NaomeError> {
1724
- let Some(init_state) = read_json(root, ".naome/init-state.json", &mut Vec::new())? else {
1725
- return Ok(false);
1726
- };
1727
-
1728
- Ok(
1729
- init_state.get("initialized").and_then(Value::as_bool) != Some(true)
1730
- || init_state.get("intakeStatus").and_then(Value::as_str) != Some("complete"),
1731
- )
1732
- }
1733
-
1734
- fn read_upgrade_baseline_signal(root: &Path) -> Result<bool, NaomeError> {
1735
- let Some(upgrade_state) = read_json(root, ".naome/upgrade-state.json", &mut Vec::new())? else {
1736
- return Ok(false);
1737
- };
1738
-
1739
- Ok(upgrade_state.get("fromVersion").is_some()
1740
- || upgrade_state
1741
- .get("pending")
1742
- .and_then(Value::as_array)
1743
- .is_some_and(|pending| !pending.is_empty())
1744
- || upgrade_state
1745
- .get("completed")
1746
- .and_then(Value::as_array)
1747
- .is_some_and(|completed| !completed.is_empty()))
1748
- }
1749
-
1750
- fn read_machine_owned_paths(root: &Path) -> Result<HashSet<String>, NaomeError> {
1751
- let Some(manifest) = read_json(root, ".naome/manifest.json", &mut Vec::new())? else {
1752
- return Ok(HashSet::new());
1753
- };
1754
-
1755
- Ok(manifest
1756
- .get("machineOwned")
1757
- .and_then(Value::as_array)
1758
- .into_iter()
1759
- .flatten()
1760
- .filter_map(Value::as_str)
1761
- .filter(|value| is_non_empty_string(value))
1762
- .map(normalize_path)
1763
- .collect())
1764
- }
1765
-
1766
- fn add_completed_task_diff_notice(
1767
- root: &Path,
1768
- notices: &mut Vec<String>,
1769
- ) -> Result<(), NaomeError> {
1770
- let changed_paths = read_git_changed_paths(root)?;
1771
- if changed_paths.is_empty() {
1772
- return Ok(());
1773
- }
1774
-
1775
- notices.push(format!("Task is complete and verified. Changed paths: {}. NAOME intent can baseline it automatically before the next distinct task; only surface human choices when intent blocks or the user explicitly asks to review, revise, cancel, or commit.", changed_paths.join(", ")));
1776
- Ok(())
1777
- }
1778
-
1779
- pub fn completed_task_harness_refresh_diff(
1780
- root: &Path,
1781
- ) -> Result<Option<CompletedTaskHarnessRefreshDiff>, NaomeError> {
1782
- let mut read_errors = Vec::new();
1783
- let Some(task_state) = read_json(root, ".naome/task-state.json", &mut read_errors)? else {
1784
- return Ok(None);
1785
- };
1786
- if !read_errors.is_empty() {
1787
- return Ok(None);
1788
- }
1789
- if task_state.get("status").and_then(Value::as_str) != Some("complete") {
1790
- return Ok(None);
1791
- }
1792
- let Some(active_task) = task_state.get("activeTask") else {
1793
- return Ok(None);
1794
- };
1795
-
1796
- let allowed_paths = string_array(active_task.get("allowedPaths")).unwrap_or_default();
1797
- let mut harness_paths = Vec::new();
1798
- let mut task_paths = Vec::new();
1799
- let mut other_paths = Vec::new();
1800
-
1801
- for path in read_git_changed_paths(root)? {
1802
- if path == CONTROL_STATE_PATH {
1803
- continue;
1804
- }
1805
- if matches_any_pattern(&path, &allowed_paths) {
1806
- task_paths.push(path);
1807
- } else if is_safe_harness_refresh_path(&path) {
1808
- harness_paths.push(path);
1809
- } else {
1810
- other_paths.push(path);
1811
- }
1812
- }
1813
-
1814
- if task_paths.is_empty() || harness_paths.is_empty() || !other_paths.is_empty() {
1815
- return Ok(None);
1816
- }
1817
-
1818
- let report = validate_task_state(
1819
- root,
1820
- TaskStateOptions {
1821
- mode: TaskStateMode::State,
1822
- harness_health: None,
1823
- },
1824
- )?;
1825
- let allowed_scope_error = format!(
1826
- "Changed files outside allowedPaths: {}",
1827
- harness_paths.join(", ")
1828
- );
1829
- if report
1830
- .errors
1831
- .iter()
1832
- .all(|error| error == &allowed_scope_error)
1833
- {
1834
- Ok(Some(CompletedTaskHarnessRefreshDiff {
1835
- harness_paths,
1836
- task_paths,
1837
- }))
1838
- } else {
1839
- Ok(None)
1840
- }
1841
- }
1842
-
1843
- fn read_git_changed_paths(root: &Path) -> Result<Vec<String>, NaomeError> {
1844
- Ok(read_git_changed_entries(root)?
1845
- .into_iter()
1846
- .map(|entry| entry.path)
1847
- .collect())
1848
- }
1849
-
1850
- fn read_git_staged_changed_entries(root: &Path) -> Result<Vec<ChangedEntry>, NaomeError> {
1851
- let output = Command::new("git")
1852
- .args(["diff", "--name-status", "--cached", "-z"])
1853
- .current_dir(root)
1854
- .output()?;
1855
- if !output.status.success() {
1856
- return Err(NaomeError::new(format!(
1857
- "git diff --name-status --cached -z failed: {}",
1858
- command_output(&output)
1859
- )));
1860
- }
1861
-
1862
- Ok(parse_name_status_output(&output.stdout))
1863
- }
1864
-
1865
- fn read_git_changed_entries(root: &Path) -> Result<Vec<ChangedEntry>, NaomeError> {
1866
- let git_check = run_git(root, ["rev-parse", "--is-inside-work-tree"])?;
1867
- if !git_check.status.success() {
1868
- return Err(NaomeError::new(
1869
- "complete task validation requires a git work tree.",
1870
- ));
1871
- }
1872
-
1873
- let mut entries: HashMap<String, ChangedEntry> = HashMap::new();
1874
- for args in [
1875
- vec!["diff", "--name-status", "-z"],
1876
- vec!["diff", "--name-status", "--cached", "-z"],
1877
- ] {
1878
- let output = Command::new("git").args(&args).current_dir(root).output()?;
1879
- if !output.status.success() {
1880
- return Err(NaomeError::new(format!(
1881
- "git {} failed: {}",
1882
- args.join(" "),
1883
- command_output(&output)
1884
- )));
1885
- }
1886
-
1887
- for entry in parse_name_status_output(&output.stdout) {
1888
- upsert_changed_entry(&mut entries, entry);
1889
- }
1890
- }
1891
-
1892
- let untracked = run_git(root, ["ls-files", "--others", "--exclude-standard", "-z"])?;
1893
- if !untracked.status.success() {
1894
- return Err(NaomeError::new(format!(
1895
- "git ls-files --others --exclude-standard -z failed: {}",
1896
- command_output(&untracked)
1897
- )));
1898
- }
1899
-
1900
- for token in split_nul(&untracked.stdout) {
1901
- let path = normalize_path(token.trim());
1902
- if !path.is_empty() {
1903
- upsert_changed_entry(
1904
- &mut entries,
1905
- ChangedEntry {
1906
- path,
1907
- status: "added".to_string(),
1908
- },
1909
- );
1910
- }
1911
- }
1912
-
1913
- let mut entries: Vec<ChangedEntry> = entries.into_values().collect();
1914
- entries.sort_by(|left, right| left.path.cmp(&right.path));
1915
- Ok(entries)
1916
- }
1917
-
1918
- fn parse_name_status_output(output: &[u8]) -> Vec<ChangedEntry> {
1919
- let tokens = split_nul(output);
1920
- let mut entries = Vec::new();
1921
- let mut index = 0;
1922
-
1923
- while index < tokens.len() {
1924
- let raw_status = &tokens[index];
1925
- index += 1;
1926
- let status_code = raw_status.chars().next().unwrap_or('M');
1927
-
1928
- if status_code == 'R' || status_code == 'C' {
1929
- let from_path = normalize_path(tokens.get(index).map(String::as_str).unwrap_or(""));
1930
- index += 1;
1931
- let to_path = normalize_path(tokens.get(index).map(String::as_str).unwrap_or(""));
1932
- index += 1;
1933
- if !from_path.is_empty() {
1934
- entries.push(ChangedEntry {
1935
- path: from_path,
1936
- status: "deleted".to_string(),
1937
- });
1938
- }
1939
- if !to_path.is_empty() {
1940
- entries.push(ChangedEntry {
1941
- path: to_path,
1942
- status: "renamed".to_string(),
1943
- });
1944
- }
1945
- continue;
1946
- }
1947
-
1948
- let path = normalize_path(tokens.get(index).map(String::as_str).unwrap_or(""));
1949
- index += 1;
1950
- if path.is_empty() {
1951
- continue;
1952
- }
1953
-
1954
- entries.push(ChangedEntry {
1955
- path,
1956
- status: git_status_code_to_evidence_status(status_code).to_string(),
1957
- });
1958
- }
1959
-
1960
- entries
1961
- }
1962
-
1963
- fn split_nul(output: &[u8]) -> Vec<String> {
1964
- output
1965
- .split(|byte| *byte == 0)
1966
- .filter(|token| !token.is_empty())
1967
- .map(|token| String::from_utf8_lossy(token).to_string())
1968
- .collect()
1969
- }
1970
-
1971
- fn git_status_code_to_evidence_status(status_code: char) -> &'static str {
1972
- match status_code {
1973
- 'A' => "added",
1974
- 'D' => "deleted",
1975
- _ => "modified",
1976
- }
1977
- }
1978
-
1979
- fn upsert_changed_entry(entries: &mut HashMap<String, ChangedEntry>, entry: ChangedEntry) {
1980
- let should_replace = entries
1981
- .get(&entry.path)
1982
- .map(|existing| status_rank(&entry.status) > status_rank(&existing.status))
1983
- .unwrap_or(true);
1984
- if should_replace {
1985
- entries.insert(entry.path.clone(), entry);
1986
- }
1987
- }
1988
-
1989
- fn status_rank(status: &str) -> u8 {
1990
- match status {
1991
- "deleted" => 4,
1992
- "renamed" => 3,
1993
- "added" => 2,
1994
- _ => 1,
1995
- }
1996
- }
1997
-
1998
- fn read_git_head(root: &Path) -> Result<Option<String>, NaomeError> {
1999
- let output = run_git(root, ["rev-parse", "HEAD"])?;
2000
- if !output.status.success() {
2001
- return Ok(None);
2002
- }
2003
- Ok(Some(
2004
- String::from_utf8_lossy(&output.stdout).trim().to_string(),
2005
- ))
2006
- }
2007
-
2008
- fn git_commit_exists(root: &Path, commit: &str) -> Result<bool, NaomeError> {
2009
- Ok(
2010
- run_git(root, ["cat-file", "-e", &format!("{commit}^{{commit}}")])?
2011
- .status
2012
- .success(),
2013
- )
2014
- }
2015
-
2016
- fn run_git<const N: usize>(
2017
- root: &Path,
2018
- args: [&str; N],
2019
- ) -> Result<std::process::Output, NaomeError> {
2020
- Ok(Command::new("git").args(args).current_dir(root).output()?)
2021
- }
2022
-
2023
- fn command_output(output: &std::process::Output) -> String {
2024
- let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
2025
- if !stderr.is_empty() {
2026
- return stderr;
2027
- }
2028
- String::from_utf8_lossy(&output.stdout).trim().to_string()
2029
- }
2030
-
2031
- fn matches_any_pattern(path: &str, patterns: &[String]) -> bool {
2032
- patterns
2033
- .iter()
2034
- .any(|pattern| matches_path_pattern(path, pattern))
2035
- }
2036
-
2037
- fn matches_path_pattern(path: &str, pattern: &str) -> bool {
2038
- let normalized_path = normalize_path(path);
2039
- let normalized_pattern = normalize_path(pattern);
2040
-
2041
- if normalized_path == normalized_pattern {
2042
- return true;
2043
- }
2044
-
2045
- if let Some(prefix) = normalized_pattern.strip_suffix("/**") {
2046
- return normalized_path == prefix || normalized_path.starts_with(&format!("{prefix}/"));
2047
- }
2048
-
2049
- if !normalized_pattern.contains('*') {
2050
- return false;
2051
- }
2052
-
2053
- let path_to_match = if normalized_pattern.contains('/') {
2054
- normalized_path
2055
- } else {
2056
- PathBuf::from(&normalized_path)
2057
- .file_name()
2058
- .map(|name| name.to_string_lossy().to_string())
2059
- .unwrap_or(normalized_path)
2060
- };
2061
- wildcard_match(path_to_match.as_bytes(), normalized_pattern.as_bytes())
2062
- }
2063
-
2064
- fn wildcard_match(value: &[u8], pattern: &[u8]) -> bool {
2065
- let (mut value_index, mut pattern_index) = (0, 0);
2066
- let mut star_index = None;
2067
- let mut match_index = 0;
2068
-
2069
- while value_index < value.len() {
2070
- if pattern_index < pattern.len()
2071
- && pattern[pattern_index] != b'*'
2072
- && pattern[pattern_index] == value[value_index]
2073
- {
2074
- value_index += 1;
2075
- pattern_index += 1;
2076
- } else if pattern_index < pattern.len() && pattern[pattern_index] == b'*' {
2077
- star_index = Some(pattern_index);
2078
- match_index = value_index;
2079
- pattern_index += 1;
2080
- } else if let Some(star) = star_index {
2081
- pattern_index = star + 1;
2082
- match_index += 1;
2083
- value_index = match_index;
2084
- } else {
2085
- return false;
2086
- }
2087
- }
2088
-
2089
- while pattern_index < pattern.len() && pattern[pattern_index] == b'*' {
2090
- pattern_index += 1;
2091
- }
2092
-
2093
- pattern_index == pattern.len()
2094
- }
2095
-
2096
- fn read_json(
2097
- root: &Path,
2098
- relative_path: &str,
2099
- errors: &mut Vec<String>,
2100
- ) -> Result<Option<Value>, NaomeError> {
2101
- let path = root.join(relative_path);
2102
- if !path.exists() {
2103
- errors.push(format!("{relative_path} is missing."));
2104
- return Ok(None);
2105
- }
2106
-
2107
- match serde_json::from_str(&fs::read_to_string(path)?) {
2108
- Ok(value) => Ok(Some(value)),
2109
- Err(error) => {
2110
- errors.push(format!("{relative_path} is not valid JSON: {error}"));
2111
- Ok(None)
2112
- }
2113
- }
2114
- }
2115
-
2116
- fn require_string(value: Option<&Value>, field_name: &str, errors: &mut Vec<String>) {
2117
- if !value
2118
- .and_then(Value::as_str)
2119
- .is_some_and(is_non_empty_string)
2120
- {
2121
- errors.push(format!("{field_name} must be a non-empty string."));
2122
- }
2123
- }
2124
-
2125
- fn require_string_array(value: Option<&Value>, field_name: &str, errors: &mut Vec<String>) {
2126
- let Some(values) = value.and_then(Value::as_array) else {
2127
- errors.push(format!("{field_name} must be a non-empty string array."));
2128
- return;
2129
- };
2130
-
2131
- if values.is_empty()
2132
- || values
2133
- .iter()
2134
- .any(|entry| !entry.as_str().is_some_and(is_non_empty_string))
2135
- {
2136
- errors.push(format!("{field_name} must be a non-empty string array."));
2137
- }
2138
- }
2139
-
2140
- fn require_string_array_allow_empty(
2141
- value: Option<&Value>,
2142
- field_name: &str,
2143
- errors: &mut Vec<String>,
2144
- ) {
2145
- let Some(values) = value.and_then(Value::as_array) else {
2146
- errors.push(format!("{field_name} must be a string array."));
2147
- return;
2148
- };
2149
-
2150
- if values
2151
- .iter()
2152
- .any(|entry| !entry.as_str().is_some_and(is_non_empty_string))
2153
- {
2154
- errors.push(format!("{field_name} must be a string array."));
2155
- }
2156
- }
2157
-
2158
- fn string_array(value: Option<&Value>) -> Option<Vec<String>> {
2159
- value.and_then(Value::as_array).and_then(|values| {
2160
- values
2161
- .iter()
2162
- .map(|entry| {
2163
- entry
2164
- .as_str()
2165
- .filter(|value| is_non_empty_string(value))
2166
- .map(ToString::to_string)
2167
- })
2168
- .collect()
2169
- })
2170
- }
2171
-
2172
- fn is_non_empty_string(value: &str) -> bool {
2173
- !value.trim().is_empty()
2174
- }
2175
-
2176
- fn is_id(value: &str) -> bool {
2177
- let mut chars = value.chars();
2178
- let Some(first) = chars.next() else {
2179
- return false;
2180
- };
2181
- (first.is_ascii_lowercase() || first.is_ascii_digit())
2182
- && value
2183
- .chars()
2184
- .all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '-')
2185
- }
2186
-
2187
- fn is_iso_datetime(value: &str) -> bool {
2188
- let bytes = value.as_bytes();
2189
- bytes.len() == 24
2190
- && bytes[4] == b'-'
2191
- && bytes[7] == b'-'
2192
- && bytes[10] == b'T'
2193
- && bytes[13] == b':'
2194
- && bytes[16] == b':'
2195
- && bytes[19] == b'.'
2196
- && bytes[23] == b'Z'
2197
- && bytes
2198
- .iter()
2199
- .enumerate()
2200
- .filter(|(index, _)| ![4, 7, 10, 13, 16, 19, 23].contains(index))
2201
- .all(|(_, byte)| byte.is_ascii_digit())
2202
- }
2203
-
2204
- fn normalize_path(value: impl AsRef<str>) -> String {
2205
- value
2206
- .as_ref()
2207
- .replace('\\', "/")
2208
- .trim_start_matches("./")
2209
- .to_string()
2210
- }