From ae5d9ac7b0313e09ad05d2c1edc85bc42ee6f4c4 Mon Sep 17 00:00:00 2001 From: arzafran Date: Thu, 1 Oct 2026 13:37:16 -0300 Subject: [PATCH 1/3] test: Programa Codex hook reinstall keeps later foreign groups in place --- tests/test_cli_codex_hook_trust.py | 34 ++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/tests/test_cli_codex_hook_trust.py b/tests/test_cli_codex_hook_trust.py index 7aa788174..57247a327 100644 --- a/tests/test_cli_codex_hook_trust.py +++ b/tests/test_cli_codex_hook_trust.py @@ -397,6 +397,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")]}]}} From 96a080955f3b8ce674cbb13db3848391097ea9f5 Mon Sep 17 00:00:00 2001 From: arzafran Date: Thu, 1 Oct 2026 13:39:12 -0300 Subject: [PATCH 2/3] fix: update Programa's Codex hooks in place on reinstall --- CLI/CLI+Hooks.swift | 39 ++++++++++++++++++++++++------ tests/test_cli_codex_hook_trust.py | 5 ++-- 2 files changed, 35 insertions(+), 9 deletions(-) diff --git a/CLI/CLI+Hooks.swift b/CLI/CLI+Hooks.swift index 1172a8121..9f13cbaee 100644 --- a/CLI/CLI+Hooks.swift +++ b/CLI/CLI+Hooks.swift @@ -1979,6 +1979,11 @@ extension ProgramaCLI { guard let rawGroups = hooks[event] as? [Any] else { throw CodexHooksError(message: "hooks.json event \(event) must be an array") } + // Codex trust keys are positional. On install the first owned handler of + // a spec event is rewritten where it stands so foreign handlers that + // follow it keep their (group, handler) positions. + let spec = install ? Self.codexHookSpecs.first { $0.event == event } : nil + var updatedInPlace = false var rewritten: [[String: Any]] = [] for (groupIndex, rawGroup) in rawGroups.enumerated() { guard var group = rawGroup as? [String: Any] else { @@ -1988,6 +1993,7 @@ extension ProgramaCLI { throw CodexHooksError(message: "hooks.json \(event) group \(groupIndex).hooks must be an array") } var handlers: [[String: Any]] = [] + var ownedOnlyGroup = false for (handlerIndex, rawHandler) in rawHandlers.enumerated() { guard let handler = rawHandler as? [String: Any] else { throw CodexHooksError(message: "hooks.json \(event) handler \(handlerIndex) must be an object") @@ -1995,9 +2001,19 @@ extension ProgramaCLI { if let command = handler["command"], !(command is String) { throw CodexHooksError(message: "hooks.json \(event) handler \(handlerIndex).command must be a string") } - if !codexIsOwnedHandler(handler, event: event) { handlers.append(handler) } + if !codexIsOwnedHandler(handler, event: event) { + handlers.append(handler) + } else if let spec, !updatedInPlace { + updatedInPlace = true + handlers.append(Self.codexCanonicalHandler(spec)) + ownedOnlyGroup = rawHandlers.count == 1 + } } - if !handlers.isEmpty { + if ownedOnlyGroup { + // A group holding only Programa's handler is replaced whole so a + // stale matcher cannot narrow it. + rewritten.append(["hooks": handlers]) + } else if !handlers.isEmpty { group["hooks"] = handlers rewritten.append(group) } @@ -2008,11 +2024,12 @@ extension ProgramaCLI { 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]]]) + let hasOwned = groups.contains { group in + (group["hooks"] as? [[String: Any]] ?? []).contains { codexIsOwnedHandler($0, event: spec.event) } + } + if !hasOwned { + groups.append(["hooks": [Self.codexCanonicalHandler(spec)]]) + } hooks[spec.event] = groups } } @@ -2021,6 +2038,14 @@ extension ProgramaCLI { 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 57247a327..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' From 133760af06bbd60ef8e42e09514d476e64b17877 Mon Sep 17 00:00:00 2001 From: arzafran Date: Thu, 1 Oct 2026 13:45:25 -0300 Subject: [PATCH 3/3] refactor: keep CLI+Hooks.swift within its line budget --- CLI/CLI+Hooks.swift | 45 ++++++++++----------------------------------- 1 file changed, 10 insertions(+), 35 deletions(-) diff --git a/CLI/CLI+Hooks.swift b/CLI/CLI+Hooks.swift index 9f13cbaee..7cd845221 100644 --- a/CLI/CLI+Hooks.swift +++ b/CLI/CLI+Hooks.swift @@ -1975,15 +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") } - // Codex trust keys are positional. On install the first owned handler of - // a spec event is rewritten where it stands so foreign handlers that - // follow it keep their (group, handler) positions. + // 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 updatedInPlace = false var rewritten: [[String: Any]] = [] for (groupIndex, rawGroup) in rawGroups.enumerated() { guard var group = rawGroup as? [String: Any] else { @@ -1993,7 +1991,6 @@ extension ProgramaCLI { throw CodexHooksError(message: "hooks.json \(event) group \(groupIndex).hooks must be an array") } var handlers: [[String: Any]] = [] - var ownedOnlyGroup = false for (handlerIndex, rawHandler) in rawHandlers.enumerated() { guard let handler = rawHandler as? [String: Any] else { throw CodexHooksError(message: "hooks.json \(event) handler \(handlerIndex) must be an object") @@ -2001,37 +1998,19 @@ extension ProgramaCLI { if let command = handler["command"], !(command is String) { 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, !updatedInPlace { - updatedInPlace = true - handlers.append(Self.codexCanonicalHandler(spec)) - ownedOnlyGroup = rawHandlers.count == 1 - } + if !codexIsOwnedHandler(handler, event: event) { handlers.append(handler) } + else if let spec, updatedEvents.insert(event).inserted { handlers.append(Self.codexCanonicalHandler(spec)) } } - if ownedOnlyGroup { - // A group holding only Programa's handler is replaced whole so a - // stale matcher cannot narrow it. - rewritten.append(["hooks": handlers]) - } else if !handlers.isEmpty { + 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]] ?? [] - let hasOwned = groups.contains { group in - (group["hooks"] as? [[String: Any]] ?? []).contains { codexIsOwnedHandler($0, event: spec.event) } - } - if !hasOwned { - groups.append(["hooks": [Self.codexCanonicalHandler(spec)]]) - } - 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) @@ -2039,11 +2018,7 @@ extension ProgramaCLI { } private static func codexCanonicalHandler(_ spec: CodexHookSpec) -> [String: Any] { - [ - "type": "command", - "command": codexHookCommand(spec.commandEvent), - "timeout": spec.timeout, - ] + ["type": "command", "command": codexHookCommand(spec.commandEvent), "timeout": spec.timeout] } private func codexValidateForeignHandlerPositions(