ace-overseer 0.17.0 → 0.18.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 860e9ff0d0d664c563c1fa9139d5f0d18b2e4c869239424e881dd2a018ad12bc
4
- data.tar.gz: 55c0694f24f3a4d8ea9492df5f552e42fb2171cc75bd848988b642171f1b1e3c
3
+ metadata.gz: 7357625f81ac8808666214eb562e7cfd77e103287d148b2ddd379ce360336d5f
4
+ data.tar.gz: d4ef2e4c662a0ab2bf552b04e3ba9a2a27143dde016fbf511cbf1ca6d2c14b30
5
5
  SHA512:
6
- metadata.gz: 6bb2d12eb03983cc5b10f9b71f72fea46c25c39e6394e02060a9e505767400d2a57a1da1794140235a9e1b6f0f01fa01f89287755b5d7af6490497312e1375ed
7
- data.tar.gz: 830f8f444002616d28121d391b95d85cc50a07c18b6bf0f63a4e7186d332cb7a118d7b3ef059e7d11d24e7e37f37a2305eeb15bcda5a63ecc2fc42469dd5f204
6
+ metadata.gz: 9e85b077290b2e41ec49e5b366004fa51c1ac4649b6593ebef8da141477c742e9ffd9adb21c7f3e48c8538d85f5441f5853df89b4413836202e6b90eb6a34fba
7
+ data.tar.gz: b225d3a2dd947aeba55d60d9c1f5f4a02af4af348534afde2ba5edf86e311dfba1cff801d988698a38f33b0a01634c30c108f89607c2e1a973fe4980e8921b28
data/CHANGELOG.md CHANGED
@@ -7,6 +7,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.18.1] - 2026-10-01
11
+
12
+ ### Technical
13
+ - Follow the ace-assign 0.60 release line for durable review campaign evidence.
14
+
15
+ ## [0.18.0] - 2026-09-29
16
+
17
+ ### Added
18
+ - Preservation manifest for prune: `ace-overseer prune --preservation FILE` accepts a strict `version: 1` YAML manifest declaring cross-repository destinations (source repo/base/head, destination repo/base/head/branch); entries are resolved to exact commits, and duplicate, self-destination, or unmatched entries fail before any apply action.
19
+ - Git preservation proofs on the actual prune path: accepted-ancestor containment against the surviving base branch, or verified-destination proof by full-tree equality or exact path-by-path transition (paths, modes, symlinks, binary content) across separate bases. Patch-range proofs require the independently recorded attempt baseline (`base_head`), and the baseline itself must survive on an accepted ref; empty or truncated ranges are never proof.
20
+ - Fail-closed Lab prune classification: `ace-overseer prune WORK --runtime lab` requires an authoritative terminal Work state plus preservation data (`repo`/`head`/`branch`) provable in the hosted repository; apply re-reads state immediately before `lab work destroy` and blocked Works are never destroyed.
21
+
22
+ ### Changed
23
+ - BREAKING: `--force` no longer removes unsafe worktrees; it only skips the confirmation for already-safe candidates. Missing, failed or ambiguous preservation evidence, dirty tracked or untracked files, active or uncertain attempts, unreadable lifecycle state, and failed rechecks now always block deletion -- including under `--yes` and `--quiet`.
24
+ - Prune preview is read-only (no stale-metadata mutation); apply holds a durable lifecycle exclusion shared with assignment/driver/work-on start paths from final evidence reads through removal, deletes the worktree without force or untracked suppression, and deletes the branch only after re-verifying its tip identity. Removal is recorded so later starts fail closed instead of recreating a deleted identity.
25
+ - Assignment cache prune requires the durable evidence ref (`refs/ace/execution`, never deleted) to confirm every attempt terminal for managed assignments; only the assignment cache directory is removed.
26
+ - Unmatched explicit prune targets are errors, apply exits nonzero when any candidate is blocked or removal fails (independent safe candidates still complete), quiet mode suppresses progress but never failure signaling, and dry-run performs the same proof classification with no cleanup side effects.
27
+
10
28
  ## [0.17.0] - 2026-09-29
11
29
 
12
30
  ### Added
@@ -380,7 +398,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
380
398
  ## [0.4.17] - 2026-03-02
381
399
 
382
400
  ### Changed
383
- - Replace `ace-taskflow` dependency with `ace-task` — migrate `WorkOnOrchestrator` and `PruneSafetyChecker` to use `Ace::Task::Organisms::TaskManager` API
401
+ - Replace `ace-taskflow` dependency with `ace-task` -- migrate `WorkOnOrchestrator` and `PruneSafetyChecker` to use `Ace::Task::Organisms::TaskManager` API
384
402
  - Remove bare `require "ace/taskflow"` import, add `require "ace/task"`
