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,256 @@
1
+ """``kiro-ide-hook`` projection — direct-file copy + ``then.command``
2
+ placeholder expansion (RFC-0005, v0.4).
3
+
4
+ The projection mode is ``direct-file`` byte-for-byte for askAgent-shaped
5
+ hooks (no scan surface) and parse-modify-emit for runCommand-shaped
6
+ hooks that contain ``${hook-body:<name>}`` placeholders in
7
+ ``then.command``. RFC-0005 § Substitution rules pins the placeholder
8
+ mechanics:
9
+
10
+ 1. Scan surface — ``then.command`` only. Every other field in the
11
+ ``.kiro.hook`` JSON (``then.prompt``, ``when.patterns``, ``name``,
12
+ ``description``, …) passes through verbatim.
13
+ 2. Verbatim substitution — no shell quoting; pack authors quote
14
+ placeholders themselves.
15
+ 3. Multiple placeholders allowed; single-pass resolution. Resolved
16
+ text is NOT re-scanned.
17
+ 4. Placeholder grammar — strict regex ``\\$\\{hook-body:[a-zA-Z0-9_-]+\\}``.
18
+ Validated upstream at ``validate`` time (T-C2's
19
+ ``check_kiro_ide_hook`` rail in ``scope_rails.py``); a malformed
20
+ placeholder reaching the projector is a defense-in-depth refusal.
21
+ 5. Unresolvable references refuse — same defense-in-depth.
22
+
23
+ The output path's ``<pack>`` placeholder resolves to the source
24
+ pack's directory name; the ``<name>`` placeholder resolves to the
25
+ ``.kiro.hook`` file's bare name (extension stripped). Both are
26
+ substituted into the contract-declared ``target.repo`` template.
27
+
28
+ This module is stdlib-only — ``json`` + ``re`` + ``shutil`` +
29
+ ``pathlib``.
30
+ """
31
+
32
+ from __future__ import annotations
33
+
34
+ import json
35
+ import re
36
+ import shutil
37
+ from pathlib import Path
38
+
39
+
40
+ class KiroIdeHookRefusal(Exception):
41
+ """Raised when projection refuses to write.
42
+
43
+ All reachable paths are defense-in-depth (the validate rail
44
+ refuses these cases upstream); the exception only fires when
45
+ a caller skipped validate or supplied a malformed pack
46
+ directly to the projector.
47
+ """
48
+
49
+
50
+ # Strict placeholder grammar — same regex as the validate rail.
51
+ # Kept inline rather than imported so this module's contract is
52
+ # self-evident from its source; the rail's regex carries the same
53
+ # grammar.
54
+ _HOOK_BODY_PLACEHOLDER_RE = re.compile(r"\$\{hook-body:([a-zA-Z0-9_-]+)\}")
55
+
56
+ # Loose `${...}` matcher; an offender that matches this but not the
57
+ # strict regex above is a malformed placeholder.
58
+ _ANY_PLACEHOLDER_RE = re.compile(r"\$\{[^}]*\}")
59
+
60
+ # `.kiro.hook` is a compound extension; pathlib treats ``.hook`` as
61
+ # the suffix and ``.kiro`` as the prior segment. Endswith-check on
62
+ # the literal extension is more readable than juggling ``suffixes``.
63
+ _KIRO_HOOK_EXTENSION = ".kiro.hook"
64
+
65
+
66
+ def project(
67
+ pack_path: Path,
68
+ output_root: Path,
69
+ target_template: str,
70
+ hook_body_target_dir: str,
71
+ ) -> None:
72
+ """Project every ``.apm/kiro-ide-hooks/<name>.kiro.hook`` under
73
+ *pack_path* into *output_root* per the *target_template* shape.
74
+
75
+ Arguments:
76
+ pack_path: pack source root.
77
+ output_root: where to write projected files (the per-pack base
78
+ the caller passes in — typically ``<dist>/<pack>/`` or the
79
+ repo root for ``make build --self``).
80
+ target_template: contract-declared
81
+ ``[adapter.kiro.projections.kiro-ide-hook].target.repo``,
82
+ e.g. ``".kiro/hooks/<pack>/<name>.kiro.hook"``. The
83
+ ``<pack>`` and ``<name>`` placeholders resolve at projection
84
+ time. The pre-bump v0.3 contract carries no such field
85
+ (the v0.4 declaration is probe-gated, T-CONTRACT); callers
86
+ targeting v0.3 contracts simply don't invoke this projector.
87
+ hook_body_target_dir: the projected hook-body directory for
88
+ same-pack ``${hook-body:<name>}`` references — e.g.
89
+ ``"tools/hooks"`` at repo scope (the Kiro adapter's legacy
90
+ ``[[adapter.kiro.projection]] primitive = "hook-body"``
91
+ target). Resolved placeholders emit
92
+ ``./{hook_body_target_dir}/<actual-filename>``.
93
+
94
+ Raises:
95
+ KiroIdeHookRefusal: on JSON parse failure, on a malformed
96
+ placeholder reaching projection, or on an unresolvable
97
+ placeholder. All three are validate-rail-covered cases;
98
+ the projector refuses defensively rather than emitting a
99
+ silently-wrong artifact.
100
+ """
101
+ source_dir = pack_path / ".apm" / "kiro-ide-hooks"
102
+ if not source_dir.exists():
103
+ return
104
+ pack_name = pack_path.name
105
+ hook_body_files = _collect_hook_body_files(pack_path)
106
+
107
+ for entry in sorted(source_dir.iterdir()):
108
+ if not entry.name.endswith(_KIRO_HOOK_EXTENSION):
109
+ continue
110
+ if not entry.is_file() or entry.is_symlink():
111
+ continue
112
+
113
+ bare_name = entry.name[: -len(_KIRO_HOOK_EXTENSION)]
114
+ if not bare_name:
115
+ # A file named exactly `.kiro.hook` would land as a dotfile
116
+ # at `.kiro/hooks/<pack>/.kiro.hook` and collide with any
117
+ # other dotfile in the directory. Defense-in-depth refusal —
118
+ # the validate rail catches this upstream too.
119
+ raise KiroIdeHookRefusal(
120
+ f"pack {pack_name}'s kiro-ide-hook entry has an "
121
+ f"empty bare name; expected <name>.kiro.hook with "
122
+ f"<name> non-empty"
123
+ )
124
+ resolved_target = (
125
+ target_template
126
+ .replace("<pack>", pack_name)
127
+ .replace("<name>", bare_name)
128
+ )
129
+ target_path = output_root / resolved_target.lstrip("/")
130
+ target_path.parent.mkdir(parents=True, exist_ok=True)
131
+
132
+ raw_bytes = entry.read_bytes()
133
+
134
+ # askAgent byte-copy shortcut. RFC's placeholder grammar uses
135
+ # `${` as the unambiguous prefix; if the raw file carries no
136
+ # such substring AND the parsed JSON's then.type is askAgent,
137
+ # the file has no expansion work to do and a byte copy
138
+ # preserves the source's key order, whitespace, and trailing
139
+ # newline.
140
+ if b"${" not in raw_bytes:
141
+ try:
142
+ parsed = json.loads(raw_bytes.decode("utf-8"))
143
+ except (json.JSONDecodeError, UnicodeDecodeError) as exc:
144
+ raise KiroIdeHookRefusal(
145
+ f"pack {pack_name}'s kiro-ide-hook {entry.name} "
146
+ f"failed to parse: {exc}"
147
+ )
148
+ if (
149
+ isinstance(parsed, dict)
150
+ and isinstance(parsed.get("then"), dict)
151
+ and parsed["then"].get("type") == "askAgent"
152
+ ):
153
+ shutil.copy2(entry, target_path, follow_symlinks=False)
154
+ continue
155
+ # Non-askAgent without placeholders — also byte-copy. No
156
+ # scan surface; nothing to rewrite.
157
+ shutil.copy2(entry, target_path, follow_symlinks=False)
158
+ continue
159
+
160
+ # Otherwise: parse, expand, re-emit. The parse step also
161
+ # catches malformed JSON the validate rail would already have
162
+ # refused.
163
+ try:
164
+ body = json.loads(raw_bytes.decode("utf-8"))
165
+ except (json.JSONDecodeError, UnicodeDecodeError) as exc:
166
+ raise KiroIdeHookRefusal(
167
+ f"pack {pack_name}'s kiro-ide-hook {entry.name} "
168
+ f"failed to parse: {exc}"
169
+ )
170
+ if not isinstance(body, dict):
171
+ raise KiroIdeHookRefusal(
172
+ f"pack {pack_name}'s kiro-ide-hook {entry.name} "
173
+ f"is not a JSON object"
174
+ )
175
+
176
+ then = body.get("then")
177
+ command = then.get("command") if isinstance(then, dict) else None
178
+ if isinstance(command, str):
179
+ new_command = _expand_placeholders(
180
+ command,
181
+ pack_name=pack_name,
182
+ file_name=entry.name,
183
+ hook_body_files=hook_body_files,
184
+ hook_body_target_dir=hook_body_target_dir.rstrip("/"),
185
+ )
186
+ then["command"] = new_command
187
+
188
+ # Emit with stable formatting — `indent=2` matches the
189
+ # fixtures' shape and the RFC's example, `sort_keys=False`
190
+ # preserves source ordering best-effort, trailing newline for
191
+ # POSIX-friendliness.
192
+ target_path.write_text(
193
+ json.dumps(body, indent=2, sort_keys=False) + "\n",
194
+ encoding="utf-8",
195
+ )
196
+
197
+
198
+ def _collect_hook_body_files(pack_path: Path) -> dict[str, str]:
199
+ """Return basename → filename for every hook-body the pack ships.
200
+
201
+ e.g. ``{"lint": "lint.py", "format": "format.sh"}``. Used by
202
+ placeholder resolution to emit the actual extension. Symlinks
203
+ silently skipped (Rail B is the gate for symlinked hook-bodies
204
+ at user scope; at repo scope the safer default is to ignore).
205
+ """
206
+ out: dict[str, str] = {}
207
+ hook_body_dir = pack_path / ".apm" / "hooks"
208
+ if not hook_body_dir.exists():
209
+ return out
210
+ for entry in sorted(hook_body_dir.iterdir()):
211
+ if entry.is_symlink():
212
+ continue
213
+ if entry.is_file():
214
+ out[entry.stem] = entry.name
215
+ return out
216
+
217
+
218
+ def _expand_placeholders(
219
+ command: str,
220
+ *,
221
+ pack_name: str,
222
+ file_name: str,
223
+ hook_body_files: dict[str, str],
224
+ hook_body_target_dir: str,
225
+ ) -> str:
226
+ """Single-pass placeholder expansion against ``then.command``.
227
+
228
+ Refuses (defense-in-depth) on malformed or unresolvable
229
+ placeholders even though the validate rail covered these
230
+ upstream. Resolved text is NOT re-scanned (RFC § Substitution
231
+ rules clause 3) — single pass via ``re.sub``.
232
+ """
233
+ # Defense-in-depth check 1 — malformed placeholder.
234
+ for match in _ANY_PLACEHOLDER_RE.finditer(command):
235
+ literal = match.group(0)
236
+ if not _HOOK_BODY_PLACEHOLDER_RE.fullmatch(literal):
237
+ raise KiroIdeHookRefusal(
238
+ f"pack {pack_name}'s kiro-ide-hook {file_name} "
239
+ f"contains malformed placeholder '{literal}'; "
240
+ f"expected ${{hook-body:<name>}} with name "
241
+ f"matching [a-zA-Z0-9_-]+"
242
+ )
243
+
244
+ def _resolve(match: re.Match[str]) -> str:
245
+ name = match.group(1)
246
+ filename = hook_body_files.get(name)
247
+ if filename is None:
248
+ raise KiroIdeHookRefusal(
249
+ f"pack {pack_name}'s kiro-ide-hook {file_name} "
250
+ f"references unknown hook-body "
251
+ f"'${{hook-body:{name}}}'; no such hook-body "
252
+ f"in pack"
253
+ )
254
+ return f"./{hook_body_target_dir}/{filename}"
255
+
256
+ return _HOOK_BODY_PLACEHOLDER_RE.sub(_resolve, command)
@@ -0,0 +1,264 @@
1
+ """``merge-into-agent-json`` projection mode — Kiro at both scopes.
2
+
3
+ Merges a pack's ``.apm/hook-wiring/*.toml`` content into a **pack-owned**
4
+ agent JSON at ``<scope-root>/.kiro/agents/<attach-to-agent>.json`` under
5
+ the ``hooks`` key. The mode reuses ``user-merge-json``'s
6
+ array-append-with-id discipline, with structural differences from the
7
+ Claude-Code-user-scope shape:
8
+
9
+ 1. **Target is pack-owned, not adopter-shared.** Adopter hand-edits to
10
+ the agent JSON are squatting on a managed surface — the next upgrade
11
+ replaces the file via the agent primitive's ``direct-file``
12
+ projection. No collision detection, no ``--force-merge`` flag.
13
+ 2. **Agent file must exist before merge runs.** RFC-0005 establishes
14
+ the build-pipeline ordering invariant — agents project before any
15
+ wiring merges run. The absent-file case is a refuse-with-internal-
16
+ error path, exercised only via test instrumentation.
17
+ 3. **Per-agent target, scope-conditional.** Each wiring TOML targets
18
+ a single agent named by its ``attach-to-agent`` field. The caller
19
+ (T8b) is responsible for resolving the target file path; this
20
+ module operates on the resolved path.
21
+
22
+ This module is stdlib-only — ``json`` + ``pathlib`` + ``tempfile``.
23
+
24
+ T8b will own the install/uninstall CLI threading; T7 enforces the
25
+ pipeline ordering in the iterator. T6 (this module) ships the merge
26
+ engine plus the per-adapter event-vocabulary rail used by
27
+ ``commands/validate.py``.
28
+ """
29
+
30
+ from __future__ import annotations
31
+
32
+ import json
33
+ import os
34
+ import tempfile
35
+ from pathlib import Path
36
+
37
+ from agentbundle.build.projections.hook_id import synthesize_id
38
+
39
+
40
+ class AgentJsonRefusal(Exception):
41
+ """Raised when ``project`` / ``unproject`` refuses to write.
42
+
43
+ The exception's string is the refuse-and-explain text RFC-0005
44
+ specifies. CLI callers (T8b) catch and print to stderr without
45
+ paraphrasing.
46
+ """
47
+
48
+
49
+ def project(
50
+ target_path: Path,
51
+ pack_name: str,
52
+ wiring_tomls: dict[str, dict],
53
+ ) -> list[tuple[str, str]]:
54
+ """Merge *wiring_tomls* into the agent JSON at *target_path*.
55
+
56
+ Arguments:
57
+ target_path: resolved path to the agent JSON. **Must exist** —
58
+ the build pipeline (T7) projects agents before wiring runs.
59
+ Absent → refuse with the ``internal:`` text.
60
+ pack_name: the pack's ``[pack].name``. Substituted into id tags.
61
+ wiring_tomls: map of wiring TOML basename (no ``.toml``) → parsed
62
+ TOML body (typically ``{"attach-to-agent": "<name>", "hooks":
63
+ {"<Event>": [entries]}}``). The ``attach-to-agent`` field is
64
+ not consumed here — the caller has already resolved
65
+ *target_path* using it.
66
+
67
+ Returns:
68
+ List of ``(event, id)`` tuples reflecting every owned entry
69
+ written. T8b records these in the state file's
70
+ ``hook-wiring-owned`` table so ``unproject`` can be precise.
71
+
72
+ Raises:
73
+ AgentJsonRefusal: missing agent file (pipeline-ordering
74
+ violation), unparseable JSON, wrong-shape ``hooks`` or
75
+ ``hooks.<event>``.
76
+ """
77
+ if not target_path.exists():
78
+ # The text below extends RFC-0005's bare `internal: <agent-file>
79
+ # missing` shape with diagnostic context. This is intentional —
80
+ # the message is a CLI-internal-error string, not an
81
+ # adopter-facing contract, so we trade brevity for the breadcrumb
82
+ # "agent must project before wiring" that points at the
83
+ # pipeline-ordering invariant. Tests assert the substrings
84
+ # `internal:` / `missing` / `agent must project before wiring`
85
+ # to keep T8b's CLI handler portable across text refinements.
86
+ raise AgentJsonRefusal(
87
+ f"internal: {target_path} missing at hook-wiring merge time; "
88
+ f"agent must project before wiring"
89
+ )
90
+
91
+ data = _load_agent_json(target_path)
92
+ _shape_check_hooks(target_path, data)
93
+
94
+ owned: list[tuple[str, str]] = []
95
+ for basename, body in wiring_tomls.items():
96
+ entry_id = synthesize_id(pack_name, basename)
97
+ hooks_in_wiring = body.get("hooks", {}) if isinstance(body, dict) else {}
98
+ if not isinstance(hooks_in_wiring, dict):
99
+ continue
100
+ for event, incoming_entries in hooks_in_wiring.items():
101
+ if not isinstance(incoming_entries, list):
102
+ continue
103
+ data.setdefault("hooks", {})
104
+ data["hooks"].setdefault(event, [])
105
+ event_array = data["hooks"][event]
106
+ _shape_check_event_array(target_path, event, event_array)
107
+ for incoming in incoming_entries:
108
+ if not isinstance(incoming, dict):
109
+ continue
110
+ tagged = dict(incoming)
111
+ tagged["id"] = entry_id
112
+ _merge_one_entry(event_array, tagged)
113
+ owned.append((event, entry_id))
114
+
115
+ _atomic_write(target_path, data)
116
+ # Deduplicate (event, id) tuples — see T5's note in user_merge_json.
117
+ seen: set[tuple[str, str]] = set()
118
+ result: list[tuple[str, str]] = []
119
+ for item in owned:
120
+ if item not in seen:
121
+ seen.add(item)
122
+ result.append(item)
123
+ return result
124
+
125
+
126
+ def unproject(target_path: Path, owned: list[tuple[str, str]]) -> None:
127
+ """Remove every ``(event, id)`` pair in *owned* from *target_path*.
128
+
129
+ Empty ``hooks.<event>`` arrays are removed. The agent file itself
130
+ is **never** removed by this function — that's the agent
131
+ primitive's ``direct-file`` uninstall's responsibility (RFC-0005
132
+ § Conflict, idempotency, uninstall).
133
+
134
+ If the target file is absent, ``unproject`` is a no-op — same
135
+ rationale as T5's ``user_merge_json.unproject``: a state row
136
+ pointing at a now-absent file is an orphan-in-state condition that
137
+ T9's reconcile surfaces. Refusing here would block uninstall of
138
+ unrelated packs whose own target files happen to be absent.
139
+ """
140
+ if not target_path.exists():
141
+ return
142
+
143
+ data = _load_agent_json(target_path)
144
+ _shape_check_hooks(target_path, data)
145
+
146
+ hooks = data.get("hooks")
147
+ if not isinstance(hooks, dict):
148
+ return
149
+
150
+ owned_by_event: dict[str, set[str]] = {}
151
+ for event, entry_id in owned:
152
+ owned_by_event.setdefault(event, set()).add(entry_id)
153
+
154
+ for event, ids_to_remove in owned_by_event.items():
155
+ if event not in hooks:
156
+ continue
157
+ event_array = hooks[event]
158
+ if not isinstance(event_array, list):
159
+ continue
160
+ hooks[event] = [
161
+ e for e in event_array
162
+ if not (isinstance(e, dict) and e.get("id") in ids_to_remove)
163
+ ]
164
+ if not hooks[event]:
165
+ del hooks[event]
166
+
167
+ _atomic_write(target_path, data)
168
+
169
+
170
+ # ---------------------------------------------------------------------------
171
+ # Internals
172
+ # ---------------------------------------------------------------------------
173
+
174
+
175
+ def _load_agent_json(target_path: Path) -> dict:
176
+ """Read the agent JSON. Raises ``AgentJsonRefusal`` on
177
+ unparseable content (matches T5's `user_merge_json` shape)."""
178
+ try:
179
+ text = target_path.read_text(encoding="utf-8")
180
+ except OSError as exc:
181
+ raise AgentJsonRefusal(
182
+ f"cannot parse {target_path}: {exc}; fix or back up the file and retry"
183
+ ) from exc
184
+ if not text.strip():
185
+ # An empty agent JSON file is a Kiro-side artifact (an agent
186
+ # body with no fields yet) — treat as `{}` for merge.
187
+ return {}
188
+ try:
189
+ data = json.loads(text)
190
+ except json.JSONDecodeError as exc:
191
+ raise AgentJsonRefusal(
192
+ f"cannot parse {target_path}: {exc}; fix or back up the file and retry"
193
+ ) from exc
194
+ if not isinstance(data, dict):
195
+ raise AgentJsonRefusal(
196
+ f"cannot parse {target_path}: top-level value is "
197
+ f"{type(data).__name__}, expected object; fix or back up "
198
+ f"the file and retry"
199
+ )
200
+ return data
201
+
202
+
203
+ def _shape_check_hooks(target_path: Path, data: dict) -> None:
204
+ if "hooks" in data and not isinstance(data["hooks"], dict):
205
+ raise AgentJsonRefusal(
206
+ f"{target_path}: hooks has unexpected shape {type(data['hooks']).__name__}; "
207
+ f"expected object"
208
+ )
209
+
210
+
211
+ def _shape_check_event_array(target_path: Path, event: str, value: object) -> None:
212
+ if not isinstance(value, list):
213
+ raise AgentJsonRefusal(
214
+ f"{target_path}: hooks.{event} has unexpected shape {type(value).__name__}; "
215
+ f"expected array"
216
+ )
217
+
218
+
219
+ def _merge_one_entry(event_array: list, tagged_entry: dict) -> None:
220
+ """Append or replace-in-place by id.
221
+
222
+ No adopter-collision branch: the agent JSON is pack-owned (RFC-0005
223
+ § What this section does NOT add — no ``--force-merge`` for Kiro).
224
+ Adopter hand-edits to the agent file are squatting on a managed
225
+ surface; the next upgrade replaces the file via the agent
226
+ primitive's ``direct-file`` projection.
227
+ """
228
+ incoming_id = tagged_entry["id"]
229
+ for index, existing in enumerate(event_array):
230
+ if isinstance(existing, dict) and existing.get("id") == incoming_id:
231
+ event_array[index] = tagged_entry
232
+ return
233
+ event_array.append(tagged_entry)
234
+
235
+
236
+ def _atomic_write(target_path: Path, data: dict) -> None:
237
+ """Write *data* to *target_path* via temp + rename + dir-fsync.
238
+
239
+ Same shape as T5's ``user_merge_json._atomic_write`` — see there
240
+ for the directory-fsync rationale.
241
+ """
242
+ target_path.parent.mkdir(parents=True, exist_ok=True)
243
+ serialised = json.dumps(data, indent=2, sort_keys=False) + "\n"
244
+ with tempfile.NamedTemporaryFile(
245
+ mode="w",
246
+ encoding="utf-8",
247
+ dir=str(target_path.parent),
248
+ prefix=target_path.name + ".",
249
+ suffix=".tmp",
250
+ delete=False,
251
+ ) as tmp:
252
+ tmp.write(serialised)
253
+ tmp.flush()
254
+ os.fsync(tmp.fileno())
255
+ tmp_path = Path(tmp.name)
256
+ tmp_path.replace(target_path)
257
+ try:
258
+ dir_fd = os.open(str(target_path.parent), os.O_RDONLY)
259
+ try:
260
+ os.fsync(dir_fd)
261
+ finally:
262
+ os.close(dir_fd)
263
+ except OSError:
264
+ pass
@@ -0,0 +1,58 @@
1
+ """Shared `merge-json` projection — claude-code's settings.local.json
2
+ and codex's hooks.json share this implementation.
3
+
4
+ Both adapters' hook-wiring lands in a JSON file with the same
5
+ ``{ "<managed-key>": { "<event>": [...handlers...] } }`` shape; the
6
+ build-pipeline dispatcher in each adapter calls this function for any
7
+ projection rule with ``mode == "merge-json"``.
8
+
9
+ Originally private to ``adapters/claude_code.py`` as
10
+ ``_project_merge_json``; lifted to this sibling module by
11
+ docs/specs/dropped-primitives-coverage (T2) so codex.py can reuse the
12
+ exact same code path without re-implementing.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import json
18
+ import tomllib
19
+ from pathlib import Path
20
+ from typing import Any
21
+
22
+
23
+ def project_merge_json(source_dir: Path, output_root: Path, rule: dict) -> None:
24
+ """Merge TOML hook-wiring source files into a JSON target file.
25
+
26
+ Reads every ``*.toml`` under ``source_dir`` (sorted), pulls the
27
+ payload at ``rule["managed-key"]`` (default ``"hooks"``), and
28
+ merges into ``output_root / rule["target-path"]``'s managed key.
29
+ Existing non-managed keys in the JSON target are preserved.
30
+
31
+ Output is serialised with ``indent=2, sort_keys=True`` and a
32
+ trailing newline — idempotent across re-runs.
33
+ """
34
+ target_path = output_root / rule["target-path"].lstrip("/")
35
+ managed_key = rule.get("managed-key", "hooks")
36
+
37
+ incoming: dict[str, Any] = {}
38
+ for entry in sorted(source_dir.iterdir()):
39
+ if entry.is_file() and entry.suffix == ".toml":
40
+ payload = tomllib.loads(entry.read_text(encoding="utf-8"))
41
+ for key, value in payload.get(managed_key, {}).items():
42
+ incoming[key] = value
43
+ if not incoming:
44
+ return
45
+
46
+ existing: dict[str, Any] = {}
47
+ if target_path.exists():
48
+ existing = json.loads(target_path.read_text(encoding="utf-8"))
49
+
50
+ merged = dict(existing.get(managed_key, {}))
51
+ merged.update(incoming)
52
+ existing[managed_key] = merged
53
+
54
+ target_path.parent.mkdir(parents=True, exist_ok=True)
55
+ target_path.write_text(
56
+ json.dumps(existing, indent=2, sort_keys=True) + "\n",
57
+ encoding="utf-8",
58
+ )