diff --git a/CHANGELOG.md b/CHANGELOG.md index 24a32cf..34b40ac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -80,6 +80,14 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), **Upgrading: no `task-init` re-run needed.** All seven installers already symlink `$SCRIPT_DIR/../bin/task-done` and never their own copy — the drift-guarded `install-shared-symlinks` stanza — so no installer-written artifact changes and no repo needs refreshing. ### Fixed +- **kiro: `task-init`, `task-remove` and `task-config set` now handle an agent config kiro-cli has upgraded to its "universal" format (#267).** kiro-cli's V3 engine rewrites `.kiro/agents/*.json` into that format in three cases: when you accept the "upgrade your agent configs" dialog, when `chat.enableAutoAgentUpgrade` is `true`, and when you run `kiro-cli agent upgrade`. The rewrite turns `hooks` from an object keyed by trigger into an array of `{name, trigger, action: {type, command}, timeout}`. The radio hooks survived the rewrite, but nothing that edits them afterwards could read the array. Re-running `task-init ` warned `could not merge radio hooks` for every agent, because jq failed with `Cannot index array with string "agentSpawn"`. Removal was worse because it was silent: `lib/loadout-artifacts.sh`'s strip skipped any `hooks` that was not an object, so `task-remove` and `task-config set` reported the file as kept because it "may be yours", and left every radio hook in it. + - **The merge** (all three kiro installers, `kiro-radio-hooks-jq-merge` and `kiro-radio-stray-hook-verify`, still drift-guarded) now recognises radio's entries by their command in either shape (`.command` in task-init's own format, `.action.command` in the universal one). It adds a missing entry in the shape the file already uses. In the universal format that entry is `{name: "radio-", trigger, action: {type: "command", command}, timeout: 10}`, which is the timeout the upgrade gives an entry that had none. It never converts a config from one format to the other. A universal config with no `hooks` key at all gets an array too. That case is detected by `permissions`, the field kiro-cli's upgrade keys the hooks shape on: an object `hooks` beside `permissions` is the hybrid that kiro-cli reports `out of sync (not rebuilt)`. `kiroUpgrade` cannot be the marker, because a V3-only config carries no fingerprint. The `radio-` name can repeat one of the user's hook names. kiro-cli 2.32.0 accepts duplicate hook names, so the merge does nothing to avoid them. Verified on kiro-cli 2.32.0: a merged config passes `kiro-cli agent validate`, and a second `kiro-cli agent upgrade --dry-run` still reports it `already universal`, so the `kiroUpgrade` fingerprint is not invalidated. The stale-radio-command warning reads the array too. + - **The strip** drops radio-owned entries from the array, keeps the user's own entries exactly as kiro-cli wrote them, and drops `hooks` only once it is empty. + - An upgraded config is never the copy `task-init` ships, because kiro-cli also rewrites `tools` and adds `permissions` and `kiroUpgrade`. So removal keeps the file, with only the radio hooks stripped, rather than deleting it. That is the existing keep-bias (#219), and `--purge` still deletes it. + - The #257 launch warning no longer warns you off `true`. It still names `false` as the fix, and the kiro steering templates and the README now say the upgraded format works. + + Tests run against fixtures that are real `kiro-cli agent upgrade` output, not hand-written JSON. They live in `tests/fixtures/kiro-universal/`, together with the inputs and a regeneration recipe. The new tests cover a no-op re-run over an upgraded config, a hook-less upgraded config (real fixture, plus a variant without `kiroUpgrade` that pins the `permissions` marker), entries added in the array shape (name, trigger, command and timeout), idempotence, object-format configs beside it staying objects, the stray warning on the array, each kiro loadout's own `--loadout` default, the strip through `task-remove` (apply and `--dry-run`) and through `task-config set`, and the warning losing its caveat. Each new installer test was run against the pre-fix `kiro-gh/bin/task-init` and each strip test against the pre-fix lib, and all of them failed. The one exception is "object-format configs stay objects", which passes on the old code by design. It guards the opposite mistake, a merge that normalises every config to the universal format, and a merge mutated to do that turned it red. **Upgrading: no `task-init` re-run is needed for removal**, since `lib/` takes effect through the existing symlinks. **Re-run `task-init `** to get the merge fix (the warning goes away on that same run) and the updated steering doc. + - **`task-remove` and the removal phase of `task-config set` no longer hang at 100% CPU on a large `CLAUDE.md` under bash 3.2 with a UTF-8 locale (#266).** Two "is anything left?" checks in `lib/loadout-artifacts.sh` stripped whitespace with `${s//[[:space:]]/}`: `_la_strip_line`, which decides whether `CLAUDE.md` (or `.gitignore`) held only task-init's line, and `_la_workflow_doc_tail`, which decides whether the workflow doc needs a `.bak`. Bash 3.2's pattern matcher is superlinear on a bracket class under a multibyte locale. On macOS's `/bin/bash` an 8KB UTF-8 file took 25s, and a ~30KB `CLAUDE.md` never finished. Both now go through a new `_la_has_content`, a `grep -q '[^[:space:]]'` on a here-string. It is not a pipe, because under the callers' `pipefail` a `grep -q` that exits early would SIGPIPE the writer and a non-blank file would read as blank, so the user's `CLAUDE.md` would be deleted. `_la_workflow_doc_tail` now returns the tail verbatim, not whitespace-stripped. Its only caller tests it for emptiness. The repo has no other `${var//[[:class:]]/}` uses. New tests drive both functions on a ~40KB UTF-8 file under a UTF-8 locale with a 20s alarm, and pin the whitespace-only cases. On the old code both large-file tests hit the alarm, and forcing `_la_has_content` to either answer turned tests red. The large-file tests only discriminate on bash 3.2; elsewhere they pin behaviour. **Upgrading: no `task-init` re-run needed.** `lib/` takes effect through the existing `~/.local/bin` symlinks. Workaround on an older checkout: `LC_ALL=C task-remove …`. - **kiro-cli's V3 engine holds a dispatched worker's first turn with an agent-config upgrade dialog (#257): the launchers now warn.** With the V3 engine selected, kiro-cli opens "Your agent configs are still in the V2 format. Upgrade them to run on V3?" before the positional first turn. Observed on kiro-cli 2.32.0 on the exact launch line `task-work` builds, `--tui` included. Today V3 means `chat.agentEngine = v3`. kiro-cli's help calls v2 "the pre-3.0 default", so V3 is expected to become the default with 3.0. What the probes established, each in a pty against a real kiro-cli 2.32.0: diff --git a/README.md b/README.md index 339eb51..9aff72b 100644 --- a/README.md +++ b/README.md @@ -1138,7 +1138,7 @@ Radio auto-submit and focus, on every combo (#246, #254): A kiro worker's first turn is the task reference, passed as `kiro-cli chat`'s positional `[INPUT]`. kiro-cli runs it as soon as it starts, unless a startup prompt gets there first, and in a dispatched tab nobody answers one, so the worker shows its welcome message and sits idle as if it had never been given a ref (#157). Two such prompts are within reach. The classic UI's deprecation dialog is avoided by pinning `--tui` on the launch line when the installed kiro-cli accepts it, which also overrides a `chat.ui = legacy` setting. kiro-cli also documents a trust-all confirmation at startup for `-a/--trust-all`. That one was not observed here, and it is not ours to skip, so `task-work` and the kiro `task-reviewer`, which is trust-all by default, warn on stderr unless `chat.disableTrustAllConfirmation` is true. Set it with `kiro-cli settings chat.disableTrustAllConfirmation true`, or answer the prompt in the worker's tab. -A third prompt comes with kiro-cli's V3 engine (#257): "Your agent configs are still in the V2 format. Upgrade them to run on V3?" It was observed on kiro-cli 2.32.0 holding the first turn on the exact launch line `task-work` builds, `--tui` included, whenever the launch runs on V3: today that means `chat.agentEngine = v3`. kiro-cli's own help calls v2 "the pre-3.0 default", so V3 is expected to become the default with 3.0, and then every kiro launch is exposed. The dialog is the `chat.enableAutoAgentUpgrade` setting. When the setting is unset it prompts, `true` rewrites the configs without asking, and `false` never asks. kiro-cli has no flag or env var that sets it for one launch. So `task-work`, `task-recreate-worker` and the kiro `task-reviewer` warn on stderr when the launch would run on V3 and the setting is unset. None of them warns under `--no-launch`. Set it once with `kiro-cli settings chat.enableAutoAgentUpgrade false`. With `false`, V3 loads the agent configs `task-init` wrote as they are, and the radio hooks fire. `true` skips the dialog too, but kiro-cli then rewrites `.kiro/agents/*.json` into its universal format, where `hooks` is an array. The rewrite keeps the radio hooks, but `task-init` cannot yet merge hooks into that shape, and a re-run warns `could not merge radio hooks` for every agent. The same applies to answering "Enable auto-upgrade" in the dialog, or running `kiro-cli agent upgrade` yourself. +A third prompt comes with kiro-cli's V3 engine (#257): "Your agent configs are still in the V2 format. Upgrade them to run on V3?" It was observed on kiro-cli 2.32.0 holding the first turn on the exact launch line `task-work` builds, `--tui` included, whenever the launch runs on V3: today that means `chat.agentEngine = v3`. kiro-cli's own help calls v2 "the pre-3.0 default", so V3 is expected to become the default with 3.0, and then every kiro launch is exposed. The dialog is the `chat.enableAutoAgentUpgrade` setting. When the setting is unset it prompts, `true` rewrites the configs without asking, and `false` never asks. kiro-cli has no flag or env var that sets it for one launch. So `task-work`, `task-recreate-worker` and the kiro `task-reviewer` warn on stderr when the launch would run on V3 and the setting is unset. None of them warns under `--no-launch`. Set it once with `kiro-cli settings chat.enableAutoAgentUpgrade false`. With `false`, V3 loads the agent configs `task-init` wrote as they are, and the radio hooks fire. `true` skips the dialog too, but kiro-cli then rewrites `.kiro/agents/*.json` into its universal format, where `hooks` is an array of `{name, trigger, action: {type, command}, timeout}` entries. The same rewrite happens when you answer "Enable auto-upgrade" in the dialog or run `kiro-cli agent upgrade` yourself. The radio hooks survive it. Since #267, `task-init` and `task-remove` handle that format too. A re-run of `task-init` recognises radio's entries by their command and adds any that are missing in the array shape, with the name `radio-` and the 10-second timeout the upgrade itself gives an entry. `task-remove` and `task-config set` strip only those entries. Neither one converts a config from one format to the other. --- diff --git a/kiro-gh/bin/task-init b/kiro-gh/bin/task-init index 75631ec..5ed272b 100755 --- a/kiro-gh/bin/task-init +++ b/kiro-gh/bin/task-init @@ -291,6 +291,29 @@ _radio_install_hooks() { fi # region:kiro-radio-hooks-jq-merge + # Two shapes of `hooks` reach this merge (#267). task-init writes an object + # keyed by trigger, {"agentSpawn": [{"command": …}], …}. kiro-cli's V3 engine + # can rewrite the file into its "universal" format, where `hooks` is an array + # of {name, trigger, action: {type, command}, timeout} — on the upgrade + # dialog, under chat.enableAutoAgentUpgrade=true, or by `kiro-cli agent + # upgrade`. The merge keeps whichever shape the file already has and never + # converts one into the other: a radio entry is recognised by its command in + # either shape, and a missing one is added in the shape around it, with the + # name / timeout the upgrade itself gives an entry (verified on 2.32.0; + # `kiro-cli agent validate` accepts the result, and a re-run of the upgrade + # still reports the file "already universal"). + # + # A universal config with no hooks at all has no `hooks` to read the shape + # off, so its shape comes from `permissions`. That is the field kiro-cli's + # upgrade keys the hooks shape on (probed on 2.32.0): an object `hooks` in a + # file that has `permissions` is reported "out of sync (not rebuilt)", with or + # without the `kiroUpgrade` fingerprint, and an array is accepted in every + # case. `kiroUpgrade` alone would miss a V3-only config, which has no + # fingerprint, and give it the hybrid kiro-cli refuses to rebuild. + # + # The `radio-` name can repeat a user entry's name. kiro-cli 2.32.0 + # does not require unique names (`agent validate`, `agent upgrade` and + # `agent list` all accept duplicates), so nothing here avoids one. local target tmp merged=0 for target in "$agents_dir"/*.json; do [[ -f "$target" ]] || continue @@ -300,22 +323,36 @@ _radio_install_hooks() { --arg ups "$busy_cmd" \ --arg stop "$ready_cmd" \ ' + def is_radio: (.command // .action.command? // "") | startswith("radio "); def add_radio(arr; cmd): - if (arr // []) | any((.command // "") | startswith("radio ")) then arr + if (arr // []) | any(is_radio) then arr else (arr // []) + [{command: cmd}] end; + def add_radio_u(trig; cmd): + if any(.[]; .trigger == trig and is_radio) then . + else . + [{name: "radio-\(trig)", trigger: trig, + action: {type: "command", command: cmd}, timeout: 10}] + end; # `agentStop` is not a valid kiro-cli trigger — an agent config carrying # one fails to load wholesale, taking every other hook with it. Drop any # radio-owned entry there; leave anything we did not write alone rather # than silently editing a key that is not ours. def drop_radio(arr): - [ (arr // [])[] | select(((.command // "") | startswith("radio ")) | not) ]; - .hooks //= {} - | .hooks.agentSpawn = add_radio(.hooks.agentSpawn; $spawn) - | .hooks.userPromptSubmit = add_radio(.hooks.userPromptSubmit; $ups) - | .hooks.stop = add_radio(.hooks.stop; $stop) - | .hooks.agentStop = drop_radio(.hooks.agentStop) - | if (.hooks.agentStop | length) == 0 then del(.hooks.agentStop) else . end + [ (arr // [])[] | select(is_radio | not) ]; + if (.hooks | type) == "array" or (.hooks == null and has("permissions")) then + .hooks |= ( (. // []) + | add_radio_u("agentSpawn"; $spawn) + | add_radio_u("userPromptSubmit"; $ups) + | add_radio_u("stop"; $stop) + | map(select((.trigger == "agentStop" and is_radio) | not)) ) + else + .hooks //= {} + | .hooks.agentSpawn = add_radio(.hooks.agentSpawn; $spawn) + | .hooks.userPromptSubmit = add_radio(.hooks.userPromptSubmit; $ups) + | .hooks.stop = add_radio(.hooks.stop; $stop) + | .hooks.agentStop = drop_radio(.hooks.agentStop) + | if (.hooks.agentStop | length) == 0 then del(.hooks.agentStop) else . end + end ' "$target" >"$tmp"; then rm -f "$tmp" echo "Warning: could not merge radio hooks into $target (invalid JSON?)" >&2 @@ -330,20 +367,26 @@ _radio_install_hooks() { # A radio-prefixed command that isn't the one we install blocks add_radio from # installing the right one, so the repo would keep a stale hook while # task-init prints success — the same trap #172 found on the claude side. - # Warn loudly rather than silently rewriting a command someone chose. + # Warn loudly rather than silently rewriting a command someone chose. Both + # `hooks` shapes are read, as in the merge above (#267). local stray="" one for target in "$agents_dir"/*.json; do [[ -f "$target" ]] || continue one=$(jq -r \ --arg spawn "$register_cmd" --arg ups "$busy_cmd" --arg stop "$ready_cmd" \ --arg name "${target##*/}" ' - def stray(arr; evt; expected): - [ (arr // [])[] | .command // "" + def entries(evt): + if (.hooks | type) == "array" + then [ .hooks[] | select(.trigger == evt) | .action.command? // "" ] + else [ (.hooks[evt]? // [])[] | .command // "" ] + end; + def stray(evt; expected): + [ entries(evt)[] | select(startswith("radio ") and . != expected) | "\($name) \(evt): \(.) — replace with: \(expected)" ]; - ( stray(.hooks.agentSpawn; "agentSpawn"; $spawn) - + stray(.hooks.userPromptSubmit; "userPromptSubmit"; $ups) - + stray(.hooks.stop; "stop"; $stop) + ( stray("agentSpawn"; $spawn) + + stray("userPromptSubmit"; $ups) + + stray("stop"; $stop) ) | .[] ' "$target" 2>/dev/null || true) if [[ -n "$one" ]]; then stray+="$one"$'\n'; fi diff --git a/kiro-gh/steering/gh-workflow.example.md b/kiro-gh/steering/gh-workflow.example.md index 5a886ac..1f231e8 100644 --- a/kiro-gh/steering/gh-workflow.example.md +++ b/kiro-gh/steering/gh-workflow.example.md @@ -69,7 +69,7 @@ Use the issue title as prefix: `: ` - `--focus` — switch to the new tab. By default focus stays on the tab you ran `task-work` from, so the PM can dispatch without being pulled away (#254) - `--auto` — accepted for symmetry with claude, and a no-op here: the permission model stays `-a/--trust-all` (#206), and auto-submit is already on -Under kiro-cli's V3 engine (`chat.agentEngine = v3` today; kiro-cli's help calls v2 "the pre-3.0 default"), every dispatched kiro launch — `task-work`, `task-recreate-worker`, `task-reviewer` — would first stop at a dialog offering to upgrade your agent configs to the V3 format. Nobody answers it in a dispatched tab, so the first turn never runs. The launchers warn when the launch would run on V3 and `chat.enableAutoAgentUpgrade` is unset. Set it once with `kiro-cli settings chat.enableAutoAgentUpgrade false`. With `false` the radio hooks keep working under V3. `true`, or accepting the upgrade, rewrites `.kiro/agents/*.json` into a shape `task-init` cannot merge radio hooks into yet (#257). +Under kiro-cli's V3 engine (`chat.agentEngine = v3` today; kiro-cli's help calls v2 "the pre-3.0 default"), every dispatched kiro launch — `task-work`, `task-recreate-worker`, `task-reviewer` — would first stop at a dialog offering to upgrade your agent configs to the V3 format. Nobody answers it in a dispatched tab, so the first turn never runs. The launchers warn when the launch would run on V3 and `chat.enableAutoAgentUpgrade` is unset. Set it once with `kiro-cli settings chat.enableAutoAgentUpgrade false`. With `false` the radio hooks keep working under V3 (#257). `true`, or accepting the upgrade, rewrites `.kiro/agents/*.json` into kiro-cli's universal format instead; the radio hooks keep working there too, and `task-init` and `task-remove` handle that format since #267. If local `` is strictly behind `origin/`, `task-work` auto-refreshes and forks the new worktree from `origin/` instead of the stale local tip. Pass `--from` to override. diff --git a/kiro-local/bin/task-init b/kiro-local/bin/task-init index e43693e..e0869fc 100755 --- a/kiro-local/bin/task-init +++ b/kiro-local/bin/task-init @@ -297,6 +297,29 @@ _radio_install_hooks() { fi # region:kiro-radio-hooks-jq-merge + # Two shapes of `hooks` reach this merge (#267). task-init writes an object + # keyed by trigger, {"agentSpawn": [{"command": …}], …}. kiro-cli's V3 engine + # can rewrite the file into its "universal" format, where `hooks` is an array + # of {name, trigger, action: {type, command}, timeout} — on the upgrade + # dialog, under chat.enableAutoAgentUpgrade=true, or by `kiro-cli agent + # upgrade`. The merge keeps whichever shape the file already has and never + # converts one into the other: a radio entry is recognised by its command in + # either shape, and a missing one is added in the shape around it, with the + # name / timeout the upgrade itself gives an entry (verified on 2.32.0; + # `kiro-cli agent validate` accepts the result, and a re-run of the upgrade + # still reports the file "already universal"). + # + # A universal config with no hooks at all has no `hooks` to read the shape + # off, so its shape comes from `permissions`. That is the field kiro-cli's + # upgrade keys the hooks shape on (probed on 2.32.0): an object `hooks` in a + # file that has `permissions` is reported "out of sync (not rebuilt)", with or + # without the `kiroUpgrade` fingerprint, and an array is accepted in every + # case. `kiroUpgrade` alone would miss a V3-only config, which has no + # fingerprint, and give it the hybrid kiro-cli refuses to rebuild. + # + # The `radio-` name can repeat a user entry's name. kiro-cli 2.32.0 + # does not require unique names (`agent validate`, `agent upgrade` and + # `agent list` all accept duplicates), so nothing here avoids one. local target tmp merged=0 for target in "$agents_dir"/*.json; do [[ -f "$target" ]] || continue @@ -306,22 +329,36 @@ _radio_install_hooks() { --arg ups "$busy_cmd" \ --arg stop "$ready_cmd" \ ' + def is_radio: (.command // .action.command? // "") | startswith("radio "); def add_radio(arr; cmd): - if (arr // []) | any((.command // "") | startswith("radio ")) then arr + if (arr // []) | any(is_radio) then arr else (arr // []) + [{command: cmd}] end; + def add_radio_u(trig; cmd): + if any(.[]; .trigger == trig and is_radio) then . + else . + [{name: "radio-\(trig)", trigger: trig, + action: {type: "command", command: cmd}, timeout: 10}] + end; # `agentStop` is not a valid kiro-cli trigger — an agent config carrying # one fails to load wholesale, taking every other hook with it. Drop any # radio-owned entry there; leave anything we did not write alone rather # than silently editing a key that is not ours. def drop_radio(arr): - [ (arr // [])[] | select(((.command // "") | startswith("radio ")) | not) ]; - .hooks //= {} - | .hooks.agentSpawn = add_radio(.hooks.agentSpawn; $spawn) - | .hooks.userPromptSubmit = add_radio(.hooks.userPromptSubmit; $ups) - | .hooks.stop = add_radio(.hooks.stop; $stop) - | .hooks.agentStop = drop_radio(.hooks.agentStop) - | if (.hooks.agentStop | length) == 0 then del(.hooks.agentStop) else . end + [ (arr // [])[] | select(is_radio | not) ]; + if (.hooks | type) == "array" or (.hooks == null and has("permissions")) then + .hooks |= ( (. // []) + | add_radio_u("agentSpawn"; $spawn) + | add_radio_u("userPromptSubmit"; $ups) + | add_radio_u("stop"; $stop) + | map(select((.trigger == "agentStop" and is_radio) | not)) ) + else + .hooks //= {} + | .hooks.agentSpawn = add_radio(.hooks.agentSpawn; $spawn) + | .hooks.userPromptSubmit = add_radio(.hooks.userPromptSubmit; $ups) + | .hooks.stop = add_radio(.hooks.stop; $stop) + | .hooks.agentStop = drop_radio(.hooks.agentStop) + | if (.hooks.agentStop | length) == 0 then del(.hooks.agentStop) else . end + end ' "$target" >"$tmp"; then rm -f "$tmp" echo "Warning: could not merge radio hooks into $target (invalid JSON?)" >&2 @@ -336,20 +373,26 @@ _radio_install_hooks() { # A radio-prefixed command that isn't the one we install blocks add_radio from # installing the right one, so the repo would keep a stale hook while # task-init prints success — the same trap #172 found on the claude side. - # Warn loudly rather than silently rewriting a command someone chose. + # Warn loudly rather than silently rewriting a command someone chose. Both + # `hooks` shapes are read, as in the merge above (#267). local stray="" one for target in "$agents_dir"/*.json; do [[ -f "$target" ]] || continue one=$(jq -r \ --arg spawn "$register_cmd" --arg ups "$busy_cmd" --arg stop "$ready_cmd" \ --arg name "${target##*/}" ' - def stray(arr; evt; expected): - [ (arr // [])[] | .command // "" + def entries(evt): + if (.hooks | type) == "array" + then [ .hooks[] | select(.trigger == evt) | .action.command? // "" ] + else [ (.hooks[evt]? // [])[] | .command // "" ] + end; + def stray(evt; expected): + [ entries(evt)[] | select(startswith("radio ") and . != expected) | "\($name) \(evt): \(.) — replace with: \(expected)" ]; - ( stray(.hooks.agentSpawn; "agentSpawn"; $spawn) - + stray(.hooks.userPromptSubmit; "userPromptSubmit"; $ups) - + stray(.hooks.stop; "stop"; $stop) + ( stray("agentSpawn"; $spawn) + + stray("userPromptSubmit"; $ups) + + stray("stop"; $stop) ) | .[] ' "$target" 2>/dev/null || true) if [[ -n "$one" ]]; then stray+="$one"$'\n'; fi diff --git a/kiro-local/steering/local-workflow.example.md b/kiro-local/steering/local-workflow.example.md index ee80fa5..71c5c1e 100644 --- a/kiro-local/steering/local-workflow.example.md +++ b/kiro-local/steering/local-workflow.example.md @@ -84,7 +84,7 @@ Use the task title as prefix: `: ` - `--focus` — switch to the new tab. By default focus stays on the tab you ran `task-work` from, so the PM can dispatch without being pulled away (#254) - `--auto` — accepted for symmetry with claude, and a no-op here: the permission model stays `-a/--trust-all` (#206), and auto-submit is already on -Under kiro-cli's V3 engine (`chat.agentEngine = v3` today; kiro-cli's help calls v2 "the pre-3.0 default"), every dispatched kiro launch — `task-work`, `task-recreate-worker`, `task-reviewer` — would first stop at a dialog offering to upgrade your agent configs to the V3 format. Nobody answers it in a dispatched tab, so the first turn never runs. The launchers warn when the launch would run on V3 and `chat.enableAutoAgentUpgrade` is unset. Set it once with `kiro-cli settings chat.enableAutoAgentUpgrade false`. With `false` the radio hooks keep working under V3. `true`, or accepting the upgrade, rewrites `.kiro/agents/*.json` into a shape `task-init` cannot merge radio hooks into yet (#257). +Under kiro-cli's V3 engine (`chat.agentEngine = v3` today; kiro-cli's help calls v2 "the pre-3.0 default"), every dispatched kiro launch — `task-work`, `task-recreate-worker`, `task-reviewer` — would first stop at a dialog offering to upgrade your agent configs to the V3 format. Nobody answers it in a dispatched tab, so the first turn never runs. The launchers warn when the launch would run on V3 and `chat.enableAutoAgentUpgrade` is unset. Set it once with `kiro-cli settings chat.enableAutoAgentUpgrade false`. With `false` the radio hooks keep working under V3 (#257). `true`, or accepting the upgrade, rewrites `.kiro/agents/*.json` into kiro-cli's universal format instead; the radio hooks keep working there too, and `task-init` and `task-remove` handle that format since #267. If local `` is strictly behind `origin/`, `task-work` auto-refreshes and forks the new worktree from `origin/` instead of the stale local tip. Pass `--from` to override. diff --git a/kiro-notion/bin/task-init b/kiro-notion/bin/task-init index 6bd7b01..4720399 100755 --- a/kiro-notion/bin/task-init +++ b/kiro-notion/bin/task-init @@ -230,6 +230,29 @@ _radio_install_hooks() { fi # region:kiro-radio-hooks-jq-merge + # Two shapes of `hooks` reach this merge (#267). task-init writes an object + # keyed by trigger, {"agentSpawn": [{"command": …}], …}. kiro-cli's V3 engine + # can rewrite the file into its "universal" format, where `hooks` is an array + # of {name, trigger, action: {type, command}, timeout} — on the upgrade + # dialog, under chat.enableAutoAgentUpgrade=true, or by `kiro-cli agent + # upgrade`. The merge keeps whichever shape the file already has and never + # converts one into the other: a radio entry is recognised by its command in + # either shape, and a missing one is added in the shape around it, with the + # name / timeout the upgrade itself gives an entry (verified on 2.32.0; + # `kiro-cli agent validate` accepts the result, and a re-run of the upgrade + # still reports the file "already universal"). + # + # A universal config with no hooks at all has no `hooks` to read the shape + # off, so its shape comes from `permissions`. That is the field kiro-cli's + # upgrade keys the hooks shape on (probed on 2.32.0): an object `hooks` in a + # file that has `permissions` is reported "out of sync (not rebuilt)", with or + # without the `kiroUpgrade` fingerprint, and an array is accepted in every + # case. `kiroUpgrade` alone would miss a V3-only config, which has no + # fingerprint, and give it the hybrid kiro-cli refuses to rebuild. + # + # The `radio-` name can repeat a user entry's name. kiro-cli 2.32.0 + # does not require unique names (`agent validate`, `agent upgrade` and + # `agent list` all accept duplicates), so nothing here avoids one. local target tmp merged=0 for target in "$agents_dir"/*.json; do [[ -f "$target" ]] || continue @@ -239,22 +262,36 @@ _radio_install_hooks() { --arg ups "$busy_cmd" \ --arg stop "$ready_cmd" \ ' + def is_radio: (.command // .action.command? // "") | startswith("radio "); def add_radio(arr; cmd): - if (arr // []) | any((.command // "") | startswith("radio ")) then arr + if (arr // []) | any(is_radio) then arr else (arr // []) + [{command: cmd}] end; + def add_radio_u(trig; cmd): + if any(.[]; .trigger == trig and is_radio) then . + else . + [{name: "radio-\(trig)", trigger: trig, + action: {type: "command", command: cmd}, timeout: 10}] + end; # `agentStop` is not a valid kiro-cli trigger — an agent config carrying # one fails to load wholesale, taking every other hook with it. Drop any # radio-owned entry there; leave anything we did not write alone rather # than silently editing a key that is not ours. def drop_radio(arr): - [ (arr // [])[] | select(((.command // "") | startswith("radio ")) | not) ]; - .hooks //= {} - | .hooks.agentSpawn = add_radio(.hooks.agentSpawn; $spawn) - | .hooks.userPromptSubmit = add_radio(.hooks.userPromptSubmit; $ups) - | .hooks.stop = add_radio(.hooks.stop; $stop) - | .hooks.agentStop = drop_radio(.hooks.agentStop) - | if (.hooks.agentStop | length) == 0 then del(.hooks.agentStop) else . end + [ (arr // [])[] | select(is_radio | not) ]; + if (.hooks | type) == "array" or (.hooks == null and has("permissions")) then + .hooks |= ( (. // []) + | add_radio_u("agentSpawn"; $spawn) + | add_radio_u("userPromptSubmit"; $ups) + | add_radio_u("stop"; $stop) + | map(select((.trigger == "agentStop" and is_radio) | not)) ) + else + .hooks //= {} + | .hooks.agentSpawn = add_radio(.hooks.agentSpawn; $spawn) + | .hooks.userPromptSubmit = add_radio(.hooks.userPromptSubmit; $ups) + | .hooks.stop = add_radio(.hooks.stop; $stop) + | .hooks.agentStop = drop_radio(.hooks.agentStop) + | if (.hooks.agentStop | length) == 0 then del(.hooks.agentStop) else . end + end ' "$target" >"$tmp"; then rm -f "$tmp" echo "Warning: could not merge radio hooks into $target (invalid JSON?)" >&2 @@ -269,20 +306,26 @@ _radio_install_hooks() { # A radio-prefixed command that isn't the one we install blocks add_radio from # installing the right one, so the repo would keep a stale hook while # task-init prints success — the same trap #172 found on the claude side. - # Warn loudly rather than silently rewriting a command someone chose. + # Warn loudly rather than silently rewriting a command someone chose. Both + # `hooks` shapes are read, as in the merge above (#267). local stray="" one for target in "$agents_dir"/*.json; do [[ -f "$target" ]] || continue one=$(jq -r \ --arg spawn "$register_cmd" --arg ups "$busy_cmd" --arg stop "$ready_cmd" \ --arg name "${target##*/}" ' - def stray(arr; evt; expected): - [ (arr // [])[] | .command // "" + def entries(evt): + if (.hooks | type) == "array" + then [ .hooks[] | select(.trigger == evt) | .action.command? // "" ] + else [ (.hooks[evt]? // [])[] | .command // "" ] + end; + def stray(evt; expected): + [ entries(evt)[] | select(startswith("radio ") and . != expected) | "\($name) \(evt): \(.) — replace with: \(expected)" ]; - ( stray(.hooks.agentSpawn; "agentSpawn"; $spawn) - + stray(.hooks.userPromptSubmit; "userPromptSubmit"; $ups) - + stray(.hooks.stop; "stop"; $stop) + ( stray("agentSpawn"; $spawn) + + stray("userPromptSubmit"; $ups) + + stray("stop"; $stop) ) | .[] ' "$target" 2>/dev/null || true) if [[ -n "$one" ]]; then stray+="$one"$'\n'; fi diff --git a/kiro-notion/steering/notion-workflow.example.md b/kiro-notion/steering/notion-workflow.example.md index 4efe7ca..255aaf6 100644 --- a/kiro-notion/steering/notion-workflow.example.md +++ b/kiro-notion/steering/notion-workflow.example.md @@ -58,7 +58,7 @@ Use the task name as prefix: `: ` - `--focus` — switch to the new tab. By default focus stays on the tab you ran `task-work` from, so the PM can dispatch without being pulled away (#254) - `--auto` — accepted for symmetry with claude, and a no-op here: the permission model stays `-a/--trust-all` (#206), and auto-submit is already on -Under kiro-cli's V3 engine (`chat.agentEngine = v3` today; kiro-cli's help calls v2 "the pre-3.0 default"), every dispatched kiro launch — `task-work`, `task-recreate-worker`, `task-reviewer` — would first stop at a dialog offering to upgrade your agent configs to the V3 format. Nobody answers it in a dispatched tab, so the first turn never runs. The launchers warn when the launch would run on V3 and `chat.enableAutoAgentUpgrade` is unset. Set it once with `kiro-cli settings chat.enableAutoAgentUpgrade false`. With `false` the radio hooks keep working under V3. `true`, or accepting the upgrade, rewrites `.kiro/agents/*.json` into a shape `task-init` cannot merge radio hooks into yet (#257). +Under kiro-cli's V3 engine (`chat.agentEngine = v3` today; kiro-cli's help calls v2 "the pre-3.0 default"), every dispatched kiro launch — `task-work`, `task-recreate-worker`, `task-reviewer` — would first stop at a dialog offering to upgrade your agent configs to the V3 format. Nobody answers it in a dispatched tab, so the first turn never runs. The launchers warn when the launch would run on V3 and `chat.enableAutoAgentUpgrade` is unset. Set it once with `kiro-cli settings chat.enableAutoAgentUpgrade false`. With `false` the radio hooks keep working under V3 (#257). `true`, or accepting the upgrade, rewrites `.kiro/agents/*.json` into kiro-cli's universal format instead; the radio hooks keep working there too, and `task-init` and `task-remove` handle that format since #267. If local `` is strictly behind `origin/`, `task-work` auto-refreshes and forks the new worktree from `origin/` instead of the stale local tip. Pass `--from` to override. diff --git a/lib/kiro-launch.sh b/lib/kiro-launch.sh index 3d5b437..d9ccdb6 100644 --- a/lib/kiro-launch.sh +++ b/lib/kiro-launch.sh @@ -108,12 +108,13 @@ aw_kiro_engine() { # either value of the setting skips the dialog. Read with kiro-cli from # , so workspace-scoped settings count. Always returns 0. # -# The fix it names is `false`, not `true`. Both skip the dialog, but true lets -# kiro-cli rewrite .kiro/agents/*.json in place into its "universal" format, -# whose `hooks` is an array: the radio hooks survive that rewrite, but -# task-init's hook merge reads `hooks` as an object and fails on every -# re-run. Under false the configs stay as task-init wrote them, and V3 loads -# them as they are, radio hooks firing (verified on 2.32.0). +# The fix it names is `false`. Under false the configs stay as task-init wrote +# them, and V3 loads them as they are, radio hooks firing (verified on 2.32.0). +# `true` skips the dialog too, by rewriting .kiro/agents/*.json in place into +# kiro-cli's "universal" format, whose `hooks` is an array. That used to be a +# caveat worth printing, because task-init's hook merge and task-remove's strip +# read `hooks` only as an object; both handle the array shape since #267, so +# the message no longer warns the user off it. aw_kiro_warn_upgrade_gate() { local workdir="${1:-$PWD}" what="${2:-the agent}" v [[ "$(aw_kiro_engine "$workdir")" == v3 ]] || return 0 @@ -125,8 +126,6 @@ aw_kiro_warn_upgrade_gate() { echo " dialog before its first turn, and nobody types in a dispatched tab" echo " (#257). Answer it in the tab, or skip it for good:" echo " kiro-cli settings chat.enableAutoAgentUpgrade false" - echo " (true skips it too, but rewrites .kiro/agents/*.json into a format" - echo " task-init cannot merge radio hooks into yet.)" } >&2 return 0 } diff --git a/lib/loadout-artifacts.sh b/lib/loadout-artifacts.sh index 00f5d57..d8ce08a 100644 --- a/lib/loadout-artifacts.sh +++ b/lib/loadout-artifacts.sh @@ -254,23 +254,31 @@ la_owned_paths() { # Print the agent config with every radio-owned hook entry removed, emptied # triggers dropped, and `hooks` itself dropped if that empties it. # -# The `(.command // "") | startswith("radio ")` test is the exact mirror of the +# The `is_radio` test is the exact mirror of the # guard kiro-*/bin/task-init's _radio_install_hooks merge uses to recognise its # own work (#218/#222) — an entry of the user's own on the same trigger is left # alone. Unlike the install merge this sweeps *every* trigger rather than the # three it writes, so the invalid `agentStop` entries older task-inits left # behind come out too. +# +# Both `hooks` shapes are handled, as in the merge (#267): task-init's object +# keyed by trigger, and the array kiro-cli's "universal" format rewrites it into, +# whose entries carry the command at `.action.command`. Each keeps its shape. _la_strip_agent_hooks() { jq ' + def is_radio: (.command // .action.command? // "") | startswith("radio "); def drop_radio(arr): - [ (arr // [])[] | select(((.command // "") | startswith("radio ")) | not) ]; + [ (arr // [])[] | select(is_radio | not) ]; ( if (.hooks | type) == "object" then .hooks = ( .hooks | with_entries(.value = drop_radio(.value)) | with_entries(select((.value | length) > 0)) ) + elif (.hooks | type) == "array" + then .hooks = drop_radio(.hooks) else . end ) - | ( if (.hooks | type) == "object" and (.hooks | length) == 0 - then del(.hooks) else . end ) + | ( if (.hooks | type) == "object" or (.hooks | type) == "array" + then (if (.hooks | length) == 0 then del(.hooks) else . end) + else . end ) ' "$1" } diff --git a/tests/fixtures/kiro-universal/README.md b/tests/fixtures/kiro-universal/README.md new file mode 100644 index 0000000..4a102a4 --- /dev/null +++ b/tests/fixtures/kiro-universal/README.md @@ -0,0 +1,76 @@ +# kiro "universal" agent configs + +Agent configs as kiro-cli's V3 engine rewrites them, into the format it calls +"universal" (#267). Here `hooks` is an array of +`{name, trigger, action: {type, command}, timeout}` and is no longer an object +keyed by trigger. kiro-cli also adds a `permissions.rules` block and a +`kiroUpgrade` fingerprint. Tests copy these into `.kiro/agents/` to exercise +task-init's radio-hook merge and `lib/loadout-artifacts.sh`'s strip against the +shape kiro-cli actually writes. Hand-built JSON would only show what the shape +*should* look like. + +## Provenance + +Each file at the top level is the unedited output of + + kiro-cli agent upgrade --no-backup + +on **kiro-cli 2.32.0**, run over the matching input in `v2/`. The inputs are +minimal configs in task-init's own shape, with the radio commands copied +verbatim from what `kiro-gh/bin/task-init` installs. They are kept small on +purpose so the fixtures do not go stale whenever a shipped prompt changes. To +regenerate: + + d=$(mktemp -d); h=$(mktemp -d); cp v2/*.json "$d"/ + env -u TASK_FORCE_ROLE HOME="$h" TASK_FORCE_HOME="$h/tf" \ + "/Applications/Kiro CLI.app/Contents/MacOS/kiro-cli-chat" agent upgrade --no-backup "$d" + cp "$d"/*.json . + +Run it with HOME pointed at a scratch directory, as above, so that kiro-cli +cannot reach your real `~/.kiro`. The `kiro-cli` launcher looks for +`$HOME/.local/bin/kiro-cli-chat`, so under a scratch HOME you have to call the +`kiro-cli-chat` binary by its real path. The command above uses the macOS +install path. The current fixtures were all produced this way, and kiro-cli +wrote its own state (`.kiro/settings/cli.json`, `data.sqlite3`) into the +scratch HOME. `radio-and-user.json`, `radio-only.json` and `user-only.json` +were first made under the real HOME. Regenerating them under a scratch HOME +gave byte-identical files. + +| File | Input hooks | What it is for | +|------|-------------|----------------| +| `radio-and-user.json` | the three radio hooks (kiro-gh), plus the user's own `stop` hook and a `preToolUse` hook with a `matcher` and `timeout_ms` | an upgraded config of a repo task-init already set up: the merge must leave it alone, and the strip must take out the radio entries and nothing else | +| `radio-only.json` | the three radio hooks only | the strip empties `hooks` and drops the key | +| `user-only.json` | the user's own `agentSpawn` and `stop` hooks | a config with no radio hooks yet: the merge must add them in the universal shape | +| `no-hooks.json` | none — no `hooks` key | the upgrade writes no `hooks` either, so the merge has no shape to read: it must create an array, not the object-in-a-universal-file hybrid | + +Things the upgrade did that the tests rely on: + +- Entries are named `-` and sorted by trigger. +- `timeout` is in seconds (`timeout_ms: 5000` became `5`), and an entry that + had none got `10`. + +A config that task-init's merge then edits still passes +`kiro-cli agent validate`, and a second `kiro-cli agent upgrade --dry-run` +reports it `already universal`. The `kiroUpgrade` fingerprint does not make +kiro-cli reject or re-upgrade it. + +Two more findings on 2.32.0, probed with `agent upgrade --dry-run`, which +classifies each file it is given: + +- **`permissions` decides the hooks shape, not `kiroUpgrade`.** + - Put an object `hooks` in a file that has `permissions` and kiro-cli + reports it `out of sync (not rebuilt)`, whether or not the file carries + `kiroUpgrade`. An array `hooks` is accepted in every case. + - Drop `permissions` from an upgraded file and kiro-cli offers to upgrade + it again. Drop `kiroUpgrade` instead and the file stays + `already universal`. + - A file with no `allowedTools` is classed `v3-only`, and such a file + carries no fingerprint. + - So for a file with no `hooks` key, the merge picks the shape from + `has("permissions")`. +- **Hook `name`s need not be unique.** `agent validate`, `agent upgrade` and + `agent list` all accept two entries with the same name. Whether both of + them fire in a live session was not tested. + +`agent validate` cannot settle either question. It accepted every variant +tried, the hybrid included, and it exits 0 even when it prints an error. diff --git a/tests/fixtures/kiro-universal/no-hooks.json b/tests/fixtures/kiro-universal/no-hooks.json new file mode 100644 index 0000000..1563284 --- /dev/null +++ b/tests/fixtures/kiro-universal/no-hooks.json @@ -0,0 +1,28 @@ +{ + "name": "reviewer", + "description": "fixture", + "prompt": "fixture prompt", + "tools": [ + "*" + ], + "allowedTools": [ + "fs_read" + ], + "permissions": { + "rules": [ + { + "capability": "fs_read", + "effect": "allow" + }, + { + "capability": "skill", + "effect": "allow" + } + ] + }, + "kiroUpgrade": { + "revision": 2, + "fingerprint": "sha256:13ede5f409abc0581e8d935286cc7510d708536e69784bef88a9f4f441413cd3", + "source": "sha256:43fa375c34dceda7a39f13739996df8a611b1186a75142c816c084ca6fc43dba" + } +} diff --git a/tests/fixtures/kiro-universal/radio-and-user.json b/tests/fixtures/kiro-universal/radio-and-user.json new file mode 100644 index 0000000..6edf2cf --- /dev/null +++ b/tests/fixtures/kiro-universal/radio-and-user.json @@ -0,0 +1,76 @@ +{ + "name": "worker", + "description": "fixture", + "prompt": "fixture prompt", + "tools": [ + "*" + ], + "allowedTools": [ + "fs_read" + ], + "hooks": [ + { + "name": "agentSpawn-0", + "trigger": "agentSpawn", + "action": { + "type": "command", + "command": "radio register --role $TASK_FORCE_ROLE --tab $ZELLIJ_TAB --repo $(git rev-parse --show-toplevel 2>/dev/null) --agent kiro --loadout ${TASK_FORCE_LOADOUT:-kiro-gh}" + }, + "timeout": 10 + }, + { + "name": "preToolUse-0", + "trigger": "preToolUse", + "matcher": "execute_bash", + "action": { + "type": "command", + "command": "./guard.sh" + }, + "timeout": 5 + }, + { + "name": "stop-0", + "trigger": "stop", + "action": { + "type": "command", + "command": "radio ready" + }, + "timeout": 10 + }, + { + "name": "stop-1", + "trigger": "stop", + "action": { + "type": "command", + "command": "./my-own-stop.sh" + }, + "timeout": 10 + }, + { + "name": "userPromptSubmit-0", + "trigger": "userPromptSubmit", + "action": { + "type": "command", + "command": "radio busy" + }, + "timeout": 10 + } + ], + "permissions": { + "rules": [ + { + "capability": "fs_read", + "effect": "allow" + }, + { + "capability": "skill", + "effect": "allow" + } + ] + }, + "kiroUpgrade": { + "revision": 2, + "fingerprint": "sha256:f556250df13346b60186e34aaf828658bdaa07391d455ef588cb4a1071dd90ff", + "source": "sha256:fa2e1926bc4b1e2870e8a93c66e8f6286a1839b2c0f18ee93d0cd8396d4fd148" + } +} diff --git a/tests/fixtures/kiro-universal/radio-only.json b/tests/fixtures/kiro-universal/radio-only.json new file mode 100644 index 0000000..51ecfeb --- /dev/null +++ b/tests/fixtures/kiro-universal/radio-only.json @@ -0,0 +1,57 @@ +{ + "name": "pm", + "description": "fixture", + "prompt": "fixture prompt", + "tools": [ + "*" + ], + "allowedTools": [ + "fs_read" + ], + "hooks": [ + { + "name": "agentSpawn-0", + "trigger": "agentSpawn", + "action": { + "type": "command", + "command": "radio register --role $TASK_FORCE_ROLE --tab $ZELLIJ_TAB --repo $(git rev-parse --show-toplevel 2>/dev/null) --agent kiro --loadout ${TASK_FORCE_LOADOUT:-kiro-gh}" + }, + "timeout": 10 + }, + { + "name": "stop-0", + "trigger": "stop", + "action": { + "type": "command", + "command": "radio ready" + }, + "timeout": 10 + }, + { + "name": "userPromptSubmit-0", + "trigger": "userPromptSubmit", + "action": { + "type": "command", + "command": "radio busy" + }, + "timeout": 10 + } + ], + "permissions": { + "rules": [ + { + "capability": "fs_read", + "effect": "allow" + }, + { + "capability": "skill", + "effect": "allow" + } + ] + }, + "kiroUpgrade": { + "revision": 2, + "fingerprint": "sha256:77256a8f5a9fd52c18cb02663fd26845d6f9b4559244a5cdc75a719f3a9bfab0", + "source": "sha256:ed5caa7fd2c7babfe8906be86dc15224183be87a7dfd924f02221b4f4faee8e2" + } +} diff --git a/tests/fixtures/kiro-universal/user-only.json b/tests/fixtures/kiro-universal/user-only.json new file mode 100644 index 0000000..7bb611f --- /dev/null +++ b/tests/fixtures/kiro-universal/user-only.json @@ -0,0 +1,48 @@ +{ + "name": "planner", + "description": "fixture", + "prompt": "fixture prompt", + "tools": [ + "*" + ], + "allowedTools": [ + "fs_read" + ], + "hooks": [ + { + "name": "agentSpawn-0", + "trigger": "agentSpawn", + "action": { + "type": "command", + "command": "./my-own-spawn.sh" + }, + "timeout": 10 + }, + { + "name": "stop-0", + "trigger": "stop", + "action": { + "type": "command", + "command": "./my-own-stop.sh" + }, + "timeout": 10 + } + ], + "permissions": { + "rules": [ + { + "capability": "fs_read", + "effect": "allow" + }, + { + "capability": "skill", + "effect": "allow" + } + ] + }, + "kiroUpgrade": { + "revision": 2, + "fingerprint": "sha256:bdadb8bd5d754d764dd23d6ad26e906a6f6e57049c0f3633e15f06e6d11a5d4c", + "source": "sha256:bf6896f04837982133c5530e9851894aee72a1e42a3d622b1a8fc45bd3c6678d" + } +} diff --git a/tests/fixtures/kiro-universal/v2/no-hooks.json b/tests/fixtures/kiro-universal/v2/no-hooks.json new file mode 100644 index 0000000..d0c96f5 --- /dev/null +++ b/tests/fixtures/kiro-universal/v2/no-hooks.json @@ -0,0 +1,11 @@ +{ + "name": "reviewer", + "description": "fixture", + "prompt": "fixture prompt", + "tools": [ + "*" + ], + "allowedTools": [ + "fs_read" + ] +} diff --git a/tests/fixtures/kiro-universal/v2/radio-and-user.json b/tests/fixtures/kiro-universal/v2/radio-and-user.json new file mode 100644 index 0000000..736643a --- /dev/null +++ b/tests/fixtures/kiro-universal/v2/radio-and-user.json @@ -0,0 +1,38 @@ +{ + "name": "worker", + "description": "fixture", + "prompt": "fixture prompt", + "tools": [ + "*" + ], + "allowedTools": [ + "fs_read" + ], + "hooks": { + "agentSpawn": [ + { + "command": "radio register --role $TASK_FORCE_ROLE --tab $ZELLIJ_TAB --repo $(git rev-parse --show-toplevel 2>/dev/null) --agent kiro --loadout ${TASK_FORCE_LOADOUT:-kiro-gh}" + } + ], + "userPromptSubmit": [ + { + "command": "radio busy" + } + ], + "stop": [ + { + "command": "radio ready" + }, + { + "command": "./my-own-stop.sh" + } + ], + "preToolUse": [ + { + "command": "./guard.sh", + "matcher": "execute_bash", + "timeout_ms": 5000 + } + ] + } +} diff --git a/tests/fixtures/kiro-universal/v2/radio-only.json b/tests/fixtures/kiro-universal/v2/radio-only.json new file mode 100644 index 0000000..485efd6 --- /dev/null +++ b/tests/fixtures/kiro-universal/v2/radio-only.json @@ -0,0 +1,28 @@ +{ + "name": "pm", + "description": "fixture", + "prompt": "fixture prompt", + "tools": [ + "*" + ], + "allowedTools": [ + "fs_read" + ], + "hooks": { + "agentSpawn": [ + { + "command": "radio register --role $TASK_FORCE_ROLE --tab $ZELLIJ_TAB --repo $(git rev-parse --show-toplevel 2>/dev/null) --agent kiro --loadout ${TASK_FORCE_LOADOUT:-kiro-gh}" + } + ], + "userPromptSubmit": [ + { + "command": "radio busy" + } + ], + "stop": [ + { + "command": "radio ready" + } + ] + } +} diff --git a/tests/fixtures/kiro-universal/v2/user-only.json b/tests/fixtures/kiro-universal/v2/user-only.json new file mode 100644 index 0000000..4aa8c49 --- /dev/null +++ b/tests/fixtures/kiro-universal/v2/user-only.json @@ -0,0 +1,23 @@ +{ + "name": "planner", + "description": "fixture", + "prompt": "fixture prompt", + "tools": [ + "*" + ], + "allowedTools": [ + "fs_read" + ], + "hooks": { + "stop": [ + { + "command": "./my-own-stop.sh" + } + ], + "agentSpawn": [ + { + "command": "./my-own-spawn.sh" + } + ] + } +} diff --git a/tests/helpers/common.bash b/tests/helpers/common.bash index eaa5521..8546e0b 100644 --- a/tests/helpers/common.bash +++ b/tests/helpers/common.bash @@ -81,6 +81,10 @@ TASK_REMOVE="$REPO_ROOT_REAL/bin/task-remove" # `radio unregister` on stdin instead of hand-building JSON — see # tests/fixtures/hook-payloads/README.md for provenance and counts. HOOK_PAYLOADS="$REPO_ROOT_REAL/tests/fixtures/hook-payloads" +# kiro agent configs rewritten by a real `kiro-cli agent upgrade` into the +# "universal" format, whose `hooks` is an array (#267) — see +# tests/fixtures/kiro-universal/README.md for how they were made. +KIRO_UNIVERSAL="$REPO_ROOT_REAL/tests/fixtures/kiro-universal" # Build a PATH directory holding everything radio needs *except* jq, so the # jq-less fail-safe branches can be exercised (#172 for stop-hook, #192 for diff --git a/tests/kiro_gh_task_init.bats b/tests/kiro_gh_task_init.bats index 26d1c9f..32ecee3 100644 --- a/tests/kiro_gh_task_init.bats +++ b/tests/kiro_gh_task_init.bats @@ -372,6 +372,127 @@ teardown() { assert [ -f "$TARGET_DIR/.kiro/hooks/mine.json" ] } +# --------------------------------------------------------------------------- +# kiro-cli's "universal" agent-config format (#267): `hooks` is an array +# --------------------------------------------------------------------------- +# Fixtures are real `kiro-cli agent upgrade` output — see +# tests/fixtures/kiro-universal/README.md. + +@test "universal format: re-running over an upgraded config with radio hooks changes nothing (#267)" { + run "$KIRO_GH_TASK_INIT" + assert_success + cp "$KIRO_UNIVERSAL/radio-and-user.json" "$TARGET_DIR/.kiro/agents/worker.json" + + run "$KIRO_GH_TASK_INIT" + assert_success + # The failure this replaces: every agent warned `could not merge radio hooks`. + refute_output --partial "could not merge radio hooks" + refute_output --partial "could not upgrade" + run bash -c "diff <(jq -S . '$KIRO_UNIVERSAL/radio-and-user.json') <(jq -S . '$TARGET_DIR/.kiro/agents/worker.json')" + assert_success +} + +@test "universal format: missing radio hooks are added in the array shape, user hooks kept (#267)" { + run "$KIRO_GH_TASK_INIT" + assert_success + cp "$KIRO_UNIVERSAL/user-only.json" "$TARGET_DIR/.kiro/agents/planner.json" + + run "$KIRO_GH_TASK_INIT" + assert_success + refute_output --partial "could not merge radio hooks" + # Still an array — never converted back to the object task-init writes. + run jq -r '.hooks | type' "$TARGET_DIR/.kiro/agents/planner.json" + assert_output "array" + run jq -r '.hooks[] | select(.action.command | startswith("radio ")) | [.name, .trigger, .action.type, .action.command, .timeout] | @tsv' \ + "$TARGET_DIR/.kiro/agents/planner.json" + assert_line --index 0 --partial $'radio-agentSpawn\tagentSpawn\tcommand\tradio register --role $TASK_FORCE_ROLE' + assert_line --index 0 --partial $'--loadout ${TASK_FORCE_LOADOUT:-kiro-gh}\t10' + assert_line --index 1 $'radio-userPromptSubmit\tuserPromptSubmit\tcommand\tradio busy\t10' + assert_line --index 2 $'radio-stop\tstop\tcommand\tradio ready\t10' + # The user's own entries are untouched, first in line as they were. + run jq -c '.hooks[0:2]' "$TARGET_DIR/.kiro/agents/planner.json" + assert_output "$(jq -c '.hooks' "$KIRO_UNIVERSAL/user-only.json")" + # And everything outside `hooks` is as kiro-cli left it. + run bash -c "diff <(jq -S 'del(.hooks)' '$KIRO_UNIVERSAL/user-only.json') <(jq -S 'del(.hooks)' '$TARGET_DIR/.kiro/agents/planner.json')" + assert_success +} + +@test "universal format: a hook-less upgraded config gets an array hooks, not a hybrid object (#267)" { + # Real upgrade output of a v2 config that had no hooks: no `hooks` key at all, + # so there is no shape to read off it. An object here is the hybrid kiro-cli's + # upgrade reports "out of sync (not rebuilt)". + run "$KIRO_GH_TASK_INIT" + assert_success + cp "$KIRO_UNIVERSAL/no-hooks.json" "$TARGET_DIR/.kiro/agents/reviewer.json" + + run "$KIRO_GH_TASK_INIT" + assert_success + run jq -r '.hooks | type' "$TARGET_DIR/.kiro/agents/reviewer.json" + assert_output "array" + run jq -r '[.hooks[] | "\(.name)=\(.action.command | split(" ")[0:2] | join(" "))"] | join(",")' \ + "$TARGET_DIR/.kiro/agents/reviewer.json" + assert_output "radio-agentSpawn=radio register,radio-userPromptSubmit=radio busy,radio-stop=radio ready" + run bash -c "diff <(jq -S . '$KIRO_UNIVERSAL/no-hooks.json') <(jq -S 'del(.hooks)' '$TARGET_DIR/.kiro/agents/reviewer.json')" + assert_success +} + +@test "universal format: the shape comes from permissions, not the kiroUpgrade fingerprint (#267)" { + # Derived from the real fixture by dropping `kiroUpgrade`: the shape of a V3-only + # config, which carries no fingerprint. kiro-cli 2.32.0 still reports an object + # `hooks` in such a file "out of sync", so `permissions` has to decide it. + run "$KIRO_GH_TASK_INIT" + assert_success + run jq 'del(.kiroUpgrade)' "$KIRO_UNIVERSAL/no-hooks.json" + assert_success + printf '%s\n' "$output" > "$TARGET_DIR/.kiro/agents/reviewer.json" + + run "$KIRO_GH_TASK_INIT" + assert_success + run jq -r '.hooks | type' "$TARGET_DIR/.kiro/agents/reviewer.json" + assert_output "array" +} + +@test "universal format: the merge is idempotent (#267)" { + run "$KIRO_GH_TASK_INIT" + assert_success + cp "$KIRO_UNIVERSAL/user-only.json" "$TARGET_DIR/.kiro/agents/planner.json" + run "$KIRO_GH_TASK_INIT" + assert_success + run "$KIRO_GH_TASK_INIT" + assert_success + run jq -r '[.hooks[] | .trigger] | group_by(.) | map("\(.[0])=\(length)") | join(" ")' \ + "$TARGET_DIR/.kiro/agents/planner.json" + assert_output "agentSpawn=2 stop=2 userPromptSubmit=1" +} + +@test "universal format: object-shaped configs beside it stay objects (#267)" { + run "$KIRO_GH_TASK_INIT" + assert_success + cp "$KIRO_UNIVERSAL/user-only.json" "$TARGET_DIR/.kiro/agents/planner.json" + run "$KIRO_GH_TASK_INIT" + assert_success + for agent in pm worker reviewer; do + run jq -r '.hooks | type' "$TARGET_DIR/.kiro/agents/$agent.json" + assert_output "object" + done +} + +@test "universal format: a stale radio command in the array is warned about, not rewritten (#267)" { + run "$KIRO_GH_TASK_INIT" + assert_success + run jq '(.hooks[] | select(.trigger == "stop" and .action.command == "radio ready") | .action.command) = "radio ready && radio check"' \ + "$KIRO_UNIVERSAL/radio-and-user.json" + assert_success + printf '%s\n' "$output" > "$TARGET_DIR/.kiro/agents/worker.json" + + run "$KIRO_GH_TASK_INIT" + assert_success + assert_output --partial "worker.json stop: radio ready && radio check — replace with: radio ready" + run jq -r '[.hooks[] | select(.trigger == "stop") | .action.command] | join(",")' \ + "$TARGET_DIR/.kiro/agents/worker.json" + assert_output "radio ready && radio check,./my-own-stop.sh" +} + @test "radio-register hook embeds the loadout name (env-overridable) on agentSpawn" { run "$KIRO_GH_TASK_INIT" assert_success diff --git a/tests/kiro_local_task_init.bats b/tests/kiro_local_task_init.bats index 1451b16..09605ff 100644 --- a/tests/kiro_local_task_init.bats +++ b/tests/kiro_local_task_init.bats @@ -286,6 +286,25 @@ teardown() { assert_failure } +@test "universal format: radio hooks are merged into an upgraded config, array shape kept (#267)" { + # A real `kiro-cli agent upgrade` output (tests/fixtures/kiro-universal/). + run "$KIRO_LOCAL_TASK_INIT" + assert_success + cp "$KIRO_UNIVERSAL/user-only.json" "$TARGET_DIR/.kiro/agents/planner.json" + + run "$KIRO_LOCAL_TASK_INIT" + assert_success + refute_output --partial "could not merge radio hooks" + run jq -r '.hooks | type' "$TARGET_DIR/.kiro/agents/planner.json" + assert_output "array" + run jq -r '.hooks[] | select(.trigger == "agentSpawn" and (.action.command | startswith("radio "))) | .action.command' \ + "$TARGET_DIR/.kiro/agents/planner.json" + assert_output --partial '--loadout ${TASK_FORCE_LOADOUT:-kiro-local}' + run jq -r '[.hooks[] | .action.command] | join(",")' "$TARGET_DIR/.kiro/agents/planner.json" + assert_output --partial "./my-own-spawn.sh,./my-own-stop.sh,radio register" + assert_output --partial ",radio busy,radio ready" +} + @test "hooks are merged even when the policy KEEPS a customized agent config (#218)" { run "$KIRO_LOCAL_TASK_INIT" assert_success diff --git a/tests/kiro_notion_task_init.bats b/tests/kiro_notion_task_init.bats index c8ea570..7fc789a 100644 --- a/tests/kiro_notion_task_init.bats +++ b/tests/kiro_notion_task_init.bats @@ -265,6 +265,25 @@ teardown() { assert_failure } +@test "universal format: radio hooks are merged into an upgraded config, array shape kept (#267)" { + # A real `kiro-cli agent upgrade` output (tests/fixtures/kiro-universal/). + run "$KIRO_TASK_INIT" + assert_success + cp "$KIRO_UNIVERSAL/user-only.json" "$TARGET_DIR/.kiro/agents/planner.json" + + run "$KIRO_TASK_INIT" + assert_success + refute_output --partial "could not merge radio hooks" + run jq -r '.hooks | type' "$TARGET_DIR/.kiro/agents/planner.json" + assert_output "array" + run jq -r '.hooks[] | select(.trigger == "agentSpawn" and (.action.command | startswith("radio "))) | .action.command' \ + "$TARGET_DIR/.kiro/agents/planner.json" + assert_output --partial '--loadout ${TASK_FORCE_LOADOUT:-kiro-notion}' + run jq -r '[.hooks[] | .action.command] | join(",")' "$TARGET_DIR/.kiro/agents/planner.json" + assert_output --partial "./my-own-spawn.sh,./my-own-stop.sh,radio register" + assert_output --partial ",radio busy,radio ready" +} + @test "hooks are merged even when the policy KEEPS a customized agent config (#218)" { run "$KIRO_TASK_INIT" assert_success diff --git a/tests/task_config.bats b/tests/task_config.bats index b591855..805a599 100644 --- a/tests/task_config.bats +++ b/tests/task_config.bats @@ -416,6 +416,19 @@ JSON assert_output "./scripts/mine.sh" } +@test "set: an upgraded (universal-format) kiro agent config loses only its radio hooks (#267)" { + _init kiro-gh --owner acme --repo widgets --project 7 + cp "$KIRO_UNIVERSAL/radio-and-user.json" "$MAIN_REPO/.kiro/agents/worker.json" + + run "$TASK_CONFIG" set assistant claude --yes + assert_success + assert_output --partial "kept .kiro/agents/worker.json, radio hooks stripped" + run jq -r '.hooks | type' "$MAIN_REPO/.kiro/agents/worker.json" + assert_output "array" + run jq -r '[.hooks[] | .action.command] | join(",")' "$MAIN_REPO/.kiro/agents/worker.json" + assert_output "./guard.sh,./my-own-stop.sh" +} + @test "set: a pristine kiro agent config is deleted, hooks and all" { _init kiro-gh --owner acme --repo widgets --project 7 run "$TASK_CONFIG" set assistant claude --yes diff --git a/tests/task_remove.bats b/tests/task_remove.bats index 098bffd..e660d8c 100644 --- a/tests/task_remove.bats +++ b/tests/task_remove.bats @@ -350,6 +350,52 @@ JSON assert [ ! -e "$MAIN_REPO/.claude" ] } +# kiro-cli's "universal" agent-config format (#267) stores `hooks` as an array +# of {name, trigger, action: {command}}; the strip used to skip that shape and +# leave every radio hook behind. Fixtures are real `kiro-cli agent upgrade` +# output — tests/fixtures/kiro-universal/README.md. + +@test "an upgraded (universal-format) kiro agent config keeps the user's hooks, radio ones stripped" { + _init kiro-gh --owner acme --repo widgets --project 7 + cp "$KIRO_UNIVERSAL/radio-and-user.json" "$MAIN_REPO/.kiro/agents/worker.json" + + run "$TASK_REMOVE" --yes + assert_success + assert_output --partial "kept .kiro/agents/worker.json, radio hooks stripped" + run jq -r '.hooks | type' "$MAIN_REPO/.kiro/agents/worker.json" + assert_output "array" + run jq -r '[.hooks[] | .action.command] | join(",")' "$MAIN_REPO/.kiro/agents/worker.json" + assert_output "./guard.sh,./my-own-stop.sh" + # The user's entries are exactly as kiro-cli wrote them, and nothing outside + # `hooks` was touched. + run bash -c "diff <(jq -S '.hooks |= map(select(.action.command | startswith(\"radio \") | not))' '$KIRO_UNIVERSAL/radio-and-user.json') <(jq -S . '$MAIN_REPO/.kiro/agents/worker.json')" + assert_success +} + +@test "an upgraded kiro agent config holding only radio hooks loses the hooks key, not the file" { + _init kiro-gh --owner acme --repo widgets --project 7 + cp "$KIRO_UNIVERSAL/radio-only.json" "$MAIN_REPO/.kiro/agents/pm.json" + + run "$TASK_REMOVE" --yes + assert_success + assert_output --partial "kept .kiro/agents/pm.json, radio hooks stripped" + run jq -e 'has("hooks")' "$MAIN_REPO/.kiro/agents/pm.json" + assert_failure + run bash -c "diff <(jq -S 'del(.hooks)' '$KIRO_UNIVERSAL/radio-only.json') <(jq -S . '$MAIN_REPO/.kiro/agents/pm.json')" + assert_success +} + +@test "--dry-run names the universal-format strip and writes nothing" { + _init kiro-gh --owner acme --repo widgets --project 7 + cp "$KIRO_UNIVERSAL/radio-and-user.json" "$MAIN_REPO/.kiro/agents/worker.json" + + run "$TASK_REMOVE" --dry-run + assert_success + assert_output --partial "would keep .kiro/agents/worker.json, radio hooks stripped" + run cmp "$KIRO_UNIVERSAL/radio-and-user.json" "$MAIN_REPO/.kiro/agents/worker.json" + assert_success +} + @test "--purge deletes a customized kiro agent config rather than stripping its hooks" { _init kiro-gh --owner acme --repo widgets --project 7 run jq '.prompt = "MY OWN PROMPT"' "$MAIN_REPO/.kiro/agents/worker.json" diff --git a/tests/task_work_agents.bats b/tests/task_work_agents.bats index b93ea93..46a177c 100644 --- a/tests/task_work_agents.bats +++ b/tests/task_work_agents.bats @@ -414,6 +414,9 @@ _session_auto_submit() { assert_success assert_output --partial "the worker on its V3 engine and chat.enableAutoAgentUpgrade is" assert_output --partial "kiro-cli settings chat.enableAutoAgentUpgrade false" + # No longer warned off `true`: task-init and task-remove handle the + # universal format it upgrades the configs into since #267. + refute_output --partial "cannot merge radio hooks" assert_stub_called zellij "kiro-cli chat --agent worker" # Both read from the repo root, so workspace-scoped settings count. run sort -u "$STUB_CALLS_DIR/kiro-cli.engine-cwd" "$STUB_CALLS_DIR/kiro-cli.upgrade-cwd"