385
403
 
386
404
  ## [0.4.16] - 2026-02-26
@@ -470,14 +488,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
470
488
  ## [0.4.2] - 2026-02-19
471
489
 
472
490
  ### Added
473
- - Orchestrator subtask expansion for `work-on-tasks` preset — when a task is an orchestrator with subtasks, `AssignmentLauncher` expands subtask refs into individual foreach steps (e.g., `work-on-272.01`, `work-on-272.02`) instead of a single `work-on-272` step
491
+ - Orchestrator subtask expansion for `work-on-tasks` preset -- when a task is an orchestrator with subtasks, `AssignmentLauncher` expands subtask refs into individual foreach steps (e.g., `work-on-272.01`, `work-on-272.02`) instead of a single `work-on-272` step
474
492
  - `extract_subtask_refs` helper in `WorkOnOrchestrator` to extract subtask numbers from orchestrator task data
475
493
  - `subtask_refs:` keyword argument on `AssignmentLauncher#launch` for passing expanded subtask references
476
494
 
477
495
  ## [0.4.1] - 2026-02-19
478
496
 
479
497
  ### Added
480
- - `--watch` / `-w` option for `status` command — auto-refreshing dashboard with ANSI screen clear
498
+ - `--watch` / `-w` option for `status` command -- auto-refreshing dashboard with ANSI screen clear
481
499
  - Two-tier refresh: fast interval (15s default) refreshes assignment data only, slow interval (5min default) does full git/PR refresh
482
500
  - `collect_assignments_only` method on `WorktreeContextCollector` for lightweight assignment-only collection with cached git data
483
501
  - `collect_quick` method on `StatusCollector` to reuse previous snapshot's git data while refreshing assignments
@@ -487,7 +505,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
487
505
  ## [0.4.0] - 2026-02-19
488
506
 
489
507
  ### Added
490
- - Progress bar visualization in assignment sub-rows — filled/empty bar segments alongside numeric counts
508
+ - Progress bar visualization in assignment sub-rows -- filled/empty bar segments alongside numeric counts
491
509
  - Current step name display for running assignments (e.g., `implement` shown dimmed after progress)
492
510
  - Header row and separator line above hierarchical dashboard for column labeling
493
511
  - Blank line separators between location groups for visual breathing room
@@ -499,7 +517,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
499
517
  ## [0.3.1] - 2026-02-19
500
518
 
501
519
  ### Changed
502
- - Hierarchical status display — location header rows with assignment sub-rows replace flat single-row format
520
+ - Hierarchical status display -- location header rows with assignment sub-rows replace flat single-row format
503
521
  - `WorkContext` model uses `assignments` array instead of singular `assignment_status` + `assignment_count`
504
522
  - `WorktreeContextCollector` loads all assignments via `AssignmentDiscoverer` instead of only active via `AssignmentExecutor`
505
523
  - `StatusFormatter` emits two row types: location header (basename + PR + Git) and indented assignment sub-rows (ID + name + state + progress)
@@ -513,7 +531,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
513
531
  ## [0.3.0] - 2026-02-19
514
532
 
515
533
  ### Added
516
- - Assignment-aware status display — main branch appears in `status` when it has active assignments
534
+ - Assignment-aware status display -- main branch appears in `status` when it has active assignments
517
535
  - Assignment count shown in Assign column when location has multiple assignments (e.g., `abc12 (3)`)
518
536
  - `--assignment` / `-a` option for `prune` command to remove a specific assignment's cache directory
519
537
  - `AssignmentPruneCandidate` model for assignment-level prune safety checking
@@ -533,16 +551,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
533
551
 
534
552
  ### Changed
535
553
 
536
- - `TmuxWindowOpener` delegates entirely to `ace-tmux window` CLI — no longer manages session names, window names, or presets
554
+ - `TmuxWindowOpener` delegates entirely to `ace-tmux window` CLI -- no longer manages session names, window names, or presets
537
555
  - Remove `tmux_session_name`, `window_name_format`, and `window_preset` config options
538
- - Remove `WindowNameFormatter` atom — window naming is ace-tmux's responsibility
556
+ - Remove `WindowNameFormatter` atom -- window naming is ace-tmux's responsibility
539
557
  - `PruneOrchestrator` uses worktree path basename for window cleanup instead of formatted names
540
558
 
541
559
  ## [0.2.16] - 2026-02-19
542
560
 
543
561
  ### Changed
544
562
 
545
- - `TmuxWindowOpener` no longer manages tmux sessions — delegates entirely to ace-tmux `WindowManager` for window creation, session detection, and dedup
563
+ - `TmuxWindowOpener` no longer manages tmux sessions -- delegates entirely to ace-tmux `WindowManager` for window creation, session detection, and dedup
546
564
 
