@lamentis/naome 1.0.1 → 1.1.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 (34) hide show
  1. package/Cargo.lock +2 -2
  2. package/README.md +8 -1
  3. package/bin/naome-node.js +121 -4
  4. package/bin/naome.js +198 -3
  5. package/crates/naome-cli/Cargo.toml +1 -1
  6. package/crates/naome-cli/src/main.rs +110 -13
  7. package/crates/naome-core/Cargo.toml +1 -1
  8. package/crates/naome-core/src/decision.rs +82 -11
  9. package/crates/naome-core/src/git.rs +12 -1
  10. package/crates/naome-core/src/harness_health.rs +3 -1
  11. package/crates/naome-core/src/install_plan.rs +8 -3
  12. package/crates/naome-core/src/intent.rs +914 -0
  13. package/crates/naome-core/src/journal.rs +169 -0
  14. package/crates/naome-core/src/lib.rs +10 -1
  15. package/crates/naome-core/src/models.rs +63 -4
  16. package/crates/naome-core/src/route.rs +1000 -0
  17. package/crates/naome-core/src/task_state.rs +326 -21
  18. package/crates/naome-core/tests/decision.rs +8 -6
  19. package/crates/naome-core/tests/install_plan.rs +12 -3
  20. package/crates/naome-core/tests/intent.rs +826 -0
  21. package/crates/naome-core/tests/route.rs +1108 -0
  22. package/crates/naome-core/tests/task_state.rs +63 -4
  23. package/native/darwin-arm64/naome +0 -0
  24. package/native/linux-x64/naome +0 -0
  25. package/package.json +1 -1
  26. package/templates/naome-root/.naome/bin/check-harness-health.js +7 -6
  27. package/templates/naome-root/.naome/bin/check-task-state.js +7 -6
  28. package/templates/naome-root/.naome/bin/naome.js +143 -13
  29. package/templates/naome-root/.naome/manifest.json +8 -7
  30. package/templates/naome-root/.naome/upgrade-state.json +1 -1
  31. package/templates/naome-root/AGENTS.md +30 -5
  32. package/templates/naome-root/docs/naome/agent-workflow.md +45 -24
  33. package/templates/naome-root/docs/naome/execution.md +55 -51
  34. package/templates/naome-root/docs/naome/index.md +10 -3
