ace-handbook 0.31.1 → 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: 358264b1f225530c26fd23fcb8528d7e665b927daaa452350536a18c90148e9a
4
- data.tar.gz: 776f9919aa44963a4f779ea662d8741e826436aed7ab23c7698a511190dab8ea
3
+ metadata.gz: a640260822cd7518266797ae62628ae62a4cae9f6a96ca7604ed274245ac2915
4
+ data.tar.gz: 9f7e5b5515524a15a2547a874f0324961bb6e38489026d16f316bf2358bed2b6
5
5
  SHA512:
6
- metadata.gz: 90c8229c6064db0967a97d999138395f57b7570c56d5742e31d717a0ccfbbef83e7127d771c095c960017c3ba021bd661196337e445d365821deb6ee01eee7ba
7
- data.tar.gz: e30d53307d64883ff5d89a46aab6f4592559f72d1f6014ed4bc174f1554bd7f53a86cde03be5ade43afb85c1ead270b52094085b7dbd6a44fc1bdca632c014f0
6
+ metadata.gz: b67c9b8c9ad1c7a21121cd2e8694da47bfd545b2142dee84dfbcff70917049a0cb634e2194b938079db3f3a0d128ed3de1ca7e6b65585e97900c669a86ffd1c2
7
+ data.tar.gz: ad70a65d103696d260014b279073266ef0beab0e79a2d54ac4e575bc3217a5c1d290623a132e9bebce9192e49b7e89c609b34944ea50c47f3889f2d724fafc45
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.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
+
15
+ ## [0.32.0] - 2026-09-27
16
+
17
+ ### Added
18
+ - Provider manifests may declare an optional `prompts_dir`; `ace-handbook sync` now projects canonical prompt templates from `ace-handbook-integration-*` packages (`handbook/prompts/*.md`) into that directory (idempotent writes; prunes only stale files carrying an `ace-handbook-integration-*` provenance marker, leaving user-authored templates untouched). Sync summary output gains a `; prompts <dir> (N prompts, U updated, R removed)` segment when a provider projects prompts.
19
+ - Shipped `wfi://handbook/manage-overrides` (with paired skill `as-handbook-manage-overrides`): canonical override documentation covering all overridable surfaces (workflows, cookbooks, guides), the source-priority resolution rule, skills' non-overridability ("override the workflow the skill calls"), the `.agents/` sync-projection status, and planted-competitor verification probes.
20
+
10
21
  ### Changed
11
22
  - Tightened `wfi://release/rubygems-publish` baseline guidance with an OTP-burst publication contract (build all artifacts before OTP; push in ≤5 concurrent waves; defer metadata verification) for project overlays.
12
23
 
data/README.md CHANGED
@@ -36,7 +36,7 @@
36
36
 
37
37
  **Integrate with resource discovery and context loading** - pair with [ace-nav](../ace-support-nav) for workflow and resource discovery, [ace-bundle](../ace-bundle) for loading complete workflow instructions, and provider integrations (`ace-handbook-integration-*`) that can project canonical handbook skills into harness-native folders on request.
38
38
 
39
- **Sync and inspect skill projections** - run `ace-handbook sync` to project canonical skills into `.agents/skills/` by default, or use `ace-handbook sync --provider PROVIDER` for harness-native folders such as `.codex/skills/`.
39
+ **Sync and inspect skill projections** - run `ace-handbook sync` to project canonical skills into `.agents/skills/` by default, or use `ace-handbook sync --provider PROVIDER` for harness-native folders such as `.codex/skills/`. Providers whose manifest also declares `prompts_dir` additionally receive canonical prompt templates from the matching integration package's `handbook/prompts/*.md` (projected flat into that directory; user-authored templates without an `ace-handbook-integration-*` provenance marker are never pruned).
40
40
 
41
41
  **Extend handbook content in normal projects** - put project-specific workflows, guides, cookbooks, templates, and skills under `.ace-handbook/` and discover them with protocol URLs (`wfi://`, `guide://`, `cookbook://`, `tmpl://`, `skill://`). See [Usage Guide](docs/usage.md) for path conventions.