547
565
  ## [0.2.15] - 2026-02-19
548
566
 
@@ -559,7 +577,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
559
577
  ## [0.2.13] - 2026-02-19
560
578
 
561
579
  ### Added
562
- - Progress callbacks (`on_progress:`) for `work-on` and `prune` orchestrators — one-line status output per step
580
+ - Progress callbacks (`on_progress:`) for `work-on` and `prune` orchestrators -- one-line status output per step
563
581
  - Prune now displays safe/skipped candidates with reasons before the "Continue?" confirmation prompt
564
582
 
565
583
  ### Changed
data/docs/usage.md CHANGED
@@ -69,14 +69,31 @@ Arguments:
69
69
  Options:
70
70
 
71
71
  - `--assignment`, `-a`: prune a specific assignment by ID
72
- - `--force`, `-f`: force-remove unsafe worktrees
72
+ - `--force`, `-f`: skip the interactive confirmation for already-safe candidates; never bypasses preservation, dirtiness, lifecycle or revalidation blocks
73
73
  - `--yes`, `-y`: skip interactive confirmation
74
- - `--dry-run`: list prune candidates only
75
- - `--runtime`: `tmux` (default) or `lab`; Lab accepts dry-run only and delegates destruction to the exact `lab work destroy WORK --confirm` command
76
- - `--quiet`, `-q`: suppress non-essential output
74
+ - `--dry-run`: run the same proof classification as apply, changing no worktrees, refs, assignment state or metadata (a dry-run receipt is not reusable authorization)
75
+ - `--preservation FILE`: YAML manifest (`version: 1`, `candidates:`) declaring cross-repository destinations for migrated work; a claim to verify, never proof by itself
76
+ - `--runtime`: `tmux` (default) or `lab`
77
+ - `--quiet`, `-q`: suppress progress output; blocked/failed results still print and signal through the exit code
77
78
  - `--debug`, `-d`: show debug output
78
79
  - `--help`, `-h`: show help
79
80
 
81
+ ### Prune safety (enforced)
82
+
83
+ Every candidate is deleted only after executed proofs; missing, failed or
84
+ ambiguous evidence preserves the candidate, and `--force`/`--yes` never
85
+ override a block:
86
+
87
+ - Preservation: source HEAD is contained in the surviving accepted base branch, or a declared destination is verified -- accepted on its surviving destination branch -- with full-tree equality or an exact content transition (paths, modes, symlinks, binary content) across separate bases. Patch-range proofs additionally require the independently recorded attempt baseline, and that baseline itself must survive on an accepted ref.
88
+
89
+ - No active writer: recorded attempts must be terminal (active or uncertain attempts block; unreadable attempt state blocks); assignment/driver/start paths and prune share a durable exclusion that survives removal of the target, so no second writer can enter during deletion; dirty tracked or untracked files are work and block deletion, even with `--force`.
90
+
91
+ - Assignment cache: cleanup removes only the assignment cache directory after the durable journal evidence (`refs/ace/execution`, never deleted) confirms every attempt terminal; the journal checkout is never removed.
92
+
93
+ - Exit codes: dry-run always exits 0 (blocked candidates are listed); apply exits nonzero when any selected candidate is blocked or removal fails, while independent safe candidates still complete.
94
+
95
+ - Unmatched explicit targets are errors; empty automatic selection is a no-op; cancelled confirmation deletes nothing.
96
+
80
97
  ## Example Flows
81
98
 
82
99
  Start task work: `ace-overseer work-on --task 8q4.t.umu.1`.
@@ -85,6 +102,25 @@ Check dashboard: `ace-overseer status`.
85
102
 
86
103
  Preview then prune: `ace-overseer prune --dry-run`, then `ace-overseer prune --yes`.
87
104
 
105
+ Prune migrated work with a declared destination:
106
+
107
+ ```bash
108
+ mkdir -p .ace-local/prune
109
+ cat > .ace-local/prune/destinations.yml <<'YAML'
110
+ version: 1
111
+ candidates:
112
+ - worktree_path: /abs/path/.ace-wt/task.230
113
+ source_repo: /abs/path
114
+ source_base: <base-sha>
115
+ source_head: <head-sha>
116
+ destination_repo: /abs/successor
117
+ destination_base: <dest-base-sha>
118
+ destination_head: <accepted-head-sha>
119
+ destination_branch: refs/heads/main
120
+ YAML
121
+ ace-overseer prune task.230 --preservation .ace-local/prune/destinations.yml --dry-run
122
+ ```
123
+
88
124
  ## Lab Runtime
89
125
 
