@lamentis/naome 1.4.1 → 1.4.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/Cargo.lock +2 -2
  2. package/README.md +17 -122
  3. package/crates/naome-cli/Cargo.toml +1 -1
  4. package/crates/naome-cli/src/main.rs +14 -5
  5. package/crates/naome-cli/src/task_commands/agent_snapshot.rs +173 -0
  6. package/crates/naome-cli/src/task_commands/can_edit.rs +64 -0
  7. package/crates/naome-cli/src/task_commands/check_run/output.rs +34 -0
  8. package/crates/naome-cli/src/task_commands/check_run/receipts.rs +163 -0
  9. package/crates/naome-cli/src/task_commands/check_run/verification.rs +165 -0
  10. package/crates/naome-cli/src/task_commands/check_run.rs +196 -0
  11. package/crates/naome-cli/src/task_commands/commit_preflight.rs +89 -0
  12. package/crates/naome-cli/src/task_commands/common.rs +39 -1
  13. package/crates/naome-cli/src/task_commands/compact_proof.rs +69 -0
  14. package/crates/naome-cli/src/task_commands/complete.rs +43 -0
  15. package/crates/naome-cli/src/task_commands/loop_control.rs +73 -0
  16. package/crates/naome-cli/src/task_commands/path_policy.rs +57 -0
  17. package/crates/naome-cli/src/task_commands/planner/checks.rs +166 -0
  18. package/crates/naome-cli/src/task_commands/planner/impact.rs +35 -0
  19. package/crates/naome-cli/src/task_commands/planner/mod.rs +24 -0
  20. package/crates/naome-cli/src/task_commands/preflight.rs +208 -0
  21. package/crates/naome-cli/src/task_commands/readiness.rs +14 -10
  22. package/crates/naome-cli/src/task_commands/record.rs +176 -37
  23. package/crates/naome-cli/src/task_commands/repair.rs +58 -11
  24. package/crates/naome-cli/src/task_commands/scope_suggestions.rs +109 -0
  25. package/crates/naome-cli/src/task_commands.rs +26 -3
  26. package/crates/naome-cli/tests/task_cli_agent_controls.rs +9 -16
  27. package/crates/naome-cli/tests/task_cli_fast_flow.rs +290 -0
  28. package/crates/naome-cli/tests/task_cli_loop.rs +383 -0
  29. package/crates/naome-cli/tests/task_cli_loop_edit.rs +144 -0
  30. package/crates/naome-cli/tests/task_cli_support/mod.rs +28 -0
  31. package/crates/naome-core/Cargo.toml +1 -1
  32. package/crates/naome-core/src/lib.rs +7 -7
  33. package/crates/naome-core/src/task_state/evidence_fingerprint.rs +47 -0
  34. package/crates/naome-core/src/task_state/mod.rs +2 -0
  35. package/crates/naome-core/src/task_state/status/control/repair.rs +2 -2
  36. package/crates/naome-core/src/task_state/status/model.rs +2 -0
  37. package/crates/naome-core/src/task_state/status/proof.rs +59 -9
  38. package/crates/naome-core/src/task_state/status/proof_read.rs +14 -0
  39. package/crates/naome-core/src/task_state/status/report_context.rs +23 -1
  40. package/crates/naome-core/src/task_state/status/transition.rs +29 -1
  41. package/crates/naome-core/tests/task_status.rs +122 -0
  42. package/installer/context.js +1 -1
  43. package/installer/harness-verification.js +2 -6
  44. package/installer/manifest-state.js +2 -2
  45. package/installer/native.js +3 -31
  46. package/native/darwin-arm64/naome +0 -0
  47. package/native/linux-x64/naome +0 -0
  48. package/package.json +1 -1
  49. package/templates/naome-root/.naome/bin/check-harness-health.js +2 -2
  50. package/templates/naome-root/.naome/bin/check-task-state.js +4 -39
  51. package/templates/naome-root/.naome/bin/naome.js +2 -30
  52. package/templates/naome-root/.naome/manifest.json +2 -2
