From 4429adc34c6c5df01965b3cc3d2afb95e12a4580 Mon Sep 17 00:00:00 2001 From: Samir Hanna Verza Date: Tue, 4 Aug 2026 00:04:24 -0300 Subject: [PATCH] feat(workstream): add a v2-engine Kiro CLI managed-workstream adapter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add `ai-memory run kiro` (alias `kiro-cli`) for the AWS Kiro CLI (#356), deliberately scoped to the binary's default v2 agent engine. Cross-engine resume is prevented by construction, not detection: Kiro v3 sessions occupy a separate id space the v2 engine cannot resume (the resume hint printed after a v3 session silently starts a fresh v2 session unless --v3 is added), and the v3 persisted-session format is not publicly documented — so any `--v3`, `--mode`, or non-v2 `--agent-engine` selection turns the whole invocation into an unmanaged passthrough with argv byte-identical. Headless `--no-interactive` runs persist to Kiro's v1 SQLite store rather than the v2 session files this adapter reads, so they pass through too, as do the one-shot list/delete/model flags and every root utility subcommand except `chat` (list verified against kiro-cli 2.16.0 --help-all). Launch planning: session ids are server-assigned, so a fresh launch injects nothing and the session is linked by the session-start hook or discovered post-exit; a returning session appends `--resume-id ` after user arguments (accepted at the root and on `chat`, verified on the 2.16.0 binary). Explicit `-r/--resume`, `--resume-id`, `--resume-picker`/`--list` selections always win. Kiro's `-v` is verbose, not version, and stays managed. Discovery/import: the interactive store is flat — `$KIRO_HOME/sessions/cli/.json` metadata + `.jsonl` event stream — and the adapter matches checkouts on the metadata `cwd`, requiring the metadata session_id to agree with the file stem. The parser imports the versioned v1 envelope (Prompt / AssistantMessage with toolUse parts / ToolResults), ignores unknown record kinds for forward compatibility, and annotates unknown envelope versions and non-text parts as explicit losses. The stream is not verified append-only, so it shares Kimi's rewrite tolerance: a prefix-hash cursor that resets and replays on in-place rewrites, with stable line-hash record ids deduplicating server-side. `--yolo` maps to the official v2 flag `--trust-all-tools`, treats `-a`/`--trust-tools` as already-satisfying (an explicit narrower trust set is never widened), and maps nothing on non-v2 engines — v3 replaced the flag with permissions.yaml — with a stderr notice. Kiro joins the automatic bare-`run` pool (client and server side) and the deterministic phase of the acceptance script, including a byte-identical `--v3` passthrough check; the real-harness phase skips kiro with an explanation because scripted headless turns cannot write the store the adapter reads. Session-file shapes derive from public kiro-cli 1.29.x references and are documented as pending revalidation on a live logged-in install; the CLI argument contract was verified against kiro-cli 2.16.0 locally. Closes #356. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01LJvj7D7czyZzqk4pDXgLxY --- README.md | 6 +- crates/ai-memory-cli/src/commands/run.rs | 41 +++- crates/ai-memory-hooks/src/workstream.rs | 3 +- crates/ai-memory-workstream/src/harness.rs | 55 ++++- crates/ai-memory-workstream/src/transcript.rs | 201 +++++++++++++++++- docs/managed-workstreams.md | 6 +- scripts/managed-workstream-acceptance.sh | 31 +++ 7 files changed, 334 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 10ffcf85..8ec8d9d2 100644 --- a/README.md +++ b/README.md @@ -74,13 +74,15 @@ priors are at the [bottom](#influences-and-prior-art). and full-ledger search. Delivered packets are origin-marked; Claude transcript import rejects a packet that Claude persisted and read back through a tool. `ai-memory run` with no harness continues the newest usable Claude Code, - Codex, OpenCode, Pi, Crush, or Kimi Code session for this checkout. On first + Codex, OpenCode, Pi, Crush, Kimi Code, or Kiro CLI session for this checkout. + On first explicit use, an interactive launcher can adopt a previous session from the same checkout; later switches cannot select unrelated native history. Native arguments pass through unchanged except the wrapper-owned `--yolo` and `--fresh`; direct commands are unaffected. `kimi-code` and `kimi-cli` are accepted aliases for - the installed `kimi` command. + the installed `kimi` command, and `kiro-cli` for the installed `kiro-cli` + command (`ai-memory run kiro`, default v2 engine only). - **Per-repository capture exclusions.** A nearest-marker `[capture]` `ignore_paths` policy drops matching recognized file-tool events before they reach the local spool or server. See [the capture policy reference](docs/marker-file.md#capture-exclusions). diff --git a/crates/ai-memory-cli/src/commands/run.rs b/crates/ai-memory-cli/src/commands/run.rs index 11b18481..203cf3ab 100644 --- a/crates/ai-memory-cli/src/commands/run.rs +++ b/crates/ai-memory-cli/src/commands/run.rs @@ -36,13 +36,14 @@ const PREPARE_BUSY_RETRY_INTERVAL: Duration = Duration::from_millis(250); const IMPORT_BATCH_EVENTS: usize = 400; const IMPORT_BATCH_BYTES: usize = 1024 * 1024; const ADOPTION_CANDIDATE_LIMIT: usize = 8; -const AUTO_HARNESSES: [ManagedHarness; 6] = [ +const AUTO_HARNESSES: [ManagedHarness; 7] = [ ManagedHarness::Claude, ManagedHarness::Codex, ManagedHarness::OpenCode, ManagedHarness::Pi, ManagedHarness::Crush, ManagedHarness::Kimi, + ManagedHarness::Kiro, ]; #[derive(Debug, Clone)] @@ -293,6 +294,16 @@ pub(super) async fn run_from(config: &Config, args: RunArgs, cwd: &Path) -> Resu } } if args.yolo || trailing_yolo { + // Kiro's official dangerous mode exists on the v2 engine only + // (`--trust-all-tools`); the v3 engine replaced it with + // permissions.yaml and documents no CLI equivalent, so the wrapper + // maps nothing there and says so instead of failing silently. + if harness == ManagedHarness::Kiro && kiro_selects_non_default_engine(&plan.args) { + eprintln!( + "ai-memory: --yolo maps to no verified flag on the selected Kiro engine \ + (v3 replaced --trust-all-tools with permissions.yaml); launching without it" + ); + } apply_yolo(harness, &mut plan.args); } if plan.mode == LaunchMode::Session @@ -631,7 +642,7 @@ fn filter_usable_auto_sessions( fn no_auto_session_error() -> anyhow::Error { anyhow!( - "no Claude Code, Codex, OpenCode, Pi, Crush, or Kimi Code session was found for this directory; start one explicitly with `ai-memory run claude`, `ai-memory run codex`, `ai-memory run opencode`, `ai-memory run pi`, `ai-memory run crush`, or `ai-memory run kimi`" + "no Claude Code, Codex, OpenCode, Pi, Crush, Kimi Code, or Kiro CLI session was found for this directory; start one explicitly with `ai-memory run claude`, `ai-memory run codex`, `ai-memory run opencode`, `ai-memory run pi`, `ai-memory run crush`, `ai-memory run kimi`, or `ai-memory run kiro`" ) } @@ -1648,6 +1659,32 @@ mod tests { ); } + #[test] + fn kiro_cli_alias_selects_the_kiro_adapter() { + for name in ["kiro", "kiro-cli"] { + let cli = Cli::try_parse_from([ + OsStr::new("ai-memory"), + OsStr::new("run"), + OsStr::new(name), + OsStr::new("--model"), + OsStr::new("sonnet"), + ]) + .unwrap(); + let CliCommand::Run(args) = cli.command else { + panic!("expected run command"); + }; + assert!( + matches!(args.harness, Some(crate::cli::RunHarnessChoice::Kiro)), + "{name}" + ); + assert_eq!( + args.native_args, + ["--model", "sonnet"].map(OsString::from).to_vec() + ); + assert_eq!(managed_harness(args.harness.unwrap()), ManagedHarness::Kiro); + } + } + #[test] fn bare_run_and_wrapper_yolo_parse_without_a_harness() { let cli = Cli::try_parse_from(["ai-memory", "run", "--yolo"]).unwrap(); diff --git a/crates/ai-memory-hooks/src/workstream.rs b/crates/ai-memory-hooks/src/workstream.rs index 03c2b763..290ce0e8 100644 --- a/crates/ai-memory-hooks/src/workstream.rs +++ b/crates/ai-memory-hooks/src/workstream.rs @@ -144,13 +144,14 @@ async fn prepare_run( "managed run requires a supported command-line harness", ); } - const AUTO_AGENTS: [AgentKind; 6] = [ + const AUTO_AGENTS: [AgentKind; 7] = [ AgentKind::ClaudeCode, AgentKind::Codex, AgentKind::OpenCode, AgentKind::Pi, AgentKind::Crush, AgentKind::KimiCode, + AgentKind::KiroCli, ]; if request.automatic_harness && (!AUTO_AGENTS.contains(&request.agent) diff --git a/crates/ai-memory-workstream/src/harness.rs b/crates/ai-memory-workstream/src/harness.rs index e5ae6364..aeb75d1e 100644 --- a/crates/ai-memory-workstream/src/harness.rs +++ b/crates/ai-memory-workstream/src/harness.rs @@ -97,6 +97,28 @@ impl ManagedHarness { } } +/// Whether a Kiro CLI invocation targets an agent engine other than the +/// default v2 engine — `--v3`, `--mode` (a v3-only option), or an +/// `--agent-engine` value that is not `v2` (the `chat` subcommand's +/// engine selector, verified on kiro-cli 2.16.0). +/// +/// Kiro v3 sessions live in a separate id space and cannot be resumed by +/// the v2 engine (nor vice versa), and the v3 persisted-session format is +/// not publicly documented, so managed continuity covers the v2 engine +/// only. Any non-v2 engine selection makes the whole invocation pass +/// through — no session injection, no adoption, no import — which keeps +/// incompatible v2/v3 sessions from being cross-resumed by construction. +#[must_use] +pub fn kiro_selects_non_default_engine(args: &[OsString]) -> bool { + if has_flag(args, &["--v3", "--mode"]) { + return true; + } + if !has_flag(args, &["--agent-engine"]) { + return false; + } + flag_value(args, &["--agent-engine"]).as_deref() != Some("v2") +} + /// Whether the planned native invocation participates in session continuity. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum LaunchMode { @@ -315,11 +337,42 @@ fn noninteractive_invocation(harness: ManagedHarness, args: &[OsString]) -> bool } fn launch_mode(harness: ManagedHarness, args: &[OsString]) -> LaunchMode { - if has_flag(args, &["--help", "-h", "--version", "-v"]) + // Kiro's `-v` is verbose (its version short flag is `-V`), so the + // generic version-flag check must not send `kiro-cli -v` through + // unmanaged. + let version_flags: &[&str] = if harness == ManagedHarness::Kiro { + &["--help", "-h", "--version", "-V", "--help-all"] + } else { + &["--help", "-h", "--version", "-v"] + }; + if has_flag(args, version_flags) || has_flag(args, &["--no-session", "--no-session-persistence"]) { return LaunchMode::Passthrough; } + if harness == ManagedHarness::Kiro { + // Managed continuity is verified for the default v2 engine only; + // any other engine selection passes straight through (see + // `kiro_selects_non_default_engine`). Headless `--no-interactive` + // runs persist to the v1 SQLite store rather than the v2 session + // files this adapter reads, and the one-shot list/delete flags + // never open a session, so none of them is session-bearing. + if kiro_selects_non_default_engine(args) + || has_flag( + args, + &[ + "--no-interactive", + "--list-sessions", + "-l", + "--list-models", + "--delete-session", + "-d", + ], + ) + { + return LaunchMode::Passthrough; + } + } if harness == ManagedHarness::Codex && first_arg_is(args, "exec") && args diff --git a/crates/ai-memory-workstream/src/transcript.rs b/crates/ai-memory-workstream/src/transcript.rs index cda7f94b..bb33ecc7 100644 --- a/crates/ai-memory-workstream/src/transcript.rs +++ b/crates/ai-memory-workstream/src/transcript.rs @@ -300,7 +300,9 @@ fn export_jsonl( // on fork/compaction/resume, Grok on rewind) — a byte-offset id // would silently change meaning. Hashing the raw line keeps // record ids (and therefore server-side event dedup) stable - // across rewrites. + // across rewrites. Kiro records reuse one message_id across an + // exchange's Prompt/AssistantMessage/ToolResults records, so + // the line hash is its unique record id as well. let raw = line.strip_suffix(b"\n").unwrap_or(&line); format!("{:x}", Sha256::digest(raw)) } else { @@ -367,6 +369,17 @@ fn export_jsonl( }) } +/// Journals the harness may rewrite in place (fork/compaction/resume), +/// where a byte-offset cursor would silently change meaning: the adapter +/// validates a prefix hash before trusting the offset and derives record +/// ids from the raw line bytes. Kimi is a confirmed rewriter; Kiro's +/// event stream is not verified append-only, so it gets the same +/// tolerance defensively (a rewrite resets the cursor and the stable +/// line-hash record ids dedupe the replay server-side). +const fn rewrite_tolerant_journal(harness: ManagedHarness) -> bool { + matches!(harness, ManagedHarness::Kimi | ManagedHarness::Kiro) +} + fn hash_file_prefix(file: &mut File, len: u64) -> Result> { file.seek(SeekFrom::Start(0))?; let mut hasher = Sha256::new(); @@ -944,6 +957,185 @@ fn kimi_timestamp(value: &Value) -> Option { .map(|timestamp| timestamp.to_string()) } +/// Import one Kiro v2-engine session event: a versioned envelope +/// `{"version":"v1","kind":…,"data":{"message_id":…,"content":[…],"meta":…}}` +/// whose `content` parts carry their own `kind`/`data`. Visible kinds: +/// `Prompt` (user), `AssistantMessage` (assistant text + `toolUse` parts), +/// `ToolResults` (`toolResult` parts). Unknown record kinds within the +/// `v1` envelope are ignored so newer Kiro versions stay +/// forward-compatible; a non-`v1` envelope version is annotated instead — +/// that axis signals a schema break, not an additive record type. +fn parse_kiro( + value: &Value, + session: &str, + record_id: &str, + events: &mut Vec, + losses: &mut Vec, +) { + match value.get("version").and_then(Value::as_str) { + Some("v1") => {} + Some(other) => { + losses.push(format!( + "Kiro records with unsupported envelope version {other} were skipped" + )); + return; + } + None => { + losses.push("Kiro records without an envelope version were skipped".into()); + return; + } + } + let data = value.get("data").unwrap_or(&Value::Null); + let occurred_at = kiro_timestamp(data); + let parts = data + .get("content") + .and_then(Value::as_array) + .map_or(&[][..], Vec::as_slice); + match value + .get("kind") + .and_then(Value::as_str) + .unwrap_or_default() + { + "Prompt" => { + for (index, part) in parts.iter().enumerate() { + match part.get("kind").and_then(Value::as_str).unwrap_or_default() { + "text" => { + if let Some(text) = part.get("data").and_then(Value::as_str) { + push_event( + events, + AgentKind::KiroCli, + session, + record_id, + index, + WorkstreamEventKind::Message, + Some("user"), + text, + occurred_at.clone(), + json!({}), + ); + } + } + _ => { + losses + .push("Kiro non-text content parts were intentionally excluded".into()); + } + } + } + } + "AssistantMessage" => { + for (index, part) in parts.iter().enumerate() { + match part.get("kind").and_then(Value::as_str).unwrap_or_default() { + "text" => { + if let Some(text) = part.get("data").and_then(Value::as_str) { + push_event( + events, + AgentKind::KiroCli, + session, + record_id, + index, + WorkstreamEventKind::Message, + Some("assistant"), + text, + occurred_at.clone(), + json!({}), + ); + } + } + "toolUse" => { + let tool = part.get("data").unwrap_or(&Value::Null); + let name = first_string(tool, &["name", "toolName", "tool_name"]) + .unwrap_or("tool"); + let arguments = tool + .get("input") + .or_else(|| tool.get("args")) + .or_else(|| tool.get("arguments")) + .map(compact_json) + .unwrap_or_else(|| compact_json(tool)); + push_event( + events, + AgentKind::KiroCli, + session, + record_id, + index, + WorkstreamEventKind::ToolCall, + Some("assistant"), + &format!("{name}: {arguments}"), + occurred_at.clone(), + json!({ + "tool": name, + "tool_call_id": first_string(tool, &["tool_use_id", "toolUseId", "id"]) + }), + ); + } + _ => { + losses + .push("Kiro non-text content parts were intentionally excluded".into()); + } + } + } + } + "ToolResults" => { + for (index, part) in parts.iter().enumerate() { + if part.get("kind").and_then(Value::as_str) != Some("toolResult") { + continue; + } + let result = part.get("data").unwrap_or(&Value::Null); + let body = kiro_result_text(result) + .filter(|text| !text.trim().is_empty()) + .unwrap_or_else(|| compact_json(result)); + push_event( + events, + AgentKind::KiroCli, + session, + record_id, + index, + WorkstreamEventKind::ToolResult, + Some("tool"), + &body, + occurred_at.clone(), + json!({ + "tool_call_id": first_string(result, &["tool_use_id", "toolUseId", "id"]), + "is_error": result + .get("is_error") + .or_else(|| result.get("isError")) + .and_then(Value::as_bool) + }), + ); + } + } + _ => {} + } +} + +/// Kiro event timestamps ride `data.meta.timestamp` as a unix epoch in +/// milliseconds. +fn kiro_timestamp(data: &Value) -> Option { + let millis = data.get("meta")?.get("timestamp").and_then(Value::as_i64)?; + jiff::Timestamp::from_millisecond(millis) + .ok() + .map(|timestamp| timestamp.to_string()) +} + +/// Text of a Kiro tool result: its `content` is an array of the same +/// `{kind, data}` parts the message records use (text parts join), with +/// plain-string `content`/`output`/`text` fields tolerated as fallbacks. +fn kiro_result_text(result: &Value) -> Option { + if let Some(parts) = result.get("content").and_then(Value::as_array) { + let texts: Vec<&str> = parts + .iter() + .filter(|part| part.get("kind").and_then(Value::as_str) == Some("text")) + .filter_map(|part| part.get("data").and_then(Value::as_str)) + .collect(); + if !texts.is_empty() { + return Some(texts.join("\n")); + } + } + ["content", "output", "text"] + .iter() + .find_map(|key| result.get(*key).and_then(Value::as_str)) + .map(str::to_owned) +} + /// Subagent journals (`agents//wire.jsonl`) are not imported in /// v1; annotate the gap once so the omission is visible in the ledger. fn annotate_kimi_subagents(path: &Path, losses: &mut Vec) { @@ -1602,6 +1794,13 @@ fn locate_session_file( } } } + if harness == ManagedHarness::Kiro { + // The store is flat: `/.jsonl`. + let exact = root.join(format!("{id}.jsonl")); + if exact.is_file() { + return Ok(Some(exact)); + } + } let mut files = collect_files(&root, |path| transcript_file(harness, path))?; files.sort_by_key(|path| temporary_transcript(path)); for path in &files { diff --git a/docs/managed-workstreams.md b/docs/managed-workstreams.md index 5a824a36..7f97e809 100644 --- a/docs/managed-workstreams.md +++ b/docs/managed-workstreams.md @@ -118,8 +118,8 @@ directory, including automatic harness selection. `continue` therefore accepts ## Automatic harness selection With no harness name, `ai-memory run` inspects checkout-local sessions for -Claude Code, Codex, OpenCode, Pi, Crush, and Kimi Code. For an empty workstream -it resumes +Claude Code, Codex, OpenCode, Pi, Crush, Kimi Code, and Kiro CLI. For an empty +workstream it resumes the newest session automatically. For an established workstream, server state takes precedence: ai-memory resumes the most recently linked harness that still has a usable local session. It never chooses a newer but obsolete session from @@ -225,6 +225,7 @@ is labelled completed evidence and must never be replayed as a pending call. | Pi | generated `--session-id` | `--session ` | `~/.pi/agent/sessions/**/*.jsonl` | | Crush | native default creation | `--session ` | `/.crush/crush.db` opened read-only | | Kimi Code | native default creation | `--session ` | `$KIMI_CODE_HOME/sessions/*/*/agents/main/wire.jsonl` | +| Kiro CLI | native default creation | `--resume-id ` | `$KIRO_HOME/sessions/cli/.jsonl` (+ sibling `.json` metadata) | | OMP | native default creation | `--resume=` | `~/.omp/agent/sessions/**/*.jsonl` | | Grok Build CLI | generated `--session-id` | `--resume ` | `$GROK_HOME/sessions/*/*/chat_history.jsonl` | | Antigravity CLI | native default creation | `--conversation ` | `~/.gemini/antigravity-cli/conversations/.db` metadata plus lifecycle-hook capture | @@ -276,6 +277,7 @@ ai-memory install-hooks --agent opencode --apply ai-memory install-hooks --agent pi --apply ai-memory install-hooks --agent omp --apply ai-memory install-hooks --agent kimi-code --apply +ai-memory install-hooks --agent kiro-cli --apply ``` Kimi Code hooks installed as native `ai-memory hook` commands automatically diff --git a/scripts/managed-workstream-acceptance.sh b/scripts/managed-workstream-acceptance.sh index b7bc4651..39768363 100755 --- a/scripts/managed-workstream-acceptance.sh +++ b/scripts/managed-workstream-acceptance.sh @@ -217,6 +217,37 @@ case "${AI_MEMORY_ACCEPTANCE_FAKE_MODE:-argv}" in packet=$(jq -r '.options.global_context_paths[-1]' "$CRUSH_GLOBAL_CONFIG/crush.json") cp "$packet" "$AI_MEMORY_ACCEPTANCE_CRUSH_PACKET_LOG" ;; + kiro) + printf '%s\n' "$@" >"$AI_MEMORY_ACCEPTANCE_ARGV_LOG" + # Honor `--resume-id ` (resume); a fresh launch mints its own id + # because Kiro CLI session ids are server-assigned UUIDs. + session_id="" + previous_arg="" + for arg in "$@"; do + if [ "$previous_arg" = --resume-id ]; then + session_id=$arg + fi + previous_arg=$arg + done + if [ -z "$session_id" ]; then + session_id=$(cat /proc/sys/kernel/random/uuid) + fi + # Real layout: flat store, one `.json` metadata + `.jsonl` + # event-stream pair per session; discovery must read the metadata cwd. + store="${KIRO_HOME:?kiro fake mode requires KIRO_HOME}/sessions/cli" + mkdir -p "$store" + stream="$store/$session_id.jsonl" + if [ ! -f "$store/$session_id.json" ]; then + printf '{"session_id":"%s","cwd":"%s","created_at":"2026-08-01T10:00:00Z","updated_at":"2026-08-01T10:00:00Z","title":"acceptance","session_state":{"version":"v1","conversation_metadata":{}}}\n' \ + "$session_id" "$PWD" >"$store/$session_id.json" + : >"$stream" + fi + sentinel=${AI_MEMORY_ACCEPTANCE_SENTINEL:-AMWS-FAKE-KIRO} + printf '{"version":"v1","kind":"Prompt","data":{"message_id":"m-%s-u","content":[{"kind":"text","data":"%s"}],"meta":{"timestamp":%s}}}\n' \ + "$(date +%s)" "$sentinel" "$(date +%s)000" >>"$stream" + printf '{"version":"v1","kind":"AssistantMessage","data":{"message_id":"m-%s-a","content":[{"kind":"text","data":"%s reply"}],"meta":{"timestamp":%s}}}\n' \ + "$(date +%s)" "$sentinel" "$(date +%s)000" >>"$stream" + ;; kimi) printf '%s\n' "$@" >"$AI_MEMORY_ACCEPTANCE_ARGV_LOG" # Honor `--session ` (resume); a fresh launch mints its own id