90
126
  Lab commands are available only where `/usr/local/bin/lab` is installed. ACE
@@ -98,7 +134,7 @@ does not read Lab credentials and does not call Podman or Herdr directly.
98
134
  - `ace-overseer prompt --work WORK --file PATH`: forward prompt text from a file to the Work pane. Piped stdin is also supported; prompt text is never passed as a process argument.
99
135
  - `ace-overseer review --work WORK --pr NUMBER`: prepare an exact-head admin review checkout and pane.
100
136
  - `ace-overseer stop --work WORK`: stop the assigned process without destroying Work state.
101
- - `ace-overseer prune WORK... --runtime lab --dry-run`: preview exact Work destruction commands; rerun with `--yes` to delegate each destruction to Lab.
137
+ - `ace-overseer prune WORK... --runtime lab --dry-run`: classify each Work -- documented terminal state, no in-flight work, and preservation data (`repo`/`head`/`branch`) provable in the hosted repository. Apply with `--yes` re-verifies state immediately before delegating each destruction to `lab work destroy WORK --confirm`; blocked Works are never destroyed and the run exits nonzero. The raw Lab CLI cannot make the state check and the destruction atomic, so this adapter reports the path unsupported and preserves the Work; only a Lab surface with an atomic guarded destroy delegates.
102
138
 
103
139
  Example:
104
140
 
@@ -6,10 +6,9 @@ doc-type: workflow
6
6
  title: Overseer Workflow
7
7
  purpose: Binding process contract for the overseer lifecycle (work-on, status, prune) with executed-check contracts for prune safety and status truth.
8
8
  ace-docs:
9
- last-updated: '2026-09-28'
10
- last-checked: '2026-09-28'
9
+ last-updated: '2026-09-29'
10
+ last-checked: '2026-09-29'
11
11
  ---
12
-
13
12
  # Overseer Workflow
14
13
 
15
14
  ## Goal
