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