42
42
 
@@ -0,0 +1,34 @@
1
+ ---
2
+ name: as-handbook-manage-overrides
3
+ description: Manage project-local overrides of ace-managed docs (workflows, cookbooks, guides) and verify resolution precedence
4
+ # bundle: wfi://handbook/manage-overrides
5
+ # context: no-fork
6
+ # agent: general-purpose
7
+ user-invocable: true
8
+ allowed-tools:
9
+ - Bash(ace-nav:*)
10
+ - Bash(ace-handbook:*)
11
+ - Bash(ace-bundle:*)
12
+ - Read
13
+ - Write
14
+ - Edit
15
+ - Glob
16
+ - LS
17
+ argument-hint: "[surface|uri] [action: plan|verify]"
18
+ last_modified: 2026-09-20
19
+ source: ace-handbook
20
+ integration:
21
+ targets:
22
+ - claude
23
+ - codex
24
+ - gemini
25
+ - opencode
26
+ - pi
27
+ providers: {}
28
+ skill:
29
+ kind: workflow
30
+ execution:
31
+ workflow: wfi://handbook/manage-overrides
32
+ ---
33
+
34
+ Load and run `ace-bundle wfi://handbook/manage-overrides` in the current project, then follow the loaded workflow as the source of truth and execute it end-to-end instead of only summarizing it.
@@ -60,6 +60,7 @@ Brief description of what this cookbook accomplishes.
60
60
 
61
61
  - Package-owned cookbooks (inside `ace-handbook`): `ace-handbook/handbook/cookbooks/`
62
62
  - Project-local cookbooks (outside package scope): `.ace-handbook/cookbooks/`
63
+ - Project-local document overrides (workflows, cookbooks, guides) and resolution precedence are governed canonically by `wfi://handbook/manage-overrides`
63
64
 
64
65
  ## Process Steps
65
66
 