@@ -79,7 +78,7 @@ ace-overseer prune --yes
79
78
  ```
80
79
 
81
80
  - Targeted: `ace-overseer prune <task-ref|folder>...` or `ace-overseer prune --assignment <assignment-id>`
82
- - Lab runtime: `ace-overseer prune <work-id>... --runtime lab --dry-run`, rerun with `--yes` to delegate each destruction to Lab.
81
+ - Lab runtime: `ace-overseer prune <work-id>... --runtime lab --dry-run`, rerun with `--yes` to delegate each destruction to Lab. The raw Lab CLI cannot make the no-writer check and the destruction atomic, so it is classified unsupported and preserved; only a Lab surface with an atomic guarded destroy delegates.
83
82
 
84
83
  #### Prune safety (non-negotiable executed check)
85
84
 
@@ -101,43 +100,25 @@ candidate:
101
100
  plus content equivalence. A matching commit subject is never sufficient
102
101
  proof:
103
102
 
104
- 1. Declare the destination concretely: successor repository plus
105
- branch/PR/commit that received the work.
106
- 2. Verify the destination actually accepted the work, executed in this
107
- session:
108
-
109
- ```bash
110
- git -C <successor-repo> merge-base --is-ancestor <dest-ref> <dest-branch>
111
- ```
112
-
113
- rc=0 proves the destination landed on the declared branch (or read
114
- the merged/accepted state from the forge hosting the PR).
115
-
116
- 3. Prove the destination carries the same work by content, not by
117
- name. Pass with exactly one of:
118
-
119
- - Tree/artifact equivalence -- the squashed result is the same
120
- content:
121
-
122
- ```bash
123
- test "$(git -C <successor-repo> rev-parse '<dest-ref>^{tree}')" \
124
- = "$(git rev-parse '<work-head>^{tree}')"
125
- ```
126
-
127
- - Patch equivalence -- the same changes, compared inside the
128
- successor repository. Fetch the source proof refs first and
129
- range-diff with separate source and destination bases:
130
-
131
- ```bash
132
- git -C <successor-repo> fetch <source-repo> <source-base> <work-head>
133
- git -C <successor-repo> range-diff \
134
- <source-base>...<work-head> <dest-base>...<dest-ref>
135
- ```
136
-
137
- with no substantive differences; or diff the normalized patches
138
- (`git show --format=` output of each side) and require an empty
139
- diff.
140
-
103
+ 1. Declare the destination concretely in a preservation manifest
104
+ (`--preservation FILE`, schema in `docs/usage.md`): successor
105
+ repository, separate destination base/head, and the surviving
106
+ destination branch. The manifest is a claim to verify -- never
107
+ authorization or proof by itself.
108
+ 2. Run `ace-overseer prune <target> --preservation FILE --dry-run`. The
109
+ tool executes the proof in this session: it verifies the declared
110
+ source identity against the live worktree (repo and current HEAD),
111
+ verifies the destination base/head are accepted on the surviving
112
+ destination branch, and proves content by full-tree equality or an
113
+ exact path-by-path transition (paths, modes, symlinks, binary
114
+ content) between separate bases. Never fetch proof refs into shared
115
+ repositories or worktrees, and never interpret diff-tool
116
+ output, commit titles, PR reports, or CI status as content proof.
117
+ 3. Patch-range proof additionally requires the independently recorded
118
+ attempt baseline (`base_head`), and that baseline itself must be
119
+ preserved on a surviving accepted ref; without it only full-tree
120
+ equality or accepted ancestry proves preservation. Empty or
121
+ caller-truncated ranges are not evidence.
141
122
  4. Ambiguity preserves: conflicting hashes, an unverifiable destination,
142
123
  or a failed equivalence check means no proof -- block the prune per
143
124
  step 5.
@@ -151,9 +132,13 @@ candidate:
151
132
 
152
133
  For assignment-backed candidates also check `ace-assign status`; for Lab
153
134
  runtime candidates check `ace-overseer status --runtime lab`. A running
154
- assignment, an in-flight Lab work, or any unaccounted writer blocks the
155
- prune. Missing or unreadable lifecycle state counts as an active writer
156
- -- preserve.
135
+ assignment, an active or uncertain attempt, an in-flight Lab work, or any
136
+ unaccounted writer blocks the prune. Missing or unreadable lifecycle
137
+ state counts as an active writer -- preserve. `ace-overseer prune`
138
+ enforces this itself: apply holds a durable exclusion shared with every
139
+ supported start path from final evidence reads through removal, so a
140
+ writer cannot start into a candidate being deleted, and a candidate that
141
+ changes after preview is re-blocked at the destructive boundary.
157
142
 
158
143
  4. A described or remembered proof is never sufficient -- run the commands
159
144
  and observe the results in this session.
@@ -161,18 +146,18 @@ candidate:
161
146
  5. Any commit without a proven copy, any failed or ambiguous equivalence,
162
147
  or any active-writer evidence **blocks the prune** for that worktree.
163
148
  Report the blocked candidate (ref, head SHA, missing proof) to the
164
- operator; never silently drop it and never force past a failed proof.
165
- Preserve on ambiguity.
149
+ operator; never silently drop it and never bypass a failed proof with
150
+ `--force`. Preserve on ambiguity.
166
151
 
167
152
  ## Non-Negotiable Contracts Summary
168
153
 
169
154
  | Contract | Claim | Executed proof |
170
155
  |--------------|-------|----------------|
171
- | Prune safety | "The work is preserved and nothing is actively writing" | `git merge-base --is-ancestor <work-head> <base>` (rc=0), or verified destination + tree/artifact or patch equivalence; plus `ace-overseer status --format json` (assignment/Lab status where applicable) showing no active writer |
156
+ | Prune safety | "The work is preserved and nothing is actively writing" | `git merge-base --is-ancestor <work-head> <base>` (rc=0), or a verified declared destination (--preservation) with tree/artifact or exact content-transition equivalence; plus the executed no-writer reconciliation (overseer/assignment/Lab status, terminal attempts, durable prune/start exclusion) |
172
157
  | Status truth | "The task is blocked on the owner" | An executable non-secret check run in this session; stale tasks closed/corrected in the same change |
173
158
 
174
159
  ## Success Criteria
175
160
 
176
161
  - Work starts only through `ace-overseer work-on` (or the Lab-runtime equivalent), never through ad-hoc manual worktree provisioning.
177
162
  - Every status review re-verified pending owner-blocked tasks with an executed check and corrected stale state in the same change.
178
- - Every pruned worktree/branch had an executed preservation proof (ancestor containment, or a verified destination with tree/artifact or patch equivalence) and an executed no-active-writer check; candidates without complete proof were reported as blocked, not removed.
163
+ - Every pruned worktree/branch had an executed preservation proof (ancestor containment, or a verified destination with tree/artifact or exact content-transition equivalence) and an executed no-active-writer check; candidates without complete proof were reported as blocked, not removed; `--force` and `--yes` never bypassed a safety block.
@@ -7,24 +7,31 @@ module Ace
7
7
  class Prune < Ace::Support::Cli::Command
8
8
  include Ace::Support::Cli::Base
9
9
 
10
- desc "Clean up completed task worktrees"
10
+ desc "Clean up completed task worktrees with executed preservation and no-writer proofs"
11
11
 
12
12
  argument :targets, required: false, type: :array, desc: "Task refs or folder names to prune"
13
13
 
14
14
  option :assignment, aliases: ["-a"], type: :string, desc: "Prune a specific assignment by ID"
15
- option :force, aliases: ["-f"], type: :boolean, default: false, desc: "Force-remove unsafe worktrees"
15
+ option :force, aliases: ["-f"], type: :boolean, default: false,
16
+ desc: "Skip the confirmation prompt for already-safe candidates; never bypasses safety blocks"
16
17
  option :yes, aliases: ["-y"], type: :boolean, default: false, desc: "Skip confirmation"
17
- option :dry_run, type: :boolean, default: false, desc: "Show candidates only"
18
- option :quiet, aliases: ["-q"], type: :boolean, default: false, desc: "Suppress non-essential output"
18
+ option :dry_run, type: :boolean, default: false, desc: "Show candidates only (no cleanup side effects)"
19
+ option :quiet, aliases: ["-q"], type: :boolean, default: false,
20
+ desc: "Suppress progress output; failures still print and signal via exit code"
19
21
  option :debug, aliases: ["-d"], type: :boolean, default: false, desc: "Show debug output"
20
22
  option :runtime, default: "tmux", desc: "Runtime (tmux, lab)"
23
+ option :preservation, type: :string,
24
+ desc: "YAML manifest (version 1) declaring verified cross-repository destinations"
21
25
 
22
- def initialize(orchestrator: nil, input: $stdin, output: $stdout, lab_client: nil)
26
+ def initialize(orchestrator: nil, input: $stdin, output: $stdout, lab_client: nil,
27
+ lab_safety_checker: nil, preservation_checker: nil)
23
28
  super()
24
29
  @orchestrator = orchestrator || Organisms::PruneOrchestrator.new
25
30
  @input = input
26
31
  @output = output
27
32
  @lab_client = lab_client || Molecules::LabClient.new
33
+ @lab_safety_checker = lab_safety_checker || Molecules::LabPruneSafetyChecker.new
34
+ @preservation_checker = preservation_checker || Molecules::GitPreservationChecker.new
28
35
  end
29
36
 
30
37
  def call(**options)
@@ -45,15 +52,22 @@ module Ace
45
52
  force: options[:force],
46
53
  targets: targets,
47
54
  assignment_id: options[:assignment],
55
+ preservation_manifest: options[:preservation],
48
56
  input: @input,
49
57
  output: @output,
50
58
  on_progress: progress
51
59
  )
52
60
 
53
- return if options[:quiet]
54
-
55
61
  if options[:assignment]
56
62
  print_assignment_result(result)
63
+ return if options[:dry_run] || result[:aborted]
64
+
65
+ raise Ace::Support::Cli::Error, "Assignment #{options[:assignment]} is blocked: " \
66
+ "#{result[:assignment_candidate].reasons.join(", ")}" if result[:blocked]
67
+ unless result[:pruned_assignments].any?
68
+ raise Ace::Support::Cli::Error, "Failed to remove assignment #{options[:assignment]}"
69
+ end
70
+
57
71
  return
58
72
  end
59
73
 
@@ -62,14 +76,19 @@ module Ace
62
76
  return
63
77
  end
64
78
 
65
- if result[:aborted]
66
- puts "Prune aborted."
67
- return
68
- end
79
+ puts "Prune aborted." if result[:aborted]
80
+ return if result[:aborted]
69
81
 
70
82
  print_apply(result)
71
- rescue => e
72
- raise Ace::Support::Cli::Error.new(e.message)
83
+
84
+ blocked_count = Array(result[:unsafe]).length + Array(result[:blocked]).length +
85
+ Array(result[:failed]).length
86
+ return if blocked_count.zero?
87
+
88
+ raise Ace::Support::Cli::Error,
89
+ "#{blocked_count} candidate(s) blocked or failed; nothing unsafe was removed"
90
+ rescue Ace::Overseer::Error => e
91
+ raise Ace::Support::Cli::Error, e.message
73
92
  end
74
93
 
75
94
  private
@@ -77,23 +96,112 @@ module Ace
77
96
  def prune_lab(**options)
78
97
  raise Ace::Support::Cli::Error, "--assignment is not supported with Lab runtime; provide exact Work IDs" if options[:assignment]
79
98
  raise Ace::Support::Cli::Error, "--force is not supported with Lab runtime" if options[:force]
99
+ raise Ace::Support::Cli::Error, "--preservation is not supported with Lab runtime" if options[:preservation]
80
100
 
81
101
  works = Array(options[:targets]).map(&:to_s)
82
102
  raise Ace::Support::Cli::Error, "provide at least one exact Lab Work ID to prune" if works.empty?
83
103
 
84
- if options[:dry_run]
85
- return if options[:quiet]
104
+ classifications = if atomic_destroy_supported?
105
+ works.to_h do |work|
106
+ [work, @lab_safety_checker.check(
107
+ lab_client: @lab_client,
108
+ work_id: work,
109
+ preservation_proof: lab_preservation_proof(work)
110
+ )]
111
+ end
112
+ else
113
+ # The raw Lab surface cannot make the no-writer check and the
114
+ # destruction atomic; claiming a probe was safe would be a
115
+ # lie, so the whole path is unsupported and preserved.
116
+ unsupported = Molecules::LabPruneSafetyChecker::Classification.new(
117
+ safe?: false,
118
+ reason: "lab runtime cannot make the no-writer check and destruction atomic; " \
119
+ "preserve the Work instead"
120
+ )
121
+ works.to_h { |work| [work, unsupported] }
122
+ end
86
123
 
87
- works.each { |work| puts "Would destroy Lab Work #{work}: /usr/local/bin/lab work destroy #{work} --confirm" }
124
+ if options[:dry_run]
125
+ print_lab_dry_run(classifications)
88
126
  return
89
127
  end
90
128
 
91
129
  raise Ace::Support::Cli::Error, "Lab prune requires --yes after reviewing --dry-run" unless options[:yes]
92
130
 
131
+ blocked = []
132
+ destroyed = []
93
133
  works.each do |work|
134
+ classification = classifications[work]
135
+ unless classification.safe?
136
+ blocked << [work, classification.reason]
137
+ next
138
+ end
139
+
140
+ # Guarded delegation: re-read the authoritative state
141
+ # immediately before destroy; a changed state aborts.
142
+ recheck = @lab_safety_checker.check(
143
+ lab_client: @lab_client,
144
+ work_id: work,
145
+ preservation_proof: lab_preservation_proof(work)
146
+ )
147
+ unless recheck.safe?
148
+ blocked << [work, "state changed before destroy: #{recheck.reason}"]
149
+ next
150
+ end
151
+
94
152
  result = @lab_client.call("work", "destroy", work, "--confirm", json: false)
153
+ destroyed << work
95
154
  puts result unless options[:quiet]
96
155
  end
156
+
157
+ blocked.each do |work, reason|
158
+ puts "Blocked: lab work #{work}: #{reason}"
159
+ end
160
+ puts "#{destroyed.length} lab work(s) destroyed." unless options[:quiet]
161
+ return if blocked.empty?
162
+
163
+ raise Ace::Support::Cli::Error, "#{blocked.length} lab work(s) blocked; nothing unsafe was destroyed"
164
+ end
165
+
166
+ # Delegation is only honest when the Lab surface itself can make
167
+ # the state check and the destruction atomic. The raw CLI adapter
168
+ # cannot, so it reports unsupported; adapters with an atomic
169
+ # guarded destroy opt in.
170
+ def atomic_destroy_supported?
171
+ @lab_client.respond_to?(:supports_atomic_destroy?) && @lab_client.supports_atomic_destroy?
172
+ end
173
+
174
+ # Preservation evidence for a Lab Work comes from the Work's own
175
+ # documented surviving identity (repo/head/branch). A surface that
176
+ # does not document it cannot prove preservation.
177
+ def lab_preservation_proof(work)
178
+ entry = @lab_client.work_entry(work)
179
+ return Models::PreservationProof.blocked("lab work #{work} not found in authoritative status") if entry.nil?
180
+
181
+ repo = entry["repo"].to_s
182
+ head = entry["head"].to_s
183
+ branch = entry["branch"].to_s
184
+ if repo.empty? || head.empty? || branch.empty? || !repo.start_with?("/")
185
+ return Models::PreservationProof.blocked(
186
+ "lab work #{work} does not document preservation data (repo/head/branch)"
187
+ )
188
+ end
189
+
190
+ accepted_base = @preservation_checker.accepted_base_for(repo)
191
+ @preservation_checker.ancestry_proof(repo: repo, head: head, accepted_base: accepted_base)
192
+ rescue Ace::Overseer::Error => e
193
+ Models::PreservationProof.blocked("lab preservation evidence unavailable: #{e.message}")
194
+ end
195
+
196
+ def print_lab_dry_run(classifications)
197
+ puts "Lab prune classification:"
198
+ classifications.each do |work, classification|
199
+ if classification.safe?
200
+ puts " #{work}: safe (terminal, preserved, no in-flight work)"
201
+ else
202
+ puts " #{work}: BLOCKED — #{classification.reason}"
203
+ end
204
+ end
97
205
  end
98
206
 
99
207
  def print_assignment_result(result)
@@ -112,6 +220,7 @@ module Ace
112
220
  end
113
221
 
114
222
  if result[:blocked]
223
+ puts "Blocked: assignment #{candidate.assignment_id}: #{candidate.reasons.join(", ")}"
115
224
  return
116
225
  end
117
226
 
@@ -124,27 +233,36 @@ module Ace
124
233
  end
125
234
 
126
235
  def print_dry_run(result)
127
- forced = Array(result[:forced])
128
236
  puts "Candidates for cleanup:"
129
- if result[:safe].empty? && forced.empty?
237
+ if result[:safe].empty?
130
238
  puts " (none)"
131
239
  else
132
240
  result[:safe].each do |candidate|
133
241
  puts " task.#{candidate.task_id} - #{candidate.worktree_path}"
134
242
  end
135
- forced.each do |candidate|
136
- puts " task.#{candidate.task_id} - #{candidate.worktree_path} [FORCE]"
243
+ end
244
+ if result[:unsafe].any?
245
+ puts "Blocked candidates:"
246
+ result[:unsafe].each do |candidate|
247
+ puts " task.#{candidate.task_id} - #{candidate.worktree_path}"
248
+ candidate.reasons.each { |reason| puts " - #{reason}" }
137
249
  end
138
250
  end
139
251
  puts
140
- total = result[:safe].length + forced.length
141
- puts "#{total} worktree(s) can be pruned."
252
+ puts "#{result[:safe].length} worktree(s) can be pruned; " \
253
+ "#{result[:unsafe].length} blocked."
142
254
  end
143
255
 
144
256
  def print_apply(result)
145
257
  result[:pruned].each do |candidate|
146
258
  puts "Removed worktree task.#{candidate.task_id}"
147
259
  end
260
+ Array(result[:unsafe]).each do |candidate|
261
+ puts "Blocked: task.#{candidate.task_id}: #{candidate.reasons.join(", ")}"
262
+ end
263
+ Array(result[:blocked]).each do |entry|
264
+ puts "Blocked after recheck: task.#{entry[:candidate].task_id}: #{entry[:reasons].join(", ")}"
265
+ end
148
266
  result[:failed].each do |entry|
149
267
  puts "Failed to remove task.#{entry[:candidate].task_id}: #{entry[:error]}"
150
268
  end
@@ -4,18 +4,21 @@ module Ace
4
4
  module Overseer
5
5
  module Models
6
6
  class AssignmentPruneCandidate
7
- attr_reader :assignment_id, :assignment_name, :assignment_state, :location_path, :reasons
7
+ attr_reader :assignment_id, :assignment_name, :assignment_state, :location_path,
8
+ :attempts_terminal, :reasons
8
9
 
9
- def initialize(assignment_id:, assignment_name:, assignment_state:, location_path:, reasons: [])
10
+ def initialize(assignment_id:, assignment_name:, assignment_state:, location_path:,
11
+ attempts_terminal: true, reasons: [])
10
12
  @assignment_id = assignment_id.to_s.freeze
11
13
  @assignment_name = assignment_name.to_s.freeze
12
14
  @assignment_state = assignment_state.to_s.freeze
13
15
  @location_path = location_path.to_s.freeze
16
+ @attempts_terminal = attempts_terminal
14
17
  @reasons = reasons.map(&:to_s).freeze
15
18
  end
16
19
 
17
20
  def safe_to_prune?
18
- assignment_state == "completed"
21
+ assignment_state == "completed" && attempts_terminal
19
22
  end
20
23
 
21
24
  def to_h
@@ -24,6 +27,7 @@ module Ace
24
27
  assignment_name: assignment_name,
25
28
  assignment_state: assignment_state,
26
29
  location_path: location_path,
30
+ attempts_terminal: attempts_terminal,
27
31
  reasons: reasons,
28
32
  safe_to_prune: safe_to_prune?
29
33
  }
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ace
4
+ module Overseer
5
+ module Models
6
+ # Outcome of a preservation proof for one prune candidate.
7
+ #
8
+ # A proof is preserved only through an executed Git identity: accepted
9
+ # ancestor containment, full-tree equality, or an exact content
10
+ # transition against a verified destination. Anything else — missing,
11
+ # failed or ambiguous evidence — is not preserved and carries the
12
+ # blocking reason.
13
+ class PreservationProof
14
+ attr_reader :method, :reason, :head
15
+
16
+ def initialize(preserved:, method: nil, reason: nil, head: nil)
17
+ @preserved = preserved
18
+ @method = method
19
+ @reason = reason
20
+ @head = head
21
+ end
22
+
23
+ def self.preserved(method, head: nil)
24
+ new(preserved: true, method: method, head: head)
25
+ end
26
+
27
+ def self.blocked(reason)
28
+ new(preserved: false, reason: reason)
29
+ end
30
+
31
+ def preserved?
32
+ @preserved
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end