diff --git a/CLI/CLI+Hooks.swift b/CLI/CLI+Hooks.swift index 1172a8121..7cd845221 100644 --- a/CLI/CLI+Hooks.swift +++ b/CLI/CLI+Hooks.swift @@ -1975,10 +1975,13 @@ extension ProgramaCLI { } if rawHooks == nil, !install { return root } var hooks = rawHooks as? [String: Any] ?? [:] + var updatedEvents = Set() for event in Array(hooks.keys) { guard let rawGroups = hooks[event] as? [Any] else { throw CodexHooksError(message: "hooks.json event \(event) must be an array") } + // Trust keys are positional: rewrite Programa's first handler in place; a sole-handler group drops stale matchers. + let spec = install ? Self.codexHookSpecs.first { $0.event == event } : nil var rewritten: [[String: Any]] = [] for (groupIndex, rawGroup) in rawGroups.enumerated() { guard var group = rawGroup as? [String: Any] else { @@ -1996,31 +1999,28 @@ extension ProgramaCLI { throw CodexHooksError(message: "hooks.json \(event) handler \(handlerIndex).command must be a string") } if !codexIsOwnedHandler(handler, event: event) { handlers.append(handler) } + else if let spec, updatedEvents.insert(event).inserted { handlers.append(Self.codexCanonicalHandler(spec)) } } if !handlers.isEmpty { + if rawHandlers.count == 1, codexIsOwnedHandler(handlers[0], event: event) { group = [:] } group["hooks"] = handlers rewritten.append(group) } } - if rewritten.isEmpty { hooks.removeValue(forKey: event) } - else { hooks[event] = rewritten } + hooks[event] = rewritten.isEmpty ? nil : rewritten } - if install { - for spec in Self.codexHookSpecs { - var groups = hooks[spec.event] as? [[String: Any]] ?? [] - groups.append(["hooks": [[ - "type": "command", - "command": Self.codexHookCommand(spec.commandEvent), - "timeout": spec.timeout, - ] as [String: Any]]]) - hooks[spec.event] = groups - } + for spec in Self.codexHookSpecs where install && !updatedEvents.contains(spec.event) { + hooks[spec.event] = (hooks[spec.event] as? [[String: Any]] ?? []) + [["hooks": [Self.codexCanonicalHandler(spec)]]] } root["hooks"] = hooks try codexValidateForeignHandlerPositions(before: source, after: root) return root } + private static func codexCanonicalHandler(_ spec: CodexHookSpec) -> [String: Any] { + ["type": "command", "command": codexHookCommand(spec.commandEvent), "timeout": spec.timeout] + } + private func codexValidateForeignHandlerPositions( before: [String: Any], after: [String: Any] diff --git a/tests/test_cli_codex_hook_trust.py b/tests/test_cli_codex_hook_trust.py index 7aa788174..e5e55b753 100644 --- a/tests/test_cli_codex_hook_trust.py +++ b/tests/test_cli_codex_hook_trust.py @@ -370,17 +370,18 @@ def test_invalid_config_fails_before_hooks_are_mutated(self) -> None: ) def test_install_refuses_to_reassign_a_foreign_positional_trust_key(self) -> None: - """Removing a legacy group must not shift foreign trust onto Programa's replacement.""" + """Dropping a duplicate Programa group must not shift foreign trust positions.""" hooks = { "hooks": { "Stop": [ + {"hooks": [owned_handler("stop")]}, {"hooks": [owned_handler("stop")]}, {"hooks": [foreign_handler("position-sensitive-stop")]}, ] } } original_hooks = self.write_hooks(hooks) - foreign_key = f"{self.codex_home.resolve() / 'hooks.json'}:stop:1:0" + foreign_key = f"{self.codex_home.resolve() / 'hooks.json'}:stop:2:0" original_config = ( f'[hooks.state.{json.dumps(foreign_key)}]\n' 'trusted_hash = "sha256:foreign-position"\n' @@ -397,6 +398,40 @@ def test_install_refuses_to_reassign_a_foreign_positional_trust_key(self) -> Non self.assertEqual((self.codex_home / "hooks.json").read_bytes(), original_hooks) self.assertEqual((self.codex_home / "config.toml").read_bytes(), original_config) + def test_reinstall_updates_programa_in_place_when_foreign_groups_follow_it(self) -> None: + """A later tool's groups must keep their positions across a Programa reinstall.""" + foreign_command = ( + 'bun --no-env-file "/x/darkroom/source/src/scripts/codex-hook.ts" ' + '"src/hooks/session-end.ts"' + ) + hooks: dict[str, Any] = {"hooks": {}} + for event, command_event in ( + ("SessionEnd", "session-end"), + ("UserPromptSubmit", "prompt-submit"), + ): + hooks["hooks"][event] = [ + {"hooks": [owned_handler(command_event)]}, + {"hooks": [{"type": "command", "command": foreign_command, "timeout": 5}]}, + ] + self.write_hooks(hooks) + + install = self.run_cli("install-hooks") + self.assert_succeeded(install) + + installed = self.read_hooks()["hooks"] + for event in ("SessionEnd", "UserPromptSubmit"): + groups = installed[event] + self.assertEqual(len(groups), 2, f"{event} must not gain or lose groups") + self.assertTrue( + is_programa_handler(event, groups[0]["hooks"][0]["command"]), + f"{event} Programa handler must stay at 0:0", + ) + self.assertEqual( + groups[1]["hooks"], + [{"type": "command", "command": foreign_command, "timeout": 5}], + f"{event} foreign handler must stay at 1:0", + ) + def test_uninstall_refuses_to_delete_a_user_comment_with_stale_trust(self) -> None: """A positional trust table cannot own comments that follow its assignments.""" hooks = {"hooks": {"SessionStart": [{"hooks": [owned_handler("session-start")]}]}}