@@ -0,0 +1,142 @@
1
+ ---
2
+ doc-type: workflow
3
+ name: manage-overrides
4
+ title: Manage Project-Local Overrides
5
+ description: Canonical reference for ace-managed document override surfaces, resolution precedence, and verification probes
6
+ purpose: Documentation for ace-handbook/handbook/workflow-instructions/handbook/manage-overrides.wf.md
7
+ allowed-tools:
8
+ - Bash(ace-nav:*)
9
+ - Bash(ace-bundle:*)
10
+ - Bash(ace-handbook:*)
11
+ - Read
12
+ - Write
13
+ - Edit
14
+ ace-docs:
15
+ last-updated: 2026-09-20
16
+ last-checked: 2026-09-20
17
+ ---
18
+
19
+ # Manage Project-Local Overrides
20
+
21
+ ## Goal
22
+
23
+ Answer, from shipped documentation and tooling, the three override questions: which ace-managed documents can this
24
+ project override, where do the project-local files live, and who wins when a project-local file and a gem file share
25
+ the same name. This workflow is the canonical reference; `ace-nav resolve --why` makes the same precedence observable
26
+ per URI.
27
+
28
+ ## Prerequisites
29
+
30
+ - Understanding of handbook asset conventions (workflows, cookbooks, guides, skills)
31
+ - `ace-nav` and `ace-handbook` available in the project bundle
32
+
33
+ ## Override Surfaces
34
+
35
+ | Surface | Protocol | Project-local location | Overridable? | Winner of duplicates (shipped defaults) |
36
+ |---------|----------|------------------------|--------------|------------------------------------------|
37
+ | Workflows | `wfi://` | `.ace-handbook/workflow-instructions/<ns>/<name>.wf.md` | Yes | Project-local, when its source priority is lower than the gem source's (e.g. the shipped `local` source at priority 5 vs gem at 10) |
38
+ | Cookbooks | `cookbook://` | `.ace-handbook/cookbooks/<name>.cookbook.md` | Yes | The candidate from the source with the lower priority number. The shipped project-local default registers priority 20 and the gem source 10, so **the gem wins by default**; register a project cookbook source with priority < 10 to make project-local win |
39
+ | Guides | `guide://` | `handbook/guides/<name>.g.md` | Yes, with registration | Requires a project source registration (`.ace/nav/protocols/guide-sources/local.yml` → `handbook/guides`); with priority < 10 the project-local guide wins. Without registration the gem wins |
40
+ | Skills | `skill://` | none | **No** | Always the gem. Skills are thin layers; override the workflow the skill calls instead |
41
+ | `.agents/skills/` projection | — | written by `ace-handbook sync` | Never edit | Not a resolution source. Neutral `agents`-provider mirror regenerated (and stale entries pruned) on every sync; it does not ship |
42
+
43
+ ## Resolution Rule
44
+
45
+ Sources for a protocol are scanned in ascending priority order (lower number = higher precedence); the first source
46
+ containing the resource wins and remaining duplicates are ignored. Registration defaults: project sources 10, user
47
+ sources 50, gem defaults 100 — an individual source registration may pin another value (this is why the shipped
48
+ `local` workflow source at priority 5 beats the gem at 10, while the shipped project-local cookbook source at 20 loses
49
+ to the gem at 10).
50
+
51
+ To answer "why did this candidate win?" for a concrete URI without planting probe files:
52
+
53
+ ```bash
54
+ ace-nav resolve wfi://release/publish --why
55
+ # uri: wfi://release/publish
56
+ # rule: Candidates are tried in ascending source-priority order ...
57
+ # candidates (resolution order):
58
+ # 1. [priority 5] @local (project) → ... ← winner
59
+ # 2. [priority 10] @ace-handbook (gem-default) → ...
60
+ ```
61
+
62
+ ## Skills Are Not Overridable
63
+
64
+ Skills (`skill://`) are thin launch layers that load and execute a workflow; they carry no logic worth overriding.
65
+ Consequently no project-local skill source exists: competitors planted in `.ace-handbook/skills/` or `.agents/skills/`
66
+ are ignored and resolution always returns the gem file. The design rule is: **override the workflow the skill calls**
67
+ (under `.ace-handbook/workflow-instructions/`), not the skill. To change a skill's own wording, change the canonical
68
+ `handbook/skills/<name>/SKILL.md` in the owning gem and run `ace-handbook sync`.
69
+
70
+ ## Process Steps
71
+
72
+ 1. **Identify the surface** you need to override (workflow, cookbook, or guide) and its canonical gem path.
73
+ 2. **Create the project-local file** at the surface's project-local location (see table), keeping the canonical
74
+ frontmatter and structure so tools keep resolving it.
75
+ 3. **Ensure the source registration makes your file win:**
76
+ - workflows: keep `.ace/nav/protocols/wfi-sources/local.yml` priority below the gem source (shipped default: 5).
77
+ - cookbooks: register `.ace/nav/protocols/cookbook-sources/local.yml` with priority < 10 (the shipped
78
+ project-local default of 20 loses to the gem's 10).
79
+ - guides: register `.ace/nav/protocols/guide-sources/local.yml` pointing at `handbook/guides` with priority < 10;
80
+ without a registration the project-local file is never the winner.
81
+ 4. **Verify precedence with `ace-nav resolve <uri> --why`:** your file must be listed first and marked
82
+ `← winner`. For a list of all duplicate candidates use `ace-nav list '<protocol>://<name>'`.
83
+ 5. **Never override skills or the `.agents/` projection.** Point the skill at an overridden workflow, or edit the
84
+ canonical skill in its gem.
85
+ 6. **Document the override** in the project's own agent guidance so maintainers know the file shadows a gem asset and
86
+ should be re-checked on gem upgrades.
87
+
88
+ ## Success Criteria
89
+
90
+ - [ ] Each project-local override file lives at the canonical project-local path for its surface.
91
+ - [ ] `ace-nav resolve <uri> --why` shows the project-local candidate first, marked `← winner` (or, for skills, shows
92
+ the gem candidate only).
93
+ - [ ] No files were planted in `.ace-handbook/skills/` or `.agents/skills/` expecting resolution changes.
94
+ - [ ] `ace-handbook sync` runs clean after any canonical skill change.
95
+
96
+ ## Validation Commands
97
+
98
+ ```bash
99
+ ace-bundle wfi://handbook/manage-overrides
100
+ ace-nav resolve wfi://release/publish --why
101
+ ace-nav list 'cookbook://setup-starting-a-multi-ruby-gem-monorepo-with-ace'
102
+ ace-handbook sync
103
+ ```
104
+
105
+ ## Planted-Competitor Verification Probes
106
+
107
+ Executed against ace-handbook 0.31.1 / ace-support-nav 0.28.6 (2026-09-20). Plant a project-local competitor file,
108
+ run the resolve, inspect the first line of the winner, then remove the plant:
109
+
110
+ ```bash
111
+ # Workflows: project-local WINS
112
+ mkdir -p .ace-handbook/workflow-instructions/handbook
113
+ printf '# OVERRIDE\n' > .ace-handbook/workflow-instructions/handbook/manage-cookbooks.wf.md
114
+ ace-nav resolve wfi://handbook/manage-cookbooks # → .ace-handbook/... (project-local)
115
+ rm .ace-handbook/workflow-instructions/handbook/manage-cookbooks.wf.md
116
+
117
+ # Cookbooks: with shipped defaults the GEM wins (project-local source priority 20 > gem 10)
118
+ mkdir -p .ace-handbook/cookbooks
119
+ printf '# OVERRIDE\n' > .ace-handbook/cookbooks/setup-starting-a-multi-ruby-gem-monorepo-with-ace.cookbook.md
120
+ ace-nav resolve cookbook://setup-starting-a-multi-ruby-gem-monorepo-with-ace # → ace-handbook/handbook/... (gem)
121
+ ace-nav list 'cookbook://setup-starting-a-multi-ruby-gem-monorepo-with-ace' # both candidates listed
122
+ rm .ace-handbook/cookbooks/setup-starting-a-multi-ruby-gem-monorepo-with-ace.cookbook.md
123
+
124
+ # Skills: gem WINS even with competitors in both overlay locations
125
+ mkdir -p .ace-handbook/skills/as-review-pr .agents/skills/as-review-pr
126
+ printf '# OVERRIDE\n' > .ace-handbook/skills/as-review-pr/SKILL.md
127
+ printf '# OVERRIDE\n' > .agents/skills/as-review-pr/SKILL.md
128
+ ace-nav resolve skill://as-review-pr # → ace-review/handbook/skills/... (gem)
129
+ rm .ace-handbook/skills/as-review-pr/SKILL.md
130
+ rmdir .ace-handbook/skills/as-review-pr .ace-handbook/skills
131
+ ace-handbook sync # regenerates .agents/skills/ and prunes the plant
132
+ ```
133
+
134
+ ## Error Handling
135
+
136
+ - **Override silently loses:** inspect `ace-nav resolve <uri> --why`; the losing candidate's source priority is too
137
+ high — lower it in the source registration.
138
+ - **Guide override not discovered at all:** no project guide source is registered; add
139
+ `.ace/nav/protocols/guide-sources/local.yml` (see Process Step 3).
140
+ - **Skill edit appears to do nothing:** skills are not overridable; override the workflow the skill calls.
141
+ - **`.agents/skills/` edits keep disappearing:** expected — `ace-handbook sync` regenerates the projection and prunes
142
+ stale entries; edit canonical skills in the owning gem.
@@ -34,6 +34,19 @@ module Ace
34
34
  manifest(provider).fetch("output_dir")
35
35
  end
36
36
 
37
+ def prompts_dir(provider)
38
+ manifest(provider)["prompts_dir"]
39
+ end
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
+
37
50
  def manifests
38
51
  @manifests ||= begin
39
52
  all_paths = local_manifest_paths + installed_manifest_paths
@@ -22,9 +22,12 @@ module Ace
22
22
  return if quiet
23
23
 
24
24
  results.each do |result|
25
- puts "synced #{result[:provider]} -> #{result[:relative_output_dir]} " \
26
- "(#{result[:projected_skills]} skills, #{result[:updated_files]} updated, " \
27
- "#{result[:removed_entries]} removed)"
25
+ line = "synced #{result[:provider]} -> #{result[:relative_output_dir]} " \
26
+ "(#{result[:projected_skills]} skills, #{result[:updated_files]} updated, " \
27
+ "#{result[:removed_entries]} removed)"
28
+ line += format_prompts_summary(result) if result.key?(:relative_prompts_dir)
29
+ line += format_extensions_summary(result) if result.key?(:relative_extensions_dir)
30
+ puts line
28
31
  end
29
32
  print_inventory_summary(results)
30
33
  rescue => e
@@ -33,6 +36,18 @@ module Ace
33
36
 
34
37
  private
35
38
 
39
+ def format_prompts_summary(result)
40
+ "; prompts #{result[:relative_prompts_dir]} " \
41
+ "(#{result[:projected_prompts]} prompts, #{result[:updated_prompt_files]} updated, " \
42
+ "#{result[:removed_prompt_entries]} removed)"
43
+ end
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
+
36
51
  def print_inventory_summary(results)
37
52
  source_breakdown = results.first&.fetch(:source_breakdown, {})
38
53
  return if source_breakdown.nil? || source_breakdown.empty?
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ace
4
+ module Handbook
5
+ module Models
6
+ class PromptTemplate
7
+ attr_reader :provider, :source_path, :frontmatter, :content
8
+
9
+ def initialize(provider:, source_path:, frontmatter:, content:)
10
+ @provider = provider
11
+ @source_path = source_path
12
+ @frontmatter = frontmatter
13
+ @content = content
14
+ end
15
+
16
+ def name
17
+ File.basename(source_path, ".md")
18
+ end
19
+
20
+ def source
21
+ frontmatter["source"].to_s
22
+ end
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+
5
+ module Ace
6
+ module Handbook
7
+ module Organisms
8
+ # Discovers canonical provider prompt templates shipped by integration packages.
9
+ #
10
+ # Each integration package owns its provider's prompt templates in
11
+ # handbook/prompts/*.md; they project into the provider manifest's prompts_dir.
12
+ class PromptTemplateInventory
13
+ FRONTMATTER_PATTERN = /\A---\s*\n(.*?)\n---\s*\n?(.*)\z/m
14
+ PACKAGE_GLOB = "ace-handbook-integration-*"
15
+ GEM_NAME_PREFIX = "ace-handbook-integration-"
16
+
17
+ attr_reader :project_root
18
+
19
+ def initialize(project_root: Ace::Handbook.project_root, gem_roots: nil)
20
+ @project_root = project_root
21
+ @gem_roots = gem_roots
22
+ end
23
+
24
+ def all
25
+ package_roots.filter_map { |root, provider| parse_templates(root, provider) }
26
+ .each_with_object({}) do |(provider, templates), index|
27
+ index[provider] = (index[provider] || []).concat(templates)
28
+ end
29
+ end
30
+
31
+ def for_provider(provider)
32
+ all.fetch(provider.to_s, [])
33
+ end
34
+
35
+ private
36
+
37
+ def package_roots
38
+ (local_package_roots + installed_package_roots).sort.uniq.filter_map do |root|
39
+ provider = package_provider(root)
40
+ next if provider.nil? || provider.empty?
41
+
42
+ [root, provider]
43
+ end
44
+ end
45
+
46
+ def local_package_roots
47
+ Dir.glob(File.join(project_root, PACKAGE_GLOB)).select { |path| File.directory?(path) }
48
+ end
49
+
50
+ def installed_package_roots
51
+ return @gem_roots unless @gem_roots.nil?
52
+
53
+ Gem::Specification.find_all
54
+ .select { |spec| spec.name.start_with?(GEM_NAME_PREFIX) }
55
+ .map(&:full_gem_path)
56
+ end
57
+
58
+ def package_provider(root)
59
+ manifests = Dir.glob(File.join(root, ".ace-defaults", "handbook", "providers", "*.yml")).sort
60
+ return nil if manifests.empty?
61
+
62
+ data = YAML.safe_load_file(manifests.first, permitted_classes: [Date, Time], aliases: true) || {}
63
+ data["provider"].to_s
64
+ rescue StandardError
65
+ nil
66
+ end
67
+
68
+ def parse_templates(root, provider)
69
+ templates = Dir.glob(File.join(root, "handbook", "prompts", "*.md")).sort.filter_map do |path|
70
+ match = File.read(path).match(FRONTMATTER_PATTERN)
71
+ frontmatter = match ? parse_frontmatter(match[1]) : {}
72
+ Models::PromptTemplate.new(provider: provider, source_path: path, frontmatter: frontmatter, content: File.read(path))
73
+ end
74
+ [provider, templates]
75
+ end
76
+
77
+ def parse_frontmatter(raw)
78
+ YAML.safe_load(raw, permitted_classes: [Date, Time], aliases: true) || {}
79
+ rescue StandardError
80
+ {}
81
+ end
82
+ end
83
+ end
84
+ end
85
+ end
@@ -1,22 +1,31 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "fileutils"
4
+ require "json"
5
+ require "pathname"
6
+ require "tempfile"
7
+ require "yaml"
4
8
 
5
9
  module Ace
6
10
  module Handbook
7
11
  module Organisms
8
12
  class ProviderSyncer
9
- attr_reader :project_root, :registry, :inventory, :config
13
+ PROJECTION_SOURCE_PREFIX = "ace-handbook-integration-"
14
+ EXTENSION_RECEIPT_NAME = ".ace-handbook-projection.json"
15
+
16
+ attr_reader :project_root, :registry, :inventory, :prompt_inventory, :config
10
17
 
11
18
  def initialize(
12
19
  project_root: Ace::Handbook.project_root,
13
20
  registry: nil,
14
21
  inventory: nil,
22
+ prompt_inventory: nil,
15
23
  config: nil
16
24
  )
17
25
  @project_root = project_root
18
26
  @registry = registry || Atoms::ProviderRegistry.new(project_root: project_root)
19
27
  @inventory = inventory || SkillInventory.new(project_root: project_root)
28
+ @prompt_inventory = prompt_inventory || PromptTemplateInventory.new(project_root: project_root)
20
29
  @config = config || Ace::Handbook.config.resolve_namespace("handbook").to_h
21
30
  end
22
31
 
@@ -31,6 +40,15 @@ module Ace
31
40
  private
32
41
 
33
42
  def sync_provider(provider, skills:, source_breakdown:)
43
+ result = sync_skills(provider, skills: skills, source_breakdown: source_breakdown)
44
+ prompts_result = sync_prompts(provider)
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
49
+ end
50
+
51
+ def sync_skills(provider, skills:, source_breakdown:)
34
52
  output_dir = File.join(project_root, registry.output_dir(provider))
35
53
  prepare_output_dir(output_dir)
36
54
 
@@ -64,6 +82,35 @@ module Ace
64
82
  }
