ace-handbook 0.32.0 → 0.33.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 1e70a71fff6e61cbaa6724591eb965481b3b92153aed554c5fd6ad8b51f3e01e
4
- data.tar.gz: 99db5b08f74300bfbc87b71f2621a0bcee70934d92a89d7a142884adb8a2e434
3
+ metadata.gz: a640260822cd7518266797ae62628ae62a4cae9f6a96ca7604ed274245ac2915
4
+ data.tar.gz: 9f7e5b5515524a15a2547a874f0324961bb6e38489026d16f316bf2358bed2b6
5
5
  SHA512:
6
- metadata.gz: 34c7119d2d01e4ad2ff58a6b91bc5b4b092fb42a575abc33319d3ee6f4c7e237058f1135c19ab560565ff3fa91a696e39a9b92e27b6b839f855de0bf20de0c64
7
- data.tar.gz: c1b28858004960a5ef381e50bde467910b6694bfb29179e7cdd7014d45d25a2eb47ece9d75996cef92e35c30892705ca7687a6340f169b86e07781a287f8e499
6
+ metadata.gz: b67c9b8c9ad1c7a21121cd2e8694da47bfd545b2142dee84dfbcff70917049a0cb634e2194b938079db3f3a0d128ed3de1ca7e6b65585e97900c669a86ffd1c2
7
+ data.tar.gz: ad70a65d103696d260014b279073266ef0beab0e79a2d54ac4e575bc3217a5c1d290623a132e9bebce9192e49b7e89c609b34944ea50c47f3889f2d724fafc45
data/CHANGELOG.md CHANGED
@@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.33.0] - 2026-09-28
11
+
12
+ ### Added
13
+ - 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)`.
14
+
10
15
  ## [0.32.0] - 2026-09-27
11
16
 
12
17
  ### Added
@@ -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
- skills_result = sync_skills(provider, skills: skills, source_breakdown: source_breakdown)
43
+ result = sync_skills(provider, skills: skills, source_breakdown: source_breakdown)
40
44
  prompts_result = sync_prompts(provider)
41
- return skills_result if prompts_result.nil?
42
-
43
- skills_result.merge(prompts_result)
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 StandardError
449
+ rescue
193
450
  ""
194
451
  end
195
452
  end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Ace
4
4
  module Handbook
5
- VERSION = "0.32.0"
5
+ VERSION = "0.33.0"
6
6
  end
7
7
  end
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.32.0
4
+ version: 0.33.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Michal Czyz
8
8
  bindir: exe
9
9
  cert_chain: []
10
- date: 2026-09-27 00:00:00.000000000 Z
10
+ date: 2026-09-29 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: ace-support-config