@@ -0,0 +1,1108 @@
1
+ use std::fs;
2
+ use std::path::{Path, PathBuf};
3
+ use std::process::Command;
4
+ use std::time::{SystemTime, UNIX_EPOCH};
5
+
6
+ use naome_core::{evaluate_route, explain_route, EvaluationOptions, RouteOptions};
7
+ use serde_json::{json, Value};
8
+
9
+ #[test]
10
+ fn dry_route_reports_auto_baseline_without_mutating() {
11
+ let repo = TestRepo::completed_task_with_diff("route-dry-auto");
12
+ let before = repo.git_stdout(&["rev-parse", "HEAD"]);
13
+
14
+ let route = evaluate_route(
15
+ repo.path(),
16
+ "Start a new task for README polish.",
17
+ RouteOptions {
18
+ execute: false,
19
+ evaluation: EvaluationOptions::offline(),
20
+ },
21
+ )
22
+ .unwrap();
23
+
24
+ assert_eq!(
25
+ route.policy_action,
26
+ "auto_commit_completed_task_then_create_new_task"
27
+ );
28
+ assert!(!route.mutation_performed);
29
+ assert!(!route.can_create_task);
30
+ assert_eq!(repo.git_stdout(&["rev-parse", "HEAD"]), before);
31
+ assert!(!route.user_message.contains("commit_task_baseline"));
32
+ assert!(route.human_options.is_empty());
33
+ }
34
+
35
+ #[test]
36
+ fn execute_route_auto_baselines_then_admits_next_task_and_writes_local_journal() {
37
+ let repo = TestRepo::completed_task_with_diff("route-execute-auto");
38
+
39
+ let route = evaluate_route(
40
+ repo.path(),
41
+ "Start a new task for README polish.",
42
+ RouteOptions {
43
+ execute: true,
44
+ evaluation: EvaluationOptions::offline(),
45
+ },
46
+ )
47
+ .unwrap();
48
+
49
+ assert_eq!(
50
+ route.policy_action,
51
+ "auto_commit_completed_task_then_create_new_task"
52
+ );
53
+ assert!(route.mutation_performed);
54
+ assert!(route.can_create_task);
55
+ assert_eq!(route.next_decision.state, "ready_for_task");
56
+ assert!(route
57
+ .executed_actions
58
+ .contains(&"commit_task_baseline".to_string()));
59
+ assert!(repo.git_status_short().is_empty());
60
+
61
+ let journal = fs::read_to_string(repo.path().join(".naome/task-journal.jsonl")).unwrap();
62
+ assert!(journal.contains("\"taskId\":\"readme-task\""));
63
+ assert!(journal.contains("\"outcome\":\"route_auto_baseline\""));
64
+ }
65
+
66
+ #[test]
67
+ fn execute_route_baselines_completed_task_and_creates_worktree_for_unrelated_user_edit() {
68
+ let repo = TestRepo::completed_task_with_unrelated_user_edit("route-preserve-user-edit");
69
+
70
+ let route = evaluate_route(
71
+ repo.path(),
72
+ "Add another line to README as a new task.",
73
+ RouteOptions {
74
+ execute: true,
75
+ evaluation: EvaluationOptions::offline(),
76
+ },
77
+ )
78
+ .unwrap();
79
+
80
+ assert_eq!(
81
+ route.policy_action,
82
+ "auto_commit_completed_task_then_create_isolated_task_worktree"
83
+ );
84
+ assert!(route.mutation_performed);
85
+ assert!(route.can_create_task);
86
+ assert_eq!(route.next_decision.state, "ready_for_task");
87
+ assert!(route.user_message.contains("isolated task worktree"));
88
+ assert!(route.human_options.is_empty());
89
+ assert_ne!(route.task_root, repo.path().to_string_lossy());
90
+ assert!(route.worktree.is_some());
91
+ assert_eq!(repo.git_status_short(), "M USER.md");
92
+ assert_eq!(
93
+ TestRepo::git_status_short_at(Path::new(&route.task_root)),
94
+ ""
95
+ );
96
+
97
+ let committed_paths = repo.git_stdout(&["show", "--name-only", "--format=", "HEAD"]);
98
+ assert!(committed_paths.contains("README.md"));
99
+ assert!(!committed_paths.contains("USER.md"));
100
+ }
101
+
102
+ #[test]
103
+ fn execute_route_creates_worktree_for_dirty_repo_new_task() {
104
+ let repo = TestRepo::new("route-dirty-worktree");
105
+ repo.init_git();
106
+ repo.write_file("README.md", "# Baseline\n");
107
+ repo.write_file("USER.md", "user baseline\n");
108
+ repo.write_base_naome_state(json!({ "status": "idle", "activeTask": null }));
109
+ repo.git(&["add", "."]);
110
+ repo.git(&["commit", "-m", "baseline"]);
111
+ repo.write_file("USER.md", "user local edit\n");
112
+
113
+ let route = evaluate_route(
114
+ repo.path(),
115
+ "Add another line to README as a new task.",
116
+ RouteOptions {
117
+ execute: true,
118
+ evaluation: EvaluationOptions::offline(),
119
+ },
120
+ )
121
+ .unwrap();
122
+
123
+ assert_eq!(route.policy_action, "create_isolated_task_worktree");
124
+ assert!(route.mutation_performed);
125
+ assert!(route.can_create_task);
126
+ assert_eq!(route.next_decision.state, "ready_for_task");
127
+ assert!(route.worktree.is_some());
128
+ assert_eq!(repo.git_status_short(), "M USER.md");
129
+ assert_eq!(
130
+ TestRepo::git_status_short_at(Path::new(&route.task_root)),
131
+ ""
132
+ );
133
+ }
134
+
135
+ #[test]
136
+ fn execute_route_does_not_mutate_or_offer_clear_commit_for_dirty_unowned_diff() {
137
+ let repo = TestRepo::new("route-dirty-commit-request");
138
+ repo.init_git();
139
+ repo.write_file("README.md", "# Baseline\n");
140
+ repo.write_base_naome_state(json!({ "status": "idle", "activeTask": null }));
141
+ repo.git(&["add", "."]);
142
+ repo.git(&["commit", "-m", "baseline"]);
143
+ repo.write_file("README.md", "# Manual edit\n");
144
+ let before_head = repo.git_stdout(&["rev-parse", "HEAD"]);
145
+ let before_status = repo.git_status_short();
146
+
147
+ let route = evaluate_route(
148
+ repo.path(),
149
+ "clear_or_commit_unowned_diff",
150
+ RouteOptions {
151
+ execute: true,
152
+ evaluation: EvaluationOptions::offline(),
153
+ },
154
+ )
155
+ .unwrap();
156
+
157
+ assert_eq!(route.policy_action, "block_unowned_diff");
158
+ assert!(!route.mutation_performed);
159
+ assert!(!route.can_create_task);
160
+ assert_eq!(route.executed_actions, Vec::<String>::new());
161
+ assert_eq!(repo.git_stdout(&["rev-parse", "HEAD"]), before_head);
162
+ assert_eq!(repo.git_status_short(), before_status);
163
+ assert!(!route
164
+ .human_options
165
+ .contains(&"clear_or_commit_unowned_diff".to_string()));
166
+ }
167
+
168
+ #[test]
169
+ fn execute_route_commits_user_diff_after_quality_gate_passes() {
170
+ let repo = TestRepo::new("route-user-diff-quality-pass");
171
+ repo.init_git();
172
+ repo.write_file("README.md", "# Baseline\n");
173
+ repo.write_base_naome_state(json!({ "status": "idle", "activeTask": null }));
174
+ repo.write_readme_quality_verification(Vec::new(), vec!["diff-check"]);
175
+ repo.git(&["add", "."]);
176
+ repo.git(&["commit", "-m", "baseline"]);
177
+ repo.write_file("README.md", "# Manual edit\n");
178
+
179
+ let route = evaluate_route(
180
+ repo.path(),
181
+ "commit my changes",
182
+ RouteOptions {
183
+ execute: true,
184
+ evaluation: EvaluationOptions::offline(),
185
+ },
186
+ )
187
+ .unwrap();
188
+
189
+ assert_eq!(route.policy_action, "commit_user_diff_with_quality_gate");
190
+ assert!(route.allowed);
191
+ assert!(route.mutation_performed);
192
+ assert_eq!(
193
+ route.executed_actions,
194
+ vec![
195
+ "run_user_diff_quality_gate".to_string(),
196
+ "commit_user_diff".to_string()
197
+ ]
198
+ );
199
+ assert_eq!(repo.git_status_short(), "");
200
+
201
+ let committed_paths = repo.git_stdout(&["show", "--name-only", "--format=", "HEAD"]);
202
+ assert!(committed_paths.contains("README.md"));
203
+ assert!(route.user_message.contains("quality gates passed"));
204
+ }
205
+
206
+ #[test]
207
+ fn execute_route_preserves_staged_rename_when_committing_user_diff() {
208
+ let repo = TestRepo::new("route-user-diff-staged-rename");
209
+ repo.init_git();
210
+ repo.write_file("README.md", "# Baseline\n");
211
+ repo.write_base_naome_state(json!({ "status": "idle", "activeTask": null }));
212
+ repo.write_naome_json(
213
+ "verification.json",
214
+ json!({
215
+ "schema": "naome.verification.v1",
216
+ "version": 1,
217
+ "status": "ready",
218
+ "checks": [
219
+ {
220
+ "id": "diff-check",
221
+ "command": "git diff --check",
222
+ "cwd": ".",
223
+ "purpose": "Reject whitespace errors.",
224
+ "cost": "fast",
225
+ "source": "test",
226
+ "evidence": ["README.md", "INTRO.md"],
227
+ "lastVerified": null
228
+ }
229
+ ],
230
+ "changeTypes": [
231
+ {
232
+ "id": "docs",
233
+ "description": "Repository docs.",
234
+ "paths": ["README.md", "INTRO.md"],
235
+ "requiredChecks": ["diff-check"],
236
+ "recommendedChecks": [],
237
+ "humanReview": false
238
+ }
239
+ ],
240
+ "releaseGates": []
241
+ }),
242
+ );
243
+ repo.git(&["add", "."]);
244
+ repo.git(&["commit", "-m", "baseline"]);
245
+ repo.git(&["mv", "README.md", "INTRO.md"]);
246
+
247
+ let route = evaluate_route(
248
+ repo.path(),
249
+ "commit my changes",
250
+ RouteOptions {
251
+ execute: true,
252
+ evaluation: EvaluationOptions::offline(),
253
+ },
254
+ )
255
+ .unwrap();
256
+
257
+ assert_eq!(route.policy_action, "commit_user_diff_with_quality_gate");
258
+ assert!(route.allowed);
259
+ assert!(route.mutation_performed);
260
+ assert_eq!(repo.git_status_short(), "");
261
+
262
+ let committed_paths = repo.git_stdout(&["show", "--name-status", "--format=", "HEAD"]);
263
+ assert!(committed_paths.contains("README.md"));
264
+ assert!(committed_paths.contains("INTRO.md"));
265
+ }
266
+
267
+ #[test]
268
+ fn execute_route_refuses_user_diff_commit_without_quality_coverage() {
269
+ let repo = TestRepo::new("route-user-diff-no-quality-coverage");
270
+ repo.init_git();
271
+ repo.write_file("README.md", "# Baseline\n");
272
+ repo.write_base_naome_state(json!({ "status": "idle", "activeTask": null }));
273
+ repo.git(&["add", "."]);
274
+ repo.git(&["commit", "-m", "baseline"]);
275
+ repo.write_file("README.md", "# Manual edit\n");
276
+ let before_head = repo.git_stdout(&["rev-parse", "HEAD"]);
277
+
278
+ let route = evaluate_route(
279
+ repo.path(),
280
+ "commit my changes",
281
+ RouteOptions {
282
+ execute: true,
283
+ evaluation: EvaluationOptions::offline(),
284
+ },
285
+ )
286
+ .unwrap();
287
+
288
+ assert_eq!(route.policy_action, "commit_user_diff_with_quality_gate");
289
+ assert!(!route.allowed);
290
+ assert!(!route.mutation_performed);
291
+ assert_eq!(route.executed_actions, vec!["run_user_diff_quality_gate"]);
292
+ assert_eq!(repo.git_stdout(&["rev-parse", "HEAD"]), before_head);
293
+ assert!(repo.git_status_short().contains("README.md"));
294
+ assert!(route.user_message.contains("No quality coverage"));
295
+ }
296
+
297
+ #[test]
298
+ fn execute_route_refuses_user_diff_commit_when_check_mutates_after_diff_check() {
299
+ let repo = TestRepo::new("route-user-diff-mutating-check");
300
+ repo.init_git();
301
+ repo.write_file("README.md", "# Baseline\n");
302
+ repo.write_base_naome_state(json!({ "status": "idle", "activeTask": null }));
303
+ repo.write_readme_quality_verification(
304
+ vec![json!({
305
+ "id": "mutate-readme-after-check",
306
+ "command": "node -e \"require('fs').writeFileSync('README.md', '# Mutated \\\\n')\"",
307
+ "cwd": ".",
308
+ "purpose": "Simulate a mutating check that dirties checked content.",
309
+ "cost": "fast",
310
+ "source": "test",
311
+ "evidence": ["README.md"],
312
+ "lastVerified": null
313
+ })],
314
+ vec!["mutate-readme-after-check"],
315
+ );
316
+ repo.git(&["add", "."]);
317
+ repo.git(&["commit", "-m", "baseline"]);
318
+ repo.write_file("README.md", "# Manual edit\n");
319
+ let before_head = repo.git_stdout(&["rev-parse", "HEAD"]);
320
+
321
+ let route = evaluate_route(
322
+ repo.path(),
323
+ "commit my changes",
324
+ RouteOptions {
325
+ execute: true,
326
+ evaluation: EvaluationOptions::offline(),
327
+ },
328
+ )
329
+ .unwrap();
330
+
331
+ assert_eq!(route.policy_action, "commit_user_diff_with_quality_gate");
332
+ assert!(!route.allowed);
333
+ assert!(!route.mutation_performed);
334
+ assert_eq!(route.executed_actions, vec!["run_user_diff_quality_gate"]);
335
+ assert_eq!(repo.git_stdout(&["rev-parse", "HEAD"]), before_head);
336
+ assert!(repo.git_status_short().contains("README.md"));
337
+ assert!(route.user_message.contains("trailing whitespace"));
338
+ }
339
+
340
+ #[test]
341
+ fn execute_route_refuses_user_diff_commit_when_diff_check_adds_paths() {
342
+ let repo = TestRepo::new("route-user-diff-mutating-diff-check");
343
+ repo.init_git();
344
+ repo.write_file("README.md", "# Baseline\n");
345
+ repo.write_base_naome_state(json!({ "status": "idle", "activeTask": null }));
346
+ repo.write_naome_json(
347
+ "verification.json",
348
+ json!({
349
+ "schema": "naome.verification.v1",
350
+ "version": 1,
351
+ "status": "ready",
352
+ "checks": [
353
+ {
354
+ "id": "diff-check",
355
+ "command": "node -e \"const fs = require('fs'); const marker = '.git/naome-diff-check-marker'; if (fs.existsSync(marker)) { fs.writeFileSync('NEW.md', '# unexpected\\\\n'); } else { fs.writeFileSync(marker, 'x'); }\"",
356
+ "cwd": ".",
357
+ "purpose": "Simulate a mutating diff check.",
358
+ "cost": "fast",
359
+ "source": "test",
360
+ "evidence": ["README.md"],
361
+ "lastVerified": null
362
+ }
363
+ ],
364
+ "changeTypes": [
365
+ {
366
+ "id": "readme",
367
+ "description": "README changes.",
368
+ "paths": ["README.md"],
369
+ "requiredChecks": ["diff-check"],
370
+ "recommendedChecks": [],
371
+ "humanReview": false
372
+ }
373
+ ],
374
+ "releaseGates": []
375
+ }),
376
+ );
377
+ repo.git(&["add", "."]);
378
+ repo.git(&["commit", "-m", "baseline"]);
379
+ repo.write_file("README.md", "# Manual edit\n");
380
+ let before_head = repo.git_stdout(&["rev-parse", "HEAD"]);
381
+
382
+ let route = evaluate_route(
383
+ repo.path(),
384
+ "commit my changes",
385
+ RouteOptions {
386
+ execute: true,
387
+ evaluation: EvaluationOptions::offline(),
388
+ },
389
+ )
390
+ .unwrap();
391
+
392
+ assert_eq!(route.policy_action, "commit_user_diff_with_quality_gate");
393
+ assert!(!route.allowed);
394
+ assert!(!route.mutation_performed);
395
+ assert_eq!(route.executed_actions, vec!["run_user_diff_quality_gate"]);
396
+ assert_eq!(repo.git_stdout(&["rev-parse", "HEAD"]), before_head);
397
+ assert!(repo.git_status_short().contains("NEW.md"));
398
+ assert!(route
399
+ .user_message
400
+ .contains("Quality checks changed the diff path set"));
401
+ }
402
+
403
+ #[test]
404
+ fn execute_route_refuses_user_diff_commit_when_quality_gate_fails() {
405
+ let repo = TestRepo::new("route-user-diff-quality-fail");
406
+ repo.init_git();
407
+ repo.write_file("README.md", "# Baseline\n");
408
+ repo.write_base_naome_state(json!({ "status": "idle", "activeTask": null }));
409
+ repo.write_readme_quality_verification(Vec::new(), vec!["diff-check"]);
410
+ repo.git(&["add", "."]);
411
+ repo.git(&["commit", "-m", "baseline"]);
412
+ repo.write_file("README.md", "# Manual edit \n");
413
+ let before_head = repo.git_stdout(&["rev-parse", "HEAD"]);
414
+
415
+ let route = evaluate_route(
416
+ repo.path(),
417
+ "commit my changes",
418
+ RouteOptions {
419
+ execute: true,
420
+ evaluation: EvaluationOptions::offline(),
421
+ },
422
+ )
423
+ .unwrap();
424
+
425
+ assert_eq!(route.policy_action, "commit_user_diff_with_quality_gate");
426
+ assert!(!route.allowed);
427
+ assert!(!route.mutation_performed);
428
+ assert_eq!(route.executed_actions, vec!["run_user_diff_quality_gate"]);
429
+ assert_eq!(repo.git_stdout(&["rev-parse", "HEAD"]), before_head);
430
+ assert!(repo.git_status_short().contains("README.md"));
431
+ assert_eq!(route.human_options, vec!["review_unowned_diff"]);
432
+ assert!(route.user_message.contains("quality gate failed"));
433
+ }
434
+
435
+ #[test]
436
+ fn execute_route_baselines_harness_refresh_before_dirty_repo_worktree() {
437
+ let repo = TestRepo::new("route-dirty-harness-refresh-worktree");
438
+ repo.init_git();
439
+ repo.write_file("README.md", "# Baseline\n");
440
+ repo.write_file("USER.md", "user baseline\n");
441
+ repo.write_file("AGENTS.md", "# Agent Instructions\n\nOld harness.\n");
442
+ repo.write_base_naome_state(json!({ "status": "idle", "activeTask": null }));
443
+ repo.write_naome_json(
444
+ "manifest.json",
445
+ json!({
446
+ "name": "naome",
447
+ "harnessVersion": "1.1.0",
448
+ "profile": "old",
449
+ "machineOwned": ["AGENTS.md"],
450
+ "projectOwned": [".naome/manifest.json", ".naome/task-state.json"],
451
+ "integrity": {}
452
+ }),
453
+ );
454
+ repo.git(&["add", "."]);
455
+ repo.git(&["commit", "-m", "baseline"]);
456
+ repo.write_file("AGENTS.md", "# Agent Instructions\n\nUpdated by sync.\n");
457
+ repo.write_naome_json(
458
+ "manifest.json",
459
+ json!({
460
+ "name": "naome",
461
+ "harnessVersion": "1.1.0",
462
+ "profile": "standard",
463
+ "machineOwned": ["AGENTS.md"],
464
+ "projectOwned": [".naome/manifest.json", ".naome/task-state.json"],
465
+ "integrity": {}
466
+ }),
467
+ );
468
+ repo.write_file("USER.md", "user local edit\n");
469
+
470
+ let route = evaluate_route(
471
+ repo.path(),
472
+ "Add another line to README as a new task.",
473
+ RouteOptions {
474
+ execute: true,
475
+ evaluation: EvaluationOptions::offline(),
476
+ },
477
+ )
478
+ .unwrap();
479
+
480
+ assert_eq!(
481
+ route.policy_action,
482
+ "auto_commit_harness_refresh_then_create_isolated_task_worktree"
483
+ );
484
+ assert_eq!(
485
+ route.executed_actions,
486
+ vec![
487
+ "commit_harness_refresh_baseline".to_string(),
488
+ "create_task_worktree".to_string()
489
+ ]
490
+ );
491
+ assert!(route.can_create_task);
492
+ assert_eq!(route.next_decision.state, "ready_for_task");
493
+ assert!(route.worktree.is_some());
494
+ assert_eq!(repo.git_status_short(), "M USER.md");
495
+ assert_eq!(
496
+ TestRepo::git_status_short_at(Path::new(&route.task_root)),
497
+ ""
498
+ );
499
+
500
+ let committed_paths = repo.git_stdout(&["show", "--name-only", "--format=", "HEAD"]);
501
+ assert!(committed_paths.contains("AGENTS.md"));
502
+ assert!(committed_paths.contains(".naome/manifest.json"));
503
+ assert!(!committed_paths.contains("USER.md"));
504
+ }
505
+
506
+ #[test]
507
+ fn execute_route_baselines_pure_harness_refresh_before_new_task_without_worktree() {
508
+ let repo = TestRepo::new("route-pure-harness-refresh-no-worktree");
509
+ repo.init_git();
510
+ repo.write_file("README.md", "# Baseline\n");
511
+ repo.write_file("AGENTS.md", "# Agent Instructions\n\nOld harness.\n");
512
+ repo.write_base_naome_state(json!({ "status": "idle", "activeTask": null }));
513
+ repo.write_naome_json(
514
+ "manifest.json",
515
+ json!({
516
+ "name": "naome",
517
+ "harnessVersion": "1.1.0",
518
+ "profile": "old",
519
+ "machineOwned": ["AGENTS.md"],
520
+ "projectOwned": [".naome/manifest.json", ".naome/task-state.json"],
521
+ "integrity": {}
522
+ }),
523
+ );
524
+ repo.git(&["add", "."]);
525
+ repo.git(&["commit", "-m", "baseline"]);
526
+ let admission_head = repo.git_stdout(&["rev-parse", "HEAD"]);
527
+ repo.write_base_naome_state(completed_task_state(&admission_head));
528
+ repo.git(&["add", ".naome/task-state.json"]);
529
+ repo.git(&["commit", "-m", "task state"]);
530
+ repo.write_file("AGENTS.md", "# Agent Instructions\n\nUpdated by sync.\n");
531
+ repo.write_naome_json(
532
+ "manifest.json",
533
+ json!({
534
+ "name": "naome",
535
+ "harnessVersion": "1.1.0",
536
+ "profile": "standard",
537
+ "machineOwned": ["AGENTS.md"],
538
+ "projectOwned": [".naome/manifest.json", ".naome/task-state.json"],
539
+ "integrity": {}
540
+ }),
541
+ );
542
+
543
+ let route = evaluate_route(
544
+ repo.path(),
545
+ "Add another line to README as a new task.",
546
+ RouteOptions {
547
+ execute: true,
548
+ evaluation: EvaluationOptions::offline(),
549
+ },
550
+ )
551
+ .unwrap();
552
+
553
+ assert_eq!(
554
+ route.policy_action,
555
+ "auto_commit_harness_refresh_then_create_new_task"
556
+ );
557
+ assert_eq!(
558
+ route.executed_actions,
559
+ vec!["commit_harness_refresh_baseline".to_string()]
560
+ );
561
+ assert!(route.mutation_performed);
562
+ assert!(route.can_create_task);
563
+ assert!(route.worktree.is_none());
564
+ assert_eq!(route.task_root, repo.path().to_string_lossy());
565
+ assert_eq!(route.next_decision.state, "ready_for_task");
566
+ assert_eq!(repo.git_status_short(), "");
567
+
568
+ let committed_paths = repo.git_stdout(&["show", "--name-only", "--format=", "HEAD"]);
569
+ assert!(committed_paths.contains("AGENTS.md"));
570
+ assert!(committed_paths.contains(".naome/manifest.json"));
571
+ assert!(!committed_paths.contains("README.md"));
572
+ }
573
+
574
+ #[test]
575
+ fn execute_route_repair_request_baselines_harness_refresh_only() {
576
+ let repo = TestRepo::new("route-harness-refresh-repair-only");
577
+ repo.init_git();
578
+ repo.write_file("README.md", "# Baseline\n");
579
+ repo.write_file("USER.md", "user baseline\n");
580
+ repo.write_file("AGENTS.md", "# Agent Instructions\n\nOld harness.\n");
581
+ repo.write_base_naome_state(json!({ "status": "idle", "activeTask": null }));
582
+ repo.write_naome_json(
583
+ "manifest.json",
584
+ json!({
585
+ "name": "naome",
586
+ "harnessVersion": "1.1.0",
587
+ "profile": "old",
588
+ "machineOwned": ["AGENTS.md"],
589
+ "projectOwned": [".naome/manifest.json", ".naome/task-state.json"],
590
+ "integrity": {}
591
+ }),
592
+ );
593
+ repo.git(&["add", "."]);
594
+ repo.git(&["commit", "-m", "baseline"]);
595
+ repo.write_file("AGENTS.md", "# Agent Instructions\n\nUpdated by sync.\n");
596
+ repo.write_naome_json(
597
+ "manifest.json",
598
+ json!({
599
+ "name": "naome",
600
+ "harnessVersion": "1.1.0",
601
+ "profile": "standard",
602
+ "machineOwned": ["AGENTS.md"],
603
+ "projectOwned": [".naome/manifest.json", ".naome/task-state.json"],
604
+ "integrity": {}
605
+ }),
606
+ );
607
+ repo.write_file("USER.md", "user local edit\n");
608
+
609
+ let route = evaluate_route(
610
+ repo.path(),
611
+ "please repair all",
612
+ RouteOptions {
613
+ execute: true,
614
+ evaluation: EvaluationOptions::offline(),
615
+ },
616
+ )
617
+ .unwrap();
618
+
619
+ assert_eq!(route.policy_action, "auto_commit_harness_refresh_baseline");
620
+ assert!(route.mutation_performed);
621
+ assert_eq!(
622
+ route.executed_actions,
623
+ vec!["commit_harness_refresh_baseline".to_string()]
624
+ );
625
+ assert_eq!(repo.git_status_short(), "M USER.md");
626
+
627
+ let committed_paths = repo.git_stdout(&["show", "--name-only", "--format=", "HEAD"]);
628
+ assert!(committed_paths.contains("AGENTS.md"));
629
+ assert!(committed_paths.contains(".naome/manifest.json"));
630
+ assert!(!committed_paths.contains("USER.md"));
631
+ }
632
+
633
+ #[test]
634
+ fn execute_route_refuses_to_create_more_than_max_isolated_worktrees() {
635
+ let repo = TestRepo::new("route-worktree-limit");
636
+ repo.init_git();
637
+ repo.write_file("README.md", "# Baseline\n");
638
+ repo.write_file("USER.md", "user baseline\n");
639
+ repo.write_base_naome_state(json!({ "status": "idle", "activeTask": null }));
640
+ repo.git(&["add", "."]);
641
+ repo.git(&["commit", "-m", "baseline"]);
642
+ repo.write_file("USER.md", "user local edit\n");
643
+ let common_dir = repo.git_stdout(&["rev-parse", "--git-common-dir"]);
644
+ let worktree_root = repo.path().join(common_dir).join("naome").join("worktrees");
645
+ fs::create_dir_all(&worktree_root).unwrap();
646
+ for index in 0..25 {
647
+ fs::create_dir_all(worktree_root.join(format!("stale-{index}"))).unwrap();
648
+ }
649
+
650
+ let error = evaluate_route(
651
+ repo.path(),
652
+ "Add another line to README as a new task.",
653
+ RouteOptions {
654
+ execute: true,
655
+ evaluation: EvaluationOptions::offline(),
656
+ },
657
+ )
658
+ .unwrap_err();
659
+
660
+ assert!(error
661
+ .to_string()
662
+ .contains("Too many NAOME task worktrees are present"));
663
+ }
664
+
665
+ #[test]
666
+ fn dry_route_plans_harness_refresh_split_before_completed_task_baseline() {
667
+ let repo = TestRepo::completed_task_with_harness_refresh_diff("route-dry-harness-refresh");
668
+ let before = repo.git_stdout(&["rev-parse", "HEAD"]);
669
+
670
+ let route = evaluate_route(
671
+ repo.path(),
672
+ "Start a new task for README polish.",
673
+ RouteOptions {
674
+ execute: false,
675
+ evaluation: EvaluationOptions::offline(),
676
+ },
677
+ )
678
+ .unwrap();
679
+
680
+ assert_eq!(
681
+ route.policy_action,
682
+ "auto_commit_harness_refresh_then_completed_task_then_create_new_task"
683
+ );
684
+ assert!(!route.mutation_performed);
685
+ assert!(!route.can_create_task);
686
+ assert!(route.human_options.is_empty());
687
+ assert!(route.intent.risk_codes.is_empty());
688
+ assert_eq!(repo.git_stdout(&["rev-parse", "HEAD"]), before);
689
+ }
690
+
691
+ #[test]
692
+ fn execute_route_splits_harness_refresh_then_completed_task_baseline() {
693
+ let repo = TestRepo::completed_task_with_harness_refresh_diff("route-execute-harness-refresh");
694
+
695
+ let route = evaluate_route(
696
+ repo.path(),
697
+ "Start a new task for README polish.",
698
+ RouteOptions {
699
+ execute: true,
700
+ evaluation: EvaluationOptions::offline(),
701
+ },
702
+ )
703
+ .unwrap();
704
+
705
+ assert_eq!(
706
+ route.policy_action,
707
+ "auto_commit_harness_refresh_then_completed_task_then_create_new_task"
708
+ );
709
+ assert!(route.mutation_performed);
710
+ assert!(route.can_create_task);
711
+ assert_eq!(
712
+ route.executed_actions,
713
+ vec![
714
+ "commit_harness_refresh_baseline".to_string(),
715
+ "commit_task_baseline".to_string()
716
+ ]
717
+ );
718
+ assert_eq!(route.next_decision.state, "ready_for_task");
719
+ assert!(repo.git_status_short().is_empty());
720
+
721
+ let log = repo.git_stdout(&["log", "--format=%s", "-2"]);
722
+ assert!(log.contains("chore(naome): baseline completed task"));
723
+ assert!(log.contains("chore(naome): baseline harness refresh"));
724
+
725
+ let journal = fs::read_to_string(repo.path().join(".naome/task-journal.jsonl")).unwrap();
726
+ assert!(journal.contains("\"taskId\":\"readme-task\""));
727
+ assert!(journal.contains("\"outcome\":\"route_auto_baseline\""));
728
+ }
729
+
730
+ #[test]
731
+ fn execute_route_does_not_mutate_when_prompt_blocks_commit() {
732
+ let repo = TestRepo::completed_task_with_diff("route-no-commit");
733
+ let before = repo.git_stdout(&["rev-parse", "HEAD"]);
734
+
735
+ let route = evaluate_route(
736
+ repo.path(),
737
+ "Do not commit. Start a new task after this.",
738
+ RouteOptions {
739
+ execute: true,
740
+ evaluation: EvaluationOptions::offline(),
741
+ },
742
+ )
743
+ .unwrap();
744
+
745
+ assert_eq!(route.policy_action, "block_auto_baseline_due_to_no_commit");
746
+ assert!(!route.mutation_performed);
747
+ assert!(!route.can_create_task);
748
+ assert_eq!(repo.git_stdout(&["rev-parse", "HEAD"]), before);
749
+ assert!(repo.git_status_short().contains("README.md"));
750
+ }
751
+
752
+ #[test]
753
+ fn explicit_route_commit_baseline_leaves_unrelated_user_edit_unstaged() {
754
+ let repo = TestRepo::completed_task_with_diff("route-commit-task-scope");
755
+ repo.write_file("USER.md", "user baseline\n");
756
+ repo.git(&["add", "USER.md"]);
757
+ repo.git(&["commit", "-m", "user baseline"]);
758
+ repo.write_file("README.md", "# Changed\n");
759
+ repo.write_file("USER.md", "user local edit\n");
760
+ repo.git(&["add", "USER.md"]);
761
+
762
+ let route = evaluate_route(
763
+ repo.path(),
764
+ "commit_task_baseline",
765
+ RouteOptions {
766
+ execute: true,
767
+ evaluation: EvaluationOptions::offline(),
768
+ },
769
+ )
770
+ .unwrap();
771
+
772
+ assert_eq!(route.policy_action, "commit_task_baseline");
773
+ assert!(route.mutation_performed);
774
+ assert_eq!(repo.git_status_short(), "M USER.md");
775
+
776
+ let committed_paths = repo.git_stdout(&["show", "--name-only", "--format=", "HEAD"]);
777
+ assert!(committed_paths.contains("README.md"));
778
+ assert!(!committed_paths.contains("USER.md"));
779
+ }
780
+
781
+ #[test]
782
+ fn execute_route_journals_external_commit_after_completed_task() {
783
+ let repo = TestRepo::completed_task_with_diff("route-external-commit");
784
+ repo.git(&["add", "-A"]);
785
+ repo.git(&["commit", "-m", "manual baseline"]);
786
+ assert!(repo.git_status_short().is_empty());
787
+
788
+ let route = evaluate_route(
789
+ repo.path(),
790
+ "Create a new task for README polish.",
791
+ RouteOptions {
792
+ execute: true,
793
+ evaluation: EvaluationOptions::offline(),
794
+ },
795
+ )
796
+ .unwrap();
797
+
798
+ assert_eq!(route.repo_state_before, "ready_for_task");
799
+ assert!(route.mutation_performed);
800
+ assert!(route.can_create_task);
801
+ assert!(route
802
+ .executed_actions
803
+ .contains(&"journal_external_task_baseline".to_string()));
804
+
805
+ let journal = fs::read_to_string(repo.path().join(".naome/task-journal.jsonl")).unwrap();
806
+ assert!(journal.contains("\"outcome\":\"external_baseline\""));
807
+ assert!(journal.contains("\"taskId\":\"readme-task\""));
808
+ }
809
+
810
+ #[test]
811
+ fn explain_reports_winning_rule_and_mutation_plan_without_executing() {
812
+ let repo = TestRepo::completed_task_with_diff("route-explain");
813
+ let before = repo.git_stdout(&["rev-parse", "HEAD"]);
814
+
815
+ let explain = explain_route(
816
+ repo.path(),
817
+ "Start a new task for README polish.",
818
+ EvaluationOptions::offline(),
819
+ )
820
+ .unwrap();
821
+
822
+ assert_eq!(
823
+ explain.winning_rule,
824
+ "completed_task_valid_new_task_auto_baseline"
825
+ );
826
+ assert!(explain.would_mutate);
827
+ assert!(explain
828
+ .discarded_candidate_actions
829
+ .contains(&"review_task_diff".to_string()));
830
+ assert!(explain
831
+ .required_context
832
+ .contains(&"docs/naome/execution.md".to_string()));
833
+ assert!(!explain.user_message.contains("commit_task_baseline"));
834
+ assert_eq!(repo.git_stdout(&["rev-parse", "HEAD"]), before);
835
+ }
836
+
837
+ #[test]
838
+ fn unhealthy_harness_route_blocks_normal_work() {
839
+ let repo = TestRepo::new("route-unhealthy");
840
+ repo.init_git();
841
+ repo.write_file("README.md", "# Baseline\n");
842
+ repo.write_base_naome_state(json!({ "status": "idle", "activeTask": null }));
843
+ repo.write_file(".naome/bin/check-harness-health.js", "process.exit(1);\n");
844
+ repo.git(&["add", "."]);
845
+ repo.git(&["commit", "-m", "baseline"]);
846
+
847
+ let route = evaluate_route(
848
+ repo.path(),
849
+ "Create a new task.",
850
+ RouteOptions {
851
+ execute: true,
852
+ evaluation: EvaluationOptions::online(),
853
+ },
854
+ )
855
+ .unwrap();
856
+
857
+ assert_eq!(route.repo_state_before, "harness_unhealthy");
858
+ assert!(!route.mutation_performed);
859
+ assert!(!route.can_create_task);
860
+ assert!(!route.human_options.is_empty());
861
+ }
862
+
863
+ struct TestRepo {
864
+ root: PathBuf,
865
+ }
866
+
867
+ impl TestRepo {
868
+ fn new(name: &str) -> Self {
869
+ let nonce = SystemTime::now()
870
+ .duration_since(UNIX_EPOCH)
871
+ .unwrap()
872
+ .as_nanos();
873
+ let root = std::env::temp_dir().join(format!("naome-route-{name}-{nonce}"));
874
+ fs::create_dir_all(root.join(".naome")).unwrap();
875
+ Self { root }
876
+ }
877
+
878
+ fn completed_task_with_diff(name: &str) -> Self {
879
+ let repo = Self::new(name);
880
+ repo.init_git();
881
+ repo.write_file("README.md", "# Baseline\n");
882
+ repo.write_base_naome_state(json!({ "status": "idle", "activeTask": null }));
883
+ repo.git(&["add", "."]);
884
+ repo.git(&["commit", "-m", "baseline"]);
885
+ let admission_head = repo.git_stdout(&["rev-parse", "HEAD"]);
886
+ repo.write_base_naome_state(completed_task_state(&admission_head));
887
+ repo.git(&["add", ".naome/task-state.json"]);
888
+ repo.git(&["commit", "-m", "task state"]);
889
+ repo.write_file("README.md", "# Changed\n");
890
+ repo
891
+ }
892
+
893
+ fn completed_task_with_unrelated_user_edit(name: &str) -> Self {
894
+ let repo = Self::new(name);
895
+ repo.init_git();
896
+ repo.write_file("README.md", "# Baseline\n");
897
+ repo.write_file("USER.md", "user baseline\n");
898
+ repo.write_base_naome_state(json!({ "status": "idle", "activeTask": null }));
899
+ repo.git(&["add", "."]);
900
+ repo.git(&["commit", "-m", "baseline"]);
901
+ let admission_head = repo.git_stdout(&["rev-parse", "HEAD"]);
902
+ repo.write_base_naome_state(completed_task_state(&admission_head));
903
+ repo.git(&["add", ".naome/task-state.json"]);
904
+ repo.git(&["commit", "-m", "task state"]);
905
+ repo.write_file("README.md", "# Changed\n");
906
+ repo.write_file("USER.md", "user local edit\n");
907
+ repo
908
+ }
909
+
910
+ fn completed_task_with_harness_refresh_diff(name: &str) -> Self {
911
+ let repo = Self::completed_task_with_diff(name);
912
+ repo.write_file("AGENTS.md", "# Agent Instructions\n\nUpdated by sync.\n");
913
+ repo.write_file(
914
+ ".naome/manifest.json",
915
+ r#"{
916
+ "name": "naome",
917
+ "harnessVersion": "1.1.0",
918
+ "profile": "standard",
919
+ "machineOwned": ["AGENTS.md"],
920
+ "projectOwned": [".naome/manifest.json", ".naome/task-state.json"],
921
+ "integrity": {}
922
+ }
923
+ "#,
924
+ );
925
+ repo
926
+ }
927
+
928
+ fn path(&self) -> &Path {
929
+ &self.root
930
+ }
931
+
932
+ fn write_base_naome_state(&self, task_state: serde_json::Value) {
933
+ self.write_naome_json(
934
+ "init-state.json",
935
+ json!({ "initialized": true, "intakeStatus": "complete" }),
936
+ );
937
+ self.write_naome_json("upgrade-state.json", json!({ "status": "complete" }));
938
+ self.write_naome_json(
939
+ "verification.json",
940
+ json!({
941
+ "schema": "naome.verification.v1",
942
+ "version": 1,
943
+ "status": "ready",
944
+ "checks": [
945
+ {
946
+ "id": "diff-check",
947
+ "command": "git diff --check",
948
+ "cwd": ".",
949
+ "purpose": "Reject whitespace errors.",
950
+ "cost": "fast",
951
+ "source": "test",
952
+ "evidence": ["README.md"],
953
+ "lastVerified": null
954
+ }
955
+ ],
956
+ "changeTypes": [],
957
+ "releaseGates": []
958
+ }),
959
+ );
960
+ self.write_naome_json("task-state.json", task_state);
961
+ }
962
+
963
+ fn write_readme_quality_verification(
964
+ &self,
965
+ extra_checks: Vec<Value>,
966
+ required_checks: Vec<&str>,
967
+ ) {
968
+ let mut checks = vec![json!({
969
+ "id": "diff-check",
970
+ "command": "git diff --check",
971
+ "cwd": ".",
972
+ "purpose": "Reject whitespace errors.",
973
+ "cost": "fast",
974
+ "source": "test",
975
+ "evidence": ["README.md"],
976
+ "lastVerified": null
977
+ })];
978
+ checks.extend(extra_checks);
979
+
980
+ self.write_naome_json(
981
+ "verification.json",
982
+ json!({
983
+ "schema": "naome.verification.v1",
984
+ "version": 1,
985
+ "status": "ready",
986
+ "checks": checks,
987
+ "changeTypes": [
988
+ {
989
+ "id": "readme",
990
+ "description": "README changes.",
991
+ "paths": ["README.md"],
992
+ "requiredChecks": required_checks,
993
+ "recommendedChecks": [],
994
+ "humanReview": false
995
+ }
996
+ ],
997
+ "releaseGates": []
998
+ }),
999
+ );
1000
+ }
1001
+
1002
+ fn write_naome_json(&self, file_name: &str, value: serde_json::Value) {
1003
+ let path = self.root.join(".naome").join(file_name);
1004
+ fs::write(
1005
+ path,
1006
+ format!("{}\n", serde_json::to_string_pretty(&value).unwrap()),
1007
+ )
1008
+ .unwrap();
1009
+ }
1010
+
1011
+ fn write_file(&self, relative_path: &str, content: &str) {
1012
+ let path = self.root.join(relative_path);
1013
+ if let Some(parent) = path.parent() {
1014
+ fs::create_dir_all(parent).unwrap();
1015
+ }
1016
+ fs::write(path, content).unwrap();
1017
+ }
1018
+
1019
+ fn init_git(&self) {
1020
+ self.git(&["init"]);
1021
+ self.git(&["config", "user.email", "naome@example.com"]);
1022
+ self.git(&["config", "user.name", "NAOME Test"]);
1023
+ }
1024
+
1025
+ fn git(&self, args: &[&str]) {
1026
+ let output = Command::new("git")
1027
+ .args(args)
1028
+ .current_dir(&self.root)
1029
+ .output()
1030
+ .unwrap();
1031
+ assert!(
1032
+ output.status.success(),
1033
+ "git {:?} failed\nstdout:\n{}\nstderr:\n{}",
1034
+ args,
1035
+ String::from_utf8_lossy(&output.stdout),
1036
+ String::from_utf8_lossy(&output.stderr)
1037
+ );
1038
+ }
1039
+
1040
+ fn git_stdout(&self, args: &[&str]) -> String {
1041
+ let output = Command::new("git")
1042
+ .args(args)
1043
+ .current_dir(&self.root)
1044
+ .output()
1045
+ .unwrap();
1046
+ assert!(output.status.success());
1047
+ String::from_utf8_lossy(&output.stdout).trim().to_string()
1048
+ }
1049
+
1050
+ fn git_status_short(&self) -> String {
1051
+ self.git_stdout(&["status", "--short"])
1052
+ }
1053
+
1054
+ fn git_status_short_at(root: &Path) -> String {
1055
+ let output = Command::new("git")
1056
+ .args(["status", "--short"])
1057
+ .current_dir(root)
1058
+ .output()
1059
+ .unwrap();
1060
+ assert!(output.status.success());
1061
+ String::from_utf8_lossy(&output.stdout).trim().to_string()
1062
+ }
1063
+ }
1064
+
1065
+ fn completed_task_state(admission_head: &str) -> serde_json::Value {
1066
+ json!({
1067
+ "schema": "naome.task-state.v1",
1068
+ "version": 1,
1069
+ "status": "complete",
1070
+ "activeTask": {
1071
+ "id": "readme-task",
1072
+ "request": "Change README.",
1073
+ "userPrompt": {
1074
+ "receivedAt": "2026-05-06T00:00:00.000Z",
1075
+ "text": "Change README."
1076
+ },
1077
+ "admission": {
1078
+ "command": "node .naome/bin/check-task-state.js --admission",
1079
+ "cwd": ".",
1080
+ "exitCode": 0,
1081
+ "checkedAt": "2026-05-06T00:00:00.000Z",
1082
+ "gitHead": admission_head,
1083
+ "changedPaths": []
1084
+ },
1085
+ "allowedPaths": ["README.md"],
1086
+ "declaredChangeTypes": ["product-docs"],
1087
+ "requiredCheckIds": ["diff-check"],
1088
+ "proofResults": [
1089
+ {
1090
+ "checkId": "diff-check",
1091
+ "command": "git diff --check",
1092
+ "cwd": ".",
1093
+ "exitCode": 0,
1094
+ "checkedAt": "2026-05-06T00:00:00.000Z",
1095
+ "evidence": ["README.md"]
1096
+ }
1097
+ ],
1098
+ "revisions": [],
1099
+ "humanReview": {
1100
+ "required": false,
1101
+ "approved": false,
1102
+ "reason": null
1103
+ }
1104
+ },
1105
+ "blocker": null,
1106
+ "updatedAt": "2026-05-06T00:00:00.000Z"
1107
+ })
1108
+ }