65
83
  end
66
84
 
85
+ def sync_prompts(provider)
86
+ prompts_dir = registry.prompts_dir(provider)
87
+ return nil if prompts_dir.nil? || prompts_dir.to_s.empty?
88
+
89
+ output_dir = File.join(project_root, prompts_dir)
90
+ FileUtils.mkdir_p(output_dir)
91
+
92
+ expected = {}
93
+ updated_files = 0
94
+
95
+ prompt_inventory.for_provider(provider).each do |template|
96
+ output_path = File.join(output_dir, "#{template.name}.md")
97
+ expected[template.name] = output_path
98
+ next if File.exist?(output_path) && File.read(output_path) == template.content
99
+
100
+ File.write(output_path, template.content)
101
+ updated_files += 1
102
+ end
103
+
104
+ removed_entries = prune_stale_prompt_files(output_dir, expected.keys)
105
+
106
+ {
107
+ relative_prompts_dir: prompts_dir,
108
+ projected_prompts: expected.size,
109
+ updated_prompt_files: updated_files,
110
+ removed_prompt_entries: removed_entries
111
+ }
112
+ end
113
+
67
114
  def summarize_sources(skills)
68
115
  skills.each_with_object(Hash.new(0)) do |skill, memo|
69
116
  source = skill.source.to_s
