ace-handbook 0.32.0 → 0.34.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +11 -0
- data/handbook/workflow-instructions/handbook/perform-delivery.wf.md +148 -262
- data/lib/ace/handbook/atoms/provider_registry.rb +9 -0
- data/lib/ace/handbook/cli/commands/sync.rb +7 -0
- data/lib/ace/handbook/organisms/provider_syncer.rb +262 -5
- data/lib/ace/handbook/version.rb +1 -1
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 6bac5cea7762cdf27f71764a6d79f8149763f83d6fc001157db3b869f0313869
|
|
4
|
+
data.tar.gz: 3062b5aa4e48907c9eeb26cc81d713ad8ccecdc948a9270e6324ea7e1eccd596
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 0fa6aa552edbfae44a919b680f396adf7dff0b1dfd4c7e26d5c9c9eae183719ec169604bebe58591e9854a1eafa41a700e676d9c1378bed14e7718283a676fb9
|
|
7
|
+
data.tar.gz: 6e61af1f361bcb6909fccbda5281a0342793ba0a79fd02369befdc29aa21ef5d30b2477725165e31bcbdf018c131ae17ab8a6cab740672c1f786e7a5d3d6ca66
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.34.0] - 2026-10-05
|
|
11
|
+
|
|
12
|
+
### Changed
|
|
13
|
+
|
|
14
|
+
- Canonical delivery consumes neutral attempt-bound PR operations, current executed test/independent review receipts and exact authorized merge evidence; optional release preparation remains separate from publication.
|
|
15
|
+
|
|
16
|
+
## [0.33.0] - 2026-09-28
|
|
17
|
+
|
|
18
|
+
### Added
|
|
19
|
+
- Provider manifests may declare an optional `extensions_dir`; `ace-handbook sync` now projects extension assets from `ace-handbook-integration-*` packages (`handbook/extensions/**`) into that directory (idempotent copies; prunes only stale paths recorded in the `.ace-handbook-projection.json` receipt, leaving user-authored extensions untouched). Destinations are validated before anything is written; a failed projection rolls back overwritten **and** pruned assets so the previous installation stays complete and loadable, and the retry needs no manual cleanup. Sync summary output gains a `; extensions <dir> (N files, U updated, R removed)` segment when a provider projects extensions. `ProviderRegistry` exposes `extensions_dir(provider)` and `package_root(provider)`.
|
|
20
|
+
|
|
10
21
|
## [0.32.0] - 2026-09-27
|
|
11
22
|
|
|
12
23
|
### Added
|
|
@@ -1,272 +1,158 @@
|
|
|
1
1
|
---
|
|
2
2
|
doc-type: workflow
|
|
3
|
-
title: Perform
|
|
4
|
-
purpose: Coordinate
|
|
5
|
-
ace-docs:
|
|
6
|
-
last-updated: 2026-03-12
|
|
7
|
-
last-checked: 2026-03-21
|
|
3
|
+
title: Perform attempt-bound delivery
|
|
4
|
+
purpose: Coordinate implementation, exact-head review and authorized delivery on the selected forge
|
|
8
5
|
---
|
|
9
6
|
|
|
10
|
-
# Perform
|
|
7
|
+
# Perform attempt-bound delivery
|
|
11
8
|
|
|
12
9
|
## Goal
|
|
13
10
|
|
|
14
|
-
|
|
11
|
+
Deliver one committed candidate in explicit standalone or managed mode, using
|
|
12
|
+
the selected forge and current executed tests and independent review.
|
|
15
13
|
|
|
16
14
|
## Prerequisites
|
|
17
15
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
6.
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
```
|
|
162
|
-
✓ Step N complete: [step description]
|
|
163
|
-
→ Next: Step N+1 - [next step description]
|
|
164
|
-
```
|
|
165
|
-
|
|
166
|
-
3. Read todo list to confirm next step
|
|
167
|
-
4. Continue IMMEDIATELY to next step (no user prompt needed unless blocked)
|
|
168
|
-
|
|
169
|
-
**On Failure:**
|
|
170
|
-
|
|
171
|
-
1. Mark the current todo as blocked (keep as in_progress)
|
|
172
|
-
2. Output failure message:
|
|
173
|
-
|
|
174
|
-
```
|
|
175
|
-
✗ Step N failed: [step description]
|
|
176
|
-
→ Error: [error description]
|
|
177
|
-
→ Action needed: [what user needs to do]
|
|
178
|
-
```
|
|
179
|
-
|
|
180
|
-
3. Ask user for guidance using AskUserQuestion
|
|
181
|
-
4. Resume workflow after issue is resolved
|
|
182
|
-
|
|
183
|
-
## Auto-Continue Protocol
|
|
184
|
-
|
|
185
|
-
**CRITICAL:** This workflow should AUTO-CONTINUE between steps without waiting for user input.
|
|
186
|
-
|
|
187
|
-
The only times to pause and ask the user:
|
|
188
|
-
|
|
189
|
-
* Plan mode approval (Phase 1)
|
|
190
|
-
* Step failure requiring user intervention
|
|
191
|
-
* Ambiguous instructions requiring clarification
|
|
192
|
-
* External blockers (CI failures, merge conflicts, etc.)
|
|
193
|
-
|
|
194
|
-
For all other transitions, immediately proceed to the next step after checkpoint.
|
|
195
|
-
|
|
196
|
-
## Scope Variations
|
|
197
|
-
|
|
198
|
-
### Full Delivery (default)
|
|
199
|
-
|
|
200
|
-
All steps from implementation through review cycles. Load `ace-bundle wfi://handbook/perform-delivery`.
|
|
201
|
-
|
|
202
|
-
### Implementation Only
|
|
203
|
-
|
|
204
|
-
Skip PR and review phases (useful for WIP branches). Load `ace-bundle wfi://handbook/perform-delivery`.
|
|
205
|
-
|
|
206
|
-
### Review Only
|
|
207
|
-
|
|
208
|
-
Skip implementation, start from PR creation. Load `ace-bundle wfi://handbook/perform-delivery`.
|
|
209
|
-
|
|
210
|
-
### Custom Instructions
|
|
211
|
-
|
|
212
|
-
Provide inline instructions instead of task reference. Load `ace-bundle wfi://handbook/perform-delivery`.
|
|
213
|
-
|
|
214
|
-
## Success Criteria
|
|
215
|
-
|
|
216
|
-
* All todo items marked as completed
|
|
217
|
-
* No skipped or forgotten steps
|
|
218
|
-
* All commits pushed to remote
|
|
219
|
-
* PR created (if applicable)
|
|
220
|
-
* All HIGH/CRITICAL review feedback addressed
|
|
221
|
-
* Test suite passing
|
|
222
|
-
* Task marked as done in taskflow
|
|
223
|
-
|
|
224
|
-
## Error Handling
|
|
225
|
-
|
|
226
|
-
**Step Fails to Complete:**
|
|
227
|
-
|
|
228
|
-
* Keep todo item as in_progress
|
|
229
|
-
* Log the error with context
|
|
230
|
-
* Ask user for guidance
|
|
231
|
-
* Resume from failed step after resolution
|
|
232
|
-
|
|
233
|
-
**User Interruption:**
|
|
234
|
-
|
|
235
|
-
* Save current state (which step is in progress)
|
|
236
|
-
* Allow resume from interruption point
|
|
237
|
-
* Maintain todo list across session
|
|
238
|
-
|
|
239
|
-
**External Tool Failure:**
|
|
240
|
-
|
|
241
|
-
* Report which tool failed and the error
|
|
242
|
-
* Suggest manual alternatives if available
|
|
243
|
-
* Wait for user guidance before proceeding
|
|
244
|
-
|
|
245
|
-
## Usage Examples
|
|
246
|
-
|
|
247
|
-
### Full Task Delivery
|
|
248
|
-
|
|
249
|
-
> "Perform delivery for task 215.03"
|
|
250
|
-
|
|
251
|
-
This will:
|
|
252
|
-
1. Load task context
|
|
253
|
-
2. Implement the task
|
|
254
|
-
3. Commit, release, mark done
|
|
255
|
-
4. Create PR
|
|
256
|
-
5. Review and iterate
|
|
257
|
-
|
|
258
|
-
### Review Cycle Only
|
|
259
|
-
|
|
260
|
-
> "Perform delivery for task 215.03 --scope review"
|
|
261
|
-
|
|
262
|
-
This will:
|
|
263
|
-
1. Create/update PR
|
|
264
|
-
2. Review and implement feedback
|
|
265
|
-
3. Run test suite
|
|
266
|
-
4. Iterate until clean
|
|
267
|
-
|
|
268
|
-
### Custom Workflow
|
|
269
|
-
|
|
270
|
-
> "Perform delivery: implement the dark mode feature, commit, create PR, and run one review cycle"
|
|
271
|
-
|
|
272
|
-
This will parse the custom instructions and create a todo list matching the requested steps.
|
|
16
|
+
- A reviewed task or an explicit implementation scope and project.
|
|
17
|
+
- For managed delivery, a managed assignment and an active scoped attempt for each
|
|
18
|
+
delivery action; use the actual assignment and
|
|
19
|
+
attempt IDs from the execution boundary, never caller-invented actor identity.
|
|
20
|
+
- For remote delivery, a pushed candidate and explicit fork/canonical provenance:
|
|
21
|
+
mode, head_repository_url, head_ref, base_repository_url and base_ref.
|
|
22
|
+
- An applicable authorization for each effect. Transport credentials do not grant
|
|
23
|
+
permission to merge, publish, deploy or synchronize.
|
|
24
|
+
|
|
25
|
+
Choose standalone mode for ordinary local task/inline implementation, tests, review
|
|
26
|
+
and optional requested version/changelog preparation without a managed assignment.
|
|
27
|
+
Choose managed mode when the request supplies an assignment or requires its
|
|
28
|
+
canonical attempt/receipt boundary. Standalone output cannot satisfy managed or
|
|
29
|
+
protected acceptance. Local-only implementation needs no forge selection or
|
|
30
|
+
scoped attempt. If remote delivery is requested,
|
|
31
|
+
use forge_server or forge_default: true, mutually exclusive; otherwise use the
|
|
32
|
+
repository remote. Preserve the first resolved server/repository/PR identity
|
|
33
|
+
throughout the assignment. Missing service or authorization yields a specific
|
|
34
|
+
blocker; this workflow does not invent grants or impersonate an integrator.
|
|
35
|
+
|
|
36
|
+
## Project context loading
|
|
37
|
+
|
|
38
|
+
Read the task or inline scope and actual committed diff. In managed mode also
|
|
39
|
+
read assignment status, current attempt and retained delivery parameters. If a PR
|
|
40
|
+
is recorded, inspect it with
|
|
41
|
+
`ace-git pr show IDENTIFIER --format json --server NAME` using the retained selection
|
|
42
|
+
(or `--default-server`, otherwise remote resolution). Inside ACE use source
|
|
43
|
+
`bin/ace-*` commands. Load the relevant implementation and test instructions.
|
|
44
|
+
|
|
45
|
+
## Standalone delivery
|
|
46
|
+
|
|
47
|
+
Implement and commit the requested local task/inline scope using existing task
|
|
48
|
+
and Git tools. Execute appropriate tests and independent review, retain their
|
|
49
|
+
actual artifact references and candidate SHA, and record unfinished criteria.
|
|
50
|
+
No managed assignment, protected service receipt or canonical qjl acceptance is
|
|
51
|
+
claimed by this route. Local scope may end here.
|
|
52
|
+
|
|
53
|
+
For an explicitly authorized standalone remote scope, use existing neutral
|
|
54
|
+
primitives on the chosen base repository/forge. Record actual head/base
|
|
55
|
+
repository URLs and refs; canonical provenance means matching repositories,
|
|
56
|
+
while fork provenance requires its explicit source URL. A pushed source ref must
|
|
57
|
+
match the candidate SHA. Draft creation defaults to true:
|
|
58
|
+
|
|
59
|
+
`ace-git pr create --server NAME --head HEAD-REF --head-repo HEAD-REPOSITORY-URL --base BASE-REF --expected-head CANDIDATE-SHA --title TITLE --body-file DESCRIPTION --format json`
|
|
60
|
+
|
|
61
|
+
Read the returned exact PR identity. Update with
|
|
62
|
+
`ace-git pr update IDENTIFIER --server NAME --expected-head CANDIDATE-SHA --body-file DESCRIPTION --format json`.
|
|
63
|
+
Use the retained default/remote selection instead of `--server NAME` when
|
|
64
|
+
applicable. Review through the existing neutral review workflow with the same
|
|
65
|
+
selection. Before readiness or merge, require passed executed tests and accepted
|
|
66
|
+
independent review for that exact candidate and the actual operation authorization;
|
|
67
|
+
the low-level PR command does not establish these gates itself. Readiness uses
|
|
68
|
+
`ace-git pr ready IDENTIFIER --server NAME --expected-head CANDIDATE-SHA --format json`.
|
|
69
|
+
Only an actually authorized standalone integrator may call
|
|
70
|
+
`ace-git pr merge IDENTIFIER --server NAME --expected-head CANDIDATE-SHA --method METHOD --format json`.
|
|
71
|
+
This is not a worker fallback when a managed/protected service is unavailable.
|
|
72
|
+
Remote errors or uncertain effects remain explicit unresolved outcomes; use
|
|
73
|
+
authoritative reads and existing provider reconciliation, never blind replay.
|
|
74
|
+
|
|
75
|
+
Perform any requested local version/changelog changes before final test/review
|
|
76
|
+
and merge evidence is accepted. Preparation after a completed delivery is a
|
|
77
|
+
separate follow-up candidate/scope and must obtain its own required evidence.
|
|
78
|
+
Publication remains separately authorized and gated, including OTP when required.
|
|
79
|
+
|
|
80
|
+
## Managed delivery steps
|
|
81
|
+
|
|
82
|
+
1. Record the requested delivery steps and their owners before implementation.
|
|
83
|
+
Follow the existing task-work plan. Commit the implementation and run appropriate
|
|
84
|
+
package tests. If requested, perform local version/changelog preparation with
|
|
85
|
+
`ace-bundle wfi://release/local` before final evidence and merge; it does not
|
|
86
|
+
publish. Keep unfinished task criteria open; a bounded source checkpoint
|
|
87
|
+
cannot mark an entire installed task done.
|
|
88
|
+
2. For remote delivery, prepare a concrete title and description from the changes
|
|
89
|
+
and executed validation. Load `ace-bundle wfi://git/pr/create` and use the actual
|
|
90
|
+
managed assignment and active delivery attempt. The executable operation is
|
|
91
|
+
`ace-assign delivery --assignment ID --attempt ATTEMPT --operation create --title TITLE --body-file DESCRIPTION`.
|
|
92
|
+
Provide `--parameters FILE` if required for the assignment's explicit delivery
|
|
93
|
+
mapping. Creation defaults to a draft. Record its returned server, repositories,
|
|
94
|
+
refs, PR number/URL and exact candidate_head.
|
|
95
|
+
3. Execute the independent review workflow `ace-bundle wfi://review/pr` against that
|
|
96
|
+
exact PR and retained forge selection. Preserve its actual round completion rule,
|
|
97
|
+
findings and reviewer identities. Fix confirmed blocking findings and rerun
|
|
98
|
+
affected tests. Every new committed candidate, including task-only edits,
|
|
99
|
+
invalidates prior ready/merge evidence. Historical approval remains history.
|
|
100
|
+
4. Obtain coordinator-accepted current-head test and independent review receipts
|
|
101
|
+
through the existing assignment boundary. File existence, fabricated approval,
|
|
102
|
+
exit zero or a worker claim cannot replace acceptance. Distinguish base_head,
|
|
103
|
+
candidate_head, evidence_git_ref and journal_commit; evidence commits do not
|
|
104
|
+
advance the deliverable branch. CI status is visible and advisory; failed
|
|
105
|
+
executed tests, stale evidence or unresolved review findings still block.
|
|
106
|
+
5. Prepare the final PR body with the concrete behavior, executed checks,
|
|
107
|
+
independent verdict and remaining limitations. Load
|
|
108
|
+
`ace-bundle wfi://git/pr/update` and execute
|
|
109
|
+
`ace-assign delivery --assignment ID --attempt ATTEMPT --operation update --body-file DESCRIPTION`.
|
|
110
|
+
Mark ready only with accepted exact-head reference files:
|
|
111
|
+
`ace-assign delivery --assignment ID --attempt ATTEMPT --operation ready --tests TEST-REF --review REVIEW-REF`.
|
|
112
|
+
Each reference file contains only attempt_id and receipt_digest.
|
|
113
|
+
6. Hand off merge only to the existing authorized integrator/service for the exact
|
|
114
|
+
PR, candidate and operation. Its executor uses the neutral provider merge with
|
|
115
|
+
both required values:
|
|
116
|
+
`ace-git pr merge IDENTIFIER --server NAME --expected-head CANDIDATE-SHA --method METHOD --format json`.
|
|
117
|
+
METHOD is the authorized squash, merge or rebase choice, never an implicit
|
|
118
|
+
default. The assignment worker consumes a completed verified service receipt:
|
|
119
|
+
`ace-assign delivery --assignment ID --attempt ATTEMPT --operation merge --tests TEST-REF --review REVIEW-REF --service-request REQUEST`.
|
|
120
|
+
This command does not dispatch or execute its own pending merge. If no actual
|
|
121
|
+
configured executor or valid receipt is available, retain the routed blocker.
|
|
122
|
+
7. Local version/changelog preparation is optional requested work from step1.
|
|
123
|
+
It must be included before final exact-head evidence and merge. Preparation
|
|
124
|
+
requested after a completed merge starts a separate follow-up candidate/scope;
|
|
125
|
+
the old merge does not accept its amended content.
|
|
126
|
+
Publication, deployment and synchronization are distinct operations with their
|
|
127
|
+
own valid authorization, executed technical evidence and any required OTP.
|
|
128
|
+
Authorized merge does not authorize them. Do not add an unnecessary proposal
|
|
129
|
+
for an already authorized operation or fabricate an unavailable proposal API.
|
|
130
|
+
8. Record the exact delivered scope, candidate, receipt references, review verdict
|
|
131
|
+
and remaining gates. A requested delivery-only scope can finish after its
|
|
132
|
+
authorized merge; publication is not mandatory. Keep the task in progress until
|
|
133
|
+
all of its actual acceptance criteria are satisfied.
|
|
134
|
+
|
|
135
|
+
## Uncertainty and resume
|
|
136
|
+
|
|
137
|
+
In managed mode, read assignment status and retained qjl delivery events before
|
|
138
|
+
resuming. Repeat read/check operations safely. On interrupted create, update or readiness, use the
|
|
139
|
+
same delivery command for exact-selector reconciliation. One matching draft may
|
|
140
|
+
be adopted; zero remains unresolved and multiple matches conflict. A different
|
|
141
|
+
requested effect cannot be reported complete merely because an earlier effect
|
|
142
|
+
was recovered.
|
|
143
|
+
|
|
144
|
+
Unknown merge/publication remains unknown pending its existing owner's
|
|
145
|
+
reconciliation. Never blindly replay an effect, substitute a generic PR result
|
|
146
|
+
for a service receipt or translate a blocker into direct gh/fj commands.
|
|
147
|
+
|
|
148
|
+
## Success criteria
|
|
149
|
+
|
|
150
|
+
- The requested scope has real artifact, current test and independent review
|
|
151
|
+
evidence for its exact candidate.
|
|
152
|
+
- Remote identity and explicit provenance remain fixed with honest unresolved
|
|
153
|
+
outcomes preserved; managed mode records them in the existing attempt journal.
|
|
154
|
+
- Readiness and authorized integration consume current evidence; managed merge
|
|
155
|
+
additionally consumes exact service receipts. Standalone output cannot satisfy
|
|
156
|
+
managed/protected acceptance. CI alone neither grants nor vetoes acceptance.
|
|
157
|
+
- Optional publication remains separately authorized and unfinished installed
|
|
158
|
+
criteria remain visible.
|
|
@@ -38,6 +38,15 @@ module Ace
|
|
|
38
38
|
manifest(provider)["prompts_dir"]
|
|
39
39
|
end
|
|
40
40
|
|
|
41
|
+
def extensions_dir(provider)
|
|
42
|
+
manifest(provider)["extensions_dir"]
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def package_root(provider)
|
|
46
|
+
manifest_dir = File.dirname(manifest(provider).fetch("_manifest_path"))
|
|
47
|
+
File.expand_path("../../..", manifest_dir)
|
|
48
|
+
end
|
|
49
|
+
|
|
41
50
|
def manifests
|
|
42
51
|
@manifests ||= begin
|
|
43
52
|
all_paths = local_manifest_paths + installed_manifest_paths
|
|
@@ -26,6 +26,7 @@ module Ace
|
|
|
26
26
|
"(#{result[:projected_skills]} skills, #{result[:updated_files]} updated, " \
|
|
27
27
|
"#{result[:removed_entries]} removed)"
|
|
28
28
|
line += format_prompts_summary(result) if result.key?(:relative_prompts_dir)
|
|
29
|
+
line += format_extensions_summary(result) if result.key?(:relative_extensions_dir)
|
|
29
30
|
puts line
|
|
30
31
|
end
|
|
31
32
|
print_inventory_summary(results)
|
|
@@ -41,6 +42,12 @@ module Ace
|
|
|
41
42
|
"#{result[:removed_prompt_entries]} removed)"
|
|
42
43
|
end
|
|
43
44
|
|
|
45
|
+
def format_extensions_summary(result)
|
|
46
|
+
"; extensions #{result[:relative_extensions_dir]} " \
|
|
47
|
+
"(#{result[:projected_extensions]} files, #{result[:updated_extension_files]} updated, " \
|
|
48
|
+
"#{result[:removed_extension_entries]} removed)"
|
|
49
|
+
end
|
|
50
|
+
|
|
44
51
|
def print_inventory_summary(results)
|
|
45
52
|
source_breakdown = results.first&.fetch(:source_breakdown, {})
|
|
46
53
|
return if source_breakdown.nil? || source_breakdown.empty?
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "fileutils"
|
|
4
|
+
require "json"
|
|
5
|
+
require "pathname"
|
|
6
|
+
require "tempfile"
|
|
4
7
|
require "yaml"
|
|
5
8
|
|
|
6
9
|
module Ace
|
|
@@ -8,6 +11,7 @@ module Ace
|
|
|
8
11
|
module Organisms
|
|
9
12
|
class ProviderSyncer
|
|
10
13
|
PROJECTION_SOURCE_PREFIX = "ace-handbook-integration-"
|
|
14
|
+
EXTENSION_RECEIPT_NAME = ".ace-handbook-projection.json"
|
|
11
15
|
|
|
12
16
|
attr_reader :project_root, :registry, :inventory, :prompt_inventory, :config
|
|
13
17
|
|
|
@@ -36,11 +40,12 @@ module Ace
|
|
|
36
40
|
private
|
|
37
41
|
|
|
38
42
|
def sync_provider(provider, skills:, source_breakdown:)
|
|
39
|
-
|
|
43
|
+
result = sync_skills(provider, skills: skills, source_breakdown: source_breakdown)
|
|
40
44
|
prompts_result = sync_prompts(provider)
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
45
|
+
result = result.merge(prompts_result) unless prompts_result.nil?
|
|
46
|
+
extensions_result = sync_extensions(provider)
|
|
47
|
+
result = result.merge(extensions_result) unless extensions_result.nil?
|
|
48
|
+
result
|
|
44
49
|
end
|
|
45
50
|
|
|
46
51
|
def sync_skills(provider, skills:, source_breakdown:)
|
|
@@ -171,6 +176,258 @@ module Ace
|
|
|
171
176
|
stale.size
|
|
172
177
|
end
|
|
173
178
|
|
|
179
|
+
# Extension assets project from a package's handbook/extensions/ tree
|
|
180
|
+
# into the provider manifest's extensions_dir. The target directory may
|
|
181
|
+
# hold user-authored extensions, so pruning is receipt-based: only files
|
|
182
|
+
# recorded in the projection receipt from a previous sync are removed.
|
|
183
|
+
def sync_extensions(provider)
|
|
184
|
+
extensions_dir = registry.extensions_dir(provider)
|
|
185
|
+
return nil if extensions_dir.nil? || extensions_dir.to_s.empty?
|
|
186
|
+
|
|
187
|
+
source_dir = File.join(registry.package_root(provider), "handbook", "extensions")
|
|
188
|
+
expected = extension_source_files(source_dir)
|
|
189
|
+
output_dir = File.join(project_root, extensions_dir)
|
|
190
|
+
FileUtils.mkdir_p(output_dir)
|
|
191
|
+
|
|
192
|
+
receipt = read_projection_receipt(output_dir, provider)
|
|
193
|
+
# Ownership requires a valid receipt from this projection explicitly
|
|
194
|
+
# listing the path. A missing, symlinked, or corrupt receipt leaves
|
|
195
|
+
# ownership unknown: any conflicting destination file is refused so
|
|
196
|
+
# user-authored extensions can never be overwritten on a guess.
|
|
197
|
+
owned_paths = receipt&.fetch("files", []) || []
|
|
198
|
+
reject_unowned_collisions(output_dir, expected.keys, owned_paths)
|
|
199
|
+
|
|
200
|
+
# Pruning happens only after the replacement projection committed:
|
|
201
|
+
# a failed upgrade must leave the previous installation usable.
|
|
202
|
+
stale_paths = receipt&.fetch("files", [])&.-(expected.keys) || []
|
|
203
|
+
updated_files = 0
|
|
204
|
+
|
|
205
|
+
# Resolve and validate every destination before writing anything: a
|
|
206
|
+
# failure mid-copy must never leave half a projection behind (files
|
|
207
|
+
# written before a later validation error would be unowned and wedge
|
|
208
|
+
# every retry).
|
|
209
|
+
output_paths = expected.each_with_object({}) do |(relative_path, source_path), map|
|
|
210
|
+
map[relative_path] = validate_projection_destination(output_dir, relative_path, extensions_dir)
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
newly_created = []
|
|
214
|
+
saved_originals = {}
|
|
215
|
+
failed_prunes = []
|
|
216
|
+
begin
|
|
217
|
+
output_paths.each do |relative_path, output_path|
|
|
218
|
+
source_path = expected.fetch(relative_path)
|
|
219
|
+
FileUtils.mkdir_p(File.dirname(output_path))
|
|
220
|
+
created = !File.exist?(output_path)
|
|
221
|
+
# Register before copying: a copy that fails partway (disk
|
|
222
|
+
# exhaustion) must still roll back the partial file. Overwritten
|
|
223
|
+
# files are receipt-owned, so rollback restores their previous
|
|
224
|
+
# content instead of deleting them.
|
|
225
|
+
saved_originals[output_path] = File.binread(output_path) unless created
|
|
226
|
+
newly_created << output_path if created
|
|
227
|
+
next if !created && FileUtils.compare_file(source_path, output_path)
|
|
228
|
+
|
|
229
|
+
FileUtils.cp(source_path, output_path)
|
|
230
|
+
updated_files += 1
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
removed_entries = prune_stale_extension_files(output_dir, stale_paths, failed_prunes, saved_originals)
|
|
234
|
+
# Stale files whose removal failed keep their ownership until a
|
|
235
|
+
# later sync manages to delete them.
|
|
236
|
+
write_projection_receipt(output_dir, provider, expected.keys + failed_prunes)
|
|
237
|
+
rescue
|
|
238
|
+
# Roll back so a retry never finds its own partial installation
|
|
239
|
+
# standing in the way as an unowned collision, and restore the
|
|
240
|
+
# previous content of overwritten and pruned files — the previous
|
|
241
|
+
# installation must stay complete and loadable.
|
|
242
|
+
newly_created.each do |path|
|
|
243
|
+
FileUtils.rm_f(path)
|
|
244
|
+
rescue
|
|
245
|
+
nil
|
|
246
|
+
end
|
|
247
|
+
saved_originals.each do |path, content|
|
|
248
|
+
# Pruned assets restore after pruning emptied and removed their
|
|
249
|
+
# directories; a path swapped to a symlink mid-sync is never
|
|
250
|
+
# written through.
|
|
251
|
+
next if File.symlink?(path)
|
|
252
|
+
|
|
253
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
254
|
+
File.binwrite(path, content)
|
|
255
|
+
rescue
|
|
256
|
+
nil
|
|
257
|
+
end
|
|
258
|
+
newly_created.each do |path|
|
|
259
|
+
remove_empty_parent_dirs(File.dirname(path), output_dir)
|
|
260
|
+
end
|
|
261
|
+
raise
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
{
|
|
265
|
+
relative_extensions_dir: extensions_dir,
|
|
266
|
+
projected_extensions: expected.size,
|
|
267
|
+
updated_extension_files: updated_files,
|
|
268
|
+
removed_extension_entries: removed_entries
|
|
269
|
+
}
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
# The destination directory may hold user-authored extensions, so a
|
|
273
|
+
# destination file that exists before the first ACE projection (or one
|
|
274
|
+
# the receipt does not own) is never overwritten: the sync fails with
|
|
275
|
+
# a visible error instead of destroying user files.
|
|
276
|
+
def reject_unowned_collisions(output_dir, expected_relative_paths, owned_paths)
|
|
277
|
+
expected_relative_paths.each do |relative_path|
|
|
278
|
+
next if owned_paths.include?(relative_path)
|
|
279
|
+
|
|
280
|
+
candidate = safe_projection_path(output_dir, relative_path)
|
|
281
|
+
next if candidate.nil? || !File.exist?(candidate)
|
|
282
|
+
|
|
283
|
+
raise StandardError,
|
|
284
|
+
"refusing to overwrite existing file #{candidate}; it is not claimed by a valid projection receipt " \
|
|
285
|
+
"(missing, symlinked, or corrupt receipts claim nothing). Move or remove the file — or repair or remove " \
|
|
286
|
+
"#{File.join(output_dir, EXTENSION_RECEIPT_NAME)} — then rerun `ace-handbook sync`."
|
|
287
|
+
end
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
# Resolve and fully validate a destination before any file is
|
|
291
|
+
# written: every ancestor must be a directory or creatable (no
|
|
292
|
+
# regular files, no symlinks), so a mid-copy failure can never leave
|
|
293
|
+
# a half-installed projection that wedges retries.
|
|
294
|
+
def validate_projection_destination(output_dir, relative_path, extensions_dir)
|
|
295
|
+
segments = relative_path.split(File::SEPARATOR)
|
|
296
|
+
if segments.empty? || segments.any? { |segment| segment.empty? || segment == "." || segment == ".." }
|
|
297
|
+
raise StandardError, "cannot project #{relative_path.inspect}: invalid destination path"
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
current = Pathname.new(File.realpath(output_dir))
|
|
301
|
+
segments.each do |segment|
|
|
302
|
+
current = current.join(segment)
|
|
303
|
+
if File.symlink?(current.to_s)
|
|
304
|
+
raise StandardError,
|
|
305
|
+
"cannot project #{relative_path} into #{extensions_dir}: a symlinked path component would escape the projection directory"
|
|
306
|
+
end
|
|
307
|
+
next if segments.last == segment
|
|
308
|
+
|
|
309
|
+
if !current.directory? && File.exist?(current.to_s)
|
|
310
|
+
raise StandardError,
|
|
311
|
+
"cannot project #{relative_path} into #{extensions_dir}: #{current} exists and is not a directory"
|
|
312
|
+
end
|
|
313
|
+
end
|
|
314
|
+
if File.directory?(current.to_s)
|
|
315
|
+
# Receipt ownership of a path never authorizes writing through a
|
|
316
|
+
# directory planted at that path — cp would reinterpret the
|
|
317
|
+
# destination as a container and overwrite unowned content.
|
|
318
|
+
raise StandardError,
|
|
319
|
+
"cannot project #{relative_path} into #{extensions_dir}: #{current} exists and is a directory"
|
|
320
|
+
end
|
|
321
|
+
current.to_s
|
|
322
|
+
rescue Errno::ENOENT
|
|
323
|
+
raise StandardError, "cannot project #{relative_path} into #{extensions_dir}: projection directory vanished"
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
def extension_source_files(source_dir)
|
|
327
|
+
return {} unless Dir.exist?(source_dir)
|
|
328
|
+
|
|
329
|
+
source_root = Pathname.new(source_dir)
|
|
330
|
+
Dir.glob(File.join(source_dir, "**", "*"))
|
|
331
|
+
.select { |path| File.file?(path) }
|
|
332
|
+
.to_h { |path| [Pathname.new(path).relative_path_from(source_root).to_s, path] }
|
|
333
|
+
end
|
|
334
|
+
|
|
335
|
+
def prune_stale_extension_files(output_dir, stale_relative_paths, failed_prunes, rollback_ledger)
|
|
336
|
+
stale = stale_relative_paths
|
|
337
|
+
removed = 0
|
|
338
|
+
stale.each do |relative_path|
|
|
339
|
+
contained = safe_projection_path(output_dir, relative_path)
|
|
340
|
+
next if contained.nil?
|
|
341
|
+
next unless File.file?(contained)
|
|
342
|
+
|
|
343
|
+
begin
|
|
344
|
+
# Each pruned asset joins the rollback ledger before deletion:
|
|
345
|
+
# if publication later fails, restoring only overwritten files
|
|
346
|
+
# would leave the retained previous entrypoint importing a
|
|
347
|
+
# module that no longer exists.
|
|
348
|
+
rollback_ledger[contained] = File.binread(contained)
|
|
349
|
+
FileUtils.rm(contained)
|
|
350
|
+
removed += 1
|
|
351
|
+
remove_empty_parent_dirs(File.dirname(contained), output_dir)
|
|
352
|
+
rescue
|
|
353
|
+
# Keep ownership of the undeletable file: the next sync retries
|
|
354
|
+
# the removal instead of forgetting the file belongs to ACE.
|
|
355
|
+
failed_prunes << relative_path
|
|
356
|
+
end
|
|
357
|
+
end
|
|
358
|
+
removed
|
|
359
|
+
end
|
|
360
|
+
|
|
361
|
+
# Receipt files are projected data, so treat them as untrusted, and
|
|
362
|
+
# destination paths are resolved on the real filesystem: only plain
|
|
363
|
+
# relative paths whose every component stays inside output_dir — with
|
|
364
|
+
# no symlinked component, which could point anywhere — may be written
|
|
365
|
+
# or pruned.
|
|
366
|
+
def safe_projection_path(output_dir, relative_path)
|
|
367
|
+
return nil unless relative_path.is_a?(String)
|
|
368
|
+
return nil if relative_path.include?("\0")
|
|
369
|
+
|
|
370
|
+
segments = relative_path.split(File::SEPARATOR)
|
|
371
|
+
return nil if segments.empty? || segments.any? { |segment| segment.empty? || segment == "." || segment == ".." }
|
|
372
|
+
|
|
373
|
+
current = Pathname.new(File.realpath(output_dir))
|
|
374
|
+
segments.each do |segment|
|
|
375
|
+
current = current.join(segment)
|
|
376
|
+
return nil if File.symlink?(current.to_s)
|
|
377
|
+
end
|
|
378
|
+
current.to_s
|
|
379
|
+
rescue Errno::ENOENT
|
|
380
|
+
nil
|
|
381
|
+
end
|
|
382
|
+
|
|
383
|
+
def read_projection_receipt(output_dir, provider)
|
|
384
|
+
receipt_path = File.join(output_dir, EXTENSION_RECEIPT_NAME)
|
|
385
|
+
return nil if File.symlink?(receipt_path)
|
|
386
|
+
return nil unless File.file?(receipt_path)
|
|
387
|
+
|
|
388
|
+
receipt = JSON.parse(File.read(receipt_path))
|
|
389
|
+
return nil unless receipt.is_a?(Hash) && receipt["files"].is_a?(Array)
|
|
390
|
+
# A receipt from another projector (or without provenance) claims
|
|
391
|
+
# nothing: only our own source marker authorizes overwrites and
|
|
392
|
+
# pruning.
|
|
393
|
+
return nil unless receipt["source"] == "#{PROJECTION_SOURCE_PREFIX}#{provider}"
|
|
394
|
+
|
|
395
|
+
receipt
|
|
396
|
+
rescue JSON::ParserError
|
|
397
|
+
nil
|
|
398
|
+
end
|
|
399
|
+
|
|
400
|
+
def write_projection_receipt(output_dir, provider, relative_paths)
|
|
401
|
+
receipt_path = File.join(output_dir, EXTENSION_RECEIPT_NAME)
|
|
402
|
+
# Create the temp file exclusively in the destination directory: a
|
|
403
|
+
# predictable, plainly-opened temp path could follow a planted
|
|
404
|
+
# symlink and truncate a file outside the projection. The rename
|
|
405
|
+
# then replaces any symlink at the receipt path itself.
|
|
406
|
+
temp = Tempfile.create([".ace-handbook-projection", ".tmp"], output_dir)
|
|
407
|
+
begin
|
|
408
|
+
temp.write(JSON.pretty_generate(
|
|
409
|
+
"source" => "#{PROJECTION_SOURCE_PREFIX}#{provider}",
|
|
410
|
+
"files" => relative_paths.sort
|
|
411
|
+
))
|
|
412
|
+
temp.close
|
|
413
|
+
File.rename(temp.path, receipt_path)
|
|
414
|
+
ensure
|
|
415
|
+
temp.close
|
|
416
|
+
FileUtils.rm_f(temp.path)
|
|
417
|
+
end
|
|
418
|
+
end
|
|
419
|
+
|
|
420
|
+
def remove_empty_parent_dirs(dir, stop_dir)
|
|
421
|
+
until dir == stop_dir
|
|
422
|
+
FileUtils.rmdir(dir)
|
|
423
|
+
dir = File.dirname(dir)
|
|
424
|
+
end
|
|
425
|
+
rescue Errno::ENOTEMPTY, Errno::ENOENT
|
|
426
|
+
# Surviving siblings (or a vanished ancestor) stop the ascent; the
|
|
427
|
+
# sync must continue writing remaining assets and the receipt.
|
|
428
|
+
nil
|
|
429
|
+
end
|
|
430
|
+
|
|
174
431
|
# The prompts dir is shared with user-authored templates, so only files
|
|
175
432
|
# carrying an ACE integration provenance marker are ever pruned.
|
|
176
433
|
def prune_stale_prompt_files(output_dir, expected_template_names)
|
|
@@ -189,7 +446,7 @@ module Ace
|
|
|
189
446
|
|
|
190
447
|
frontmatter = YAML.safe_load(match[1], permitted_classes: [Date, Time], aliases: true)
|
|
191
448
|
frontmatter.is_a?(Hash) ? frontmatter["source"].to_s : ""
|
|
192
|
-
rescue
|
|
449
|
+
rescue
|
|
193
450
|
""
|
|
194
451
|
end
|
|
195
452
|
end
|
data/lib/ace/handbook/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: ace-handbook
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.34.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Michal Czyz
|
|
8
8
|
bindir: exe
|
|
9
9
|
cert_chain: []
|
|
10
|
-
date: 2026-
|
|
10
|
+
date: 2026-10-05 00:00:00.000000000 Z
|
|
11
11
|
dependencies:
|
|
12
12
|
- !ruby/object:Gem::Dependency
|
|
13
13
|
name: ace-support-config
|