agentbundle 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (99) hide show
  1. agentbundle/__init__.py +14 -0
  2. agentbundle/__main__.py +5 -0
  3. agentbundle/_data/adapter.schema.json +270 -0
  4. agentbundle/_data/adapter.toml +584 -0
  5. agentbundle/_data/install-marker.py +1099 -0
  6. agentbundle/_data/pack.schema.json +152 -0
  7. agentbundle/_data/plugin-manifest.derived.schema.json +33 -0
  8. agentbundle/_data/plugin-manifest.schema.json +18 -0
  9. agentbundle/build/__init__.py +206 -0
  10. agentbundle/build/__main__.py +8 -0
  11. agentbundle/build/adapter_root_bins.py +336 -0
  12. agentbundle/build/adapters/__init__.py +46 -0
  13. agentbundle/build/adapters/claude_code.py +142 -0
  14. agentbundle/build/adapters/codex.py +227 -0
  15. agentbundle/build/adapters/copilot.py +149 -0
  16. agentbundle/build/adapters/kiro.py +608 -0
  17. agentbundle/build/adapters/kiro_cli.py +53 -0
  18. agentbundle/build/adapters/kiro_ide.py +275 -0
  19. agentbundle/build/contract.py +20 -0
  20. agentbundle/build/lint_packs.py +555 -0
  21. agentbundle/build/main.py +596 -0
  22. agentbundle/build/phase_order.py +40 -0
  23. agentbundle/build/projections/__init__.py +13 -0
  24. agentbundle/build/projections/codex_agent_toml.py +232 -0
  25. agentbundle/build/projections/copilot_agent_md.py +206 -0
  26. agentbundle/build/projections/copilot_hooks_json.py +142 -0
  27. agentbundle/build/projections/direct_directory.py +41 -0
  28. agentbundle/build/projections/hook_id.py +27 -0
  29. agentbundle/build/projections/kiro_ide_hook.py +256 -0
  30. agentbundle/build/projections/merge_into_agent_json.py +264 -0
  31. agentbundle/build/projections/merge_json.py +58 -0
  32. agentbundle/build/projections/user_merge_json.py +324 -0
  33. agentbundle/build/scope_rails.py +728 -0
  34. agentbundle/build/self_host.py +1486 -0
  35. agentbundle/build/shared_libs.py +309 -0
  36. agentbundle/build/target_resolver.py +85 -0
  37. agentbundle/build/tests/__init__.py +0 -0
  38. agentbundle/build/tests/test_adapter_claude_code.py +275 -0
  39. agentbundle/build/tests/test_adapter_codex.py +699 -0
  40. agentbundle/build/tests/test_adapter_copilot.py +91 -0
  41. agentbundle/build/tests/test_adapter_kiro.py +449 -0
  42. agentbundle/build/tests/test_adapter_kiro_alias.py +105 -0
  43. agentbundle/build/tests/test_adapter_kiro_cli.py +102 -0
  44. agentbundle/build/tests/test_adapter_kiro_ide.py +173 -0
  45. agentbundle/build/tests/test_adapter_root_bins_projection.py +429 -0
  46. agentbundle/build/tests/test_build_ships_seeds.py +78 -0
  47. agentbundle/build/tests/test_contract.py +582 -0
  48. agentbundle/build/tests/test_contract_scope.py +224 -0
  49. agentbundle/build/tests/test_contract_v07.py +191 -0
  50. agentbundle/build/tests/test_contract_v08.py +230 -0
  51. agentbundle/build/tests/test_direct_directory_cleanup.py +65 -0
  52. agentbundle/build/tests/test_end_to_end_build.py +227 -0
  53. agentbundle/build/tests/test_lint_agents_md_legacy_block.py +135 -0
  54. agentbundle/build/tests/test_lint_agents_md_risk_block.py +116 -0
  55. agentbundle/build/tests/test_lint_packs.py +703 -0
  56. agentbundle/build/tests/test_load_pack_hook_wiring_safely.py +176 -0
  57. agentbundle/build/tests/test_pack_schema.py +265 -0
  58. agentbundle/build/tests/test_pack_schema_allowed_adapters.py +258 -0
  59. agentbundle/build/tests/test_pack_schema_install.py +305 -0
  60. agentbundle/build/tests/test_pipeline.py +272 -0
  61. agentbundle/build/tests/test_plugin_manifest_schema.py +327 -0
  62. agentbundle/build/tests/test_projections_merge_json.py +148 -0
  63. agentbundle/build/tests/test_scope_rails.py +398 -0
  64. agentbundle/build/tests/test_security.py +97 -0
  65. agentbundle/build/tests/test_self_host_check.py +2100 -0
  66. agentbundle/build/tests/test_shared_libs_projection.py +415 -0
  67. agentbundle/build/tests/test_shipped_packs_v07_declarations.py +100 -0
  68. agentbundle/build/tests/test_shipped_packs_v08_declarations.py +80 -0
  69. agentbundle/build/tests/test_validate.py +250 -0
  70. agentbundle/build/validate.py +141 -0
  71. agentbundle/catalogue.py +164 -0
  72. agentbundle/cli.py +486 -0
  73. agentbundle/commands/__init__.py +5 -0
  74. agentbundle/commands/_common.py +174 -0
  75. agentbundle/commands/_drop_warning.py +329 -0
  76. agentbundle/commands/adapt.py +343 -0
  77. agentbundle/commands/config.py +125 -0
  78. agentbundle/commands/diff.py +211 -0
  79. agentbundle/commands/init_state.py +279 -0
  80. agentbundle/commands/install.py +3026 -0
  81. agentbundle/commands/list_packs.py +170 -0
  82. agentbundle/commands/list_targets.py +23 -0
  83. agentbundle/commands/reconcile.py +161 -0
  84. agentbundle/commands/render.py +165 -0
  85. agentbundle/commands/scaffold.py +69 -0
  86. agentbundle/commands/uninstall.py +294 -0
  87. agentbundle/commands/upgrade.py +699 -0
  88. agentbundle/commands/validate.py +688 -0
  89. agentbundle/config.py +747 -0
  90. agentbundle/render.py +123 -0
  91. agentbundle/safety.py +633 -0
  92. agentbundle/scope.py +319 -0
  93. agentbundle/user_config.py +284 -0
  94. agentbundle/version.py +49 -0
  95. agentbundle-0.2.0.dist-info/METADATA +37 -0
  96. agentbundle-0.2.0.dist-info/RECORD +99 -0
  97. agentbundle-0.2.0.dist-info/WHEEL +5 -0
  98. agentbundle-0.2.0.dist-info/entry_points.txt +2 -0
  99. agentbundle-0.2.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,688 @@