@@ -128,6 +175,280 @@ module Ace
128
175
  stale.each { |path| FileUtils.rm_rf(path) }
129
176
  stale.size
130
177
  end
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
+
431
+ # The prompts dir is shared with user-authored templates, so only files
432
+ # carrying an ACE integration provenance marker are ever pruned.
433
+ def prune_stale_prompt_files(output_dir, expected_template_names)
434
+ stale = Dir.glob(File.join(output_dir, "*.md")).reject do |path|
435
+ expected_template_names.include?(File.basename(path, ".md"))
436
+ end.select do |path|
437
+ template_source(path).start_with?(PROJECTION_SOURCE_PREFIX)
438
+ end
439
+ stale.each { |path| FileUtils.rm(path) }
440
+ stale.size
441
+ end
442
+
443
+ def template_source(path)
444
+ match = File.read(path).match(/\A---\s*\n(.*?)\n---/m)
445
+ return "" unless match
446
+
447
+ frontmatter = YAML.safe_load(match[1], permitted_classes: [Date, Time], aliases: true)
448
+ frontmatter.is_a?(Hash) ? frontmatter["source"].to_s : ""
449
+ rescue
450
+ ""
451
+ end
131
452
  end
132
453
  end
133
454
  end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Ace
4
4
  module Handbook
