From 3b00eb3dd641f740e681f743451e883530c2b9f8 Mon Sep 17 00:00:00 2001 From: Dror Ivry Date: Thu, 3 Sep 2026 20:50:17 +0300 Subject: [PATCH 01/13] feat(kiro): ship kiro.log through the shared log shipper The support form of the shipper (no slug) collects every known per-agent log, and kiro.log was not on that list in any of the three implementations, so a no-argument run on a Kiro machine skipped the one log support asked for. The contract suites now seed seven logs and expect seven requests. --- docs/log-shipping.md | 2 +- docs/plugin-log-shipper.md | 6 +++--- plugins/antigravity/scripts/ship-logs.ps1 | 2 +- plugins/antigravity/scripts/ship-logs.sh | 2 +- plugins/codex/scripts/ship-logs.ps1 | 2 +- plugins/codex/scripts/ship-logs.sh | 2 +- plugins/copilot/scripts/ship-logs.ps1 | 2 +- plugins/copilot/scripts/ship-logs.sh | 2 +- plugins/cursor/scripts/ship-logs.ps1 | 2 +- plugins/cursor/scripts/ship-logs.sh | 2 +- plugins/gemini/scripts/ship-logs.mjs | 2 +- plugins/kiro/scripts/ship-logs.ps1 | 2 +- plugins/kiro/scripts/ship-logs.sh | 2 +- plugins/rogue/scripts/ship-logs.ps1 | 2 +- plugins/rogue/scripts/ship-logs.sh | 2 +- scripts/shared/ship-logs.ps1 | 2 +- scripts/shared/ship-logs.sh | 2 +- tests/test_ship_logs.ps1 | 5 +++-- tests/test_ship_logs.sh | 16 ++++++++-------- 19 files changed, 30 insertions(+), 29 deletions(-) diff --git a/docs/log-shipping.md b/docs/log-shipping.md index b115ede..9dffe4c 100644 --- a/docs/log-shipping.md +++ b/docs/log-shipping.md @@ -149,7 +149,7 @@ the one path. This is the same precedence the eleven dispatchers implement and ROGUE_LOG_DIR"), and its basename is arbitrary — so, exactly as for the plugin shipper, that file's lines are attributed per line off `provider=`, never per file. -Otherwise glob `/{claude,codex,cursor,gemini,copilot,antigravity}.log` plus each +Otherwise glob `/{claude,codex,cursor,gemini,copilot,antigravity,kiro}.log` plus each `.1`. Ship `.1` **before** its live file so the batch stays chronological. **Payload.** Let the task narrow the request, all optional: diff --git a/docs/plugin-log-shipper.md b/docs/plugin-log-shipper.md index e51595d..202e663 100644 --- a/docs/plugin-log-shipper.md +++ b/docs/plugin-log-shipper.md @@ -12,8 +12,8 @@ ever matters. ## Files ```text -plugins/{rogue,codex,cursor,copilot,antigravity}/scripts/ship-logs.sh byte-identical ×5 -plugins/{rogue,codex,cursor,copilot,antigravity}/scripts/ship-logs.ps1 byte-identical ×5 +plugins/{rogue,codex,cursor,copilot,antigravity,kiro}/scripts/ship-logs.sh byte-identical ×6 +plugins/{rogue,codex,cursor,copilot,antigravity,kiro}/scripts/ship-logs.ps1 byte-identical ×6 plugins/gemini/scripts/ship-logs.mjs Node-only, per repo rule tests/test_ship_logs.sh tests/test_ship_logs.ps1 @@ -23,7 +23,7 @@ Callers (one line each, no `hooks.json` change anywhere): | plugin | call site | |---|---| -| claude, codex, copilot, antigravity | `scripts/heartbeat.sh` / `heartbeat.ps1` | +| claude, codex, copilot, antigravity, kiro | `scripts/heartbeat.sh` / `heartbeat.ps1` | | gemini | `scripts/heartbeat.mjs` | | cursor | the inline beacon block in `hook.sh` / `hook.ps1` (it has no heartbeat script) | diff --git a/plugins/antigravity/scripts/ship-logs.ps1 b/plugins/antigravity/scripts/ship-logs.ps1 index 6bbeec7..df6c0cd 100644 --- a/plugins/antigravity/scripts/ship-logs.ps1 +++ b/plugins/antigravity/scripts/ship-logs.ps1 @@ -55,7 +55,7 @@ $ProgressPreference = 'SilentlyContinue' # ── constants ────────────────────────────────────────────────────────────── $SHIP_ENDPOINT_PATH = '/api/v1/hooks/logs' -$KNOWN_LOG_SLUGS = @('claude', 'codex', 'cursor', 'gemini', 'copilot', 'antigravity') +$KNOWN_LOG_SLUGS = @('claude', 'codex', 'cursor', 'gemini', 'copilot', 'antigravity', 'kiro') # Bytes scanned when fingerprinting a log's first line. NOT 200: a real log line is # timestamp + provider + event + up to 400 chars of `raw=`, i.e. commonly 500-700 # bytes, so a 200-byte window would find no newline in a typical log's first line diff --git a/plugins/antigravity/scripts/ship-logs.sh b/plugins/antigravity/scripts/ship-logs.sh index 1407878..1f1bfad 100644 --- a/plugins/antigravity/scripts/ship-logs.sh +++ b/plugins/antigravity/scripts/ship-logs.sh @@ -54,7 +54,7 @@ set -u # ── constants ────────────────────────────────────────────────────────────── SHIP_ENDPOINT_PATH="/api/v1/hooks/logs" -KNOWN_LOG_SLUGS="claude codex cursor gemini copilot antigravity" +KNOWN_LOG_SLUGS="claude codex cursor gemini copilot antigravity kiro" # Bytes scanned when fingerprinting a log's first line. NOT the 200 an earlier # draft of the design doc specified: a real log line is timestamp + provider + # event + up to 400 chars of `raw=`, i.e. commonly 500-700 bytes, so a 200-byte diff --git a/plugins/codex/scripts/ship-logs.ps1 b/plugins/codex/scripts/ship-logs.ps1 index 6bbeec7..df6c0cd 100644 --- a/plugins/codex/scripts/ship-logs.ps1 +++ b/plugins/codex/scripts/ship-logs.ps1 @@ -55,7 +55,7 @@ $ProgressPreference = 'SilentlyContinue' # ── constants ────────────────────────────────────────────────────────────── $SHIP_ENDPOINT_PATH = '/api/v1/hooks/logs' -$KNOWN_LOG_SLUGS = @('claude', 'codex', 'cursor', 'gemini', 'copilot', 'antigravity') +$KNOWN_LOG_SLUGS = @('claude', 'codex', 'cursor', 'gemini', 'copilot', 'antigravity', 'kiro') # Bytes scanned when fingerprinting a log's first line. NOT 200: a real log line is # timestamp + provider + event + up to 400 chars of `raw=`, i.e. commonly 500-700 # bytes, so a 200-byte window would find no newline in a typical log's first line diff --git a/plugins/codex/scripts/ship-logs.sh b/plugins/codex/scripts/ship-logs.sh index 1407878..1f1bfad 100644 --- a/plugins/codex/scripts/ship-logs.sh +++ b/plugins/codex/scripts/ship-logs.sh @@ -54,7 +54,7 @@ set -u # ── constants ────────────────────────────────────────────────────────────── SHIP_ENDPOINT_PATH="/api/v1/hooks/logs" -KNOWN_LOG_SLUGS="claude codex cursor gemini copilot antigravity" +KNOWN_LOG_SLUGS="claude codex cursor gemini copilot antigravity kiro" # Bytes scanned when fingerprinting a log's first line. NOT the 200 an earlier # draft of the design doc specified: a real log line is timestamp + provider + # event + up to 400 chars of `raw=`, i.e. commonly 500-700 bytes, so a 200-byte diff --git a/plugins/copilot/scripts/ship-logs.ps1 b/plugins/copilot/scripts/ship-logs.ps1 index 6bbeec7..df6c0cd 100644 --- a/plugins/copilot/scripts/ship-logs.ps1 +++ b/plugins/copilot/scripts/ship-logs.ps1 @@ -55,7 +55,7 @@ $ProgressPreference = 'SilentlyContinue' # ── constants ────────────────────────────────────────────────────────────── $SHIP_ENDPOINT_PATH = '/api/v1/hooks/logs' -$KNOWN_LOG_SLUGS = @('claude', 'codex', 'cursor', 'gemini', 'copilot', 'antigravity') +$KNOWN_LOG_SLUGS = @('claude', 'codex', 'cursor', 'gemini', 'copilot', 'antigravity', 'kiro') # Bytes scanned when fingerprinting a log's first line. NOT 200: a real log line is # timestamp + provider + event + up to 400 chars of `raw=`, i.e. commonly 500-700 # bytes, so a 200-byte window would find no newline in a typical log's first line diff --git a/plugins/copilot/scripts/ship-logs.sh b/plugins/copilot/scripts/ship-logs.sh index 1407878..1f1bfad 100644 --- a/plugins/copilot/scripts/ship-logs.sh +++ b/plugins/copilot/scripts/ship-logs.sh @@ -54,7 +54,7 @@ set -u # ── constants ────────────────────────────────────────────────────────────── SHIP_ENDPOINT_PATH="/api/v1/hooks/logs" -KNOWN_LOG_SLUGS="claude codex cursor gemini copilot antigravity" +KNOWN_LOG_SLUGS="claude codex cursor gemini copilot antigravity kiro" # Bytes scanned when fingerprinting a log's first line. NOT the 200 an earlier # draft of the design doc specified: a real log line is timestamp + provider + # event + up to 400 chars of `raw=`, i.e. commonly 500-700 bytes, so a 200-byte diff --git a/plugins/cursor/scripts/ship-logs.ps1 b/plugins/cursor/scripts/ship-logs.ps1 index 6bbeec7..df6c0cd 100644 --- a/plugins/cursor/scripts/ship-logs.ps1 +++ b/plugins/cursor/scripts/ship-logs.ps1 @@ -55,7 +55,7 @@ $ProgressPreference = 'SilentlyContinue' # ── constants ────────────────────────────────────────────────────────────── $SHIP_ENDPOINT_PATH = '/api/v1/hooks/logs' -$KNOWN_LOG_SLUGS = @('claude', 'codex', 'cursor', 'gemini', 'copilot', 'antigravity') +$KNOWN_LOG_SLUGS = @('claude', 'codex', 'cursor', 'gemini', 'copilot', 'antigravity', 'kiro') # Bytes scanned when fingerprinting a log's first line. NOT 200: a real log line is # timestamp + provider + event + up to 400 chars of `raw=`, i.e. commonly 500-700 # bytes, so a 200-byte window would find no newline in a typical log's first line diff --git a/plugins/cursor/scripts/ship-logs.sh b/plugins/cursor/scripts/ship-logs.sh index 1407878..1f1bfad 100644 --- a/plugins/cursor/scripts/ship-logs.sh +++ b/plugins/cursor/scripts/ship-logs.sh @@ -54,7 +54,7 @@ set -u # ── constants ────────────────────────────────────────────────────────────── SHIP_ENDPOINT_PATH="/api/v1/hooks/logs" -KNOWN_LOG_SLUGS="claude codex cursor gemini copilot antigravity" +KNOWN_LOG_SLUGS="claude codex cursor gemini copilot antigravity kiro" # Bytes scanned when fingerprinting a log's first line. NOT the 200 an earlier # draft of the design doc specified: a real log line is timestamp + provider + # event + up to 400 chars of `raw=`, i.e. commonly 500-700 bytes, so a 200-byte diff --git a/plugins/gemini/scripts/ship-logs.mjs b/plugins/gemini/scripts/ship-logs.mjs index 319fb16..0abd747 100644 --- a/plugins/gemini/scripts/ship-logs.mjs +++ b/plugins/gemini/scripts/ship-logs.mjs @@ -33,7 +33,7 @@ import { shellUnquote, IS_WIN } from "./shared.mjs"; // ── constants ────────────────────────────────────────────────────────────── const SHIP_ENDPOINT_PATH = "/api/v1/hooks/logs"; -const KNOWN_LOG_SLUGS = ["claude", "codex", "cursor", "gemini", "copilot", "antigravity"]; +const KNOWN_LOG_SLUGS = ["claude", "codex", "cursor", "gemini", "copilot", "antigravity", "kiro"]; // Bytes scanned when fingerprinting a log's first line. NOT 200: a real log line is // timestamp + provider + event + up to 400 chars of `raw=`, i.e. commonly 500-700 // bytes, so a 200-byte window would find no newline in a typical log's first line and diff --git a/plugins/kiro/scripts/ship-logs.ps1 b/plugins/kiro/scripts/ship-logs.ps1 index 6bbeec7..df6c0cd 100644 --- a/plugins/kiro/scripts/ship-logs.ps1 +++ b/plugins/kiro/scripts/ship-logs.ps1 @@ -55,7 +55,7 @@ $ProgressPreference = 'SilentlyContinue' # ── constants ────────────────────────────────────────────────────────────── $SHIP_ENDPOINT_PATH = '/api/v1/hooks/logs' -$KNOWN_LOG_SLUGS = @('claude', 'codex', 'cursor', 'gemini', 'copilot', 'antigravity') +$KNOWN_LOG_SLUGS = @('claude', 'codex', 'cursor', 'gemini', 'copilot', 'antigravity', 'kiro') # Bytes scanned when fingerprinting a log's first line. NOT 200: a real log line is # timestamp + provider + event + up to 400 chars of `raw=`, i.e. commonly 500-700 # bytes, so a 200-byte window would find no newline in a typical log's first line diff --git a/plugins/kiro/scripts/ship-logs.sh b/plugins/kiro/scripts/ship-logs.sh index 1407878..1f1bfad 100644 --- a/plugins/kiro/scripts/ship-logs.sh +++ b/plugins/kiro/scripts/ship-logs.sh @@ -54,7 +54,7 @@ set -u # ── constants ────────────────────────────────────────────────────────────── SHIP_ENDPOINT_PATH="/api/v1/hooks/logs" -KNOWN_LOG_SLUGS="claude codex cursor gemini copilot antigravity" +KNOWN_LOG_SLUGS="claude codex cursor gemini copilot antigravity kiro" # Bytes scanned when fingerprinting a log's first line. NOT the 200 an earlier # draft of the design doc specified: a real log line is timestamp + provider + # event + up to 400 chars of `raw=`, i.e. commonly 500-700 bytes, so a 200-byte diff --git a/plugins/rogue/scripts/ship-logs.ps1 b/plugins/rogue/scripts/ship-logs.ps1 index 6bbeec7..df6c0cd 100644 --- a/plugins/rogue/scripts/ship-logs.ps1 +++ b/plugins/rogue/scripts/ship-logs.ps1 @@ -55,7 +55,7 @@ $ProgressPreference = 'SilentlyContinue' # ── constants ────────────────────────────────────────────────────────────── $SHIP_ENDPOINT_PATH = '/api/v1/hooks/logs' -$KNOWN_LOG_SLUGS = @('claude', 'codex', 'cursor', 'gemini', 'copilot', 'antigravity') +$KNOWN_LOG_SLUGS = @('claude', 'codex', 'cursor', 'gemini', 'copilot', 'antigravity', 'kiro') # Bytes scanned when fingerprinting a log's first line. NOT 200: a real log line is # timestamp + provider + event + up to 400 chars of `raw=`, i.e. commonly 500-700 # bytes, so a 200-byte window would find no newline in a typical log's first line diff --git a/plugins/rogue/scripts/ship-logs.sh b/plugins/rogue/scripts/ship-logs.sh index 1407878..1f1bfad 100644 --- a/plugins/rogue/scripts/ship-logs.sh +++ b/plugins/rogue/scripts/ship-logs.sh @@ -54,7 +54,7 @@ set -u # ── constants ────────────────────────────────────────────────────────────── SHIP_ENDPOINT_PATH="/api/v1/hooks/logs" -KNOWN_LOG_SLUGS="claude codex cursor gemini copilot antigravity" +KNOWN_LOG_SLUGS="claude codex cursor gemini copilot antigravity kiro" # Bytes scanned when fingerprinting a log's first line. NOT the 200 an earlier # draft of the design doc specified: a real log line is timestamp + provider + # event + up to 400 chars of `raw=`, i.e. commonly 500-700 bytes, so a 200-byte diff --git a/scripts/shared/ship-logs.ps1 b/scripts/shared/ship-logs.ps1 index 6bbeec7..df6c0cd 100644 --- a/scripts/shared/ship-logs.ps1 +++ b/scripts/shared/ship-logs.ps1 @@ -55,7 +55,7 @@ $ProgressPreference = 'SilentlyContinue' # ── constants ────────────────────────────────────────────────────────────── $SHIP_ENDPOINT_PATH = '/api/v1/hooks/logs' -$KNOWN_LOG_SLUGS = @('claude', 'codex', 'cursor', 'gemini', 'copilot', 'antigravity') +$KNOWN_LOG_SLUGS = @('claude', 'codex', 'cursor', 'gemini', 'copilot', 'antigravity', 'kiro') # Bytes scanned when fingerprinting a log's first line. NOT 200: a real log line is # timestamp + provider + event + up to 400 chars of `raw=`, i.e. commonly 500-700 # bytes, so a 200-byte window would find no newline in a typical log's first line diff --git a/scripts/shared/ship-logs.sh b/scripts/shared/ship-logs.sh index 1407878..1f1bfad 100644 --- a/scripts/shared/ship-logs.sh +++ b/scripts/shared/ship-logs.sh @@ -54,7 +54,7 @@ set -u # ── constants ────────────────────────────────────────────────────────────── SHIP_ENDPOINT_PATH="/api/v1/hooks/logs" -KNOWN_LOG_SLUGS="claude codex cursor gemini copilot antigravity" +KNOWN_LOG_SLUGS="claude codex cursor gemini copilot antigravity kiro" # Bytes scanned when fingerprinting a log's first line. NOT the 200 an earlier # draft of the design doc specified: a real log line is timestamp + provider + # event + up to 400 chars of `raw=`, i.e. commonly 500-700 bytes, so a 200-byte diff --git a/tests/test_ship_logs.ps1 b/tests/test_ship_logs.ps1 index 950f652..417be32 100644 --- a/tests/test_ship_logs.ps1 +++ b/tests/test_ship_logs.ps1 @@ -307,6 +307,7 @@ $callers = @( @{ file = 'plugins/codex/scripts/heartbeat.ps1'; slug = 'codex'; family = 'openai' } @{ file = 'plugins/copilot/scripts/heartbeat.ps1'; slug = 'copilot'; family = 'copilot' } @{ file = 'plugins/antigravity/scripts/heartbeat.ps1'; slug = 'antigravity'; family = 'antigravity' } + @{ file = 'plugins/kiro/scripts/heartbeat.ps1'; slug = 'kiro'; family = 'kiro' } @{ file = 'plugins/cursor/scripts/hook.ps1'; slug = 'cursor'; family = 'cursor' } ) foreach ($caller in $callers) { @@ -339,11 +340,11 @@ foreach ($caller in $callers) { # ── the five copies are the same file ────────────────────────────────────── Write-Host '' -Write-Host '== the five plugin copies match scripts/shared/ship-logs.ps1' +Write-Host '== the six plugin copies match scripts/shared/ship-logs.ps1' # Every per-plugin difference is an ARGUMENT, which is what lets this be a byte # comparison instead of a review. scripts/shared/ is the only editable copy. $canonical = [System.IO.File]::ReadAllBytes((Join-Path $repo 'scripts/shared/ship-logs.ps1')) -foreach ($plugin in @('rogue', 'codex', 'cursor', 'copilot', 'antigravity')) { +foreach ($plugin in @('rogue', 'codex', 'cursor', 'copilot', 'antigravity', 'kiro')) { $copyPath = Join-Path $repo "plugins/$plugin/scripts/ship-logs.ps1" if (-not (Test-Path -LiteralPath $copyPath)) { Fail "missing $copyPath"; continue } $copy = [System.IO.File]::ReadAllBytes($copyPath) diff --git a/tests/test_ship_logs.sh b/tests/test_ship_logs.sh index 6dcf072..dd08710 100644 --- a/tests/test_ship_logs.sh +++ b/tests/test_ship_logs.sh @@ -39,9 +39,9 @@ pass() { echo " ok: $1"; } fail() { echo "FAIL: $1"; FAILS=$((FAILS + 1)); } check() { if [ "$2" = "$3" ]; then pass "$1"; else fail "$1 (expected [$2], got [$3])"; fi; } -SLUGS='claude codex cursor gemini copilot antigravity' -# The five plugins that ship the POSIX-sh copy (gemini ships ship-logs.mjs). -SH_PLUGINS='rogue codex cursor copilot antigravity' +SLUGS='claude codex cursor gemini copilot antigravity kiro' +# The six plugins that ship the POSIX-sh copy (gemini ships ship-logs.mjs). +SH_PLUGINS='rogue codex cursor copilot antigravity kiro' # ── the fake curl ────────────────────────────────────────────────────────── # The shipper invokes: curl … --data-binary @ -o /dev/null -w '%{http_code}' @@ -159,7 +159,7 @@ seed() { _i="$1"; while [ "$_i" -le "$2" ]; do # magic number. LINEB=$(printf '2026-08-12T00:00:01Z provider=claude event=PreToolUse outcome=allow n=1\n' | wc -c | tr -d ' ') -echo "== the five sh copies match scripts/shared/ship-logs.sh" +echo "== the six sh copies match scripts/shared/ship-logs.sh" # Every per-plugin difference is an ARGUMENT, which is what lets `cmp` enforce # lockstep instead of review. scripts/shared/ is the ONLY editable copy; the plugin # copies are committed (three plugins install straight from a git clone with no build @@ -453,12 +453,12 @@ echo "== ROGUE_SHIP_ALL=1 collects every agent's log" new_case shipall for s in $SLUGS; do seed 1 2 "$s" "$CASE/home/.rogue/logs/$s.log"; done ship "ROGUE_SHIP_ALL=1" -check "six requests" "6" "$(bodies)" +check "seven requests" "7" "$(bodies)" # agent_family is a FALLBACK HINT for a line with no provider= token, so it is sent # only for the caller's OWN log: on a foreign log the shipping plugin's family # would mislabel every line (a codex line filed under `claude`). own_fam=""; foreign_fam="none" -i=0; while [ "$i" -lt 6 ]; do +i=0; while [ "$i" -lt 7 ]; do lf=$(strf "$i" log_file); fam=$(strf "$i" agent_family) if [ "$lf" = "claude.log" ]; then own_fam="$fam" elif [ -n "$fam" ]; then foreign_fam="$fam"; fi @@ -497,10 +497,10 @@ echo "== a no-argument run collects everything and reports shipper=unknown" new_case noargs for s in $SLUGS; do seed 1 2 "$s" "$CASE/home/.rogue/logs/$s.log"; done ship_as rogue - x x -check "six requests" "6" "$(bodies)" +check "seven requests" "7" "$(bodies)" check "shipper is unknown" "unknown" "$(strf 0 shipper)" allblank=yes; i=0 -while [ "$i" -lt 6 ]; do [ -z "$(strf "$i" agent_family)" ] || allblank=no; i=$((i + 1)); done +while [ "$i" -lt 7 ]; do [ -z "$(strf "$i" agent_family)" ] || allblank=no; i=$((i + 1)); done check "no agent_family anywhere (no slug means no family)" "yes" "$allblank" echo From 7adc4ac356275c01580e7ba1cbdda28d99e7648d Mon Sep 17 00:00:00 2001 From: Dror Ivry Date: Thu, 3 Sep 2026 20:53:36 +0300 Subject: [PATCH 02/13] feat(kiro): heartbeat reports the Kiro build and the CLI default agent Two versions now ride one roster row: version is the plugin's, agent_version is the Kiro build the surface runs under (the CLI and Crew from kiro-cli --version, the IDE from the app bundle's Info.plist on macOS and the install's package.json on Windows). The CLI also reports chat.defaultAgent as default_agent, omitted when none is set: on the 2.x engine only agents that carry the Rogue hooks are covered, so a default that moved away from rogue is a machine the roster should show as uncovered. kiro-host.sh is sourceable so the status script reads the same values. --- plugins/kiro/scripts/heartbeat.ps1 | 68 ++++++++++++++++++++++++++---- plugins/kiro/scripts/heartbeat.sh | 28 ++++++++++-- plugins/kiro/scripts/kiro-host.sh | 55 ++++++++++++++++++++++++ tests/test_heartbeat_ps1.ps1 | 11 +++++ tests/test_heartbeat_sh.sh | 52 ++++++++++++++++++++++- 5 files changed, 202 insertions(+), 12 deletions(-) create mode 100755 plugins/kiro/scripts/kiro-host.sh diff --git a/plugins/kiro/scripts/heartbeat.ps1 b/plugins/kiro/scripts/heartbeat.ps1 index 96318f0..a668417 100644 --- a/plugins/kiro/scripts/heartbeat.ps1 +++ b/plugins/kiro/scripts/heartbeat.ps1 @@ -28,6 +28,8 @@ $actorName = '' $actorEmail = '' $ver = 'unknown' # plugin version, from plugin.json $agent = '' # which of the three surfaces this install reports for +$kiroVer = 'unknown' # the Kiro build itself (kiro-cli --version / the IDE install) +$kiroDefault = '' # the CLI's default agent, empty off the CLI or when unset function Dbg { param([string]$Msg) if ($env:ROGUE_DEBUG) { [Console]::Error.WriteLine("[rogue-heartbeat] $Msg") } } @@ -172,6 +174,50 @@ function Resolve-Surface { $script:agent = 'kiro_cli' } +# -- what Kiro itself reports (mirrors scripts/kiro-host.sh) ------------------- +# Two versions ride one roster row: `version` is the plugin's, `agent_version` is +# the Kiro build it runs under, so support can tell a current plugin from a stale +# Kiro. The CLI (and Crew, which drives kiro-cli) answers `kiro-cli --version`; +# the IDE has no CLI, so its version is read from the install under +# %LOCALAPPDATA%\Programs\Kiro (ROGUE_KIRO_APP overrides the path for tests). +function Get-KiroCliVersion { + if (-not (Get-Command kiro-cli -ErrorAction SilentlyContinue)) { return '' } + try { $out = (& kiro-cli --version 2>$null | Out-String) } catch { return '' } + $m = [regex]::Match([string]$out, '[0-9]+\.[0-9]+\.[0-9]+') + if ($m.Success) { return $m.Value } + return '' +} + +function Get-KiroIdeVersion { + $root = $env:ROGUE_KIRO_APP + if (-not $root -and $env:LOCALAPPDATA) { $root = Join-Path $env:LOCALAPPDATA 'Programs\Kiro' } + if (-not $root) { return '' } + $pkg = Join-Path $root 'resources\app\package.json' + if (-not (Test-Path -LiteralPath $pkg)) { return '' } + $m = [regex]::Match((Get-Content -Raw -LiteralPath $pkg), '"version"\s*:\s*"([0-9]+\.[0-9]+\.[0-9]+)') + if ($m.Success) { return $m.Groups[1].Value } + return '' +} + +# The real CLI prints the value quoted ("rogue") and errors out when none is set. +function Get-KiroDefaultAgent { + if (-not (Get-Command kiro-cli -ErrorAction SilentlyContinue)) { return '' } + try { $out = (& kiro-cli settings chat.defaultAgent 2>$null | Out-String) } catch { return '' } + return ([string]$out).Trim().Trim('"') +} + +# After Resolve-Surface: the surface picks which Kiro binary to ask, and only the +# CLI has a default agent (on the 2.x engine only agents carrying the Rogue hooks +# are covered, so a default that moved away from `rogue` is worth showing). +function Resolve-KiroHost { + switch ($agent) { + 'kiro_ide' { $script:kiroVer = Get-KiroIdeVersion; $script:kiroDefault = '' } + 'kiro_cli' { $script:kiroVer = Get-KiroCliVersion; $script:kiroDefault = Get-KiroDefaultAgent } + default { $script:kiroVer = Get-KiroCliVersion; $script:kiroDefault = '' } + } + if (-not $script:kiroVer) { $script:kiroVer = 'unknown' } +} + # The stamp slug is `kiro`, the log file's name, and NOT $agent: the three # surfaces share one install and one log, so they must share one throttle window # too - a per-surface stamp would let a machine with the IDE and the CLI both @@ -189,14 +235,19 @@ function Send-Heartbeat { $host_ = $env:COMPUTERNAME if (-not $host_) { try { $host_ = [System.Net.Dns]::GetHostName() } catch { $host_ = 'unknown' } } - $body = @{ - agent_family = 'kiro' - agent = $agent - version = $ver - host = $host_ - actor_email = [string]$actorEmail - actor_name = [string]$actorName - } | ConvertTo-Json -Compress + $fields = @{ + agent_family = 'kiro' + agent = $agent + version = $ver + agent_version = $kiroVer + host = $host_ + actor_email = [string]$actorEmail + actor_name = [string]$actorName + } + # Absent, not empty: "no field" reads as "not a CLI, or none set", which an + # empty string would blur. + if ($kiroDefault) { $fields['default_agent'] = $kiroDefault } + $body = $fields | ConvertTo-Json -Compress try { $bytes = [System.Text.Encoding]::UTF8.GetBytes($body) @@ -270,6 +321,7 @@ function Invoke-Main { Resolve-Actor Resolve-Version Resolve-Surface + Resolve-KiroHost # after the surface: it picks which Kiro binary to ask Send-Heartbeat Start-LogShipper exit 0 diff --git a/plugins/kiro/scripts/heartbeat.sh b/plugins/kiro/scripts/heartbeat.sh index 90a83c6..240507e 100755 --- a/plugins/kiro/scripts/heartbeat.sh +++ b/plugins/kiro/scripts/heartbeat.sh @@ -30,6 +30,8 @@ PLUGIN_ROOT="" AGENT="" # which of the three surfaces this install is reporting for VER="unknown" # plugin version, from plugin.json via install-id.sh HOST="unknown" # hostname; both set by resolve_version via install-id.sh +KIRO_VER="unknown" # the Kiro build itself, via kiro-host.sh +KIRO_DEFAULT="" # the CLI's default agent, empty off the CLI or when unset TRIGGER="SessionStart" # which hook fired us; anything else is rate-limited # Self-locate the plugin root from $0 (/scripts/heartbeat.sh). @@ -81,8 +83,27 @@ resolve_version() { return 0 } +# What Kiro itself reports: its build (CLI from `kiro-cli --version`, IDE from +# the app bundle) and, on the CLI, the default agent. Two versions ride one +# roster row - `version` is the plugin's, `agent_version` is Kiro's - so support +# can tell a current plugin from a stale Kiro. kiro-host.sh reads SURFACE too. +resolve_kiro_host() { + SURFACE="$AGENT" + [ -r "${PLUGIN_ROOT}/scripts/kiro-host.sh" ] && . "${PLUGIN_ROOT}/scripts/kiro-host.sh" + KIRO_VER="${ROGUE_KIRO_VERSION:-unknown}" + KIRO_DEFAULT="${ROGUE_KIRO_DEFAULT_AGENT:-}" + return 0 +} + esc() { printf '%s' "$1" | sed -e 's/\\/\\\\/g' -e 's/"/\\"/g'; } +# `,"default_agent":"…"` when the CLI reported one, nothing otherwise: an absent +# field is "not a CLI, or none set", which an empty string would blur. +default_agent_field() { + [ -n "$KIRO_DEFAULT" ] && printf ',"default_agent":"%s"' "$(esc "$KIRO_DEFAULT")" + return 0 +} + # ── beacon throttle ──────────────────────────────────────────────────────── # The rule lives in scripts/beacon.sh, a byte-identical copy of # scripts/shared/beacon.sh shared with the other sh-side plugins. Every per-plugin @@ -115,9 +136,9 @@ post_heartbeat() { _unthrottled=0 [ "$TRIGGER" = "SessionStart" ] && _unthrottled=1 rogue_beacon_claim kiro "$_unthrottled" || return 0 - _body=$(printf '{"agent_family":"kiro","agent":"%s","version":"%s","host":"%s","actor_email":"%s","actor_name":"%s"}' \ - "$(esc "$AGENT")" "$(esc "$VER")" "$(esc "$HOST")" \ - "$(esc "${ROGUE_ACTOR_EMAIL:-}")" "$(esc "${ROGUE_ACTOR_NAME:-}")") + _body=$(printf '{"agent_family":"kiro","agent":"%s","version":"%s","agent_version":"%s","host":"%s","actor_email":"%s","actor_name":"%s"%s}' \ + "$(esc "$AGENT")" "$(esc "$VER")" "$(esc "$KIRO_VER")" "$(esc "$HOST")" \ + "$(esc "${ROGUE_ACTOR_EMAIL:-}")" "$(esc "${ROGUE_ACTOR_NAME:-}")" "$(default_agent_field)") curl -sS --max-time 10 -X POST \ "${ROGUE_BASE_URL:-https://api.rogue.security}/api/v1/hooks/status" \ @@ -156,6 +177,7 @@ main() { load_actor resolve_surface "${1:-}" resolve_version # after the surface: install-id.sh keys the agent on it + resolve_kiro_host # same reason: the surface picks which Kiro binary to ask load_beacon # after load_env, so the library sees the interval knob post_heartbeat ship_logs diff --git a/plugins/kiro/scripts/kiro-host.sh b/plugins/kiro/scripts/kiro-host.sh new file mode 100755 index 0000000..1561c8f --- /dev/null +++ b/plugins/kiro/scripts/kiro-host.sh @@ -0,0 +1,55 @@ +#!/usr/bin/env bash +# Sourceable. Resolves what the Kiro HOST itself reports about this install, +# beside the plugin's own identity from install-id.sh: +# +# ROGUE_KIRO_VERSION the Kiro build the surface runs under ("unknown" +# when unreadable). The CLI and Crew (which drives +# kiro-cli) from `kiro-cli --version`; the IDE from +# the app bundle, since the IDE has no CLI. +# ROGUE_KIRO_DEFAULT_AGENT the CLI's `chat.defaultAgent`, empty when none is +# set or the surface is not the CLI. On the 2.x +# engine only agents carrying the Rogue hooks are +# covered (ADR 0001), so a default that moved away +# from `rogue` is a machine the roster should show +# as uncovered. +# +# The caller sets SURFACE before sourcing, as for install-id.sh. heartbeat.sh +# sends both values in its /hooks/status body and status.sh prints them. +# Resolution never fails the caller: every probe is best-effort and quiet. +# +# ROGUE_KIRO_APP overrides the IDE bundle path (default /Applications/Kiro.app) +# so a test never reads the developer's real install. + +_rogue_semver() { grep -oE '[0-9]+\.[0-9]+\.[0-9]+' 2>/dev/null | head -n1; } + +rogue_kiro_cli_version() { + command -v kiro-cli >/dev/null 2>&1 || return 0 + kiro-cli --version 2>/dev/null /dev/null /dev/null \ + | sed -nE 's/.*CFBundleShortVersionString<\/key>[[:space:]]*([^<]*)<\/string>.*/\1/p' | _rogue_semver) + printf '%s' "$_v" +} + +# The real CLI prints the value quoted ("rogue"); both forms are stripped. +rogue_kiro_default_agent() { + command -v kiro-cli >/dev/null 2>&1 || return 0 + kiro-cli settings chat.defaultAgent 2>/dev/null "$KIRO_ROOT/plugin.json" printf '#!/bin/sh\nprintf "%%s\\n" "$*" >> "$SB_CALLS"\nexit 0\n' > "$TMPROOT/kiro-bin/curl" chmod +x "$TMPROOT/kiro-bin/curl" +# kiro-cli 2.21.0's shape: `--version` prints "kiro-cli ", and +# `settings chat.defaultAgent` prints the value (quoted, as the real CLI does) +# or errors out when none is set. The IDE has no CLI: its version is the app +# bundle's Info.plist, pointed at through ROGUE_KIRO_APP so the suite never +# reads /Applications. +cat > "$TMPROOT/kiro-bin/kiro-cli" <<'STUB' +#!/bin/sh +case "$1 $2" in + "--version ") echo "kiro-cli 2.21.0" ;; + "settings chat.defaultAgent") [ -n "${KIRO_FAKE_DEFAULT:-}" ] && { echo "\"$KIRO_FAKE_DEFAULT\""; exit 0; } + echo "error: No value associated with chat.defaultAgent" >&2; exit 1 ;; +esac +exit 0 +STUB +chmod +x "$TMPROOT/kiro-bin/kiro-cli" +KIRO_APP="$TMPROOT/Kiro.app" +mkdir -p "$KIRO_APP/Contents" +cat > "$KIRO_APP/Contents/Info.plist" <<'PLIST' + + + CFBundleVersion26030401 + CFBundleShortVersionString + 1.0.437 + +PLIST echo 'export ROGUE_API_KEY=k' > "$KIRO_HOME/.rogue-env" KIRO_STAMP="$KIRO_HOME/.rogue/beacon/.last-kiro" # kiro_beacon → the recorded curl arguments (one line per call) @@ -235,6 +261,7 @@ kiro_beacon() { : > "$TMPROOT/kiro-calls" env -u ROGUE_API_KEY -u ROGUE_BASE_URL -u ROGUE_ACTOR_EMAIL -u ROGUE_ACTOR_NAME \ SB_CALLS="$TMPROOT/kiro-calls" PATH="$TMPROOT/kiro-bin:$PATH" HOME="$KIRO_HOME" \ + ROGUE_KIRO_APP="${ROGUE_KIRO_APP:-$TMPROOT/no-app}" KIRO_FAKE_DEFAULT="${KIRO_FAKE_DEFAULT:-}" \ "$SH" "$KIRO_ROOT/scripts/heartbeat.sh" "$1" "$2" >/dev/null 2>&1 cat "$TMPROOT/kiro-calls" } @@ -244,10 +271,33 @@ check "SessionStart posts /hooks/status" "yes" "$(has "$out" "/api/v1/hooks/stat check "the body names family kiro" "yes" "$(has "$out" '"agent_family":"kiro"')" check "the body carries the surface argument" "yes" "$(has "$out" '"agent":"kiro_ide"')" check "the version comes from plugin.json" "yes" "$(has "$out" '"version":"9.9.9"')" +check "no app bundle → agent_version unknown, never blank" "yes" "$(has "$out" '"agent_version":"unknown"')" check "the stamp slug is kiro (the log file's name)" "yes" "$([ -s "$KIRO_STAMP" ] && echo yes || echo no)" check "Stop inside the window is throttled" "" "$(kiro_beacon kiro_ide Stop)" rm -rf "${KIRO_STAMP%/*}" check "an unrecognised surface falls back to kiro_cli" "yes" "$(has "$(kiro_beacon bogus Stop)" '"agent":"kiro_cli"')" +rm -rf "${KIRO_STAMP%/*}" +echo "── the kiro heartbeat reports the host's own version and the CLI default ──" +# Two versions ride one row: `version` is the plugin's, `agent_version` is the +# Kiro build it runs under - the CLI from `kiro-cli --version`, the IDE from +# the app bundle - so support can tell a plugin that is current from a Kiro that +# is not. The CLI also reports which agent is the default: on the 2.x engine +# only agents carrying the Rogue hooks are covered, so a machine whose default +# moved away from `rogue` is a machine the roster should show as uncovered. +out="$(KIRO_FAKE_DEFAULT=rogue kiro_beacon kiro_cli SessionStart)" +check "kiro_cli reports agent_version from kiro-cli --version" "yes" "$(has "$out" '"agent_version":"2.21.0"')" +check "kiro_cli reports the default agent, unquoted" "yes" "$(has "$out" '"default_agent":"rogue"')" +rm -rf "${KIRO_STAMP%/*}" +out="$(kiro_beacon kiro_cli SessionStart)" +check "no default set → no default_agent field" "no" "$(has "$out" 'default_agent')" +rm -rf "${KIRO_STAMP%/*}" +out="$(ROGUE_KIRO_APP="$KIRO_APP" kiro_beacon kiro_ide SessionStart)" +check "kiro_ide reports agent_version from the app bundle's Info.plist" "yes" "$(has "$out" '"agent_version":"1.0.437"')" +check "the IDE reports no default agent (that is a CLI setting)" "no" "$(has "$out" 'default_agent')" +rm -rf "${KIRO_STAMP%/*}" +out="$(KIRO_FAKE_DEFAULT=rogue kiro_beacon kiro_crew SessionStart)" +check "kiro_crew reports the CLI version (Crew drives kiro-cli)" "yes" "$(has "$out" '"agent_version":"2.21.0"')" +check "kiro_crew reports no default agent" "no" "$(has "$out" 'default_agent')" rm -f "$KIRO_HOME/.rogue-env" check "unconfigured is a no-op" "" "$(kiro_beacon kiro_cli SessionStart)" From 025848569505b4e9e7fafdd47e0147e972ffd075 Mon Sep 17 00:00:00 2001 From: Dror Ivry Date: Thu, 3 Sep 2026 21:01:30 +0300 Subject: [PATCH 03/13] feat(kiro): status script reporting surfaces, hook wiring, default agent and the key Kiro has no slash-command surface for a /rogue:status skill, so the status command is scripts/status.sh (status.ps1 on Windows): credential sources and the key's last four characters, the installed surfaces with their Kiro builds, the hook file / Crew wrappers / hooked agent configs the installer wrote, the 2.x default agent and whether its config carries the hooks, the /hooks/status check with running vs latest, and the log tail. It posts the heartbeat's own body through the same helpers so it refreshes the install's roster row instead of opening a second one, and exits non-zero when unconfigured or the API did not answer 200 so a rollout can verify a machine. The sh suite runs under dash and bash in validate.yml; both installers print the command as their closing hint. --- .github/workflows/validate.yml | 5 + install.ps1 | 1 + install.sh | 1 + plugins/kiro/README.md | 51 +++++++- plugins/kiro/scripts/status.ps1 | 222 ++++++++++++++++++++++++++++++++ plugins/kiro/scripts/status.sh | 186 ++++++++++++++++++++++++++ tests/test_status_kiro_sh.sh | 196 ++++++++++++++++++++++++++++ 7 files changed, 661 insertions(+), 1 deletion(-) create mode 100644 plugins/kiro/scripts/status.ps1 create mode 100755 plugins/kiro/scripts/status.sh create mode 100644 tests/test_status_kiro_sh.sh diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index 2e3564a..8e697dd 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -98,6 +98,11 @@ jobs: TEST_SH=dash bash tests/test_actor_sh.sh TEST_SH=dash bash tests/test_install_id_sh.sh TEST_SH=dash bash tests/test_status_skill_sh.sh + # Kiro has no slash-command surface, so its status command is a script + # rather than a skill document; both shells, since a developer runs it + # by hand under bash and a managed rollout verifies under /bin/sh. + TEST_SH=dash bash tests/test_status_kiro_sh.sh + TEST_SH=bash bash tests/test_status_kiro_sh.sh bash tests/test_plugin_versions_sh.sh # Twice, because hooks.json fires auto-update.sh as `sh