@lamentis/naome 1.0.2 → 1.1.1

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 +4 -1
  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 +4 -2
  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 +1063 -0
  17. package/crates/naome-core/src/task_state.rs +372 -21
  18. package/crates/naome-core/tests/decision.rs +8 -6
  19. package/crates/naome-core/tests/install_plan.rs +9 -1
  20. package/crates/naome-core/tests/intent.rs +826 -0
  21. package/crates/naome-core/tests/route.rs +1159 -0
  22. package/crates/naome-core/tests/task_state.rs +203 -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,1063 @@
1
+ use std::collections::{BTreeSet, HashMap};
2
+ use std::fs;
3
+ use std::path::{Path, PathBuf};
4
+ use std::process::Command;
5
+
6
+ use serde::Serialize;
7
+ use serde_json::Value;
8
+
9
+ use crate::decision::{evaluate_decision, EvaluationOptions};
10
+ use crate::git;
11
+ use crate::install_plan::{LOCAL_NATIVE_BINARY_PATHS, LOCAL_ONLY_MACHINE_OWNED_PATHS};
12
+ use crate::intent::{evaluate_intent, IntentDecision};
13
+ use crate::journal::{append_task_journal, TaskJournalEntry};
14
+ use crate::models::{Decision, NaomeError};
15
+ use crate::paths;
16
+ use crate::task_state::{
17
+ completed_task_commit_paths, completed_task_harness_refresh_diff, harness_refresh_diff,
18
+ harness_refresh_with_unrelated_diff,
19
+ };
20
+
21
+ const MAX_NAOME_TASK_WORKTREES: usize = 25;
22
+
23
+ #[derive(Debug, Clone, Copy)]
24
+ pub struct RouteOptions {
25
+ pub execute: bool,
26
+ pub evaluation: EvaluationOptions,
27
+ }
28
+
29
+ #[derive(Debug, Clone, Serialize, PartialEq, Eq)]
30
+ #[serde(rename_all = "camelCase")]
31
+ pub struct RouteDecision {
32
+ pub schema: String,
33
+ pub execute: bool,
34
+ pub repo_state_before: String,
35
+ pub prompt_intent: String,
36
+ pub policy_action: String,
37
+ pub allowed: bool,
38
+ pub mutation_performed: bool,
39
+ pub executed_actions: Vec<String>,
40
+ pub can_create_task: bool,
41
+ pub user_message: String,
42
+ pub human_options: Vec<String>,
43
+ pub internal_notes: Vec<String>,
44
+ pub required_context: Vec<String>,
45
+ pub task_root: String,
46
+ pub worktree: Option<RouteWorktree>,
47
+ pub journal_entry: Option<TaskJournalEntry>,
48
+ pub next_decision: Decision,
49
+ pub intent: IntentDecision,
50
+ }
51
+
52
+ #[derive(Debug, Clone, Serialize, PartialEq, Eq)]
53
+ #[serde(rename_all = "camelCase")]
54
+ pub struct RouteWorktree {
55
+ pub path: String,
56
+ pub branch: String,
57
+ pub base_head: String,
58
+ pub source_root: String,
59
+ }
60
+
61
+ #[derive(Debug, Clone, Serialize, PartialEq, Eq)]
62
+ #[serde(rename_all = "camelCase")]
63
+ pub struct ExplainDecision {
64
+ pub schema: String,
65
+ pub repo_state: String,
66
+ pub prompt_intent: String,
67
+ pub winning_rule: String,
68
+ pub discarded_candidate_actions: Vec<String>,
69
+ pub reason_codes: Vec<String>,
70
+ pub risk_codes: Vec<String>,
71
+ pub required_context: Vec<String>,
72
+ pub would_mutate: bool,
73
+ pub user_message: String,
74
+ pub human_options: Vec<String>,
75
+ pub internal_notes: Vec<String>,
76
+ pub intent: IntentDecision,
77
+ }
78
+
79
+ pub fn evaluate_route(
80
+ root: &Path,
81
+ prompt: &str,
82
+ options: RouteOptions,
83
+ ) -> Result<RouteDecision, NaomeError> {
84
+ let initial_decision = evaluate_decision(root, options.evaluation)?;
85
+ let intent = evaluate_intent(root, prompt, options.evaluation)?;
86
+ let repo_state_before = intent.repo_state.clone();
87
+ let mut mutation_performed = false;
88
+ let mut executed_actions = Vec::new();
89
+ let mut journal_entry = None;
90
+ let mut user_message = intent.user_message.clone();
91
+ let mut task_root = root.to_path_buf();
92
+ let mut worktree = None;
93
+ let mut route_allowed = intent.allowed;
94
+ let mut human_options = intent.human_options.clone();
95
+
96
+ if options.execute {
97
+ match intent.policy_action.as_str() {
98
+ "auto_commit_completed_task_then_create_new_task" => {
99
+ let before = git_head(root)?;
100
+ git_add_completed_task_paths(root)?;
101
+ git_commit(root, "chore(naome): baseline completed task")?;
102
+ let after = git_head(root)?;
103
+ journal_entry =
104
+ append_task_journal(root, "route_auto_baseline", before, after.clone())?;
105
+ mutation_performed = true;
106
+ executed_actions.push("commit_task_baseline".to_string());
107
+ user_message =
108
+ "NAOME baselined the completed task and is ready to create the next task."
109
+ .to_string();
110
+ }
111
+ "auto_commit_completed_task_then_create_isolated_task_worktree" => {
112
+ let worktree_name_head = task_worktree_name_head(root)?;
113
+ preflight_isolated_task_worktree(root, prompt, &worktree_name_head)?;
114
+ let before = Some(worktree_name_head.clone());
115
+ git_add_completed_task_paths(root)?;
116
+ git_commit(root, "chore(naome): baseline completed task")?;
117
+ let after = git_head(root)?;
118
+ journal_entry =
119
+ append_task_journal(root, "route_auto_baseline", before, after.clone())?;
120
+ let created = create_isolated_task_worktree_with_name_head(
121
+ root,
122
+ prompt,
123
+ &worktree_name_head,
124
+ )?;
125
+ task_root = PathBuf::from(&created.path);
126
+ mutation_performed = true;
127
+ executed_actions.push("commit_task_baseline".to_string());
128
+ executed_actions.push("create_task_worktree".to_string());
129
+ user_message = format!(
130
+ "NAOME baselined the completed task and created an isolated task worktree at {}. Continue the new task there; unrelated user edits remain untouched in the original worktree.",
131
+ created.path
132
+ );
133
+ worktree = Some(created);
134
+ }
135
+ "auto_commit_harness_refresh_then_completed_task_then_create_new_task" => {
136
+ let Some(split) = completed_task_harness_refresh_diff(root)? else {
137
+ return Err(NaomeError::new(
138
+ "Unable to split harness refresh paths from completed task diff.",
139
+ ));
140
+ };
141
+ git_stage_only_paths(root, &split.harness_paths)?;
142
+ git_commit(root, "chore(naome): baseline harness refresh")?;
143
+ executed_actions.push("commit_harness_refresh_baseline".to_string());
144
+
145
+ let before = git_head(root)?;
146
+ git_add_completed_task_paths(root)?;
147
+ git_commit(root, "chore(naome): baseline completed task")?;
148
+ let after = git_head(root)?;
149
+ journal_entry =
150
+ append_task_journal(root, "route_auto_baseline", before, after.clone())?;
151
+ mutation_performed = true;
152
+ executed_actions.push("commit_task_baseline".to_string());
153
+ user_message = "NAOME baselined the harness refresh and completed task, then admitted the next task.".to_string();
154
+ }
155
+ "auto_commit_harness_refresh_then_create_isolated_task_worktree" => {
156
+ let worktree_name_head = task_worktree_name_head(root)?;
157
+ preflight_isolated_task_worktree(root, prompt, &worktree_name_head)?;
158
+ let Some(split) = harness_refresh_with_unrelated_diff(root)? else {
159
+ return Err(NaomeError::new(
160
+ "Unable to split harness refresh paths from unrelated dirty paths.",
161
+ ));
162
+ };
163
+ git_stage_only_paths(root, &split.harness_paths)?;
164
+ git_commit(root, "chore(naome): baseline harness refresh")?;
165
+ let created = create_isolated_task_worktree_with_name_head(
166
+ root,
167
+ prompt,
168
+ &worktree_name_head,
169
+ )?;
170
+ task_root = PathBuf::from(&created.path);
171
+ mutation_performed = true;
172
+ executed_actions.push("commit_harness_refresh_baseline".to_string());
173
+ executed_actions.push("create_task_worktree".to_string());
174
+ user_message = format!(
175
+ "NAOME baselined the harness refresh and created an isolated task worktree at {}. Continue the new task there; unrelated user edits remain untouched in the original worktree.",
176
+ created.path
177
+ );
178
+ worktree = Some(created);
179
+ }
180
+ "auto_commit_harness_refresh_then_create_new_task" => {
181
+ let Some(diff) = harness_refresh_diff(root)? else {
182
+ return Err(NaomeError::new(
183
+ "Unable to find deterministic harness refresh paths.",
184
+ ));
185
+ };
186
+ if !diff.unrelated_paths.is_empty() {
187
+ return Err(NaomeError::new(
188
+ "Harness refresh baseline expected no unrelated dirty paths.",
189
+ ));
190
+ }
191
+ git_stage_only_paths(root, &diff.harness_paths)?;
192
+ git_commit(root, "chore(naome): baseline harness refresh")?;
193
+ mutation_performed = true;
194
+ executed_actions.push("commit_harness_refresh_baseline".to_string());
195
+ user_message = "NAOME baselined the harness refresh and is ready to create the next task in the same worktree.".to_string();
196
+ }
197
+ "auto_commit_harness_refresh_baseline" => {
198
+ let Some(split) = harness_refresh_diff(root)? else {
199
+ return Err(NaomeError::new(
200
+ "Unable to find deterministic harness refresh paths.",
201
+ ));
202
+ };
203
+ git_stage_only_paths(root, &split.harness_paths)?;
204
+ git_commit(root, "chore(naome): baseline harness refresh")?;
205
+ mutation_performed = true;
206
+ executed_actions.push("commit_harness_refresh_baseline".to_string());
207
+ user_message = "NAOME baselined the deterministic harness refresh and left unrelated user edits untouched.".to_string();
208
+ }
209
+ "auto_commit_upgrade_baseline_then_create_new_task" => {
210
+ git_add_all(root)?;
211
+ git_commit(root, "chore(naome): baseline setup")?;
212
+ mutation_performed = true;
213
+ executed_actions.push("commit_upgrade_baseline".to_string());
214
+ user_message =
215
+ "NAOME baselined the setup changes and is ready to create the next task."
216
+ .to_string();
217
+ }
218
+ "commit_task_baseline" => {
219
+ let before = git_head(root)?;
220
+ git_add_completed_task_paths(root)?;
221
+ git_commit(root, "chore(naome): baseline completed task")?;
222
+ let after = git_head(root)?;
223
+ journal_entry =
224
+ append_task_journal(root, "naome_commit_baseline", before, after.clone())?;
225
+ mutation_performed = true;
226
+ executed_actions.push("commit_task_baseline".to_string());
227
+ user_message =
228
+ "NAOME baselined the completed task and is ready for the next request."
229
+ .to_string();
230
+ }
231
+ "commit_upgrade_baseline" => {
232
+ git_add_all(root)?;
233
+ git_commit(root, "chore(naome): baseline setup")?;
234
+ mutation_performed = true;
235
+ executed_actions.push("commit_upgrade_baseline".to_string());
236
+ }
237
+ "create_isolated_task_worktree" => {
238
+ let created = create_isolated_task_worktree(root, prompt)?;
239
+ task_root = PathBuf::from(&created.path);
240
+ mutation_performed = true;
241
+ executed_actions.push("create_task_worktree".to_string());
242
+ user_message = format!(
243
+ "NAOME created an isolated task worktree at {}. Continue the new task there; unrelated user edits remain untouched in the original worktree.",
244
+ created.path
245
+ );
246
+ worktree = Some(created);
247
+ }
248
+ "commit_user_diff_with_quality_gate" => {
249
+ let paths = git::changed_paths(root)?;
250
+ executed_actions.push("run_user_diff_quality_gate".to_string());
251
+ match run_user_diff_quality_gate(root, &paths) {
252
+ Ok(check_ids) => {
253
+ git_stage_only_paths(root, &paths)?;
254
+ git_commit(root, "chore(user): baseline verified user changes")?;
255
+ mutation_performed = true;
256
+ executed_actions.push("commit_user_diff".to_string());
257
+ user_message = format!(
258
+ "NAOME quality gates passed ({}) and committed only the current user-owned changed paths.",
259
+ check_ids.join(", ")
260
+ );
261
+ }
262
+ Err(error) => {
263
+ route_allowed = false;
264
+ human_options = vec!["review_unowned_diff".to_string()];
265
+ user_message = format!(
266
+ "NAOME user-diff quality gate failed, so no commit was created. {error}"
267
+ );
268
+ }
269
+ }
270
+ }
271
+ "create_new_task" | "create_new_task_without_auto_baseline"
272
+ if repo_state_before == "ready_for_task"
273
+ && initial_decision
274
+ .task
275
+ .as_ref()
276
+ .map(|task| task.status.clone())
277
+ .as_deref()
278
+ .is_some_and(|status| status == "complete") =>
279
+ {
280
+ journal_entry =
281
+ append_task_journal(root, "external_baseline", None, git_head(root)?)?;
282
+ if journal_entry.is_some() {
283
+ mutation_performed = true;
284
+ executed_actions.push("journal_external_task_baseline".to_string());
285
+ }
286
+ }
287
+ _ => {}
288
+ }
289
+ }
290
+
291
+ let next_decision = evaluate_decision(&task_root, options.evaluation)?;
292
+ let can_create_task = can_create_task(&intent, &next_decision, options.execute);
293
+ let required_context = required_context_for_route(&intent, &next_decision);
294
+ let mut internal_notes = intent.internal_notes.clone();
295
+ internal_notes.push(format!("route_execute:{}", options.execute));
296
+ if mutation_performed {
297
+ internal_notes.push("route_mutation_performed:true".to_string());
298
+ }
299
+ if worktree.is_some() {
300
+ internal_notes.push("route_task_root:isolation_worktree".to_string());
301
+ }
302
+
303
+ Ok(RouteDecision {
304
+ schema: "naome.route.v1".to_string(),
305
+ execute: options.execute,
306
+ repo_state_before,
307
+ prompt_intent: intent.prompt_intent.clone(),
308
+ policy_action: intent.policy_action.clone(),
309
+ allowed: route_allowed,
310
+ mutation_performed,
311
+ executed_actions,
312
+ can_create_task,
313
+ user_message,
314
+ human_options,
315
+ internal_notes,
316
+ required_context,
317
+ task_root: task_root.to_string_lossy().to_string(),
318
+ worktree,
319
+ journal_entry,
320
+ next_decision,
321
+ intent,
322
+ })
323
+ }
324
+
325
+ pub fn explain_route(
326
+ root: &Path,
327
+ prompt: &str,
328
+ options: EvaluationOptions,
329
+ ) -> Result<ExplainDecision, NaomeError> {
330
+ let intent = evaluate_intent(root, prompt, options)?;
331
+ let required_context = required_context_for_intent(&intent);
332
+ Ok(ExplainDecision {
333
+ schema: "naome.explain.v1".to_string(),
334
+ repo_state: intent.repo_state.clone(),
335
+ prompt_intent: intent.prompt_intent.clone(),
336
+ winning_rule: winning_rule(&intent),
337
+ discarded_candidate_actions: discarded_actions(&intent),
338
+ reason_codes: intent.reason_codes.clone(),
339
+ risk_codes: intent.risk_codes.clone(),
340
+ required_context,
341
+ would_mutate: would_mutate(&intent),
342
+ user_message: intent.user_message.clone(),
343
+ human_options: intent.human_options.clone(),
344
+ internal_notes: intent.internal_notes.clone(),
345
+ intent,
346
+ })
347
+ }
348
+
349
+ fn can_create_task(intent: &IntentDecision, decision: &Decision, execute: bool) -> bool {
350
+ let creation_intent = matches!(
351
+ intent.policy_action.as_str(),
352
+ "create_new_task"
353
+ | "create_new_task_without_auto_baseline"
354
+ | "auto_commit_completed_task_then_create_new_task"
355
+ | "auto_commit_completed_task_then_create_isolated_task_worktree"
356
+ | "auto_commit_harness_refresh_then_completed_task_then_create_new_task"
357
+ | "auto_commit_harness_refresh_then_create_new_task"
358
+ | "auto_commit_harness_refresh_then_create_isolated_task_worktree"
359
+ | "auto_commit_upgrade_baseline_then_create_new_task"
360
+ | "create_isolated_task_worktree"
361
+ );
362
+ if !creation_intent || decision.state != "ready_for_task" || decision.blocked {
363
+ return false;
364
+ }
365
+
366
+ execute
367
+ || matches!(
368
+ intent.policy_action.as_str(),
369
+ "create_new_task" | "create_new_task_without_auto_baseline"
370
+ )
371
+ }
372
+
373
+ fn winning_rule(intent: &IntentDecision) -> String {
374
+ match intent.policy_action.as_str() {
375
+ "block_unsafe_intent" => "unsafe_intent_precedence",
376
+ "block_auto_baseline_due_to_no_commit" => "no_commit_blocks_auto_baseline",
377
+ "continue_current_task_without_commit" => "no_commit_continues_active_task",
378
+ "review_task_diff" | "review_diff_first" | "review_current_task_diff" => {
379
+ "explicit_review_overrides_auto_baseline"
380
+ }
381
+ "cancel_task_changes" | "cancel_upgrade_baseline" | "cancel_current_task" => {
382
+ "explicit_cancel_overrides_auto_baseline"
383
+ }
384
+ "auto_commit_completed_task_then_create_new_task" => {
385
+ "completed_task_valid_new_task_auto_baseline"
386
+ }
387
+ "auto_commit_completed_task_then_create_isolated_task_worktree" => {
388
+ "completed_task_valid_with_unrelated_dirty_worktree"
389
+ }
390
+ "auto_commit_harness_refresh_then_completed_task_then_create_new_task" => {
391
+ "completed_task_harness_refresh_split_auto_baseline"
392
+ }
393
+ "auto_commit_harness_refresh_then_create_new_task" => {
394
+ "pure_harness_refresh_new_task_auto_baseline"
395
+ }
396
+ "auto_commit_harness_refresh_then_create_isolated_task_worktree" => {
397
+ "dirty_repo_harness_refresh_worktree_isolation"
398
+ }
399
+ "auto_commit_harness_refresh_baseline" => "dirty_repo_harness_refresh_repair_baseline",
400
+ "auto_commit_upgrade_baseline_then_create_new_task" => "setup_diff_new_task_auto_baseline",
401
+ "reopen_completed_task_revision" | "continue_current_task" => {
402
+ "current_task_revision_continues_task"
403
+ }
404
+ "answer_status_only" => "status_request_read_only",
405
+ "create_new_task" | "create_new_task_without_auto_baseline" => "ready_repo_new_task",
406
+ "create_isolated_task_worktree" => "dirty_repo_new_task_worktree_isolation",
407
+ "commit_user_diff_with_quality_gate" => "explicit_user_diff_commit_quality_gate",
408
+ _ => "fallback_policy",
409
+ }
410
+ .to_string()
411
+ }
412
+
413
+ fn discarded_actions(intent: &IntentDecision) -> Vec<String> {
414
+ let Some(actions_note) = intent
415
+ .internal_notes
416
+ .iter()
417
+ .find(|note| note.starts_with("internal_allowed_actions:"))
418
+ else {
419
+ return Vec::new();
420
+ };
421
+ let selected = selected_internal_action(&intent.policy_action);
422
+ actions_note
423
+ .trim_start_matches("internal_allowed_actions:")
424
+ .split(',')
425
+ .filter(|action| !action.is_empty() && Some(*action) != selected.as_deref())
426
+ .map(ToString::to_string)
427
+ .collect()
428
+ }
429
+
430
+ fn selected_internal_action(policy_action: &str) -> Option<String> {
431
+ match policy_action {
432
+ "auto_commit_completed_task_then_create_new_task"
433
+ | "auto_commit_completed_task_then_create_isolated_task_worktree"
434
+ | "commit_task_baseline" => Some("commit_task_baseline".to_string()),
435
+ "auto_commit_harness_refresh_then_create_new_task"
436
+ | "auto_commit_harness_refresh_then_create_isolated_task_worktree"
437
+ | "auto_commit_harness_refresh_baseline" => Some("commit_upgrade_baseline".to_string()),
438
+ "auto_commit_harness_refresh_then_completed_task_then_create_new_task" => {
439
+ Some("commit_task_baseline".to_string())
440
+ }
441
+ "auto_commit_upgrade_baseline_then_create_new_task" | "commit_upgrade_baseline" => {
442
+ Some("commit_upgrade_baseline".to_string())
443
+ }
444
+ other if other.starts_with("review_") || other.starts_with("cancel_") => {
445
+ Some(other.to_string())
446
+ }
447
+ _ => None,
448
+ }
449
+ }
450
+
451
+ fn would_mutate(intent: &IntentDecision) -> bool {
452
+ matches!(
453
+ intent.policy_action.as_str(),
454
+ "auto_commit_completed_task_then_create_new_task"
455
+ | "auto_commit_completed_task_then_create_isolated_task_worktree"
456
+ | "auto_commit_upgrade_baseline_then_create_new_task"
457
+ | "auto_commit_harness_refresh_then_completed_task_then_create_new_task"
458
+ | "auto_commit_harness_refresh_then_create_new_task"
459
+ | "auto_commit_harness_refresh_then_create_isolated_task_worktree"
460
+ | "auto_commit_harness_refresh_baseline"
461
+ | "create_isolated_task_worktree"
462
+ | "commit_task_baseline"
463
+ | "commit_upgrade_baseline"
464
+ | "commit_user_diff_with_quality_gate"
465
+ )
466
+ }
467
+
468
+ fn required_context_for_route(intent: &IntentDecision, decision: &Decision) -> Vec<String> {
469
+ let mut context = required_context_for_intent(intent);
470
+ if decision.state == "ready_for_task" {
471
+ push_unique(&mut context, "docs/naome/agent-workflow.md");
472
+ push_unique(&mut context, "docs/naome/testing.md");
473
+ push_unique(&mut context, ".naome/verification.json");
474
+ }
475
+ context
476
+ }
477
+
478
+ fn required_context_for_intent(intent: &IntentDecision) -> Vec<String> {
479
+ let mut context = intent.required_context.clone();
480
+ match intent.policy_action.as_str() {
481
+ "create_new_task" | "create_new_task_without_auto_baseline" => {
482
+ push_unique(&mut context, "docs/naome/agent-workflow.md");
483
+ push_unique(&mut context, "docs/naome/testing.md");
484
+ push_unique(&mut context, ".naome/verification.json");
485
+ push_unique(&mut context, "docs/naome/architecture.md");
486
+ }
487
+ "create_isolated_task_worktree" => {
488
+ push_unique(&mut context, "docs/naome/agent-workflow.md");
489
+ push_unique(&mut context, "docs/naome/testing.md");
490
+ push_unique(&mut context, ".naome/verification.json");
491
+ push_unique(&mut context, "docs/naome/architecture.md");
492
+ }
493
+ "auto_commit_completed_task_then_create_new_task"
494
+ | "auto_commit_completed_task_then_create_isolated_task_worktree"
495
+ | "auto_commit_harness_refresh_then_create_new_task"
496
+ | "auto_commit_harness_refresh_then_create_isolated_task_worktree"
497
+ | "auto_commit_harness_refresh_baseline"
498
+ | "auto_commit_harness_refresh_then_completed_task_then_create_new_task"
499
+ | "reopen_completed_task_revision"
500
+ | "review_task_diff"
501
+ | "cancel_task_changes"
502
+ | "block_auto_baseline_due_to_no_commit" => {
503
+ push_unique(&mut context, "docs/naome/execution.md");
504
+ push_unique(&mut context, ".naome/task-state.json");
505
+ }
506
+ "answer_status_only" => {
507
+ push_unique(&mut context, "docs/naome/index.md");
508
+ }
509
+ "repair_harness_only" => {
510
+ push_unique(&mut context, ".naome/manifest.json");
511
+ push_unique(&mut context, "docs/naome/index.md");
512
+ }
513
+ _ => {}
514
+ }
515
+ context
516
+ }
517
+
518
+ fn push_unique(context: &mut Vec<String>, value: &str) {
519
+ if !context.iter().any(|entry| entry == value) {
520
+ context.push(value.to_string());
521
+ }
522
+ }
523
+
524
+ fn git_head(root: &Path) -> Result<Option<String>, NaomeError> {
525
+ let output = Command::new("git")
526
+ .args(["rev-parse", "HEAD"])
527
+ .current_dir(root)
528
+ .output()?;
529
+ if !output.status.success() {
530
+ return Ok(None);
531
+ }
532
+ Ok(Some(
533
+ String::from_utf8_lossy(&output.stdout).trim().to_string(),
534
+ ))
535
+ }
536
+
537
+ fn git_add_all(root: &Path) -> Result<(), NaomeError> {
538
+ let output = Command::new("git")
539
+ .args(["add", "-A"])
540
+ .current_dir(root)
541
+ .output()?;
542
+ if output.status.success() {
543
+ Ok(())
544
+ } else {
545
+ Err(NaomeError::new(command_output(&output)))
546
+ }
547
+ }
548
+
549
+ fn git_add_completed_task_paths(root: &Path) -> Result<(), NaomeError> {
550
+ let paths = completed_task_commit_paths(root)?;
551
+ if paths.is_empty() {
552
+ return Err(NaomeError::new(
553
+ "No task-owned paths are available to commit.",
554
+ ));
555
+ }
556
+ git_stage_only_paths(root, &paths)
557
+ }
558
+
559
+ fn git_stage_only_paths(root: &Path, paths: &[String]) -> Result<(), NaomeError> {
560
+ let output = Command::new("git")
561
+ .args(["reset", "-q", "--", "."])
562
+ .current_dir(root)
563
+ .output()?;
564
+ if !output.status.success() {
565
+ return Err(NaomeError::new(command_output(&output)));
566
+ }
567
+ git_add_paths(root, paths)
568
+ }
569
+
570
+ fn git_add_paths(root: &Path, paths: &[String]) -> Result<(), NaomeError> {
571
+ if paths.is_empty() {
572
+ return Ok(());
573
+ }
574
+ let mut args = vec!["add", "--"];
575
+ args.extend(paths.iter().map(String::as_str));
576
+ let output = Command::new("git").args(args).current_dir(root).output()?;
577
+ if output.status.success() {
578
+ Ok(())
579
+ } else {
580
+ Err(NaomeError::new(command_output(&output)))
581
+ }
582
+ }
583
+
584
+ fn run_user_diff_quality_gate(
585
+ root: &Path,
586
+ changed_paths: &[String],
587
+ ) -> Result<Vec<String>, NaomeError> {
588
+ if changed_paths.is_empty() {
589
+ return Err(NaomeError::new("No changed paths are available to commit."));
590
+ }
591
+ let verification = read_head_verification(root)?;
592
+ let checks = verification_checks(&verification);
593
+ let check_ids = user_diff_check_ids(&verification, changed_paths, &checks)?;
594
+
595
+ let initial_paths = sorted_path_set(changed_paths);
596
+ let mut previous_snapshot = changed_path_snapshot(root, changed_paths)?;
597
+ for _ in 0..3 {
598
+ validate_changed_text_whitespace(root, changed_paths)?;
599
+
600
+ for check_id in &check_ids {
601
+ let Some(check) = checks.get(check_id.as_str()) else {
602
+ return Err(NaomeError::new(format!(
603
+ "Quality check {check_id} is referenced but not defined."
604
+ )));
605
+ };
606
+ run_quality_check(root, check)?;
607
+ }
608
+
609
+ let current_paths = git::changed_paths(root)?;
610
+ let current_set = sorted_path_set(&current_paths);
611
+ if current_set != initial_paths {
612
+ return Err(NaomeError::new(format!(
613
+ "Quality checks changed the diff path set from [{}] to [{}].",
614
+ initial_paths.iter().cloned().collect::<Vec<_>>().join(", "),
615
+ current_set.iter().cloned().collect::<Vec<_>>().join(", ")
616
+ )));
617
+ }
618
+
619
+ validate_changed_text_whitespace(root, changed_paths)?;
620
+ if let Some(check) = checks.get("diff-check") {
621
+ run_quality_check(root, check)?;
622
+ }
623
+ let current_paths = git::changed_paths(root)?;
624
+ let current_set = sorted_path_set(&current_paths);
625
+ if current_set != initial_paths {
626
+ return Err(NaomeError::new(format!(
627
+ "Quality checks changed the diff path set from [{}] to [{}].",
628
+ initial_paths.iter().cloned().collect::<Vec<_>>().join(", "),
629
+ current_set.iter().cloned().collect::<Vec<_>>().join(", ")
630
+ )));
631
+ }
632
+
633
+ let next_snapshot = changed_path_snapshot(root, changed_paths)?;
634
+ if next_snapshot == previous_snapshot {
635
+ return Ok(check_ids);
636
+ }
637
+ previous_snapshot = next_snapshot;
638
+ }
639
+
640
+ Err(NaomeError::new(
641
+ "Quality checks did not stabilize the user-owned diff after three runs.",
642
+ ))
643
+ }
644
+
645
+ fn validate_changed_text_whitespace(
646
+ root: &Path,
647
+ changed_paths: &[String],
648
+ ) -> Result<(), NaomeError> {
649
+ for relative_path in changed_paths {
650
+ let path = root.join(relative_path);
651
+ if !path.is_file() {
652
+ continue;
653
+ }
654
+ let Ok(content) = fs::read_to_string(&path) else {
655
+ continue;
656
+ };
657
+ for (index, line) in content.lines().enumerate() {
658
+ if line.ends_with(' ') || line.ends_with('\t') {
659
+ return Err(NaomeError::new(format!(
660
+ "{relative_path}:{} has trailing whitespace.",
661
+ index + 1
662
+ )));
663
+ }
664
+ }
665
+ }
666
+ Ok(())
667
+ }
668
+
669
+ fn read_head_verification(root: &Path) -> Result<Value, NaomeError> {
670
+ let output = Command::new("git")
671
+ .args(["show", "HEAD:.naome/verification.json"])
672
+ .current_dir(root)
673
+ .output()?;
674
+
675
+ if !output.status.success() {
676
+ return Err(NaomeError::new(
677
+ "No committed NAOME verification profile is available for user-diff quality gating.",
678
+ ));
679
+ }
680
+
681
+ Ok(serde_json::from_slice(&output.stdout)?)
682
+ }
683
+
684
+ #[derive(Debug, Clone)]
685
+ struct QualityCheck {
686
+ command: String,
687
+ cwd: String,
688
+ }
689
+
690
+ fn verification_checks(verification: &Value) -> HashMap<&str, QualityCheck> {
691
+ let mut checks = HashMap::new();
692
+ let Some(items) = verification.get("checks").and_then(Value::as_array) else {
693
+ return checks;
694
+ };
695
+
696
+ for item in items {
697
+ let Some(id) = item.get("id").and_then(Value::as_str) else {
698
+ continue;
699
+ };
700
+ let Some(command) = item.get("command").and_then(Value::as_str) else {
701
+ continue;
702
+ };
703
+ let cwd = item
704
+ .get("cwd")
705
+ .and_then(Value::as_str)
706
+ .unwrap_or(".")
707
+ .to_string();
708
+ checks.insert(
709
+ id,
710
+ QualityCheck {
711
+ command: command.to_string(),
712
+ cwd,
713
+ },
714
+ );
715
+ }
716
+
717
+ checks
718
+ }
719
+
720
+ fn user_diff_check_ids(
721
+ verification: &Value,
722
+ changed_paths: &[String],
723
+ checks: &HashMap<&str, QualityCheck>,
724
+ ) -> Result<Vec<String>, NaomeError> {
725
+ let mut ids = Vec::new();
726
+ if checks.contains_key("diff-check") {
727
+ push_unique_string(&mut ids, "diff-check");
728
+ }
729
+ if checks.contains_key("naome-harness-health") {
730
+ push_unique_string(&mut ids, "naome-harness-health");
731
+ }
732
+
733
+ let Some(change_types) = verification.get("changeTypes").and_then(Value::as_array) else {
734
+ return Err(NaomeError::new(
735
+ "No quality coverage is configured for user-owned changed paths.",
736
+ ));
737
+ };
738
+
739
+ if change_types.is_empty() {
740
+ return Err(NaomeError::new(
741
+ "No quality coverage is configured for user-owned changed paths.",
742
+ ));
743
+ }
744
+
745
+ let mut uncovered_paths = Vec::new();
746
+
747
+ for change_type in change_types {
748
+ let patterns = string_array(change_type.get("paths"));
749
+ if patterns.is_empty()
750
+ || !changed_paths
751
+ .iter()
752
+ .any(|path| paths::matches_any(path, &patterns))
753
+ {
754
+ continue;
755
+ }
756
+
757
+ for check_id in string_array(change_type.get("requiredChecks")) {
758
+ push_unique_string(&mut ids, &check_id);
759
+ }
760
+ }
761
+
762
+ for path in changed_paths {
763
+ let covered = change_types.iter().any(|change_type| {
764
+ let patterns = string_array(change_type.get("paths"));
765
+ !patterns.is_empty() && paths::matches_any(path, &patterns)
766
+ });
767
+ if !covered {
768
+ uncovered_paths.push(path.clone());
769
+ }
770
+ }
771
+
772
+ if !uncovered_paths.is_empty() {
773
+ return Err(NaomeError::new(format!(
774
+ "No quality coverage is configured for changed path(s): {}.",
775
+ uncovered_paths.join(", ")
776
+ )));
777
+ }
778
+
779
+ if ids.is_empty() {
780
+ return Err(NaomeError::new(
781
+ "No quality checks are configured for these changed paths.",
782
+ ));
783
+ }
784
+
785
+ Ok(ids)
786
+ }
787
+
788
+ fn sorted_path_set(paths: &[String]) -> BTreeSet<String> {
789
+ paths.iter().cloned().collect()
790
+ }
791
+
792
+ fn changed_path_snapshot(
793
+ root: &Path,
794
+ changed_paths: &[String],
795
+ ) -> Result<Vec<(String, Option<Vec<u8>>)>, NaomeError> {
796
+ let mut snapshot = Vec::new();
797
+ for relative_path in changed_paths {
798
+ let path = root.join(relative_path);
799
+ let content = if path.is_file() {
800
+ Some(fs::read(&path)?)
801
+ } else {
802
+ None
803
+ };
804
+ snapshot.push((relative_path.clone(), content));
805
+ }
806
+ snapshot.sort_by(|left, right| left.0.cmp(&right.0));
807
+ Ok(snapshot)
808
+ }
809
+
810
+ fn string_array(value: Option<&Value>) -> Vec<String> {
811
+ value
812
+ .and_then(Value::as_array)
813
+ .map(|items| {
814
+ items
815
+ .iter()
816
+ .filter_map(Value::as_str)
817
+ .map(ToString::to_string)
818
+ .collect()
819
+ })
820
+ .unwrap_or_default()
821
+ }
822
+
823
+ fn push_unique_string(values: &mut Vec<String>, value: &str) {
824
+ if !values.iter().any(|item| item == value) {
825
+ values.push(value.to_string());
826
+ }
827
+ }
828
+
829
+ fn run_quality_check(root: &Path, check: &QualityCheck) -> Result<(), NaomeError> {
830
+ let cwd = root.join(&check.cwd);
831
+ let output = if cfg!(windows) {
832
+ Command::new("cmd")
833
+ .args(["/C", &check.command])
834
+ .current_dir(cwd)
835
+ .output()?
836
+ } else {
837
+ Command::new("sh")
838
+ .args(["-c", &check.command])
839
+ .current_dir(cwd)
840
+ .output()?
841
+ };
842
+
843
+ if output.status.success() {
844
+ Ok(())
845
+ } else {
846
+ Err(NaomeError::new(command_output(&output)))
847
+ }
848
+ }
849
+
850
+ fn git_commit(root: &Path, message: &str) -> Result<(), NaomeError> {
851
+ let output = Command::new("git")
852
+ .args(["commit", "-m", message])
853
+ .current_dir(root)
854
+ .output()?;
855
+ if output.status.success() {
856
+ Ok(())
857
+ } else {
858
+ Err(NaomeError::new(command_output(&output)))
859
+ }
860
+ }
861
+
862
+ fn create_isolated_task_worktree(root: &Path, prompt: &str) -> Result<RouteWorktree, NaomeError> {
863
+ let name_head = task_worktree_name_head(root)?;
864
+ create_isolated_task_worktree_with_name_head(root, prompt, &name_head)
865
+ }
866
+
867
+ fn create_isolated_task_worktree_with_name_head(
868
+ root: &Path,
869
+ prompt: &str,
870
+ name_head: &str,
871
+ ) -> Result<RouteWorktree, NaomeError> {
872
+ let base_head = git_head(root)?.ok_or_else(|| {
873
+ NaomeError::new("Cannot create task worktree because the repository has no HEAD.")
874
+ })?;
875
+ let short_head = name_head.chars().take(12).collect::<String>();
876
+ let slug = prompt_slug(prompt);
877
+ let common_git_dir = git_common_dir(root)?;
878
+ let worktree_base = common_git_dir.join("naome").join("worktrees");
879
+ fs::create_dir_all(&worktree_base)?;
880
+ let worktree_count = existing_naome_task_worktree_count(&worktree_base)?;
881
+ if worktree_count >= MAX_NAOME_TASK_WORKTREES {
882
+ return Err(NaomeError::new(format!(
883
+ "Too many NAOME task worktrees are present ({worktree_count}). Finish or remove old task worktrees before creating another isolated task worktree."
884
+ )));
885
+ }
886
+
887
+ for attempt in 1..100 {
888
+ let suffix = if attempt == 1 {
889
+ String::new()
890
+ } else {
891
+ format!("-{attempt}")
892
+ };
893
+ let name = format!("{slug}-{short_head}{suffix}");
894
+ let branch = format!("naome/task/{name}");
895
+ let path = worktree_base.join(&name);
896
+ if path.exists() || git_branch_exists(root, &branch)? {
897
+ continue;
898
+ }
899
+
900
+ let path_text = path.to_string_lossy().to_string();
901
+ let output = Command::new("git")
902
+ .args(["worktree", "add", "-b", &branch, &path_text, "HEAD"])
903
+ .current_dir(root)
904
+ .output()?;
905
+ if !output.status.success() {
906
+ return Err(NaomeError::new(command_output(&output)));
907
+ }
908
+
909
+ copy_local_harness_files(root, &path)?;
910
+
911
+ return Ok(RouteWorktree {
912
+ path: path_text,
913
+ branch,
914
+ base_head,
915
+ source_root: root.to_string_lossy().to_string(),
916
+ });
917
+ }
918
+
919
+ Err(NaomeError::new(
920
+ "Cannot create a unique NAOME task worktree after 99 attempts.",
921
+ ))
922
+ }
923
+
924
+ fn preflight_isolated_task_worktree(
925
+ root: &Path,
926
+ prompt: &str,
927
+ name_head: &str,
928
+ ) -> Result<(), NaomeError> {
929
+ let short_head = name_head.chars().take(12).collect::<String>();
930
+ let slug = prompt_slug(prompt);
931
+ let common_git_dir = git_common_dir(root)?;
932
+ let worktree_base = common_git_dir.join("naome").join("worktrees");
933
+ fs::create_dir_all(&worktree_base)?;
934
+ let worktree_count = existing_naome_task_worktree_count(&worktree_base)?;
935
+ if worktree_count >= MAX_NAOME_TASK_WORKTREES {
936
+ return Err(NaomeError::new(format!(
937
+ "Too many NAOME task worktrees are present ({worktree_count}). Finish or remove old task worktrees before creating another isolated task worktree."
938
+ )));
939
+ }
940
+
941
+ for attempt in 1..100 {
942
+ let suffix = if attempt == 1 {
943
+ String::new()
944
+ } else {
945
+ format!("-{attempt}")
946
+ };
947
+ let name = format!("{slug}-{short_head}{suffix}");
948
+ let branch = format!("naome/task/{name}");
949
+ let path = worktree_base.join(&name);
950
+ if !path.exists() && !git_branch_exists(root, &branch)? {
951
+ return Ok(());
952
+ }
953
+ }
954
+
955
+ Err(NaomeError::new(
956
+ "Cannot create a unique NAOME task worktree after 99 attempts.",
957
+ ))
958
+ }
959
+
960
+ fn task_worktree_name_head(root: &Path) -> Result<String, NaomeError> {
961
+ git_head(root)?.ok_or_else(|| {
962
+ NaomeError::new("Cannot create task worktree because the repository has no HEAD.")
963
+ })
964
+ }
965
+
966
+ fn existing_naome_task_worktree_count(worktree_base: &Path) -> Result<usize, NaomeError> {
967
+ let mut count = 0;
968
+ for entry in fs::read_dir(worktree_base)? {
969
+ let entry = entry?;
970
+ if entry.file_type()?.is_dir() {
971
+ count += 1;
972
+ }
973
+ }
974
+ Ok(count)
975
+ }
976
+
977
+ fn git_common_dir(root: &Path) -> Result<PathBuf, NaomeError> {
978
+ let output = Command::new("git")
979
+ .args(["rev-parse", "--git-common-dir"])
980
+ .current_dir(root)
981
+ .output()?;
982
+ if !output.status.success() {
983
+ return Err(NaomeError::new(command_output(&output)));
984
+ }
985
+
986
+ let value = String::from_utf8_lossy(&output.stdout).trim().to_string();
987
+ let path = PathBuf::from(value);
988
+ if path.is_absolute() {
989
+ Ok(path)
990
+ } else {
991
+ Ok(root.join(path))
992
+ }
993
+ }
994
+
995
+ fn git_branch_exists(root: &Path, branch: &str) -> Result<bool, NaomeError> {
996
+ let ref_name = format!("refs/heads/{branch}");
997
+ let output = Command::new("git")
998
+ .args(["show-ref", "--verify", "--quiet", &ref_name])
999
+ .current_dir(root)
1000
+ .output()?;
1001
+ match output.status.code() {
1002
+ Some(0) => Ok(true),
1003
+ Some(1) => Ok(false),
1004
+ _ => Err(NaomeError::new(command_output(&output))),
1005
+ }
1006
+ }
1007
+
1008
+ fn copy_local_harness_files(source_root: &Path, worktree_root: &Path) -> Result<(), NaomeError> {
1009
+ let mut paths = Vec::new();
1010
+ paths.extend_from_slice(LOCAL_ONLY_MACHINE_OWNED_PATHS);
1011
+ paths.extend_from_slice(LOCAL_NATIVE_BINARY_PATHS);
1012
+
1013
+ for relative_path in paths {
1014
+ if relative_path == ".naome/archive" || relative_path == ".naome/task-journal.jsonl" {
1015
+ continue;
1016
+ }
1017
+
1018
+ let source = source_root.join(relative_path);
1019
+ if !source.is_file() {
1020
+ continue;
1021
+ }
1022
+ let destination = worktree_root.join(relative_path);
1023
+ if let Some(parent) = destination.parent() {
1024
+ fs::create_dir_all(parent)?;
1025
+ }
1026
+ fs::copy(&source, &destination)?;
1027
+ }
1028
+
1029
+ Ok(())
1030
+ }
1031
+
1032
+ fn prompt_slug(prompt: &str) -> String {
1033
+ let mut slug = String::new();
1034
+ let mut previous_dash = false;
1035
+ for character in prompt.chars().flat_map(char::to_lowercase) {
1036
+ if character.is_ascii_alphanumeric() {
1037
+ slug.push(character);
1038
+ previous_dash = false;
1039
+ } else if !previous_dash && !slug.is_empty() {
1040
+ slug.push('-');
1041
+ previous_dash = true;
1042
+ }
1043
+
1044
+ if slug.len() >= 40 {
1045
+ break;
1046
+ }
1047
+ }
1048
+
1049
+ let slug = slug.trim_matches('-').to_string();
1050
+ if slug.is_empty() {
1051
+ "task".to_string()
1052
+ } else {
1053
+ slug
1054
+ }
1055
+ }
1056
+
1057
+ fn command_output(output: &std::process::Output) -> String {
1058
+ let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
1059
+ if !stderr.is_empty() {
1060
+ return stderr;
1061
+ }
1062
+ String::from_utf8_lossy(&output.stdout).trim().to_string()
1063
+ }