5
- VERSION = "0.31.1"
5
+ VERSION = "0.33.0"
6
6
  end
7
7
  end
data/lib/ace/handbook.rb CHANGED
@@ -5,8 +5,10 @@ require "ace/support/config"
5
5
  require_relative "handbook/version"
6
6
  require_relative "handbook/atoms/provider_registry"
7
7
  require_relative "handbook/models/skill_document"
8
+ require_relative "handbook/models/prompt_template"
8
9
  require_relative "handbook/molecules/skill_projection"
9
10
  require_relative "handbook/organisms/skill_inventory"
11
+ require_relative "handbook/organisms/prompt_template_inventory"
10
12
  require_relative "handbook/organisms/provider_syncer"
11
13
  require_relative "handbook/organisms/status_collector"
12
14
 
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.31.1
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-05 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
@@ -169,6 +169,7 @@ files:
169
169
  - handbook/skills/as-handbook-manage-agents/SKILL.md
170
170
  - handbook/skills/as-handbook-manage-cookbooks/SKILL.md
171
171
  - handbook/skills/as-handbook-manage-guides/SKILL.md
172
+ - handbook/skills/as-handbook-manage-overrides/SKILL.md
172
173
  - handbook/skills/as-handbook-manage-workflows/SKILL.md
173
174
  - handbook/skills/as-handbook-parallel-research/SKILL.md