package/Cargo.lock CHANGED
@@ -76,7 +76,7 @@ checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
76
76
 
77
77
  [[package]]
78
78
  name = "naome-cli"
79
- version = "1.4.1"
79
+ version = "1.4.3"
80
80
  dependencies = [
81
81
  "naome-core",
82
82
  "serde_json",
@@ -84,7 +84,7 @@ dependencies = [
84
84
 
85
85
  [[package]]
86
86
  name = "naome-core"
87
- version = "1.4.1"
87
+ version = "1.4.3"
88
88
  dependencies = [
89
89
  "serde",
90
90
  "serde_json",
package/README.md CHANGED
@@ -6,146 +6,41 @@
6
6
  <h1 align="center">NAOME</h1>
7
7
 
8
8
  <p align="center">
9
- A deterministic repository harness for AI coding agents.
9
+ The repository harness for long-running Codex work.
10
10
  </p>
11
11
 
12
- ```shell
13
- npm install -g @lamentis/naome
14
- ```
12
+ NAOME wraps your repository with deterministic controls and workflows so
13
+ agentic AI systems can take on real software work: longer tasks, safer
14
+ iterations, and eventually more autonomous execution.
15
15
 
16
- NAOME gives coding agents a repository-local operating protocol: what to read,
17
- what to ignore, how to admit a task, which files are in scope, which checks are
18
- required, and when work is safe to commit.
16
+ You install the harness. Codex and NAOME handle the workflow.
19
17
 
20
- ## Quickstart
18
+ ## Start
21
19
 
22
- Install the CLI, then sync NAOME into a repository:
20
+ Install the Lamentis NAOME package:
23
21
 
24
22
  ```shell
25
23
  npm install -g @lamentis/naome
26
- cd /path/to/repo
27
- naome sync
28
- ```
29
-
30
- For an initialized repository, start with:
31
-
32
- ```shell
33
- naome status
34
- naome next
35
- naome doctor
36
24
  ```
37
25
 
38
- For agent-driven work, route the user's request through the harness:
39
-
40
- ```shell
41
- naome route --prompt-file /path/to/prompt.txt --execute --json
42
- ```
43
-
44
- Prompt files should start with a fenced `naome-prompt-envelope-v1` JSON
45
- envelope that normalizes the raw user text into canonical routing fields. A raw
46
- natural-language prompt routes to a non-mutating normalization decision instead
47
- of becoming a task by keyword inference.
48
-
49
- ## Why NAOME?
50
-
51
- - Keeps agents inside explicit task scope.
52
- - Blocks unowned diffs before new work starts.
53
- - Separates current task work from repository cleanup debt.
54
- - Runs changed-code quality gates without forcing legacy repositories to be
55
- perfect on day one.
56
- - Records verification proof before a task can be treated as complete.
57
- - Keeps sync fast by making baseline and deep quality scans explicit.
58
-
59
- ## Safety Model
60
-
61
- NAOME is repository-local. The files under `.naome/`, `.naomeignore`, and
62
- `docs/naome/` define the local harness contract for a repository.
63
-
64
- The harness enforces read boundaries, task admission, scope drift checks,
65
- repository-quality policy, verification phases, and commit gates. Existing debt
66
- is reportable through cleanup flows, while changed files are held to the active
67
- policy.
68
-
69
- ## CLI Reference
70
-
71
- Common commands:
26
+ Sync NAOME into your repository:
72
27
 
73
28
  ```shell
29
+ cd /path/to/your/repo
74
30
  naome sync
75
- naome update
76
- naome status
77
- naome next
78
- naome doctor
79
- naome route --prompt-file /path/to/prompt.txt --execute --json
80
- naome quality init
81
- naome quality init --baseline
82
- naome quality report
83
- naome quality report --deep
84
- naome quality check --changed
85
- naome semantic check --changed
86
- naome arch validate --changed-only
87
- naome task render-state --write --json
88
- naome commit -m "type(scope): summary"
89
31
  ```
90
32
 
91
- Architecture fitness builds a language-agnostic graph, extracts direct imports
92
- for TypeScript, JavaScript, Rust, Python, and Go, and can enforce configured
93
- layer dependency rules such as keeping domain code independent from
94
- infrastructure adapters.
95
-
96
- `naome sync` installs or repairs the local harness files. It does not run a
97
- hidden full-repository quality scan. It also migrates any active legacy
98
- task-state into the local task ledger automatically and untracks local
99
- `.naome/tasks/` runtime folders from Git. If quality policy is newly seeded,
100
- run `naome quality init --baseline` deliberately; use `--deep` or
101
- `--deep-baseline` only when you want expensive repository-wide checks.
102
-
103
- ## Repository Docs
104
-
105
- After sync, NAOME writes the agent-facing workflow into `docs/naome/`:
106
-
107
- - `docs/naome/index.md` is the entry point.
108
- - `docs/naome/agent-workflow.md` explains the active task workflow.
109
- - `docs/naome/testing.md` maps change types to required checks.
110
- - `docs/naome/repository-quality.md` explains quality, structure, and cleanup
111
- policy.
112
- - `docs/naome/architecture-fitness.md` explains architecture graph validation
113
- and agent feedback.
114
-
115
- Agents should follow the repository's NAOME docs instead of guessing workflow
116
- rules from generic project files.
33
+ Open the repository in Codex and give the agent a normal task:
117
34
 
118
- ## Configuration
119
-
120
- The main local policy files are:
121
-
122
- - `.naomeignore` for read boundaries.
123
- - `.naome/verification.json` for check phases and proof requirements.
124
- - `.naome/repository-quality.json` for file, symbol, duplicate, and semantic
125
- quality policy.
126
- - `.naome/repository-structure.json` for path role, module, and directory
127
- structure policy.
128
- - `naome.arch.yaml` for language-agnostic architecture fitness rules.
129
- - `.naome/task-state.json` for the compact committed active task projection.
130
-
131
- Product defaults stay generic. Repository-specific policy belongs in the local
132
- `.naome/` config files.
133
-
134
- ## Development
135
-
136
- Useful checks for this repository:
137
-
138
- ```shell
139
- npm run build:rust
140
- npm run test:decision-engine
141
- npm run test:naome-installer
142
- npm run pack:dry-run
143
- node .naome/bin/naome.js quality check --changed --json
144
- node .naome/bin/naome.js semantic check --changed --json
145
- node .naome/bin/naome.js arch validate --changed-only --json
146
- git diff --check
35
+ ```text
36
+ Use NAOME in this repository and implement the next change.
147
37
  ```
148
38
 
39
+ That is the product flow. You do not need to learn NAOME commands, route
40
+ prompts, select context, or manage task state. Codex follows the repository
41
+ harness, and NAOME supplies the deterministic checks, boundaries, task memory,
42
+ and verification flow behind the scenes.
43
+
149
44
  ## License
150
45
 
151
46
  NAOME is licensed under the [Apache License 2.0](../../LICENSE).
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "naome-cli"
3
- version = "1.4.1"
3
+ version = "1.4.3"
4
4
  edition.workspace = true
5
5
  license.workspace = true
6
6
  repository.workspace = true
@@ -37,12 +37,21 @@ const HELP: &str = r#"Usage:
37
37
  naome sync [--package-root <path>] [--installer-js <path>]
38
38
  naome install-plan [--harness-version <version>]
39
39
  naome seed-verification
40
- naome task status [--json] [--exit-code]
41
- naome task proof-plan [--json] [--exit-code]
42
- naome task can-transition --to complete [--json]
40
+ naome task status [--json] [--exit-code] [--agent-session <id>]
41
+ naome task proof-plan [--json] [--exit-code] [--agent-session <id>]
42
+ naome task agent-snapshot --json [--exit-code] [--agent-session <id>]
43
+ naome task preflight (--path <path>...|--from-changed) --json [--agent-session <id>]
44
+ naome task commit-preflight --json [--exit-code] [--agent-session <id>]
45
+ naome task compact-proof [--dry-run] --json [--agent-session <id>]
46
+ naome task scope-suggestions --from-changed --json [--agent-session <id>]
47
+ naome task can-edit --path <path> --json [--agent-session <id>]
48
+ naome task run-check --check <check-id> [--record-proof] --json [--agent-session <id>]
49
+ naome task loop [--execute-safe] --json [--agent-session <id>]
50
+ naome task can-transition --to complete [--json] [--agent-session <id>]
43
51
  naome task can-commit --json
44
- naome task repair --plan <id> --dry-run --json
45
- naome task record-proof --from-proof-plan [--dry-run] --json
52
+ naome task complete --from-can-transition --json [--agent-session <id>]
53
+ naome task repair --plan <id> (--dry-run|--execute-safe) --json [--agent-session <id>]
54
+ naome task record-proof --from-proof-plan [--dry-run] --json [--agent-session <id>]
46
55
  naome task request-scope --path <path> --reason <reason> --json
47
56
  naome task timeline --json
48
57
  naome task loop-snapshot --json
@@ -0,0 +1,173 @@
1
+ use std::path::Path;
2
+
3
+ use naome_core::{
4
+ task_proof_plan, task_status_exit_code, task_status_report, task_transition_readiness,
5
+ };
6
+ use serde_json::{json, Value};
7
+
8
+ use super::common::{agent_session, print_json_with_session};
9
+ use super::planner;
10
+
11
+ pub(super) fn agent_snapshot(
12
+ root: &Path,
13
+ args: &[String],
14
+ ) -> Result<(), Box<dyn std::error::Error>> {
15
+ let session = agent_session(args)?;
16
+ let status = task_status_report(root)?;
17
+ let proof_plan = task_proof_plan(root)?;
18
+ let transition = task_transition_readiness(root, "complete")?;
19
+ let planned = merge_commands(
20
+ serde_json::to_value(&proof_plan.recommended_commands)?,
21
+ planner::planned_commands(
22
+ root,
23
+ &status.scope.in_scope_changed_paths,
24
+ Some(&status.proof),
25
+ ),
26
+ );
27
+ let (safe_to_run, deferred) = planner::split_safe_commands(&planned);
28
+ let can_commit = transition.allowed && status.agent_loop.can_commit;
29
+ let value = json!({
30
+ "schema": "naome.task.agent-snapshot.v1",
31
+ "state": snapshot_state(&status),
32
+ "task": {
33
+ "state": status.state,
34
+ "taskId": status.task_id,
35
+ "request": status.request
36
+ },
37
+ "git": {
38
+ "head": status.git.head,
39
+ "admissionHead": status.git.admission_head,
40
+ "admissionHeadReachable": status.git.admission_head_reachable,
41
+ "operationInProgress": status.git.operation_in_progress,
42
+ "branchDiverged": status.git.ahead > 0 || status.git.behind > 0
43
+ },
44
+ "scope": {
45
+ "allowedPaths": status.scope.allowed_paths,
46
+ "changedPaths": status.scope.changed_paths,
47
+ "editablePaths": editable_paths(&status),
48
+ "mustNotEditPaths": status.scope.out_of_scope_changed_paths,
49
+ "outOfScopeChangedPaths": status.scope.out_of_scope_changed_paths
50
+ },
51
+ "proof": status.proof,
52
+ "checks": {
53
+ "recommended": planned,
54
+ "safeToRun": safe_to_run,
55
+ "deferred": deferred
56
+ },
57
+ "commit": {
58
+ "canCommit": can_commit,
59
+ "blockingFindings": if can_commit { json!([]) } else { serde_json::to_value(&transition.blocking_findings)? }
60
+ },
61
+ "transition": {
62
+ "canComplete": transition.allowed,
63
+ "blockingFindings": transition.blocking_findings
64
+ },
65
+ "nextAction": snapshot_next_action(&status, &proof_plan, can_commit),
66
+ "agentLoop": status.agent_loop,
67
+ "repairPlan": status.repair_plan,
68
+ "findings": status.findings,
69
+ "agentInstruction": if can_commit { "Task is commit-ready; do not edit further before committing." } else { "Follow nextAction and only execute commands listed as safeToRun." }
70
+ });
71
+ let code = task_status_exit_code(&status.findings, &status.proof);
72
+ print_json_with_session(value, session.as_deref())?;
73
+ if args.iter().any(|arg| arg == "--exit-code") {
74
+ std::process::exit(code);
75
+ }
76
+ Ok(())
77
+ }
78
+
79
+ fn merge_commands(left: Value, right: Vec<Value>) -> Vec<Value> {
80
+ let mut commands = left
81
+ .as_array()
82
+ .cloned()
83
+ .unwrap_or_default()
84
+ .into_iter()
85
+ .chain(right)
86
+ .collect::<Vec<_>>();
87
+ commands.sort_by(|a, b| {
88
+ a.get("checkId")
89
+ .and_then(Value::as_str)
90
+ .unwrap_or("")
91
+ .cmp(b.get("checkId").and_then(Value::as_str).unwrap_or(""))
92
+ });
93
+ commands.dedup_by(|a, b| {
94
+ a.get("checkId").and_then(Value::as_str) == b.get("checkId").and_then(Value::as_str)
95
+ });
96
+ commands
97
+ }
98
+
99
+ fn snapshot_state(status: &naome_core::TaskStatusReportV1) -> &'static str {
100
+ if status
101
+ .findings
102
+ .iter()
103
+ .any(|finding| finding.id.starts_with("task.git."))
104
+ {
105
+ "blocked_by_git_state"
106
+ } else if !status.scope.out_of_scope_changed_paths.is_empty() {
107
+ "blocked_by_scope_drift"
108
+ } else if !status.proof.missing_checks.is_empty() {
109
+ "blocked_by_missing_proof"
110
+ } else if !status.proof.stale_checks.is_empty() {
111
+ "blocked_by_stale_proof"
112
+ } else if status.agent_loop.can_commit {
113
+ "ready_to_commit"
114
+ } else {
115
+ "healthy"
116
+ }
117
+ }
118
+
119
+ fn editable_paths(status: &naome_core::TaskStatusReportV1) -> Vec<String> {
120
+ if matches!(
121
+ status.state.as_str(),
122
+ "idle" | "missing" | "complete" | "blocked" | "needs_human_review"
123
+ ) {
124
+ return Vec::new();
125
+ }
126
+ status.scope.allowed_paths.clone()
127
+ }
128
+
129
+ fn snapshot_next_action(
130
+ status: &naome_core::TaskStatusReportV1,
131
+ proof_plan: &naome_core::TaskProofPlanReport,
132
+ can_commit: bool,
133
+ ) -> Value {
134
+ let action_type = if !status.scope.out_of_scope_changed_paths.is_empty() {
135
+ "repair_scope"
136
+ } else if status
137
+ .findings
138
+ .iter()
139
+ .any(|finding| finding.id.starts_with("task.git."))
140
+ {
141
+ "recover_git"
142
+ } else if !status.proof.missing_checks.is_empty() || !status.proof.stale_checks.is_empty() {
143
+ "run_checks"
144
+ } else if !proof_plan.proof_recording.checks_to_record.is_empty() {
145
+ "record_proof"
146
+ } else if can_commit {
147
+ "commit_ready"
148
+ } else if status.state == "implementing" && status.agent_loop.can_continue_editing {
149
+ "edit"
150
+ } else {
151
+ "none"
152
+ };
153
+ if can_commit && action_type == "commit_ready" {
154
+ return json!({
155
+ "type": "commit_ready",
156
+ "reason": "Task is complete enough to commit; do not continue editing before commit.",
157
+ "commands": [],
158
+ "paths": status.scope.in_scope_changed_paths,
159
+ "checkIds": [],
160
+ "safeToExecute": false,
161
+ "requiresUserApproval": true
162
+ });
163
+ }
164
+ json!({
165
+ "type": action_type,
166
+ "reason": status.next_action_v2.reason,
167
+ "commands": status.next_action_v2.commands,
168
+ "paths": status.next_action_v2.paths,
169
+ "checkIds": status.next_action_v2.check_ids,
170
+ "safeToExecute": status.next_action_v2.safe_to_execute,
171
+ "requiresUserApproval": status.next_action_v2.requires_user_approval
172
+ })
173
+ }
@@ -0,0 +1,64 @@
1
+ use std::path::Path;
2
+
3
+ use naome_core::{path_matches_any, task_status_report};
4
+ use serde_json::json;
5
+
6
+ use super::common::{agent_session, print_json_with_session, value_after};
7
+ use super::path_policy::{edit_finding, is_control_path, is_ignored, normalize_requested_path};
8
+
9
+ pub(super) fn can_edit(root: &Path, args: &[String]) -> Result<(), Box<dyn std::error::Error>> {
10
+ let session = agent_session(args)?;
11
+ let Some(raw_path) = value_after(args, "--path") else {
12
+ return Err("naome task can-edit requires --path <path>".into());
13
+ };
14
+ let (path, path_error) = normalize_requested_path(raw_path);
15
+ let mut findings = Vec::new();
16
+ if let Some(message) = path_error {
17
+ findings.push(edit_finding("task.edit.unsafe_path", &message, raw_path));
18
+ } else if is_ignored(root, &path) {
19
+ findings.push(edit_finding(
20
+ "task.edit.ignored_path",
21
+ "Path is ignored by .naomeignore and is outside the active harness context.",
22
+ &path,
23
+ ));
24
+ } else if is_control_path(&path) {
25
+ findings.push(edit_finding(
26
+ "task.edit.control_path",
27
+ "NAOME control files cannot be edited through the autonomous can-edit path.",
28
+ &path,
29
+ ));
30
+ } else {
31
+ let status = task_status_report(root)?;
32
+ if status.task_id.is_none()
33
+ || matches!(
34
+ status.state.as_str(),
35
+ "idle" | "missing" | "complete" | "blocked" | "needs_human_review"
36
+ )
37
+ {
38
+ findings.push(edit_finding(
39
+ "task.edit.no_active_task",
40
+ "No editable active task is available for this path.",
41
+ &path,
42
+ ));
43
+ } else if !path_matches_any(&path, &status.scope.allowed_paths) {
44
+ findings.push(edit_finding(
45
+ "task.edit.out_of_scope",
46
+ "Path is outside activeTask.allowedPaths.",
47
+ &path,
48
+ ));
49
+ }
50
+ }
51
+
52
+ let allowed = findings.is_empty();
53
+ print_json_with_session(
54
+ json!({
55
+ "schema": "naome.task.can-edit.v1",
56
+ "path": path,
57
+ "allowed": allowed,
58
+ "reason": if allowed { "Path is inside active task scope." } else { "Path is not safe to edit for the active task." },
59
+ "findings": findings,
60
+ "agentInstruction": if allowed { "Agent may edit this path and must rerun task loop before completion." } else { "Do not edit this path unless task scope is explicitly revised." }
61
+ }),
62
+ session.as_deref(),
63
+ )
64
+ }
@@ -0,0 +1,34 @@
1
+ use serde_json::{json, Value};
2
+
3
+ pub(super) fn run_check_response(
4
+ check_id: &str,
5
+ executed: bool,
6
+ exit_code: Option<i32>,
7
+ recorded_proof: bool,
8
+ findings: Vec<Value>,
9
+ instruction: &str,
10
+ session: Option<&str>,
11
+ ) -> Value {
12
+ json!({
13
+ "schema": "naome.task.run-check.v1",
14
+ "checkId": check_id,
15
+ "executed": executed,
16
+ "exitCode": exit_code,
17
+ "recordedProof": recorded_proof,
18
+ "summary": instruction,
19
+ "findings": findings,
20
+ "agentInstruction": instruction,
21
+ "agentSession": session
22
+ })
23
+ }
24
+
25
+ pub(super) fn finding(id: &str, message: impl Into<String>) -> Value {
26
+ json!({
27
+ "id": id,
28
+ "severity": "error",
29
+ "message": message.into(),
30
+ "path": null,
31
+ "suggestedFix": "Use task proof-plan and declared safe checks before recording proof.",
32
+ "agentInstruction": "Do not record proof for failed, unknown, or unsafe checks."
33
+ })
34
+ }
@@ -0,0 +1,163 @@
1
+ use std::fs;
2
+ use std::path::{Path, PathBuf};
3
+ use std::process::Command;
4
+
5
+ use naome_core::task_evidence_fingerprint;
6
+ use serde_json::{json, Value};
7
+
8
+ const RECEIPTS_PATH: &str = "naome-task-check-runs.json";
9
+
10
+ #[derive(Debug, Clone)]
11
+ pub(in crate::task_commands) struct CheckRunReceipt {
12
+ pub(in crate::task_commands) task_id: Option<String>,
13
+ pub(in crate::task_commands) check_id: String,
14
+ pub(in crate::task_commands) command: String,
15
+ pub(in crate::task_commands) cwd: String,
16
+ pub(in crate::task_commands) exit_code: i32,
17
+ pub(in crate::task_commands) checked_at: String,
18
+ pub(in crate::task_commands) evidence_paths: Vec<String>,
19
+ pub(in crate::task_commands) evidence_fingerprint: String,
20
+ pub(in crate::task_commands) stdout_summary: String,
21
+ pub(in crate::task_commands) stderr_summary: String,
22
+ pub(in crate::task_commands) duration_ms: u128,
23
+ pub(in crate::task_commands) agent_session: Option<String>,
24
+ }
25
+
26
+ pub(in crate::task_commands) fn successful_receipts(
27
+ root: &Path,
28
+ ) -> Result<Vec<CheckRunReceipt>, Box<dyn std::error::Error>> {
29
+ let path = receipt_path(root)?;
30
+ let Ok(content) = fs::read_to_string(path) else {
31
+ return Ok(Vec::new());
32
+ };
33
+ let value: Value = serde_json::from_str(&content)?;
34
+ Ok(value
35
+ .as_array()
36
+ .into_iter()
37
+ .flatten()
38
+ .filter_map(receipt_from_value)
39
+ .filter(|receipt| receipt.exit_code == 0)
40
+ .collect())
41
+ }
42
+
43
+ pub(super) fn append_receipt(
44
+ root: &Path,
45
+ receipt: &CheckRunReceipt,
46
+ ) -> Result<(), Box<dyn std::error::Error>> {
47
+ let path = receipt_path(root)?;
48
+ if let Some(parent) = path.parent() {
49
+ fs::create_dir_all(parent)?;
50
+ }
51
+ let mut receipts = if path.exists() {
52
+ serde_json::from_str::<Value>(&fs::read_to_string(&path)?)?
53
+ .as_array()
54
+ .cloned()
55
+ .unwrap_or_default()
56
+ } else {
57
+ Vec::new()
58
+ };
59
+ receipts.push(receipt_to_value(receipt));
60
+ fs::write(
61
+ path,
62
+ format!("{}\n", serde_json::to_string_pretty(&receipts)?),
63
+ )?;
64
+ Ok(())
65
+ }
66
+
67
+ pub(in crate::task_commands) fn changed_paths(
68
+ root: &Path,
69
+ ) -> Result<Vec<String>, Box<dyn std::error::Error>> {
70
+ let output = Command::new("git")
71
+ .args(["status", "--porcelain=v1", "--untracked-files=all"])
72
+ .current_dir(root)
73
+ .output()?;
74
+ if !output.status.success() {
75
+ return Ok(Vec::new());
76
+ }
77
+ let mut paths = String::from_utf8_lossy(&output.stdout)
78
+ .lines()
79
+ .filter_map(|line| line.get(3..))
80
+ .map(|line| line.split(" -> ").last().unwrap_or(line).to_string())
81
+ .collect::<Vec<_>>();
82
+ paths.sort();
83
+ Ok(paths)
84
+ }
85
+
86
+ fn receipt_path(root: &Path) -> Result<PathBuf, Box<dyn std::error::Error>> {
87
+ let output = Command::new("git")
88
+ .args(["rev-parse", "--git-path", RECEIPTS_PATH])
89
+ .current_dir(root)
90
+ .output()?;
91
+ if output.status.success() {
92
+ let path = String::from_utf8_lossy(&output.stdout).trim().to_string();
93
+ if !path.is_empty() {
94
+ return Ok(root.join(path));
95
+ }
96
+ }
97
+ Ok(root.join(".git").join(RECEIPTS_PATH))
98
+ }
99
+
100
+ fn receipt_to_value(receipt: &CheckRunReceipt) -> Value {
101
+ json!({
102
+ "taskId": receipt.task_id,
103
+ "checkId": receipt.check_id,
104
+ "command": receipt.command,
105
+ "cwd": receipt.cwd,
106
+ "exitCode": receipt.exit_code,
107
+ "checkedAt": receipt.checked_at,
108
+ "evidencePaths": receipt.evidence_paths,
109
+ "evidenceFingerprint": receipt.evidence_fingerprint,
110
+ "stdoutSummary": receipt.stdout_summary,
111
+ "stderrSummary": receipt.stderr_summary,
112
+ "durationMs": receipt.duration_ms,
113
+ "agentSession": receipt.agent_session
114
+ })
115
+ }
116
+
117
+ fn receipt_from_value(value: &Value) -> Option<CheckRunReceipt> {
118
+ Some(CheckRunReceipt {
119
+ task_id: value
120
+ .get("taskId")
121
+ .and_then(Value::as_str)
122
+ .map(ToString::to_string),
123
+ check_id: value.get("checkId")?.as_str()?.to_string(),
124
+ command: value.get("command")?.as_str()?.to_string(),
125
+ cwd: value.get("cwd")?.as_str()?.to_string(),
126
+ exit_code: value.get("exitCode")?.as_i64()? as i32,
127
+ checked_at: value.get("checkedAt")?.as_str()?.to_string(),
128
+ evidence_paths: value
129
+ .get("evidencePaths")?
130
+ .as_array()?
131
+ .iter()
132
+ .filter_map(Value::as_str)
133
+ .map(ToString::to_string)
134
+ .collect(),
135
+ evidence_fingerprint: value
136
+ .get("evidenceFingerprint")
137
+ .and_then(Value::as_str)
138
+ .unwrap_or("")
139
+ .to_string(),
140
+ stdout_summary: value
141
+ .get("stdoutSummary")
142
+ .and_then(Value::as_str)
143
+ .unwrap_or("")
144
+ .to_string(),
145
+ stderr_summary: value
146
+ .get("stderrSummary")
147
+ .and_then(Value::as_str)
148
+ .unwrap_or("")
149
+ .to_string(),
150
+ duration_ms: value.get("durationMs").and_then(Value::as_u64).unwrap_or(0) as u128,
151
+ agent_session: value
152
+ .get("agentSession")
153
+ .and_then(Value::as_str)
154
+ .map(ToString::to_string),
155
+ })
156
+ }
157
+
158
+ pub(in crate::task_commands) fn evidence_fingerprint(
159
+ root: &Path,
160
+ paths: &[String],
161
+ ) -> Result<String, Box<dyn std::error::Error>> {
162
+ Ok(task_evidence_fingerprint(root, paths)?)
163
+ }