1
+ """`agentbundle validate` subcommand.
2
+
3
+ Validates a pack directory's ``pack.toml`` against ``pack.schema.json``,
4
+ enforces the six-recipe enumeration, and applies the spec-version gate.
5
+ With ``--strict``, runs conformance fixtures via the F-build render
6
+ pipeline when they are present; warns and exits 0 when absent (v1 ship
7
+ state — F-conformance deferred to v1.1 per RFC-0003).
8
+
9
+ Exit codes:
10
+ 0 — pack is schema-valid (and conformance fixtures pass if --strict).
11
+ 1 — any schema error, unknown recipe, version mismatch, or conformance
12
+ failure; one-line stderr reason printed for each failure.
13
+
14
+ Usage (wired by cli.py):
15
+ args.pack_path — path to a pack directory containing pack.toml.
16
+ args.strict — bool; run conformance fixtures when present.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import json
22
+ import sys
23
+ import tomllib
24
+ from pathlib import Path
25
+
26
+ from agentbundle.commands import _drop_warning
27
+ from agentbundle.commands._common import check_spec_version_gate
28
+
29
+ # Stdlib only — no third-party deps.
30
+
31
+ # Six-recipe enumerated set from the sibling distribution-adapters spec.
32
+ VALID_RECIPES: frozenset[str] = frozenset(
33
+ {
34
+ "per-pack-claude-plugin",
35
+ "per-pack-apm-package",
36
+ "marketplace",
37
+ "per-pack-overlay",
38
+ "composite-agents-md",
39
+ "composite-marketplace",
40
+ }
41
+ )
42
+
43
+ # Location of pack.schema.json relative to the repo root. The schema is
44
+ # bundled in docs/contracts/ and is also bundled at
45
+ # agentbundle/_data/pack.schema.json for zipapp use.
46
+ _HERE = Path(__file__).resolve().parent
47
+
48
+
49
+ def _schema_path() -> Path:
50
+ """Locate pack.schema.json — bundled copy preferred, dev fallback."""
51
+ bundled = _HERE.parent / "_data" / "pack.schema.json"
52
+ if bundled.exists():
53
+ return bundled
54
+ # Dev fallback: walk up from agentbundle/ package to repo root.
55
+ repo_root = _HERE.parent.parent.parent.parent
56
+ return repo_root / "docs" / "contracts" / "pack.schema.json"
57
+
58
+
59
+ def _conformance_fixtures_dir() -> Path:
60
+ """Return the expected path for conformance fixtures."""
61
+ # packages/agentbundle/tests/fixtures/conformance/
62
+ pkg_root = _HERE.parent.parent.parent
63
+ return pkg_root / "tests" / "fixtures" / "conformance"
64
+
65
+
66
+ def run(args) -> int:
67
+ """Entry point called by the CLI dispatcher. Returns exit code."""
68
+ pack_path = Path(args.pack_path)
69
+ strict: bool = getattr(args, "strict", False)
70
+
71
+ # ── 1. Locate and load pack.toml ──────────────────────────────────────
72
+ pack_toml_path = pack_path / "pack.toml"
73
+ if not pack_toml_path.exists():
74
+ print(
75
+ f"validate: pack.toml not found at {pack_toml_path}",
76
+ file=sys.stderr,
77
+ )
78
+ return 1
79
+
80
+ try:
81
+ raw_toml = pack_toml_path.read_text(encoding="utf-8")
82
+ pack_data = tomllib.loads(raw_toml)
83
+ except tomllib.TOMLDecodeError as exc:
84
+ print(
85
+ f"validate: pack.toml is not valid TOML: {exc}",
86
+ file=sys.stderr,
87
+ )
88
+ return 1
89
+
90
+ # ── 2. Spec-version gate ──────────────────────────────────────────────
91
+ gate = check_spec_version_gate(pack_data)
92
+ if gate is not None:
93
+ return gate
94
+
95
+ # ── 3. Schema validation ──────────────────────────────────────────────
96
+ schema_path = _schema_path()
97
+ if not schema_path.exists():
98
+ print(
99
+ f"validate: pack.schema.json not found at {schema_path}",
100
+ file=sys.stderr,
101
+ )
102
+ return 1
103
+
104
+ schema = json.loads(schema_path.read_text(encoding="utf-8"))
105
+
106
+ # Import validate_instance from F-build (library-first).
107
+ from agentbundle.build.validate import validate as validate_instance
108
+
109
+ errors = validate_instance(pack_data, schema)
110
+ if errors:
111
+ # RFC-0004 § *Install-scope dimension* names a specific stderr
112
+ # text for the cross-field invariant: `pack <name>: default-scope
113
+ # '<requested>' not in allowed-scopes <declared-set>`. The
114
+ # schema's `contains` failure on `$.pack.install.allowed-scopes`
115
+ # is the structural form of that violation; surface it with the
116
+ # spec-named text instead of the generic schema message so
117
+ # adopters get the actionable line.
118
+ if _is_default_scope_invariant_violation(pack_data, errors[0]):
119
+ pack_name = pack_data.get("pack", {}).get("name", pack_path.name)
120
+ install = pack_data.get("pack", {}).get("install", {})
121
+ requested = install.get("default-scope")
122
+ allowed = install.get("allowed-scopes", [])
123
+ print(
124
+ f"validate: pack {pack_name}: default-scope {requested!r} "
125
+ f"not in allowed-scopes {allowed}",
126
+ file=sys.stderr,
127
+ )
128
+ return 1
129
+ # One-line stderr: first error only (spec says "one-line reason").
130
+ print(
131
+ f"validate: schema error — {errors[0]}",
132
+ file=sys.stderr,
133
+ )
134
+ return 1
135
+
136
+ # ── 4. Recipe gate ────────────────────────────────────────────────────
137
+ recipes = _extract_recipes(pack_data)
138
+ for recipe in recipes:
139
+ if recipe not in VALID_RECIPES:
140
+ print(
141
+ f"validate: unknown recipe {recipe!r}; "
142
+ f"valid recipes are {sorted(VALID_RECIPES)}",
143
+ file=sys.stderr,
144
+ )
145
+ return 1
146
+
147
+ # ── 4a. Allowed-adapters cross-field check (RFC-0011 / AC3, AC22) ─────
148
+ # The schema admits `allowed-adapters` as `array<string>` but doesn't
149
+ # hardcode the adapter enum (it's contract-derived). Validate here:
150
+ # - every entry must be in the bundled contract's shipped-adapter set;
151
+ # - when "user" ∈ allowed-scopes, every entry must additionally be in
152
+ # the user-scope-capable subset (declare an `[adapter.<name>.scope]`
153
+ # table). The refuse-and-explain messages match the wording pinned
154
+ # in spec § *Always do*.
155
+ aa_refusal = _validate_allowed_adapters(pack_data)
156
+ if aa_refusal is not None:
157
+ print(f"validate: pack.toml: {aa_refusal}", file=sys.stderr)
158
+ return 1
159
+
160
+ # ── 4b. User-scope refusal rails (RFC-0004 A/B/C) ─────────────────────
161
+ # Rails fire only when the pack declares "user" ∈ allowed-scopes. The
162
+ # rails run *after* schema validation so we know `[pack.install]`'s
163
+ # shape (when present) is well-formed before we read it. v0.1 packs
164
+ # have implied `allowed-scopes = ["repo"]`, so the rails are
165
+ # vacuously satisfied — `_allowed_scopes` returns `["repo"]` for
166
+ # them.
167
+ from agentbundle.build.scope_rails import run_all as run_scope_rails
168
+
169
+ allowed = _allowed_scopes(pack_data)
170
+ user_scope_hooks = _user_scope_hooks_opt_in(pack_data)
171
+ rail_refusal = run_scope_rails(pack_path, allowed, user_scope_hooks)
172
+ if rail_refusal is not None:
173
+ pack_name = (
174
+ pack_data.get("pack", {}).get("name") or pack_path.name
175
+ )
176
+ print(
177
+ f"validate: {pack_name}: {rail_refusal}",
178
+ file=sys.stderr,
179
+ )
180
+ return 1
181
+
182
+ # ── 4c/4d. Kiro hook-wiring rails (RFC-0005, T2 / T6) ────────────────
183
+ # Rails 4c (attach-to-agent) and 4d (event-vocabulary) are now merged
184
+ # into a single dispatch that swallows the compatibility-only refusals
185
+ # (missing attach-to-agent, out-of-vocab event) while preserving
186
+ # exit-1 for security (symlink) and correctness (parse-fail,
187
+ # unknown-agent) violations.
188
+ #
189
+ # Spec: docs/specs/incompatible-hook-event-drop AC1–AC5, AC6b.
190
+ from agentbundle.build.scope_rails import _load_pack_hook_wiring_safely
191
+
192
+ target_adapters = _kiro_target_adapters(pack_data, pack_path)
193
+ pack_name = pack_data.get("pack", {}).get("name") or pack_path.name
194
+
195
+ if "kiro" in target_adapters:
196
+ # 1. Safe-load: security + correctness refusals (AC3, AC3b, AC4).
197
+ loaded = _load_pack_hook_wiring_safely(pack_path, pack_name)
198
+ if isinstance(loaded, str):
199
+ print(f"validate: {loaded}", file=sys.stderr)
200
+ return 1
201
+ wiring_tomls, agent_basenames = loaded
202
+
203
+ # 2. Unknown-agent refusal (AC4b) — discriminated from input data,
204
+ # NOT from inspecting check_kiro_attach_to_agent's refusal text,
205
+ # which is bytewise identical for missing-vs-unknown subcases per
206
+ # scope_rails.py:333-337 (load-bearing per round-2 review).
207
+ #
208
+ # Condition: attach is not None AND is a string AND not in
209
+ # agent_basenames — matches spec AC4b exactly.
210
+ # Empty string is preserved as "unknown agent" (kept refusal,
211
+ # exit 1) to match today's helper behavior at scope_rails.py:332
212
+ # — `"" not in agent_basenames` is True, so today's helper
213
+ # refuses on attach = ""; this spec preserves that.
214
+ for stem, body in sorted(wiring_tomls.items()):
215
+ attach = body.get("attach-to-agent") if isinstance(body, dict) else None
216
+ if (
217
+ attach is not None
218
+ and isinstance(attach, str)
219
+ and attach not in agent_basenames
220
+ ):
221
+ # Refusal text matches check_kiro_attach_to_agent's
222
+ # pinned wording byte-for-byte (RFC-0005:474). Single
223
+ # source of truth: the helper composes the same string;
224
+ # a future RFC-0005 wording change must update both.
225
+ refusal = (
226
+ f"pack {pack_name}'s hook-wiring {stem}.toml "
227
+ f"does not declare 'attach-to-agent' (or names an unknown "
228
+ f"agent); required for kiro projection"
229
+ )
230
+ print(f"validate: {refusal}", file=sys.stderr)
231
+ return 1
232
+
233
+ # 3. Compatibility drops (missing-attach OR out-of-vocab event) —
234
+ # flow to the shared enumerator + info-line emit. Single source
235
+ # of truth with the install side (AC6b).
236
+ contract = _load_adapter_contract()
237
+ info_drops = _drop_warning.enumerate_event_dropped_wirings(
238
+ pack_path, "kiro", contract,
239
+ )
240
+ if info_drops:
241
+ info = _drop_warning.format_drop_message(
242
+ mode="validate_info",
243
+ pack_name=pack_name,
244
+ adapter="kiro",
245
+ dropped_counts={},
246
+ compatible_types=[],
247
+ event_drops=info_drops,
248
+ )
249
+ print(info) # stdout per AC2 + adopter direction
250
+
251
+ # ── 4e. kiro-ide-hook validate rail (RFC-0005 v0.4, T-C2) ────────────
252
+ # Fires whenever the pack ships `.apm/kiro-ide-hooks/` content. The
253
+ # rail's `target_adapters` heuristic differs from `_kiro_target_adapters`
254
+ # — kiro-ide-hook needs no agent (file-event triggers fire
255
+ # independent of agent runtime; cf. § Pack-side schema), so a
256
+ # pack with kiro-ide-hooks but no `.apm/agents/` still targets
257
+ # kiro. Cheapest heuristic: presence of the source directory with
258
+ # at least one *.kiro.hook file.
259
+ if _dir_has_any_kiro_ide_hook(pack_path / ".apm" / "kiro-ide-hooks"):
260
+ from agentbundle.build.scope_rails import check_kiro_ide_hook
261
+
262
+ ide_event_vocab, ide_action_vocab = _kiro_ide_hook_vocabularies()
263
+ ide_hook_refusal = check_kiro_ide_hook(
264
+ pack_path=pack_path,
265
+ pack_name=pack_name,
266
+ target_adapters=("kiro", "kiro-ide"), # "kiro" alias + canonical name
267
+ ide_event_vocabulary=ide_event_vocab,
268
+ ide_action_vocabulary=ide_action_vocab,
269
+ )
270
+ if ide_hook_refusal is not None:
271
+ print(f"validate: {ide_hook_refusal}", file=sys.stderr)
272
+ return 1
273
+
274
+ # ── 5. Strict / conformance mode ─────────────────────────────────────
275
+ if strict:
276
+ conformance_dir = _conformance_fixtures_dir()
277
+ if not conformance_dir.exists():
278
+ print(
279
+ "--strict conformance fixtures not present — skipping",
280
+ file=sys.stderr,
281
+ )
282
+ # Exit 0 on schema portion (v1 carve-out).
283
+ return 0
284
+ # Conformance fixtures present — run them.
285
+ rc = _run_conformance(pack_path, conformance_dir)
286
+ if rc != 0:
287
+ return rc
288
+
289
+ return 0
290
+
291
+
292
+ def _is_default_scope_invariant_violation(pack_data: dict, first_error: str) -> bool:
293
+ """Return True when the first schema error is the cross-field invariant.
294
+
295
+ The schema's `if`/`then` block for `default-scope ∈ allowed-scopes`
296
+ surfaces as a `contains` failure on `$.pack.install.allowed-scopes`.
297
+ We also confirm the pack actually has the shape that triggered the
298
+ error (default-scope declared, allowed-scopes declared, default not
299
+ in allowed) so we don't mis-attribute an unrelated `contains`
300
+ failure to this rule.
301
+ """
302
+ install = pack_data.get("pack", {}).get("install")
303
+ if not isinstance(install, dict):
304
+ return False
305
+ default = install.get("default-scope")
306
+ allowed = install.get("allowed-scopes")
307
+ if not isinstance(default, str) or not isinstance(allowed, list):
308
+ return False
309
+ if default in allowed:
310
+ return False
311
+ # Match the validator's error path heuristically.
312
+ return (
313
+ "pack.install.allowed-scopes" in first_error
314
+ or "allowed-scopes" in first_error
315
+ )
316
+
317
+
318
+ def _user_scope_hooks_opt_in(pack_data: dict) -> bool:
319
+ """Return True iff the pack declares ``[pack.install] user-scope-hooks = true``.
320
+
321
+ RFC-0005 § Rail B — user-scope lift: the flag is the consent
322
+ gesture that lets a pack ship hook-shaped primitives at user scope.
323
+ Absent or non-boolean → False (rail still refuses the pack).
324
+ """
325
+ pack = pack_data.get("pack", {})
326
+ if not isinstance(pack, dict):
327
+ return False
328
+ install = pack.get("install", {})
329
+ if not isinstance(install, dict):
330
+ return False
331
+ flag = install.get("user-scope-hooks")
332
+ return flag is True
333
+
334
+
335
+ def _kiro_target_adapters(pack_data: dict, pack_path: Path) -> set[str]:
336
+ """Resolve the target-adapter set for the kiro hook-wiring rail.
337
+
338
+ Two paths:
339
+
340
+ - v0.6+ packs declaring ``allowed-adapters`` use the field as the
341
+ source of truth: ``"kiro" in allowed-adapters`` ⇒ ``{"kiro"}``,
342
+ else ``set()``. The rail is a no-op for v0.6+ packs that
343
+ deliberately exclude kiro.
344
+
345
+ - All other hook-wiring-capable contract versions (v0.3+, per
346
+ the ``contract_supports_hook_wiring`` predicate that replaces
347
+ the round-1 literal ``version != "0.3"`` gate) fall through
348
+ to on-disk inference: pack ships both ``.apm/agents/`` and
349
+ ``.apm/hook-wiring/`` ⇒ ``{"kiro"}``. Pre-hook-wiring
350
+ contracts (v0.1, v0.2) skip the rail.
351
+
352
+ Returns ``{"kiro"}`` when the rail should fire; empty set
353
+ (rail no-op) otherwise.
354
+ """
355
+ from agentbundle.scope import contract_supports_hook_wiring
356
+
357
+ pack = pack_data.get("pack", {})
358
+ if not isinstance(pack, dict):
359
+ return set()
360
+ contract = pack.get("adapter-contract")
361
+ if not isinstance(contract, dict):
362
+ return set()
363
+ version = contract.get("version")
364
+ if not contract_supports_hook_wiring(version):
365
+ return set()
366
+
367
+ # v0.6+ declarative early-return from allowed-adapters.
368
+ install = pack.get("install")
369
+ if isinstance(install, dict):
370
+ allowed = install.get("allowed-adapters")
371
+ if isinstance(allowed, list):
372
+ allowed_strs = [s for s in allowed if isinstance(s, str)]
373
+ return {"kiro"} if "kiro" in allowed_strs else set()
374
+
375
+ # Heuristic: kiro projection requires a same-pack agent. A pack
376
+ # with wiring but no agents has nothing to attach to.
377
+ agents_dir = pack_path / ".apm" / "agents"
378
+ wiring_dir = pack_path / ".apm" / "hook-wiring"
379
+ if not _dir_has_any_file(agents_dir, ".md"):
380
+ return set()
381
+ if not _dir_has_any_file(wiring_dir, ".toml"):
382
+ return set()
383
+ return {"kiro"}
384
+
385
+
386
+ def _dir_has_any_file(directory: Path, suffix: str) -> bool:
387
+ """Return True if *directory* exists and contains at least one file
388
+ with *suffix*. Symlinks are ignored — the kiro rail consumes them
389
+ through `check_kiro_wiring`, which mirrors rail C's symlink refusal."""
390
+ if not directory.exists():
391
+ return False
392
+ for entry in directory.iterdir():
393
+ if entry.is_file() and entry.suffix == suffix:
394
+ return True
395
+ return False
396
+
397
+
398
+ def _dir_has_any_kiro_ide_hook(directory: Path) -> bool:
399
+ """``.kiro.hook`` is a compound extension; ``Path.suffix`` only
400
+ returns ``.hook``, so the generic helper above misses it. A
401
+ dedicated check keeps the call site readable and pins the
402
+ compound-extension assumption in one place."""
403
+ if not directory.exists():
404
+ return False
405
+ for entry in directory.iterdir():
406
+ if entry.is_file() and entry.name.endswith(".kiro.hook"):
407
+ return True
408
+ return False
409
+
410
+
411
+ def _kiro_ide_hook_vocabularies() -> tuple[list[str] | None, list[str] | None]:
412
+ """Resolve the kiro adapter's ``ide-event-vocabulary`` and
413
+ ``ide-action-vocabulary`` from the bundled contract.
414
+
415
+ Returns (None, None) when the contract pre-dates v0.4 (the
416
+ ``[adapter.kiro.projections.kiro-ide-hook]`` table doesn't exist),
417
+ which makes checks 2 and 3 of the validate rail no-ops — the
418
+ rail's checks 1 / 4 / 5 (required fields, malformed placeholder,
419
+ unresolvable placeholder) still fire because they're vocabulary-
420
+ independent.
421
+
422
+ Load-at-call-time discipline: the contract file is the source of
423
+ truth; no module-level cache so a test-time swap is visible
424
+ immediately.
425
+ """
426
+ from agentbundle.build.contract import load as load_contract
427
+
428
+ here = Path(__file__).resolve().parent
429
+ bundled = here.parent / "_data" / "adapter.toml"
430
+ if bundled.exists():
431
+ contract_path = bundled
432
+ else:
433
+ contract_path = here.parent.parent.parent.parent / "docs" / "contracts" / "adapter.toml"
434
+ if not contract_path.exists():
435
+ return None, None
436
+ contract = load_contract(contract_path)
437
+ adapters = contract.get("adapter", {})
438
+ # Prefer the kiro-ide block (v0.9+); fall back to kiro alias (pre-T1).
439
+ kiro = adapters.get("kiro-ide") or adapters.get("kiro") or {}
440
+ projections = kiro.get("projections", {}) if isinstance(kiro, dict) else {}
441
+ rule = projections.get("kiro-ide-hook", {}) if isinstance(projections, dict) else {}
442
+
443
+ def _as_string_list(value: object) -> list[str] | None:
444
+ if isinstance(value, list):
445
+ return [str(v) for v in value if isinstance(v, str)]
446
+ return None
447
+
448
+ return (
449
+ _as_string_list(rule.get("ide-event-vocabulary")) if isinstance(rule, dict) else None,
450
+ _as_string_list(rule.get("ide-action-vocabulary")) if isinstance(rule, dict) else None,
451
+ )
452
+
453
+
454
+ def _load_adapter_contract() -> dict:
455
+ """Load the bundled adapter contract dict.
456
+
457
+ Same load-at-call-time discipline as
458
+ ``_kiro_ide_hook_vocabularies`` — the contract file is the source of
459
+ truth; no module-level cache so a test-time swap is visible.
460
+
461
+ Returns an empty dict when neither the bundled nor the dev-checkout
462
+ path exists (keeps the enumeration rail a no-op rather than crashing).
463
+ """
464
+ from agentbundle.build.contract import load as load_contract
465
+
466
+ here = Path(__file__).resolve().parent
467
+ bundled = here.parent / "_data" / "adapter.toml"
468
+ if bundled.exists():
469
+ contract_path = bundled
470
+ else:
471
+ contract_path = (
472
+ here.parent.parent.parent.parent / "docs" / "contracts" / "adapter.toml"
473
+ )
474
+ if not contract_path.exists():
475
+ return {}
476
+ return load_contract(contract_path)
477
+
478
+
479
+ def _validate_allowed_adapters(pack_data: dict) -> str | None:
480
+ """Cross-field check for ``[pack.install] allowed-adapters``
481
+ (RFC-0011 substrate; RFC-0012 widens to fire at both scopes).
482
+
483
+ Returns None when the field is absent / valid; returns a
484
+ refuse-and-explain string suitable for printing under the
485
+ ``validate: pack.toml: ...`` prefix on violation. Reads the
486
+ bundled adapter contract for the shipped + user-scope-capable
487
+ sets; if the contract doesn't ship a value the pack declares,
488
+ that's the publisher-vs-installer drift case. **The shipped
489
+ check fires at both scopes** (RFC-0012); the user-scope-capability
490
+ subcheck is **scope-conditional** — fires only when the pack's
491
+ resolved scope is user (so a Copilot-bearing pack at
492
+ ``default-scope = "repo"`` admits cleanly).
493
+ """
494
+ import tomllib
495
+
496
+ from agentbundle.scope import (
497
+ shipped_adapters_from_contract,
498
+ user_scope_capable_adapters_from_contract,
499
+ )
500
+
501
+ pack = pack_data.get("pack", {})
502
+ if not isinstance(pack, dict):
503
+ return None
504
+ install = pack.get("install")
505
+ if not isinstance(install, dict):
506
+ return None
507
+ declared = install.get("allowed-adapters")
508
+ if not isinstance(declared, list):
509
+ return None
510
+ declared_strs = [s for s in declared if isinstance(s, str)]
511
+ if not declared_strs:
512
+ return None
513
+ if len(declared_strs) != len(set(declared_strs)):
514
+ return "[pack.install] allowed-adapters contains duplicate values"
515
+
516
+ shipped = shipped_adapters_from_contract()
517
+ user_capable = user_scope_capable_adapters_from_contract()
518
+ user_eligible = "user" in _allowed_scopes(pack_data)
519
+
520
+ for name in declared_strs:
521
+ if name not in shipped:
522
+ return (
523
+ f"[pack.install] allowed-adapters contains {name!r}, "
524
+ f"which is not a shipped adapter under the bundled "
525
+ f"contract"
526
+ )
527
+ if user_eligible and name not in user_capable:
528
+ # Read the bundled contract version once so the message
529
+ # tracks RFC-0012's v0.7 bump without per-spec edits.
530
+ from agentbundle.build.main import _read_bundled
531
+
532
+ cv = (
533
+ tomllib.loads(_read_bundled("adapter.toml"))
534
+ .get("contract", {})
535
+ .get("version", "?")
536
+ )
537
+ return (
538
+ f"[pack.install] allowed-adapters contains {name!r}, "
539
+ f"which does not declare a user-scope root in the v{cv} "
540
+ f"adapter contract"
541
+ )
542
+
543
+ return None
544
+
545
+
546
+ def _allowed_scopes(pack_data: dict) -> list[str]:
547
+ """Return the pack's resolved allowed-scopes list.
548
+
549
+ Resolution mirrors RFC-0004 § *v0.1 vs v0.2 contract acceptance*:
550
+
551
+ - v0.1 packs (declared version "0.1", or no `[pack.adapter-contract]`)
552
+ get the implied `["repo"]`. Any stray `[pack.install]` table is
553
+ ignored.
554
+ - v0.2 packs read `[pack.install].allowed-scopes` when present; when
555
+ only `default-scope` is declared, the implied default is
556
+ `[default-scope]`.
557
+
558
+ The cross-field `default-scope ∈ allowed-scopes` invariant is owned
559
+ by the schema; we trust the schema's verdict here and only resolve
560
+ the list.
561
+ """
562
+ pack = pack_data.get("pack", {})
563
+ if not isinstance(pack, dict):
564
+ return ["repo"]
565
+ contract_version = (
566
+ pack.get("adapter-contract", {}).get("version")
567
+ if isinstance(pack.get("adapter-contract"), dict)
568
+ else None
569
+ )
570
+ # v0.2 introduced `[pack.install]`; v0.3 added `user-scope-hooks`;
571
+ # v0.6 added `allowed-adapters`. Treat any contract version >= 0.2
572
+ # as carrying the install table. The legacy v0.1 path (and any pack
573
+ # without an adapter-contract declaration) stays repo-only.
574
+ if contract_version is None or contract_version == "0.1":
575
+ return ["repo"]
576
+ install = pack.get("install", {})
577
+ if not isinstance(install, dict):
578
+ return ["repo"]
579
+ allowed = install.get("allowed-scopes")
580
+ if isinstance(allowed, list) and allowed:
581
+ return [s for s in allowed if isinstance(s, str)]
582
+ default = install.get("default-scope")
583
+ if isinstance(default, str):
584
+ return [default]
585
+ return ["repo"]
586
+
587
+
588
+ def _extract_recipes(pack_data: dict) -> list[str]:
589
+ """Return the list of recipe names the pack declares, if any.
590
+
591
+ The schema allows a pack to declare recipes at ``[pack].recipes``
592
+ (a list of strings). Returns an empty list if the field is absent or
593
+ the pack table is missing.
594
+ """
595
+ pack_table = pack_data.get("pack", {})
596
+ if not isinstance(pack_table, dict):
597
+ return []
598
+ recipes = pack_table.get("recipes", [])
599
+ if not isinstance(recipes, list):
600
+ return []
601
+ return [str(r) for r in recipes if isinstance(r, str)]
602
+
603
+
604
+ def _run_conformance(pack_path: Path, conformance_dir: Path) -> int:
605
+ """Run each conformance fixture and assert the expected output tree.
606
+
607
+ Each fixture is a subdirectory under ``conformance_dir`` containing:
608
+ - ``expected/`` — the expected rendered output tree.
609
+
610
+ We call ``render.render_pack_to_dir`` and compare file trees.
611
+ Returns 0 if all fixtures pass; 1 on first mismatch (with stderr).
612
+ """
613
+ import tempfile
614
+
615
+ from agentbundle.render import render_pack_to_dir
616
+
617
+ fixture_dirs = sorted(
618
+ d for d in conformance_dir.iterdir() if d.is_dir()
619
+ )
620
+ if not fixture_dirs:
621
+ print(
622
+ "--strict: conformance directory present but empty — skipping",
623
+ file=sys.stderr,
624
+ )
625
+ return 0
626
+
627
+ for fixture in fixture_dirs:
628
+ expected_dir = fixture / "expected"
629
+ if not expected_dir.exists():
630
+ print(
631
+ f"--strict: fixture {fixture.name!r} has no expected/ tree; skipping",
632
+ file=sys.stderr,
633
+ )
634
+ continue
635
+
636
+ with tempfile.TemporaryDirectory() as raw:
637
+ actual_dir = Path(raw)
638
+ try:
639
+ render_pack_to_dir(pack_path, actual_dir)
640
+ except Exception as exc:
641
+ print(
642
+ f"--strict: render failed for fixture {fixture.name!r}: {exc}",
643
+ file=sys.stderr,
644
+ )
645
+ return 1
646
+
647
+ mismatch = _diff_trees(expected_dir, actual_dir)
648
+ if mismatch:
649
+ print(
650
+ f"--strict: conformance failure in fixture {fixture.name!r}: "
651
+ + mismatch,
652
+ file=sys.stderr,
653
+ )
654
+ return 1
655
+
656
+ return 0
657
+
658
+
659
+ def _diff_trees(expected: Path, actual: Path) -> str:
660
+ """Return a one-line description of the first difference, or '' if identical."""
661
+ expected_files = _tree_files(expected)
662
+ actual_files = _tree_files(actual)
663
+
664
+ only_in_expected = expected_files.keys() - actual_files.keys()
665
+ if only_in_expected:
666
+ first = sorted(only_in_expected)[0]
667
+ return f"file missing from actual: {first}"
668
+
669
+ only_in_actual = actual_files.keys() - expected_files.keys()
670
+ if only_in_actual:
671
+ first = sorted(only_in_actual)[0]
672
+ return f"unexpected file in actual: {first}"
673
+
674
+ for relpath in sorted(expected_files):
675
+ if expected_files[relpath] != actual_files[relpath]:
676
+ return f"content differs: {relpath}"
677
+
678
+ return ""
679
+
680
+
681
+ def _tree_files(root: Path) -> dict[str, bytes]:
682
+ """Return all files under ``root`` as a dict of relpath → bytes."""
683
+ out: dict[str, bytes] = {}
684
+ for path in sorted(root.rglob("*")):
685
+ if path.is_file():
686
+ relpath = path.relative_to(root).as_posix()
687
+ out[relpath] = path.read_bytes()
688
+ return out