174
175
  - handbook/skills/as-handbook-perform-delivery/SKILL.md
@@ -188,6 +189,7 @@ files:
188
189
  - handbook/workflow-instructions/handbook/manage-agents.wf.md
189
190
  - handbook/workflow-instructions/handbook/manage-cookbooks.wf.md
190
191
  - handbook/workflow-instructions/handbook/manage-guides.wf.md
192
+ - handbook/workflow-instructions/handbook/manage-overrides.wf.md
191
193
  - handbook/workflow-instructions/handbook/manage-workflows.wf.md
192
194
  - handbook/workflow-instructions/handbook/parallel-research.wf.md
193
195
  - handbook/workflow-instructions/handbook/perform-delivery.wf.md
@@ -207,8 +209,10 @@ files:
207
209
  - lib/ace/handbook/cli.rb
208
210
  - lib/ace/handbook/cli/commands/status.rb
209
211
  - lib/ace/handbook/cli/commands/sync.rb
212
+ - lib/ace/handbook/models/prompt_template.rb
210
213
  - lib/ace/handbook/models/skill_document.rb
211
214
  - lib/ace/handbook/molecules/skill_projection.rb
215
+ - lib/ace/handbook/organisms/prompt_template_inventory.rb
212
216
  - lib/ace/handbook/organisms/provider_syncer.rb
213
217
  - lib/ace/handbook/organisms/skill_inventory.rb
214
218
  - lib/ace/handbook/organisms/status_collector.rb