diff --git a/CHANGELOG.md b/CHANGELOG.md index 7aba19b9..21596c54 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added +- Added version-aware Kiro CLI v3 managed workstreams. `ai-memory run kiro + --v3` reads the authenticated 2.16.2 nested `session.json` / `messages.jsonl` + store through a visible-event allowlist, persists the incompatible engine + flavor in its cursor, resumes with exact `--v3 --resume-id`, and joins bare + automatic selection alongside v2 without cross-resuming either store. Plain + returning Kiro launches recover the linked engine transparently; v3 wrapper + `--yolo` adds no flag because Kiro replaced `--trust-all-tools` with + `permissions.yaml`. A targeted compatibility path also handles Kiro 2.16.2 + writing v3 sessions to default `~/.kiro` despite custom `KIRO_HOME`: only a + resume proven to live in that fallback drops the override for its child + process. The optional deterministic acceptance runner now covers fresh, + resume, and import round trips for both engines (#356). - Added first-party Command Code MCP and stable lifecycle-hook support. `install-mcp --client command-code` merges the documented user-scope HTTP entry; `install-hooks --agent command-code` preserves existing settings and diff --git a/README.md b/README.md index ff191d4b..e2a9cec0 100644 --- a/README.md +++ b/README.md @@ -32,14 +32,14 @@ | Oh My Pi / OMP | Supported | Use `--client omp` / `--agent omp` (or `oh-my-pi`) for native `.omp` MCP config + TypeScript extension; generated extension enforces capture exclusions. | | Pi | Supported | Generated `~/.pi/agent/extensions/ai-memory.ts` extension provides lifecycle capture and an HTTP MCP bridge; generated extension enforces capture exclusions. | | Crush | Managed-only | `ai-memory run crush` resumes its project-local session database and supplies portable context through a temporary supported global-context file; no lifecycle-hook installer is provided. | -| Managed workstreams | Opt-in | `ai-memory run` provides transparent cross-harness continuity for Claude Code, Codex, OpenCode, Pi, Crush, Kimi Code, Kiro CLI v2, OMP, Grok Build CLI, and Antigravity CLI. Direct launches remain unchanged. See [`docs/managed-workstreams.md`](docs/managed-workstreams.md). | +| Managed workstreams | Opt-in | `ai-memory run` provides transparent cross-harness continuity for Claude Code, Codex, OpenCode, Pi, Crush, Kimi Code, both incompatible Kiro CLI engines, OMP, Grok Build CLI, and Antigravity CLI. Direct launches remain unchanged. See [`docs/managed-workstreams.md`](docs/managed-workstreams.md). | | Claude Desktop | MCP-only | Uses `mcp-remote`; no lifecycle hooks. | | OpenClaw | Supported | MCP config + native plugin lifecycle hooks; generated plugin enforces capture exclusions. | | Antigravity CLI | Supported | MCP config (`serverUrl`) + lifecycle hooks (`agy` alias). Only `PreInvocation` with `invocationNum = 0` maps to SessionStart; later model calls cannot consume a next-session handoff. No automatic true session-end hook, so run `ai-memory finalize-session --agent antigravity-cli` after the final turn when you need a summary, handoff, and opt-in SessionEnd consolidation. `ai-memory run antigravity` (aliases `antigravity-cli`, `agy`) adds managed workstream resume via `--conversation`; conversation text is not decoded, so the ledger for this harness comes from hook capture. | | Grok Build CLI | Supported | MCP config (`install-mcp --client grok` → `$GROK_HOME/config.toml`, default `~/.grok/config.toml`) + lifecycle hooks (`install-hooks --agent grok` → `$GROK_HOME/hooks/ai-memory.json`, default `~/.grok/hooks/ai-memory.json`, Grok-specific hook bundle). Capture works; no hook handoff injection — Grok ignores `SessionStart` stdout, so recover handoffs via MCP `memory_handoff_accept`. `ai-memory run grok` adds managed workstream resume with the context packet delivered natively through `--rules`. Skills root: `.grok/skills` / `$GROK_HOME/skills` (default `~/.grok/skills`). | | Zero | Supported | `install-mcp --client zero` (native HTTP + bearer in `~/.config/zero/config.json`) + lifecycle hooks via `install-hooks --agent zero --apply` (exec-form native commands in `~/.config/zero/hooks.json`, JSON payload on stdin, no shell). Capture works incl. specialist (subagent) events; no handoff injection — Zero discards `sessionStart` stdout, so recover handoffs via MCP `memory_handoff_accept`. | | Kimi Code | Supported | MCP config (`url` entry in `~/.kimi-code/mcp.json`) + lifecycle hooks (`[[hooks]]` in `~/.kimi-code/config.toml`, 10 events including subagent start/stop and `PostToolUseFailure` for tool-failure capture); both paths honor `$KIMI_CODE_HOME`. Handoffs inject via `UserPromptSubmit` stdout (Kimi Code discards `SessionStart` hook stdout); `ai-memory run kimi` adds managed workstream resume. | -| Kiro CLI | Supported | MCP config uses `install-mcp --client kiro-cli` (alias `kiro`) and Kiro's Bedrock-compatible schema flavor. `install-hooks --agent kiro-cli` merges v2 hooks into existing agent configs; the explicit `--agent kiro-cli-v3` target writes the incompatible standalone v3 registration. Both preserve unrelated entries, honor `$KIRO_HOME`, enforce capture exclusions, and inject pending handoffs at session start. Kiro has no true SessionEnd hook; use `ai-memory finalize-session --agent kiro-cli`, with `--session-id ` for concurrent sessions. Managed resume remains v2-only pending v3 workstream support. | +| Kiro CLI | Supported | MCP config uses `install-mcp --client kiro-cli` (alias `kiro`) and Kiro's Bedrock-compatible schema flavor. `install-hooks --agent kiro-cli` merges v2 hooks into existing agent configs; the explicit `--agent kiro-cli-v3` target writes the incompatible standalone v3 registration. Both preserve unrelated entries, honor `$KIRO_HOME`, enforce capture exclusions, and inject pending handoffs at session start. Kiro has no true SessionEnd hook; use `ai-memory finalize-session --agent kiro-cli`, with `--session-id ` for concurrent sessions. `ai-memory run kiro` manages v2; add `--v3`, `--mode`, or `--agent-engine v3` for version-safe v3 resume. | | VS Code Copilot | MCP-only | `.vscode/mcp.json` for Copilot agent mode; no lifecycle hooks (Copilot does not expose them yet). | | Zed | MCP-only | Native remote MCP under `context_servers` in Zed's user `settings.json`; no lifecycle hooks or managed-workstream support. | | Hermes Agent | Community | Core hook ingestion recognizes `agent=hermes` and Hermes' documented shell-hook `tool_name` / `tool_input` payload for concrete session attribution, tool-family titles, and capture exclusions. A community-maintained [`ai-memory-hermes-plugin`](https://github.com/MrLuciano/ai-memory-hermes-plugin) is available, but no first-party installer is shipped; review its compatibility matrix, install/uninstall scripts, and secret handling before using it. Hermes ignores session-start hook stdout, so recover handoffs through MCP. | @@ -75,7 +75,8 @@ 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. + Codex, OpenCode, Pi, Crush, Kimi Code, or Kiro CLI v2/v3 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 @@ -83,7 +84,8 @@ priors are at the [bottom](#influences-and-prior-art). `--fresh`; direct commands are unaffected. `kimi-code` and `kimi-cli` are accepted aliases for the installed `kimi` command, and `kiro-cli` for the installed `kiro-cli` - command (`ai-memory run kiro`, default v2 engine only). + command. Kiro defaults to v2; `ai-memory run kiro --v3` selects v3, while a + returning linked v3 workstream selects its engine transparently. - **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). @@ -203,6 +205,9 @@ priors are at the [bottom](#influences-and-prior-art). # Start a new Codex session in the same workstream, keeping portable history. ai-memory run --fresh codex + + # Kiro defaults to v2; select its incompatible v3 engine explicitly once. + ai-memory run kiro --v3 ``` - **"Pick the project instead of remembering where it lives."** Start from a @@ -247,8 +252,8 @@ priors are at the [bottom](#influences-and-prior-art). the workstream immediately. If a linked native transcript was deleted, ai-memory detects the orphan before launch and starts fresh; `--fresh` forces that recovery for one harness. Managed mode currently covers Claude Code, - Codex, OpenCode, Pi, Crush, Kimi Code, Kiro CLI v2, OMP, Grok Build CLI, and - Antigravity CLI; direct harness launches remain unchanged. See + Codex, OpenCode, Pi, Crush, Kimi Code, Kiro CLI v2/v3, OMP, Grok Build CLI, + and Antigravity CLI; direct harness launches remain unchanged. See [Managed cross-harness workstreams](docs/managed-workstreams.md). - **"Just put me back where I was."** From any directory, with no name to type and no list to read: @@ -991,7 +996,7 @@ diagram, crate breakdown, schema notes, and invariants. |---|---| | [`docs/install.md`](docs/install.md) | **Installation cookbook.** Every agent CLI, every alternative (curl, source build, no-docker, no-auth), and the server-on-a-different-machine (homelab/LAN) walkthrough. Read after the Quick start if your setup doesn't match the happy path. | | [`docs/usage.md`](docs/usage.md) | Handoffs, proactive memory queries, slim routing snippet + managed Agent Skills, migration from other memory tools, web UI, raw-wiki inspection, and rules-vs-facts workflow. | -| [`docs/managed-workstreams.md`](docs/managed-workstreams.md) | Optional `ai-memory run` continuity across Claude Code, Codex, OpenCode, Pi, Crush, Kimi Code, Kiro CLI v2, OMP, Grok Build CLI, and Antigravity CLI: automatic harness selection, native resume, argument forwarding, ledger search, privacy, and recovery. | +| [`docs/managed-workstreams.md`](docs/managed-workstreams.md) | Optional `ai-memory run` continuity across Claude Code, Codex, OpenCode, Pi, Crush, Kimi Code, Kiro CLI v2/v3, OMP, Grok Build CLI, and Antigravity CLI: automatic harness selection, native resume, argument forwarding, ledger search, privacy, and recovery. | | [`docs/managed-harness-contributions.md`](docs/managed-harness-contributions.md) | Protocol and acceptance bar for contributors adding managed resume, read-only transcript import, and startup context delivery to another harness. | | [`docs/marker-file.md`](docs/marker-file.md) | `.ai-memory.toml` workspace/project routing for multi-client trees, mono-repos, worktrees, and work/personal separation. | | [`docs/auto-scope.md`](docs/auto-scope.md) | `[auto_scope]` modes for shared servers: default single-slot routing, session-aware isolation, and multi-user `per_actor` behavior. | diff --git a/crates/ai-memory-cli/src/commands/run.rs b/crates/ai-memory-cli/src/commands/run.rs index 10633de3..50bdcbf6 100644 --- a/crates/ai-memory-cli/src/commands/run.rs +++ b/crates/ai-memory-cli/src/commands/run.rs @@ -16,9 +16,10 @@ use ai_memory_core::{ use ai_memory_workstream::{ ExportedTranscript, LaunchMode, LaunchPlan, ManagedHarness, NativeSessionCandidate, allows_native_session_adoption, apply_yolo, build_launch_plan, discover_native_session, - export_transcript, has_native_session_selector, inspect_repository, - kiro_selects_non_default_engine, list_native_sessions, native_session_exists, - wait_for_transcript_flush, + export_transcript, has_native_session_selector, inspect_repository, kiro_explicit_session_id, + kiro_harness_from_source_cursor, kiro_selects_non_default_engine, kiro_selects_v2_engine, + kiro_selects_v3_engine, kiro_v3_resume_uses_default_store, list_native_sessions, + native_session_exists, wait_for_transcript_flush, }; use anyhow::{Context as _, Result, anyhow}; use tokio::process::Command; @@ -37,13 +38,15 @@ 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; 8] = [ ManagedHarness::Claude, ManagedHarness::Codex, ManagedHarness::OpenCode, ManagedHarness::Pi, ManagedHarness::Crush, ManagedHarness::Kimi, + ManagedHarness::Kiro, + ManagedHarness::KiroV3, ]; #[derive(Debug, Clone)] @@ -106,7 +109,7 @@ pub(super) async fn run_from(config: &Config, args: RunArgs, cwd: &Path) -> Resu Vec::new() }; let provisional_harness = match args.harness { - Some(choice) => managed_harness(choice), + Some(choice) => managed_harness_for_args(choice, &native_args), None => auto_candidates .first() .map(|candidate| candidate.harness) @@ -130,10 +133,7 @@ pub(super) async fn run_from(config: &Config, args: RunArgs, cwd: &Path) -> Resu worktree_fingerprint: repository.worktree_fingerprint, agent: provisional_harness.agent_kind(), automatic_harness, - available_agents: auto_candidates - .iter() - .map(|candidate| candidate.harness.agent_kind()) - .collect(), + available_agents: unique_auto_agents(&auto_candidates), workstream: args.workstream, new_workstream: args.new_workstream, lease_owner: lease_owner(), @@ -179,22 +179,39 @@ pub(super) async fn run_from(config: &Config, args: RunArgs, cwd: &Path) -> Resu "managed run interrupted before the agent started" ))); } - let harness = if automatic_harness { + let resolved_harness = if automatic_harness { let resolved = prepared.resolved_agent.unwrap_or_else(|| { eprintln!( "ai-memory: the server does not support managed harness precedence; using the newest checkout-local session. Upgrade the server for established-workstream selection" ); provisional_harness.agent_kind() }); - acquired_try!(managed_harness_from_agent(resolved).ok_or_else(|| { + let selected = acquired_try!(managed_harness_from_agent(resolved).ok_or_else(|| { anyhow!( "the server selected unsupported automatic harness '{}'", resolved.as_str() ) - })) + })); + automatic_harness_flavor( + selected, + provisional_harness, + prepared.native_session_id.as_deref(), + ) } else { provisional_harness }; + let harness = if resolved_harness.agent_kind() == AgentKind::KiroCli { + acquired_try!(resolve_kiro_harness( + &native_args, + prepared.native_session_id.as_deref(), + prepared.source_cursor.as_deref(), + resolved_harness, + &home, + &repository.cwd, + )) + } else { + resolved_harness + }; acquired_try!(ensure_executable_available(harness, executable.as_deref())); let native_grok_rules = user_supplied_grok_rules(&native_args); let (mut plan, orphaned_session) = acquired_try!(build_preflighted_launch_plan( @@ -298,7 +315,9 @@ pub(super) async fn run_from(config: &Config, args: RunArgs, cwd: &Path) -> Resu // (`--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) { + if harness == ManagedHarness::KiroV3 + || 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" @@ -306,6 +325,23 @@ pub(super) async fn run_from(config: &Config, args: RunArgs, cwd: &Path) -> Resu } apply_yolo(harness, &mut plan.args); } + let remove_kiro_home = if harness == ManagedHarness::KiroV3 + && let Some(native_session_id) = plan.expected_session_id.as_deref() + { + acquired_try!(kiro_v3_resume_uses_default_store( + &home, + &repository.cwd, + plan.session_dir.as_deref(), + native_session_id, + )) + } else { + false + }; + if remove_kiro_home { + eprintln!( + "ai-memory: Kiro v3 stored this session under the default home despite custom KIRO_HOME; using the default home for this resume" + ); + } if plan.mode == LaunchMode::Session && let Some(native_session_id) = &plan.expected_session_id { @@ -371,6 +407,9 @@ pub(super) async fn run_from(config: &Config, args: RunArgs, cwd: &Path) -> Resu .stdin(Stdio::inherit()) .stdout(Stdio::inherit()) .stderr(Stdio::inherit()); + if remove_kiro_home { + command.env_remove("KIRO_HOME"); + } if let Some(context) = &crush_context { command.env("CRUSH_GLOBAL_CONFIG", context.path()); } @@ -613,6 +652,32 @@ async fn list_auto_sessions(home: &Path, cwd: &Path) -> Result Vec { + let mut agents = Vec::new(); + for candidate in candidates { + let agent = candidate.harness.agent_kind(); + if !agents.contains(&agent) { + agents.push(agent); + } + } + agents +} + +fn automatic_harness_flavor( + selected: ManagedHarness, + provisional: ManagedHarness, + linked_session_id: Option<&str>, +) -> ManagedHarness { + if selected == ManagedHarness::Kiro + && provisional.agent_kind() == AgentKind::KiroCli + && linked_session_id.is_none() + { + provisional + } else { + selected + } +} + fn filter_usable_auto_sessions( candidates: Vec, available: impl Fn(ManagedHarness) -> bool, @@ -642,10 +707,63 @@ 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`" ) } +fn resolve_kiro_harness( + native_args: &[OsString], + linked_session_id: Option<&str>, + source_cursor: Option<&str>, + fallback: ManagedHarness, + home: &Path, + cwd: &Path, +) -> Result { + if kiro_selects_v3_engine(native_args) { + return Ok(ManagedHarness::KiroV3); + } + if kiro_selects_v2_engine(native_args) { + return Ok(ManagedHarness::Kiro); + } + if kiro_selects_non_default_engine(native_args) { + return Ok(ManagedHarness::Kiro); + } + + let explicit_session_id = kiro_explicit_session_id(native_args); + if let Some(session_id) = explicit_session_id.as_deref().or(linked_session_id) { + let mut found = Vec::new(); + for harness in [ManagedHarness::Kiro, ManagedHarness::KiroV3] { + let probe = build_launch_plan(harness, None, Vec::new(), None)?; + if native_session_exists(harness, home, cwd, probe.session_dir.as_deref(), session_id)? + { + found.push(harness); + } + } + match found.as_slice() { + [harness] => return Ok(*harness), + [_, _] => { + return Err(anyhow!( + "Kiro session {} exists in both incompatible engine stores; use --fresh with an explicit --agent-engine v2 or --v3", + display_session_id(session_id) + )); + } + _ => {} + } + } + + // An exact user selector wins over the workstream's prior cursor. If its + // store is unavailable, keep Kiro's documented v2 default unless the user + // also selected v3 explicitly above. + if explicit_session_id.is_some() { + return Ok(ManagedHarness::Kiro); + } + + if let Some(harness) = source_cursor.and_then(kiro_harness_from_source_cursor) { + return Ok(harness); + } + Ok(fallback) +} + fn remove_wrapper_yolo(args: &mut Vec) -> bool { let before = args.len(); args.retain(|arg| arg != OsStr::new("--yolo")); @@ -1238,6 +1356,15 @@ const fn managed_harness(choice: RunHarnessChoice) -> ManagedHarness { } } +fn managed_harness_for_args(choice: RunHarnessChoice, native_args: &[OsString]) -> ManagedHarness { + let harness = managed_harness(choice); + if harness == ManagedHarness::Kiro && kiro_selects_v3_engine(native_args) { + ManagedHarness::KiroV3 + } else { + harness + } +} + const fn managed_harness_from_agent(agent: AgentKind) -> Option { match agent { AgentKind::ClaudeCode => Some(ManagedHarness::Claude), @@ -1687,6 +1814,118 @@ mod tests { } } + #[test] + fn kiro_engine_resolution_prefers_explicit_args_then_persisted_flavor() { + let temp = tempfile::tempdir().unwrap(); + let cwd = temp.path().join("repo"); + std::fs::create_dir_all(&cwd).unwrap(); + let v3_cursor = serde_json::json!({ + "path": "/sanitized/messages.jsonl", + "offset": 42, + "flavor": "kiro-v3", + "prefix_sha256": "fixture" + }) + .to_string(); + + assert_eq!( + managed_harness_for_args(RunHarnessChoice::Kiro, &[OsString::from("--v3")]), + ManagedHarness::KiroV3 + ); + assert_eq!( + resolve_kiro_harness( + &[], + Some("missing-session"), + Some(&v3_cursor), + ManagedHarness::Kiro, + temp.path(), + &cwd, + ) + .unwrap(), + ManagedHarness::KiroV3 + ); + assert_eq!( + resolve_kiro_harness( + &[OsString::from("--agent-engine=v2")], + Some("sess_c3774f9d-269e-40d1-aa02-2bb0c0817b4e"), + Some(&v3_cursor), + ManagedHarness::KiroV3, + temp.path(), + &cwd, + ) + .unwrap(), + ManagedHarness::Kiro + ); + assert_eq!( + resolve_kiro_harness( + &[ + OsString::from("--resume-id"), + OsString::from("missing-explicit-session"), + ], + Some("sess_c3774f9d-269e-40d1-aa02-2bb0c0817b4e"), + Some(&v3_cursor), + ManagedHarness::KiroV3, + temp.path(), + &cwd, + ) + .unwrap(), + ManagedHarness::Kiro + ); + } + + #[test] + fn automatic_kiro_flavors_share_one_server_agent_identity() { + let candidates = vec![ + AutoSessionCandidate { + harness: ManagedHarness::KiroV3, + session: NativeSessionCandidate { + native_session_id: "v3".into(), + updated_at: SystemTime::UNIX_EPOCH, + }, + }, + AutoSessionCandidate { + harness: ManagedHarness::Kiro, + session: NativeSessionCandidate { + native_session_id: "v2".into(), + updated_at: SystemTime::UNIX_EPOCH, + }, + }, + ]; + + assert_eq!(unique_auto_agents(&candidates), [AgentKind::KiroCli]); + assert_eq!( + automatic_harness_flavor(ManagedHarness::Kiro, ManagedHarness::KiroV3, None), + ManagedHarness::KiroV3 + ); + assert_eq!( + automatic_harness_flavor(ManagedHarness::Kiro, ManagedHarness::KiroV3, Some("linked"),), + ManagedHarness::Kiro + ); + } + + #[test] + fn incompatible_link_starts_fresh_in_the_explicit_kiro_engine() { + let temp = tempfile::tempdir().unwrap(); + let cwd = temp.path().join("repo"); + std::fs::create_dir_all(&cwd).unwrap(); + + let (fresh, orphaned) = build_preflighted_launch_plan( + ManagedHarness::KiroV3, + None, + vec![OsString::from("--v3")], + Some("3f6d1c2a-0000-4000-8000-000000000aaa"), + false, + temp.path(), + &cwd, + ) + .unwrap(); + assert_eq!( + orphaned.as_deref(), + Some("3f6d1c2a-0000-4000-8000-000000000aaa") + ); + assert_eq!(fresh.expected_session_id, None); + assert_eq!(fresh.args, [OsString::from("--v3")]); + } + #[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-workstream/src/harness.rs b/crates/ai-memory-workstream/src/harness.rs index 2e9a1800..10a3b7ca 100644 --- a/crates/ai-memory-workstream/src/harness.rs +++ b/crates/ai-memory-workstream/src/harness.rs @@ -26,6 +26,8 @@ pub enum ManagedHarness { Kimi, /// Amazon Kiro CLI (v2 engine). Kiro, + /// Amazon Kiro CLI (v3 engine). + KiroV3, /// Grok Build CLI (xAI). Grok, /// Google Antigravity CLI (`agy`). @@ -62,7 +64,7 @@ impl ManagedHarness { Self::Crush => AgentKind::Crush, Self::Omp => AgentKind::Omp, Self::Kimi => AgentKind::KimiCode, - Self::Kiro => AgentKind::KiroCli, + Self::Kiro | Self::KiroV3 => AgentKind::KiroCli, Self::Grok => AgentKind::Grok, Self::Antigravity => AgentKind::AntigravityCli, } @@ -79,7 +81,7 @@ impl ManagedHarness { Self::Crush => "crush", Self::Omp => "omp", Self::Kimi => "kimi", - Self::Kiro => "kiro-cli", + Self::Kiro | Self::KiroV3 => "kiro-cli", Self::Grok => "grok", Self::Antigravity => "agy", } @@ -97,6 +99,7 @@ impl ManagedHarness { Self::Omp => "omp", Self::Kimi => "kimi", Self::Kiro => "kiro", + Self::KiroV3 => "kiro-v3", Self::Grok => "grok", Self::Antigravity => "antigravity", } @@ -106,14 +109,11 @@ 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). +/// engine selector, verified on kiro-cli 2.16.2). /// /// 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. +/// the v2 engine (nor vice versa). Unknown non-v2 engines pass through rather +/// than being assigned to a known adapter. #[must_use] pub fn kiro_selects_non_default_engine(args: &[OsString]) -> bool { if has_flag(args, &["--v3", "--mode"]) { @@ -125,6 +125,29 @@ pub fn kiro_selects_non_default_engine(args: &[OsString]) -> bool { flag_value(args, &["--agent-engine"]).as_deref() != Some("v2") } +/// Whether Kiro CLI arguments explicitly select the v3 engine. +/// +/// `--mode` is v3-only. An unknown `--agent-engine` value is not treated as +/// v3: callers leave such invocations in passthrough mode instead of guessing +/// which incompatible session store they use. +#[must_use] +pub fn kiro_selects_v3_engine(args: &[OsString]) -> bool { + has_flag(args, &["--v3", "--mode"]) + || flag_value(args, &["--agent-engine"]).as_deref() == Some("v3") +} + +/// Whether Kiro CLI arguments explicitly select the v2 engine. +#[must_use] +pub fn kiro_selects_v2_engine(args: &[OsString]) -> bool { + flag_value(args, &["--agent-engine"]).as_deref() == Some("v2") +} + +/// Exact Kiro session id supplied through `--resume-id`, when present. +#[must_use] +pub fn kiro_explicit_session_id(args: &[OsString]) -> Option { + flag_value(args, &["--resume-id"]) +} + /// Whether the planned native invocation participates in session continuity. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum LaunchMode { @@ -171,6 +194,12 @@ pub fn build_launch_plan( .or_else(|| environment_session_dir(harness)); let mut expected = explicit_session_id(harness, &args); let mode = launch_mode(harness, &args); + if mode == LaunchMode::Session + && harness == ManagedHarness::KiroV3 + && !kiro_selects_v3_engine(&args) + { + args.insert(0, OsString::from("--v3")); + } if mode == LaunchMode::Session && !has_native_session_selector(harness, &args) { match harness { ManagedHarness::Claude => { @@ -249,10 +278,10 @@ pub fn build_launch_plan( expected = Some(id.to_string()); } } - ManagedHarness::Kiro => { - // Kiro assigns UUIDs to fresh sessions. A linked v2 session - // can be selected exactly; a fresh one is discovered after - // the native process exits. + ManagedHarness::Kiro | ManagedHarness::KiroV3 => { + // Both engines assign ids to fresh sessions. A linked session + // can be selected exactly after its engine-specific store has + // been validated; a fresh one is discovered after exit. if let Some(id) = linked_session_id { args.extend([OsString::from("--resume-id"), OsString::from(id)]); expected = Some(id.to_string()); @@ -311,6 +340,7 @@ pub fn apply_yolo(harness: ManagedHarness, args: &mut Vec) { Some("--trust-all-tools") } } + ManagedHarness::KiroV3 => None, ManagedHarness::Grok => Some("--yolo"), ManagedHarness::Antigravity => Some("--dangerously-skip-permissions"), }; @@ -351,7 +381,7 @@ fn noninteractive_invocation(harness: ManagedHarness, args: &[OsString]) -> bool ManagedHarness::Crush => first_arg_is(args, "run"), ManagedHarness::Pi | ManagedHarness::Omp => has_flag(args, &["--print", "-p"]), ManagedHarness::Kimi => has_flag(args, &["--prompt", "-p"]), - ManagedHarness::Kiro => has_flag(args, &["--no-interactive"]), + ManagedHarness::Kiro | ManagedHarness::KiroV3 => has_flag(args, &["--no-interactive"]), ManagedHarness::Grok => { has_flag(args, &["--single", "-p", "--prompt-file", "--prompt-json"]) } @@ -366,7 +396,8 @@ fn launch_mode(harness: ManagedHarness, args: &[OsString]) -> LaunchMode { // 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 { + let version_flags: &[&str] = if matches!(harness, ManagedHarness::Kiro | ManagedHarness::KiroV3) + { &["--help", "-h", "--version", "-V", "--help-all"] } else { &["--help", "-h", "--version", "-v"] @@ -376,14 +407,11 @@ fn launch_mode(harness: ManagedHarness, args: &[OsString]) -> LaunchMode { { 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) + if matches!(harness, ManagedHarness::Kiro | ManagedHarness::KiroV3) { + // An unknown non-v2 engine remains passthrough rather than being + // assigned to either incompatible adapter. Headless runs and one-shot + // list/delete flags are not session-bearing. + if harness == ManagedHarness::Kiro && kiro_selects_non_default_engine(args) || has_flag( args, &[ @@ -526,9 +554,9 @@ fn launch_mode(harness: ManagedHarness, args: &[OsString]) -> LaunchMode { "__plugin_run_node", ] .as_slice(), - // Every root command except `chat` in kiro-cli 2.16.0. Bare and + // Every root command except `chat` in kiro-cli 2.16.2. Bare and // flags-only invocations open chat and remain session-bearing. - ManagedHarness::Kiro => [ + ManagedHarness::Kiro | ManagedHarness::KiroV3 => [ "debug", "settings", "setup", @@ -596,7 +624,7 @@ fn launch_mode(harness: ManagedHarness, args: &[OsString]) -> LaunchMode { ] .as_slice(), }; - let first = if harness == ManagedHarness::Kiro { + let first = if matches!(harness, ManagedHarness::Kiro | ManagedHarness::KiroV3) { kiro_root_subcommand(args) } else { args.first().and_then(|arg| arg.to_str()) @@ -655,7 +683,7 @@ pub fn has_native_session_selector(harness: ManagedHarness, args: &[OsString]) - "-C", ], ), - ManagedHarness::Kiro => has_flag( + ManagedHarness::Kiro | ManagedHarness::KiroV3 => has_flag( args, &["--resume", "-r", "--resume-id", "--resume-picker", "--list"], ), @@ -703,7 +731,7 @@ fn explicit_session_id(harness: ManagedHarness, args: &[OsString]) -> Option flag_value(args, &["--session", "-S", "--resume", "-r"]), - ManagedHarness::Kiro => flag_value(args, &["--resume-id"]), + ManagedHarness::Kiro | ManagedHarness::KiroV3 => flag_value(args, &["--resume-id"]), ManagedHarness::Grok => flag_value(args, &["--resume", "-r", "--session-id", "-s"]), // A bare `--continue` names no conversation: the id is only known // after the fact, from the conversation store. @@ -811,6 +839,7 @@ fn environment_session_dir_with( // Sessions live under `/sessions///`. ManagedHarness::Kimi => value("KIMI_CODE_HOME").map(|dir| dir.join("sessions")), ManagedHarness::Kiro => value("KIRO_HOME").map(|dir| dir.join("sessions/cli")), + ManagedHarness::KiroV3 => value("KIRO_HOME").map(|dir| dir.join("sessions")), // Sessions live under `/sessions///`. ManagedHarness::Grok => value("GROK_HOME").map(|dir| dir.join("sessions")), // `agy` exposes no environment override for its conversation store. @@ -1408,6 +1437,74 @@ mod tests { "3f6d1c2a-0000-4000-8000-000000000aaa" ] ); + + let explicit = build_launch_plan( + ManagedHarness::KiroV3, + None, + vec![ + OsString::from("--resume-id"), + OsString::from("sess_5f8f43ff-d4b0-4b46-9320-f2f756ced54b"), + ], + Some("sess_c3774f9d-269e-40d1-aa02-2bb0c0817b4e"), + ) + .unwrap(); + assert_eq!( + strings(&explicit.args), + [ + "--v3", + "--resume-id", + "sess_5f8f43ff-d4b0-4b46-9320-f2f756ced54b" + ] + ); + } + + #[test] + fn kiro_v3_fresh_and_linked_launches_select_only_the_v3_store() { + let fresh = build_launch_plan( + ManagedHarness::KiroV3, + None, + vec![OsString::from("--model"), OsString::from("sonnet")], + None, + ) + .unwrap(); + assert_eq!(strings(&fresh.args), ["--v3", "--model", "sonnet"]); + assert_eq!(fresh.expected_session_id, None); + + let linked = build_launch_plan( + ManagedHarness::KiroV3, + None, + vec![OsString::from("--v3"), OsString::from("--mode=vibe")], + Some("sess_c3774f9d-269e-40d1-aa02-2bb0c0817b4e"), + ) + .unwrap(); + assert_eq!( + strings(&linked.args), + [ + "--v3", + "--mode=vibe", + "--resume-id", + "sess_c3774f9d-269e-40d1-aa02-2bb0c0817b4e" + ] + ); + } + + #[test] + fn kiro_engine_selection_distinguishes_v2_v3_and_unknown_values() { + assert!(kiro_selects_v3_engine(&[OsString::from("--v3")])); + assert!(kiro_selects_v3_engine(&[OsString::from("--mode=vibe")])); + assert!(kiro_selects_v3_engine(&[ + OsString::from("--agent-engine"), + OsString::from("v3") + ])); + assert!(kiro_selects_v2_engine(&[OsString::from( + "--agent-engine=v2" + )])); + assert!(!kiro_selects_v3_engine(&[OsString::from( + "--agent-engine=future" + )])); + assert!(kiro_selects_non_default_engine(&[OsString::from( + "--agent-engine=future" + )])); } #[test] @@ -1482,6 +1579,10 @@ mod tests { let mut v3 = vec![OsString::from("--v3")]; apply_yolo(ManagedHarness::Kiro, &mut v3); assert_eq!(strings(&v3), ["--v3"]); + + let mut managed_v3 = vec![OsString::from("--v3")]; + apply_yolo(ManagedHarness::KiroV3, &mut managed_v3); + assert_eq!(strings(&managed_v3), ["--v3"]); } #[test] @@ -1511,6 +1612,11 @@ mod tests { environment_session_dir_with(ManagedHarness::Kiro, get).as_deref(), Some(std::path::Path::new("/stores/kiro/sessions/cli")) ); + let get = |name: &str| (name == "KIRO_HOME").then(|| OsString::from("/stores/kiro")); + assert_eq!( + environment_session_dir_with(ManagedHarness::KiroV3, get).as_deref(), + Some(std::path::Path::new("/stores/kiro/sessions")) + ); } #[test] diff --git a/crates/ai-memory-workstream/src/lib.rs b/crates/ai-memory-workstream/src/lib.rs index ca2f8fd6..8e3b3e23 100644 --- a/crates/ai-memory-workstream/src/lib.rs +++ b/crates/ai-memory-workstream/src/lib.rs @@ -6,10 +6,12 @@ mod transcript; pub use harness::{ LaunchMode, LaunchPlan, ManagedHarness, allows_native_session_adoption, apply_yolo, - build_launch_plan, has_native_session_selector, kiro_selects_non_default_engine, + build_launch_plan, has_native_session_selector, kiro_explicit_session_id, + kiro_selects_non_default_engine, kiro_selects_v2_engine, kiro_selects_v3_engine, }; pub use repository::{RepositoryIdentity, inspect_repository}; pub use transcript::{ ExportedTranscript, NativeSessionCandidate, discover_native_session, export_transcript, - list_native_sessions, native_session_exists, wait_for_transcript_flush, + kiro_harness_from_source_cursor, kiro_v3_resume_uses_default_store, list_native_sessions, + native_session_exists, wait_for_transcript_flush, }; diff --git a/crates/ai-memory-workstream/src/transcript.rs b/crates/ai-memory-workstream/src/transcript.rs index ff8ff100..a40d3663 100644 --- a/crates/ai-memory-workstream/src/transcript.rs +++ b/crates/ai-memory-workstream/src/transcript.rs @@ -49,6 +49,11 @@ pub struct ExportedTranscript { struct FileCursor { path: String, offset: u64, + /// Identifies incompatible native stores that share one wire-level agent. + /// Older cursors omit this field and remain readable after exact path and + /// metadata validation. + #[serde(default, skip_serializing_if = "Option::is_none")] + flavor: Option, /// Hash of every committed byte through `offset`. Kimi Code and Grok can /// rewrite their journals in place (Kimi on fork/compaction/resume, Grok /// on rewind); Kiro's append-only behavior is not documented. Those @@ -58,12 +63,38 @@ struct FileCursor { prefix_sha256: Option, } +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +enum FileFlavor { + KiroV2, + KiroV3, +} + +/// Recover Kiro's incompatible engine flavor from an opaque transcript cursor. +/// The result is advisory only: callers must still validate the exact native +/// session against that engine's store before injecting a resume selector. +#[must_use] +pub fn kiro_harness_from_source_cursor(raw: &str) -> Option { + match serde_json::from_str::(raw).ok()?.flavor? { + FileFlavor::KiroV2 => Some(ManagedHarness::Kiro), + FileFlavor::KiroV3 => Some(ManagedHarness::KiroV3), + } +} + +const fn file_flavor(harness: ManagedHarness) -> Option { + match harness { + ManagedHarness::Kiro => Some(FileFlavor::KiroV2), + ManagedHarness::KiroV3 => Some(FileFlavor::KiroV3), + _ => None, + } +} + /// Harnesses whose JSONL journal can be rewritten in place, requiring /// prefix-validated cursors and content-hash record ids. const fn journal_rewrites_in_place(harness: ManagedHarness) -> bool { matches!( harness, - ManagedHarness::Kimi | ManagedHarness::Kiro | ManagedHarness::Grok + ManagedHarness::Kimi | ManagedHarness::Kiro | ManagedHarness::KiroV3 | ManagedHarness::Grok ) } @@ -119,15 +150,17 @@ pub async fn discover_native_session( if harness == ManagedHarness::Crush { return discover_crush(cwd, session_dir, started_at); } - let root = session_root(harness, home, session_dir); - let mut candidates = collect_files(&root, |path| transcript_file(harness, path))?; - candidates.sort_by_key(|path| modified(path)); - candidates.reverse(); + let mut candidates = collect_session_files(harness, home, session_dir)?; + candidates.sort_by(|left, right| { + modified(right) + .cmp(&modified(left)) + .then_with(|| left.cmp(right)) + }); for path in candidates.into_iter().take(512) { if modified(&path).is_some_and(|time| time + Duration::from_secs(2) < started_at) { break; } - if let Some((id, record_cwd)) = session_header(harness, &path)? + if let Some((id, record_cwd)) = session_header_for_cwd(harness, &path, cwd)? && same_path(&record_cwd, cwd) { return Ok(Some(id)); @@ -156,17 +189,21 @@ pub async fn list_native_sessions( return list_crush_sessions(cwd, session_dir, limit); } - let root = session_root(harness, home, session_dir); - let mut files = collect_files(&root, |path| transcript_file(harness, path))?; - files.sort_by_key(|path| modified(path)); - files.reverse(); + let mut files = collect_session_files(harness, home, session_dir)?; + files.sort_by(|left, right| { + modified(right) + .cmp(&modified(left)) + .then_with(|| left.cmp(right)) + }); let mut seen = HashSet::new(); let mut sessions = Vec::new(); for path in files.into_iter().take(2_000) { let Some(updated_at) = modified(&path) else { continue; }; - let Ok(Some((native_session_id, recorded_cwd))) = session_header(harness, &path) else { + let Ok(Some((native_session_id, recorded_cwd))) = + session_header_for_cwd(harness, &path, cwd) + else { continue; }; if !same_path(&recorded_cwd, cwd) @@ -206,6 +243,36 @@ pub fn native_session_exists( Ok(locate_session_file(harness, home, cwd, session_dir, native_session_id)?.is_some()) } +/// Whether a linked Kiro v3 session was found only in the default home rather +/// than the configured `KIRO_HOME` session root. +/// +/// Kiro CLI 2.16.2 writes v3 sessions to the default root while a custom +/// `KIRO_HOME` is active, then searches only the custom root on resume. The +/// launcher uses this proof to remove `KIRO_HOME` for that one native process. +pub fn kiro_v3_resume_uses_default_store( + home: &Path, + cwd: &Path, + configured_root: Option<&Path>, + native_session_id: &str, +) -> Result { + let Some(configured_root) = configured_root else { + return Ok(false); + }; + let default_root = home.join(".kiro/sessions"); + if configured_root == default_root { + return Ok(false); + } + let found = locate_session_file( + ManagedHarness::KiroV3, + home, + cwd, + Some(configured_root), + native_session_id, + )?; + Ok(found + .is_some_and(|path| path.starts_with(&default_root) && !path.starts_with(configured_root))) +} + /// Wait briefly for buffered transcript writers to settle before importing. pub async fn wait_for_transcript_flush( harness: ManagedHarness, @@ -244,9 +311,12 @@ fn export_jsonl( native_session_id: &str, source_cursor: Option<&str>, ) -> Result { + let flavor = file_flavor(harness); let cursor = source_cursor .and_then(|raw| serde_json::from_str::(raw).ok()) - .filter(|cursor| Path::new(&cursor.path) == path); + .filter(|cursor| { + Path::new(&cursor.path) == path && (cursor.flavor.is_none() || cursor.flavor == flavor) + }); let mut file = File::open(path) .with_context(|| format!("opening native transcript {}", path.display()))?; let len = file.metadata()?.len(); @@ -349,6 +419,13 @@ fn export_jsonl( &mut events, &mut losses, ), + ManagedHarness::KiroV3 => parse_kiro_v3( + &value, + native_session_id, + &record_id, + &mut events, + &mut losses, + ), ManagedHarness::Grok => parse_grok( &value, native_session_id, @@ -372,6 +449,7 @@ fn export_jsonl( source_cursor: Some(serde_json::to_string(&FileCursor { path: path.to_string_lossy().into_owned(), offset: committed_offset, + flavor, prefix_sha256: journal_rewrites_in_place(harness) .then(|| format!("{:x}", prefix_hasher.finalize())), })?), @@ -1107,6 +1185,117 @@ fn parse_kiro( } } +/// Import the visible allowlist from Kiro v3's `messages.jsonl` journal. +/// Session bookkeeping, lifecycle-hook records, usage summaries, turn +/// boundaries, and assistant operations other than visible `Say` output are +/// deliberately excluded. +fn parse_kiro_v3( + value: &Value, + session: &str, + record_id: &str, + events: &mut Vec, + losses: &mut Vec, +) { + let payload = value.get("payload").unwrap_or(&Value::Null); + let occurred_at = timestamp(value); + match payload + .get("type") + .and_then(Value::as_str) + .unwrap_or_default() + { + "user" => { + if let Some(content) = payload.get("content").and_then(Value::as_str) { + push_event( + events, + AgentKind::KiroCli, + session, + record_id, + 0, + WorkstreamEventKind::Message, + Some("user"), + content, + occurred_at, + json!({}), + ); + } + } + "assistant" => { + if payload.get("operationType").and_then(Value::as_str) != Some("Say") { + losses.push( + "Kiro v3 non-visible assistant operations were intentionally excluded".into(), + ); + return; + } + if let Some(content) = payload.get("content").and_then(Value::as_str) { + push_event( + events, + AgentKind::KiroCli, + session, + record_id, + 0, + WorkstreamEventKind::Message, + Some("assistant"), + content, + occurred_at, + json!({}), + ); + } + } + "tool_call" => { + let name = payload + .get("toolName") + .and_then(Value::as_str) + .unwrap_or("tool"); + let arguments = payload.get("args").map(compact_json).unwrap_or_default(); + push_event( + events, + AgentKind::KiroCli, + session, + record_id, + 0, + WorkstreamEventKind::ToolCall, + Some("assistant"), + &format!("{name}: {arguments}"), + occurred_at, + json!({ + "tool": name, + "tool_call_id": payload.get("toolCallId").and_then(Value::as_str) + }), + ); + } + "tool_result" => { + let Some(content) = payload.get("content").and_then(Value::as_str) else { + return; + }; + push_event( + events, + AgentKind::KiroCli, + session, + record_id, + 0, + WorkstreamEventKind::ToolResult, + Some("tool"), + content, + occurred_at, + json!({ + "tool_call_id": payload.get("toolCallId").and_then(Value::as_str), + "is_error": payload.get("success").and_then(Value::as_bool).map(|success| !success) + }), + ); + } + "ContextualHookInvoked" + | "session_metadata" + | "usage_summary" + | "turn_start" + | "turn_end" + | "session_start" + | "session_event" => { + losses.push("Kiro v3 private session records were intentionally excluded".into()); + } + _ => {} + } +} + /// Kiro event timestamps ride `data.meta.timestamp` as a unix epoch in /// milliseconds. fn kiro_timestamp(data: &Value) -> Option { @@ -1765,7 +1954,8 @@ fn locate_session_file( session_dir: Option<&Path>, id: &str, ) -> Result> { - let root = session_root(harness, home, session_dir); + let roots = session_roots(harness, home, session_dir); + let root = &roots[0]; if !valid_native_session_id(id) { return Ok(None); } @@ -1788,7 +1978,7 @@ fn locate_session_file( // Bucket names are one-way cwd hashes, so only the bucket level can // be enumerated — but the session id below it is a plain directory // name, giving an exact fast path per bucket. - if let Ok(buckets) = fs::read_dir(&root) { + if let Ok(buckets) = fs::read_dir(root) { for bucket in buckets.flatten() { let candidate = bucket.path().join(id).join("agents/main/wire.jsonl"); if session_path_matches(harness, &candidate, id, cwd)? { @@ -1811,7 +2001,31 @@ fn locate_session_file( } return Ok(None); } - let mut files = collect_files(&root, |path| transcript_file(harness, path))?; + if harness == ManagedHarness::KiroV3 { + let Some(uuid) = id.strip_prefix("sess_") else { + return Ok(None); + }; + if Uuid::parse_str(uuid).is_err() { + return Ok(None); + } + for root in roots { + let Ok(buckets) = fs::read_dir(&root) else { + continue; + }; + for bucket in buckets.take(MAX_SCAN_FILES) { + let bucket = bucket?; + if !bucket.file_type()?.is_dir() { + continue; + } + let exact = bucket.path().join(id).join("messages.jsonl"); + if session_path_matches(harness, &exact, id, cwd)? { + return Ok(Some(exact)); + } + } + } + return Ok(None); + } + let mut files = collect_session_files(harness, home, session_dir)?; files.sort_by_key(|path| temporary_transcript(path)); for path in files.into_iter().take(2_000) { if session_path_matches(harness, &path, id, cwd)? { @@ -1830,7 +2044,7 @@ fn session_path_matches( if !path.is_file() { return Ok(false); } - Ok(session_header(harness, path)? + Ok(session_header_for_cwd(harness, path, cwd)? .is_some_and(|(found, recorded_cwd)| found == id && same_path(&recorded_cwd, cwd))) } @@ -1858,6 +2072,15 @@ fn transcript_file(harness: ManagedHarness, path: &Path) -> bool { .and_then(|stem| stem.to_str()) .is_some_and(|stem| Uuid::parse_str(stem).is_ok()); } + if harness == ManagedHarness::KiroV3 { + return path.file_name().and_then(|name| name.to_str()) == Some("messages.jsonl") + && path + .parent() + .and_then(|dir| dir.file_name()) + .and_then(|name| name.to_str()) + .and_then(|name| name.strip_prefix("sess_")) + .is_some_and(|uuid| Uuid::parse_str(uuid).is_ok()); + } if harness == ManagedHarness::Antigravity { // One SQLite database per conversation, named by its id. return path.extension().is_some_and(|ext| ext == "db"); @@ -1917,6 +2140,7 @@ fn session_header(harness: ManagedHarness, path: &Path) -> Result (None, None), }; @@ -1927,6 +2151,18 @@ fn session_header(harness: ManagedHarness, path: &Path) -> Result Result> { + if harness == ManagedHarness::KiroV3 { + kiro_v3_session_header(path, cwd) + } else { + session_header(harness, path) + } +} + /// Kimi sessions are self-describing in `/state.json` — the wire /// journal itself carries no session id or cwd, and the bucket directory name /// is a one-way hash of the cwd, so neither can be inferred from the layout. @@ -1980,6 +2216,48 @@ fn kiro_session_header(path: &Path) -> Result> { Ok(Some((id.to_string(), PathBuf::from(cwd)))) } +/// Kiro v3 stores one self-describing directory per `sess_` session. +/// Only the observed schema/data-model pair is accepted, and at least one +/// recorded workspace must resolve to the current checkout. +fn kiro_v3_session_header(path: &Path, cwd: &Path) -> Result> { + if path.file_name().and_then(|name| name.to_str()) != Some("messages.jsonl") { + return Ok(None); + } + let Some(session_dir) = path.parent() else { + return Ok(None); + }; + let Some(id) = session_dir.file_name().and_then(|name| name.to_str()) else { + return Ok(None); + }; + let Some(uuid) = id.strip_prefix("sess_") else { + return Ok(None); + }; + if Uuid::parse_str(uuid).is_err() { + return Ok(None); + } + let Ok(raw) = fs::read_to_string(session_dir.join("session.json")) else { + return Ok(None); + }; + let Ok(metadata) = serde_json::from_str::(&raw) else { + return Ok(None); + }; + if metadata.get("schemaVersion").and_then(Value::as_str) != Some("1.0.0") + || metadata.get("dataModelVersion").and_then(Value::as_u64) != Some(1) + || metadata.get("id").and_then(Value::as_str) != Some(id) + { + return Ok(None); + } + let Some(workspaces) = metadata.get("workspacePaths").and_then(Value::as_array) else { + return Ok(None); + }; + let matching = workspaces + .iter() + .filter_map(Value::as_str) + .map(PathBuf::from) + .find(|workspace| same_path(workspace, cwd)); + Ok(matching.map(|workspace| (id.to_string(), workspace))) +} + /// Grok sessions are self-describing in `/summary.json` /// (`info.id` + `info.cwd`). The chat-history journal carries no session id or /// cwd, and the bucket directory name is a URL-encoded cwd that is never @@ -2316,11 +2594,49 @@ fn session_root(harness: ManagedHarness, home: &Path, override_dir: Option<&Path ManagedHarness::Omp => home.join(".omp/agent/sessions"), ManagedHarness::Kimi => home.join(".kimi-code/sessions"), ManagedHarness::Kiro => home.join(".kiro/sessions/cli"), + ManagedHarness::KiroV3 => home.join(".kiro/sessions"), ManagedHarness::Grok => home.join(".grok/sessions"), ManagedHarness::Antigravity => home.join(".gemini/antigravity-cli/conversations"), } } +/// Kiro CLI 2.16.2 honored `KIRO_HOME` for its v2 store but wrote v3 sessions +/// to the default home during acceptance. Scan the configured root first and +/// the default root as a compatibility fallback; every result still passes +/// strict metadata and checkout validation. +fn session_roots( + harness: ManagedHarness, + home: &Path, + override_dir: Option<&Path>, +) -> Vec { + let primary = session_root(harness, home, override_dir); + let mut roots = vec![primary.clone()]; + if harness == ManagedHarness::KiroV3 { + let fallback = home.join(".kiro/sessions"); + if fallback != primary { + roots.push(fallback); + } + } + roots +} + +fn collect_session_files( + harness: ManagedHarness, + home: &Path, + override_dir: Option<&Path>, +) -> Result> { + let mut seen = HashSet::new(); + let mut files = Vec::new(); + for root in session_roots(harness, home, override_dir) { + for path in collect_files(&root, |path| transcript_file(harness, path))? { + if seen.insert(path.clone()) { + files.push(path); + } + } + } + Ok(files) +} + fn collect_files(root: &Path, predicate: impl Fn(&Path) -> bool + Copy) -> Result> { if !root.is_dir() { return Ok(Vec::new()); @@ -2502,6 +2818,7 @@ mod tests { | ManagedHarness::Crush | ManagedHarness::Kimi | ManagedHarness::Kiro + | ManagedHarness::KiroV3 | ManagedHarness::Grok | ManagedHarness::Antigravity => { unreachable!() @@ -3691,6 +4008,7 @@ mod tests { const KIRO_SESSION_A: &str = "3f6d1c2a-0000-4000-8000-000000000aaa"; const KIRO_SESSION_B: &str = "3f6d1c2a-0000-4000-8000-000000000bbb"; + const KIRO_V3_SESSION: &str = "sess_c3774f9d-269e-40d1-aa02-2bb0c0817b4e"; fn write_kiro_session(root: &Path, id: &str, cwd: &Path, lines: &[Value]) { fs::write( @@ -3713,6 +4031,27 @@ mod tests { fs::write(root.join(format!("{id}.jsonl")), transcript).unwrap(); } + fn write_kiro_v3_session(root: &Path, id: &str, workspaces: &[&Path], messages: &str) { + let session_dir = root.join("checkout-fixture").join(id); + fs::create_dir_all(&session_dir).unwrap(); + fs::write( + session_dir.join("session.json"), + json!({ + "schemaVersion": "1.0.0", + "dataModelVersion": 1, + "id": id, + "workspacePaths": workspaces, + "createdAt": "2026-08-06T10:00:00Z", + "lastModifiedAt": "2026-08-06T10:05:00Z", + "agentMode": "vibe", + "status": "idle" + }) + .to_string(), + ) + .unwrap(); + fs::write(session_dir.join("messages.jsonl"), messages).unwrap(); + } + #[tokio::test] async fn kiro_discovery_and_exact_lookup_are_checkout_scoped() { let temp = tempfile::tempdir().unwrap(); @@ -3800,42 +4139,151 @@ mod tests { ); } + #[tokio::test] + async fn kiro_v3_discovery_is_checkout_scoped_and_never_cross_resumes_v2() { + let temp = tempfile::tempdir().unwrap(); + let cwd = temp.path().join("repo"); + let other = temp.path().join("other"); + let v2_root = temp.path().join("v2"); + let v3_root = temp.path().join("v3"); + fs::create_dir_all(&cwd).unwrap(); + fs::create_dir_all(&other).unwrap(); + fs::create_dir_all(&v2_root).unwrap(); + write_kiro_session(&v2_root, KIRO_SESSION_A, &cwd, &[]); + write_kiro_v3_session( + &v3_root, + KIRO_V3_SESSION, + &[&other, &cwd], + include_str!("../tests/fixtures/kiro-v3-messages.jsonl"), + ); + + let sessions = + list_native_sessions(ManagedHarness::KiroV3, temp.path(), &cwd, Some(&v3_root), 8) + .await + .unwrap(); + assert_eq!(sessions.len(), 1); + assert_eq!(sessions[0].native_session_id, KIRO_V3_SESSION); + assert!( + native_session_exists( + ManagedHarness::KiroV3, + temp.path(), + &cwd, + Some(&v3_root), + KIRO_V3_SESSION, + ) + .unwrap() + ); + assert!( + !native_session_exists( + ManagedHarness::Kiro, + temp.path(), + &cwd, + Some(&v2_root), + KIRO_V3_SESSION, + ) + .unwrap() + ); + assert!( + !native_session_exists( + ManagedHarness::KiroV3, + temp.path(), + &cwd, + Some(&v3_root), + KIRO_SESSION_A, + ) + .unwrap() + ); + } + + #[tokio::test] + async fn kiro_v3_rejects_unknown_schema_and_history_only_mirrors() { + let temp = tempfile::tempdir().unwrap(); + let cwd = temp.path().join("repo"); + let root = temp.path().join("sessions"); + fs::create_dir_all(&cwd).unwrap(); + fs::create_dir_all(root.join("cli")).unwrap(); + fs::write(root.join("cli/fixture.history"), "sanitized prompt\n").unwrap(); + write_kiro_v3_session(&root, KIRO_V3_SESSION, &[&cwd], "{}\n"); + let metadata = root + .join("checkout-fixture") + .join(KIRO_V3_SESSION) + .join("session.json"); + let mut value: Value = + serde_json::from_str(&fs::read_to_string(&metadata).unwrap()).unwrap(); + value["schemaVersion"] = Value::String("2.0.0".into()); + fs::write(&metadata, value.to_string()).unwrap(); + + assert!( + list_native_sessions(ManagedHarness::KiroV3, temp.path(), &cwd, Some(&root), 8,) + .await + .unwrap() + .is_empty() + ); + assert!( + list_native_sessions( + ManagedHarness::Kiro, + temp.path(), + &cwd, + Some(&root.join("cli")), + 8, + ) + .await + .unwrap() + .is_empty() + ); + } + + #[test] + fn kiro_v3_detects_the_custom_home_resume_mismatch() { + let temp = tempfile::tempdir().unwrap(); + let cwd = temp.path().join("repo"); + let configured = temp.path().join("custom-kiro/sessions"); + let default = temp.path().join(".kiro/sessions"); + fs::create_dir_all(&cwd).unwrap(); + fs::create_dir_all(&configured).unwrap(); + write_kiro_v3_session(&default, KIRO_V3_SESSION, &[&cwd], "{}\n"); + + assert!( + kiro_v3_resume_uses_default_store( + temp.path(), + &cwd, + Some(&configured), + KIRO_V3_SESSION, + ) + .unwrap() + ); + assert!( + !kiro_v3_resume_uses_default_store(temp.path(), &cwd, Some(&default), KIRO_V3_SESSION,) + .unwrap() + ); + } + #[test] fn kiro_export_maps_only_visible_v1_records() { let temp = tempfile::tempdir().unwrap(); let stream = temp.path().join(format!("{KIRO_SESSION_A}.jsonl")); - let records = [ - json!({"version":"v1","kind":"Prompt","data":{"message_id":"m1","content":[{"kind":"text","data":"hello kiro"}],"meta":{"timestamp":1_700_000_000_000_i64}}}), - json!({"version":"v1","kind":"AssistantMessage","data":{"message_id":"m2","content":[ - {"kind":"text","data":"visible answer"}, - {"kind":"toolUse","data":{"name":"fs_read","tool_use_id":"call_1","input":{"path":"README.md"}}} - ]}}), - json!({"version":"v1","kind":"ToolResults","data":{"message_id":"m3","content":[ - {"kind":"toolResult","data":{"tool_use_id":"call_1","content":[{"kind":"text","data":"result ok"}],"is_error":false}} - ]}}), - json!({"version":"v1","kind":"Prompt","data":{"message_id":"m4","content":[{"kind":"image","data":{"format":"png"}}]}}), - json!({"version":"v2","kind":"Prompt","data":{"message_id":"m5","content":[{"kind":"text","data":"future"}]}}), - ]; fs::write( &stream, - records - .iter() - .map(|record| format!("{record}\n")) - .collect::(), + format!( + "{}{}\n{}\n", + include_str!("../tests/fixtures/kiro-v2-messages.jsonl"), + json!({"version":"v1","kind":"Prompt","data":{"message_id":"m4","content":[{"kind":"image","data":{"format":"png"}}]}}), + json!({"version":"v2","kind":"Prompt","data":{"message_id":"m5","content":[{"kind":"text","data":"future"}]}}), + ), ) .unwrap(); let export = export_jsonl(ManagedHarness::Kiro, &stream, KIRO_SESSION_A, None).unwrap(); assert_eq!(export.events.len(), 4); - assert_eq!(export.events[0].content, "hello kiro"); + assert_eq!(export.events[0].content, "sanitized v2 prompt"); assert_eq!(export.events[0].role.as_deref(), Some("user")); assert_eq!( export.events[0].occurred_at.as_deref(), Some("2023-11-14T22:13:20Z") ); - assert_eq!(export.events[1].content, "visible answer"); + assert_eq!(export.events[1].content, "sanitized v2 reply"); assert_eq!(export.events[2].kind, WorkstreamEventKind::ToolCall); - assert_eq!(export.events[3].content, "result ok"); + assert_eq!(export.events[3].content, "sanitized v2 result"); assert!( export .losses @@ -3850,6 +4298,42 @@ mod tests { ); } + #[test] + fn kiro_v3_export_maps_only_visible_records_and_persists_flavor() { + let temp = tempfile::tempdir().unwrap(); + let stream = temp.path().join("messages.jsonl"); + fs::write( + &stream, + include_str!("../tests/fixtures/kiro-v3-messages.jsonl"), + ) + .unwrap(); + + let export = export_jsonl(ManagedHarness::KiroV3, &stream, KIRO_V3_SESSION, None).unwrap(); + assert_eq!(export.events.len(), 4); + assert_eq!(export.events[0].content, "sanitized v3 prompt"); + assert_eq!(export.events[0].role.as_deref(), Some("user")); + assert_eq!(export.events[1].content, "sanitized v3 reply"); + assert_eq!(export.events[2].kind, WorkstreamEventKind::ToolCall); + assert_eq!(export.events[3].content, "sanitized v3 result"); + assert!( + export + .losses + .iter() + .any(|loss| loss.contains("private session")) + ); + assert!( + export + .losses + .iter() + .any(|loss| loss.contains("non-visible assistant")) + ); + let cursor: Value = serde_json::from_str(export.source_cursor.as_deref().unwrap()).unwrap(); + assert_eq!( + cursor.get("flavor").and_then(Value::as_str), + Some("kiro-v3") + ); + } + #[test] fn kiro_cursor_restarts_after_an_in_place_rewrite() { let temp = tempfile::tempdir().unwrap(); diff --git a/crates/ai-memory-workstream/tests/fixtures/kiro-v2-messages.jsonl b/crates/ai-memory-workstream/tests/fixtures/kiro-v2-messages.jsonl new file mode 100644 index 00000000..d040b85f --- /dev/null +++ b/crates/ai-memory-workstream/tests/fixtures/kiro-v2-messages.jsonl @@ -0,0 +1,3 @@ +{"version":"v1","kind":"Prompt","data":{"message_id":"fixture-user","content":[{"kind":"text","data":"sanitized v2 prompt"}],"meta":{"timestamp":1700000000000}}} +{"version":"v1","kind":"AssistantMessage","data":{"message_id":"fixture-assistant","content":[{"kind":"text","data":"sanitized v2 reply"},{"kind":"toolUse","data":{"name":"fs_read","tool_use_id":"fixture-call","input":{"path":"README.md"}}}]}} +{"version":"v1","kind":"ToolResults","data":{"message_id":"fixture-result","content":[{"kind":"toolResult","data":{"tool_use_id":"fixture-call","content":[{"kind":"text","data":"sanitized v2 result"}],"is_error":false}}]}} diff --git a/crates/ai-memory-workstream/tests/fixtures/kiro-v3-messages.jsonl b/crates/ai-memory-workstream/tests/fixtures/kiro-v3-messages.jsonl new file mode 100644 index 00000000..a27ba327 --- /dev/null +++ b/crates/ai-memory-workstream/tests/fixtures/kiro-v3-messages.jsonl @@ -0,0 +1,7 @@ +{"id":"fixture-user","timestamp":"2026-08-06T10:00:00Z","payload":{"type":"user","content":"sanitized v3 prompt"}} +{"id":"fixture-private-hook","timestamp":"2026-08-06T10:00:01Z","payload":{"type":"ContextualHookInvoked","hookName":"fixture"}} +{"id":"fixture-hidden-operation","timestamp":"2026-08-06T10:00:02Z","payload":{"type":"assistant","operationType":"Think","content":"excluded private operation"}} +{"id":"fixture-assistant","timestamp":"2026-08-06T10:00:03Z","payload":{"type":"assistant","operationType":"Say","content":"sanitized v3 reply"}} +{"id":"fixture-tool","timestamp":"2026-08-06T10:00:04Z","payload":{"type":"tool_call","toolCallId":"fixture-call","toolName":"read_file","args":{"path":"README.md"},"status":"completed","kind":"read"}} +{"id":"fixture-result","timestamp":"2026-08-06T10:00:05Z","payload":{"type":"tool_result","toolCallId":"fixture-call","content":"sanitized v3 result","success":true}} +{"id":"fixture-usage","timestamp":"2026-08-06T10:00:06Z","payload":{"type":"usage_summary","inputTokens":10,"outputTokens":5}} diff --git a/docs/install.md b/docs/install.md index 50cf952e..bdbef71f 100644 --- a/docs/install.md +++ b/docs/install.md @@ -877,13 +877,12 @@ ai-memory finalize-session --agent kiro-cli --session-id `ai-memory uninstall --only hooks --apply --yes` removes only exact ai-memory entries from global v2 agents, the current project's `.kiro/agents` directory, and ai-memory's global/current-project v3 registration. A purely generated v3 -file is deleted; third-party entries in a shared file remain. Explicit -`ai-memory run kiro` (alias `kiro-cli`) still manages the default v2 engine and -honors `$KIRO_HOME`; Kiro stays -outside no-argument automatic selection until its current event stream is -validated in a logged-in real-harness acceptance run. `--v3`, `--mode`, and -non-v2 `--agent-engine` invocations pass through without managed session -injection; issue #356 tracks v3 managed-workstream support. See +file is deleted; third-party entries in a shared file remain. `ai-memory run +kiro` (alias `kiro-cli`) manages the default v2 engine and honors `$KIRO_HOME`; +add `--v3`, `--mode`, or `--agent-engine v3` for version-safe v3 resume. Once +linked, a later plain Kiro launch recovers the stored engine transparently, and +bare `ai-memory run` considers checkout-local sessions from both incompatible +stores. See [managed workstreams](managed-workstreams.md#native-adapter-behavior). ### OpenCode @@ -1460,7 +1459,7 @@ docker run --rm akitaonrails/ai-memory:latest --help # full subcommand tree | Subcommand | Pattern | What it does | |---|---|---| | `serve` | `docker compose up -d` (already done) | Run the HTTP MCP server | -| `run [harness] [args...]` | host wrapper or native binary | Opt into one managed cross-harness workstream; omit the harness to resume the newest usable local session, or name Claude Code, Codex, OpenCode, Pi, Crush, Kimi Code, Kiro CLI v2, OMP, Grok Build CLI, or Antigravity CLI explicitly; exact `--yolo` and `--fresh` flags are wrapper-owned and other native arguments pass through | +| `run [harness] [args...]` | host wrapper or native binary | Opt into one managed cross-harness workstream; omit the harness to resume the newest usable local session, or name Claude Code, Codex, OpenCode, Pi, Crush, Kimi Code, Kiro CLI v2/v3, OMP, Grok Build CLI, or Antigravity CLI explicitly; exact `--yolo` and `--fresh` flags are wrapper-owned and other native arguments pass through | | `show [--json]` | host wrapper or native binary | Choose a client-local checkout and installed managed harness, or return structured discovery data without launching; remote servers never provide checkout paths | | `continue [--workspace NAME]` | host wrapper or native binary | From any directory, revalidate and resume the newest client-local managed checkout; accepts `--yolo` and `--fresh` but no harness-native arguments | | `workstream-search [query]` | managed child or thin HTTP client | Search the complete visible managed-workstream ledger; the managed child receives its workstream id automatically | diff --git a/docs/managed-harness-contributions.md b/docs/managed-harness-contributions.md index 8646baaa..467b34a7 100644 --- a/docs/managed-harness-contributions.md +++ b/docs/managed-harness-contributions.md @@ -2,8 +2,8 @@ Managed-workstream support is narrower than MCP or lifecycle-hook support. This release can manage Claude Code, Codex, OpenCode, Pi, Crush, Kimi Code, Kiro CLI -v2, OMP, Grok Build CLI, and Antigravity CLI. Gemini CLI, Devin CLI, Cursor, and -the other integrations in the README support matrix do not become managed +v2/v3, OMP, Grok Build CLI, and Antigravity CLI. Gemini CLI, Devin CLI, Cursor, +and the other integrations in the README support matrix do not become managed merely because ai-memory can capture their hooks. A managed adapter must preserve a harness's real native session, deliver the diff --git a/docs/managed-workstreams.md b/docs/managed-workstreams.md index 3a2ef714..f23d8a6a 100644 --- a/docs/managed-workstreams.md +++ b/docs/managed-workstreams.md @@ -1,8 +1,8 @@ # Managed cross-harness workstreams `ai-memory run` is an opt-in launcher that lets one logical coding session move -between Claude Code, Codex, OpenCode, Pi, Crush, Kimi Code, Kiro CLI v2, OMP, -Grok Build CLI, and Antigravity CLI. Direct agent launches +between Claude Code, Codex, OpenCode, Pi, Crush, Kimi Code, Kiro CLI v2/v3, +OMP, Grok Build CLI, and Antigravity CLI. Direct agent launches keep their existing ai-memory behavior. There is no global mode toggle and no `switch` command: using `run` selects the current workstream and transparently creates or resumes the correct native session for the requested harness. @@ -17,8 +17,9 @@ ai-memory run codex --yolo ai-memory run claude --model opus # Kimi Code installs `kimi`; `kimi-cli` is accepted as a launcher alias ai-memory run kimi-cli -# Kiro's explicit managed adapter covers its default v2 engine +# Kiro defaults to v2; select its incompatible v3 engine explicitly once ai-memory run kiro +ai-memory run kiro --v3 # or omit the harness and continue the newest usable session automatically ai-memory run ``` @@ -120,15 +121,15 @@ 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 both Kiro CLI engines. +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 -another harness merely because that file has a later timestamp. Kiro, OMP, -Grok, and Antigravity remain available explicitly but are not in the automatic -pool. Kiro joins only after a logged-in current-format acceptance run validates -its checkout-local discovery and import behavior. +another harness merely because that file has a later timestamp. Kiro's v2 and +v3 candidates share one server agent identity, but the selected native engine +flavor remains exact. OMP, Grok, and Antigravity remain available explicitly +but are not in the automatic pool. Bare mode accepts wrapper options but not harness-native arguments or `--executable`, because their meaning depends on the selected harness. In a new @@ -229,7 +230,8 @@ 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) | +| Kiro CLI v2 | native default creation | `--resume-id ` | `$KIRO_HOME/sessions/cli/.jsonl` (+ sibling `.json` metadata) | +| Kiro CLI v3 | native default creation with `--v3` | `--v3 --resume-id ` | `$KIRO_HOME/sessions///messages.jsonl` (+ sibling `session.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 | @@ -270,8 +272,10 @@ the harness's native dangerous mode. The translation is Claude Code `--dangerously-bypass-approvals-and-sandbox`, OpenCode `--auto`, Pi `--approve`, Crush `--yolo`, Kimi Code `--yolo`, Kiro CLI v2 `--trust-all-tools`, Grok Build CLI `--yolo` (equivalent to its `--always-approve` option), and Antigravity CLI -`--dangerously-skip-permissions`. OMP currently needs no added flag. ai-memory -does not add a duplicate when the translated native flag is already present. +`--dangerously-skip-permissions`. Kiro v3 replaced the trust-all flag with +`permissions.yaml`, so ai-memory prints a notice and adds no unverified flag. +OMP currently needs no added flag. ai-memory does not add a duplicate when the +translated native flag is already present. Managed support is intentionally narrower than the general integration matrix. Gemini CLI, Devin CLI, Cursor, and other agents may @@ -322,24 +326,36 @@ contract was verified against Kimi Code v0.29.0. The managed launcher accepts `kimi`, `kimi-code`, and `kimi-cli`; all three resolve the installed `kimi` executable. -Kiro's explicit adapter covers only the default v2 engine. The audited 2.16.0 -binary and v2 fixtures expose UUID session IDs, checkout scoping, `--resume-id`, -`$KIRO_HOME`, and the flat `$KIRO_HOME/sessions/cli/.json` plus -`.jsonl` store with v1 `Prompt`, `AssistantMessage`, and `ToolResults` -events. Current official Kiro session documentation instead describes -per-directory database persistence without publishing a filename, schema, or -read-only transcript contract; its v3 guide confirms that v3 sessions are not -backward-compatible or resumable in v2. Authentication prevented producing a -new isolated live transcript during this audit. The parser therefore accepts -only the known v1 flat-store envelope, records unsupported versions as -extraction loss, imports visible text and completed tool records only, and stays -outside automatic selection until a logged-in current-format acceptance run is -recorded. Exact lookups require a UUID, matching sibling `session_id`, and an -exact canonical `cwd`; a linked ID cannot select another checkout's flat-store -transcript. `--v3`, `--mode`, and a non-v2 `--agent-engine` select incompatible -engines and pass through unchanged. The v2 `--yolo` translation is -`--trust-all-tools`; an explicit narrower `--trust-tools` choice is never -widened. See Kiro's current +Kiro's version-aware adapter was live-tested with authenticated Kiro CLI +2.16.2 in both engines. V2 uses UUID session IDs and the flat +`$KIRO_HOME/sessions/cli/.json` plus `.jsonl` store with v1 +`Prompt`, `AssistantMessage`, and `ToolResults` events. V3 uses incompatible +`sess_` IDs and nested +`$KIRO_HOME/sessions///session.json` plus +`messages.jsonl`; accepted metadata is limited to `schemaVersion = 1.0.0`, +`dataModelVersion = 1`, an exact directory/id match, and a `workspacePaths` +entry resolving to the current checkout. The v3 visible-event allowlist is +user text, assistant `Say` output, tool calls, and tool results. Session +bookkeeping, hook records, usage summaries, turn boundaries, private assistant +operations, malformed records, and unknown schema versions are not imported. + +The engines can never cross-resume: exact store metadata is checked before a +linked `--resume-id` is injected, and the incompatible engine flavor is also +stored in the opaque incremental cursor. Explicit `--v3`, v3-only `--mode`, +or `--agent-engine v3` selects v3; explicit `--agent-engine v2` selects v2; an +unknown engine value remains passthrough instead of being guessed. Once a v3 +session is linked, a later plain `ai-memory run kiro` recovers that engine +transparently. Kiro CLI 2.16.2 wrote v3 sessions below the default +`~/.kiro/sessions` even when `KIRO_HOME` redirected other state, so ai-memory +checks the configured v3 root first and that default root as a compatibility +fallback. If a linked session exists only in the fallback, ai-memory removes +`KIRO_HOME` for that one resume so Kiro can find the session; Kiro consequently +uses its default-home v3 settings/hooks for that process. Fresh launches and +versions that store the session below the configured root keep `KIRO_HOME` +unchanged. Every candidate still needs exact id, schema, and checkout metadata. +The v2 `--yolo` translation is `--trust-all-tools`; an explicit narrower +`--trust-tools` choice is never widened. V3 documents no equivalent CLI flag. +See Kiro's current [session management](https://kiro.dev/docs/cli/chat/session-management/) and [v3 compatibility](https://kiro.dev/docs/cli/v3/) references. @@ -452,9 +468,9 @@ belong in wiki pages through consolidation or explicit durable writes. project name. Wiki paths are UUID-keyed, so it moves no server directory, source checkout, or native harness session. If the source checkout path itself is renamed, absolute-path session locators used by Claude Code, Codex, OpenCode, -Pi, Kimi Code (`state.json`'s `workDir`), Kiro (`.json`'s `cwd`), OMP, and -Antigravity may still reference the old path; Crush's project-local `.crush` -database moves with the checkout. +Pi, Kimi Code (`state.json`'s `workDir`), Kiro v2 (`.json`'s `cwd`), Kiro +v3 (`session.json`'s `workspacePaths`), OMP, and Antigravity may still reference +the old path; Crush's project-local `.crush` database moves with the checkout. There is no portable, supported API that rewrites every harness's private project locator. ai-memory therefore does not mutate those stores or silently @@ -489,7 +505,8 @@ deterministic phase also covers first-run adoption, bare-mode selection and empty-directory failure, wrapper `--yolo`, lease exclusion, Crush context cleanup, a fake-mode Kimi store/resume/import round trip, an Antigravity hook/link/resume round trip, a fake-mode Kiro v2 store/resume/import round trip, -Kiro v3 passthrough, private-trajectory exclusion, and the +the equivalent Kiro v3 nested-store round trip with transparent engine recovery, +private-trajectory exclusion, and the established-workstream guard against obsolete sessions. The fake Kimi round trip also deletes the linked native session and verifies automatic fresh-session recovery and repointing. @@ -498,15 +515,16 @@ returning resume paths are all exercised. Docker wrapper host execution and remote URL preservation are covered separately by the `ai-memory-cli` packaging tests. -Kiro is intentionally skipped in the scripted real-harness phase. Its -`--no-interactive` mode writes a different v1 SQLite store, while the managed v2 -adapter reads the interactive flat JSON/JSONL store. A logged-in Kiro acceptance -therefore remains interactive: run `ai-memory run --new kiro-accept kiro`, enter -a unique prompt, quit normally, then run `ai-memory run --workstream -kiro-accept kiro-cli` and verify that Kiro resumes the same native conversation. -Repeat with `--v3` and confirm ai-memory prints no managed resume selector or -import result for that invocation. Record the Kiro version and sanitize the -paired metadata/event files before converting any new shape into a fixture. +Kiro is intentionally skipped in the scripted real-model loop. Its +`--no-interactive` mode writes a different v1 SQLite store, while both managed +adapters read the interactive v2/v3 journals. Logged-in Kiro acceptance +therefore remains interactive. For v2, run `ai-memory run --new kiro-v2-accept +kiro`, enter a unique prompt, quit normally, then run `ai-memory run +--workstream kiro-v2-accept kiro-cli` and verify the same UUID resumes. For v3, +repeat with a fresh workstream and `kiro --v3`; the second plain `kiro` launch +must transparently add `--v3 --resume-id `. Search both workstream +ledgers for the unique visible assistant replies. Record the Kiro version and +sanitize metadata/event files before changing either fixture schema. The real-harness phase treats the model as the system under transport, not as the test oracle. For each leg it records the prior ledger sequence, then diff --git a/docs/mcp-install.md b/docs/mcp-install.md index 38f27cbd..c493f264 100644 --- a/docs/mcp-install.md +++ b/docs/mcp-install.md @@ -917,10 +917,11 @@ agents, so `--config-file` is required when the active definition lives under through `agentSpawn` stdout, and honors `$KIRO_HOME`. The v3 installer writes the standalone `v1` file with PascalCase triggers, preserves third-party entries, and shares the same fail-open sanitizer and capture-exclusion -boundary. Follow [#356](https://github.com/akitaonrails/ai-memory/issues/356) -for automatic selection and version-aware managed support. Explicit -`ai-memory run kiro` (alias `kiro-cli`) manages the default v2 engine; -non-v2 engine selections pass through without session injection or import. +boundary. `ai-memory run kiro` (alias `kiro-cli`) manages the default v2 +engine; add `--v3`, `--mode`, or `--agent-engine v3` for the incompatible v3 +store. Once linked, later plain Kiro launches recover that engine +transparently, and bare `ai-memory run` considers checkout-local sessions from +both engines without cross-resuming them. Sources: , , @@ -1090,8 +1091,8 @@ that *starts* the next one - to play nicely with ai-memory: | Side | What's needed | Covered by | |---|---|---| -| **Ending side** | The agent must create a handoff through a true session-end hook, the manual finalizer, or `memory_handoff_begin`. | Built-in automatically for Claude Code, Devin CLI, Cursor, Gemini CLI, Grok Build CLI, Zero, Kimi Code, OpenClaw, OpenCode, and OMP. Codex, Antigravity CLI, Kiro CLI v2, and Command Code have no reliable true session-end event; run `ai-memory finalize-session` with the corresponding `--agent` after the final turn. | -| **Starting side** | Either (a) the session-start/plugin path injects the handoff via `/handoff`, OR (b) the model proactively calls `memory_handoff_accept` on first turn. | (a) is built-in for Claude Code / Codex / Devin CLI / Cursor / Gemini CLI / Antigravity CLI / Kimi Code / Kiro CLI v2 / Command Code / OpenClaw / OpenCode / OMP. It requires a client that consumes startup-hook stdout or an equivalent context-injection result. Grok and Zero are explicitly excluded because they discard SessionStart stdout; use (b). (b) works for any MCP-capable client if you nudge the model - see [the managed routing package](usage.md#install-the-routing-snippet-and-agent-skills). | +| **Ending side** | The agent must create a handoff through a true session-end hook, the manual finalizer, or `memory_handoff_begin`. | Built-in automatically for Claude Code, Devin CLI, Cursor, Gemini CLI, Grok Build CLI, Zero, Kimi Code, OpenClaw, OpenCode, and OMP. Codex, Antigravity CLI, both Kiro CLI engines, and Command Code have no reliable true session-end event; run `ai-memory finalize-session` with the corresponding `--agent` after the final turn. | +| **Starting side** | Either (a) the session-start/plugin path injects the handoff via `/handoff`, OR (b) the model proactively calls `memory_handoff_accept` on first turn. | (a) is built-in for Claude Code / Codex / Devin CLI / Cursor / Gemini CLI / Antigravity CLI / Kimi Code / both Kiro CLI engines / Command Code / OpenClaw / OpenCode / OMP. It requires a client that consumes startup-hook stdout or an equivalent context-injection result. Grok and Zero are explicitly excluded because they discard SessionStart stdout; use (b). (b) works for any MCP-capable client if you nudge the model - see [the managed routing package](usage.md#install-the-routing-snippet-and-agent-skills). | OpenCode uses its official `session.deleted` plugin event for true session-end delivery. Its generated plugin also sends a deduped best-effort close for any diff --git a/scripts/managed-workstream-acceptance.sh b/scripts/managed-workstream-acceptance.sh index f51a7cb8..608dab01 100755 --- a/scripts/managed-workstream-acceptance.sh +++ b/scripts/managed-workstream-acceptance.sh @@ -255,6 +255,40 @@ case "${AI_MEMORY_ACCEPTANCE_FAKE_MODE:-argv}" in 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" ;; + kiro-v3) + printf '%s\n' "$@" >"$AI_MEMORY_ACCEPTANCE_ARGV_LOG" + 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 + if command -v uuidgen >/dev/null 2>&1; then + session_id="sess_$(uuidgen | tr '[:upper:]' '[:lower:]')" + elif [ -r /proc/sys/kernel/random/uuid ]; then + session_id="sess_$(cat /proc/sys/kernel/random/uuid)" + else + printf 'kiro v3 fake mode needs uuidgen or /proc/sys/kernel/random/uuid\n' >&2 + exit 1 + fi + fi + session_dir="${KIRO_HOME:?kiro v3 fake mode requires KIRO_HOME}/sessions/checkout_fixture/$session_id" + mkdir -p "$session_dir" + stream="$session_dir/messages.jsonl" + if [ ! -f "$session_dir/session.json" ]; then + printf '{"schemaVersion":"1.0.0","dataModelVersion":1,"id":"%s","workspacePaths":["%s"],"createdAt":"2026-08-06T10:00:00Z","lastModifiedAt":"2026-08-06T10:00:00Z","agentMode":"vibe","status":"idle"}\n' \ + "$session_id" "$PWD" >"$session_dir/session.json" + : >"$stream" + fi + sentinel=${AI_MEMORY_ACCEPTANCE_SENTINEL:-AMWS-FAKE-KIRO-V3} + printf '{"id":"u-%s","timestamp":"2026-08-06T10:00:00Z","payload":{"type":"user","content":"%s"}}\n' \ + "$(date +%s)" "$sentinel" >>"$stream" + printf '{"id":"a-%s","timestamp":"2026-08-06T10:00:01Z","payload":{"type":"assistant","operationType":"Say","content":"%s reply"}}\n' \ + "$(date +%s)" "$sentinel" >>"$stream" + ;; kimi) printf '%s\n' "$@" >"$AI_MEMORY_ACCEPTANCE_ARGV_LOG" # Honor `--session ` (resume); a fresh launch mints its own id @@ -658,10 +692,9 @@ kimi_current_id=$(sqlite3 "$DATA/db/memory.sqlite" \ exit 1 } -# Kiro fake-mode fixture: the fake owns the fresh UUID, writes the documented -# flat metadata/event pair, and honors `--resume-id` on the second launch. -# These deterministic checks cover wrapper mechanics only; the current Kiro -# event schema still requires a separate logged-in interactive acceptance pass. +# Kiro fake-mode fixtures cover both incompatible native stores. The fake owns +# each fresh id, writes the sanitized schema observed in live acceptance, and +# honors the exact engine-specific resume selected by the wrapper. KIRO_FAKE_HOME="$CONFIG/kiro-fake" mkdir -p "$KIRO_FAKE_HOME" ( @@ -719,12 +752,53 @@ jq -e \ ( cd "$REPO" KIRO_HOME="$KIRO_FAKE_HOME" \ - AI_MEMORY_ACCEPTANCE_FAKE_MODE=argv \ - AI_MEMORY_ACCEPTANCE_ARGV_LOG="$TMP/kiro-v3-argv.log" \ - "$BIN" --data-dir "$DATA" run --workstream edge-kiro --executable "$FAKE" \ - kiro --v3 >"$LOGS/edge-kiro-v3.log" 2>&1 + AI_MEMORY_ACCEPTANCE_FAKE_MODE=kiro-v3 \ + AI_MEMORY_ACCEPTANCE_ARGV_LOG="$TMP/kiro-v3-first-argv.log" \ + AI_MEMORY_ACCEPTANCE_SENTINEL="AMWS-FAKE-KIRO-V3-ONE" \ + "$BIN" --data-dir "$DATA" run --new edge-kiro-v3 --executable "$FAKE" \ + --yolo kiro --v3 >"$LOGS/edge-kiro-v3-first.log" 2>&1 ) -diff -u <(printf '%s\n' --v3) "$TMP/kiro-v3-argv.log" +diff -u <(printf '%s\n' --v3) "$TMP/kiro-v3-first-argv.log" +kiro_v3_session_dir=$(find "$KIRO_FAKE_HOME/sessions" -mindepth 2 -maxdepth 2 \ + -type d -name 'sess_*' -print -quit) +[ -n "$kiro_v3_session_dir" ] || { + printf 'fake kiro v3 did not create a native session store\n' >&2 + exit 1 +} +kiro_v3_session_id=$(basename "$kiro_v3_session_dir") +kiro_v3_ws_hex=$(sqlite3 "$DATA/db/memory.sqlite" \ + "SELECT lower(hex(id)) FROM workstreams WHERE name = 'edge-kiro-v3' ORDER BY selected_at DESC LIMIT 1;") +kiro_v3_ws_id="${kiro_v3_ws_hex:0:8}-${kiro_v3_ws_hex:8:4}-${kiro_v3_ws_hex:12:4}-${kiro_v3_ws_hex:16:4}-${kiro_v3_ws_hex:20:12}" +kiro_v3_first_hits=$("$BIN" --data-dir "$DATA" workstream-search \ + --workstream-id "$kiro_v3_ws_id" --limit 100 --json "AMWS-FAKE-KIRO-V3-ONE") +jq -e --arg id "$kiro_v3_session_id" \ + '[.[] | select(.agent == "kiro-cli" and .role == "assistant" and (.content | contains("AMWS-FAKE-KIRO-V3-ONE")) and .native_session_id == $id)] | length == 1' \ + <<<"$kiro_v3_first_hits" >/dev/null || { + printf 'kiro v3 sentinel was not imported from the discovered session\n' >&2 + tail -80 "$LOGS/edge-kiro-v3-first.log" >&2 + exit 1 +} +( + cd "$REPO" + KIRO_HOME="$KIRO_FAKE_HOME" \ + AI_MEMORY_ACCEPTANCE_FAKE_MODE=kiro-v3 \ + AI_MEMORY_ACCEPTANCE_ARGV_LOG="$TMP/kiro-v3-second-argv.log" \ + AI_MEMORY_ACCEPTANCE_SENTINEL="AMWS-FAKE-KIRO-V3-TWO" \ + "$BIN" --data-dir "$DATA" run --workstream edge-kiro-v3 --executable "$FAKE" \ + kiro >"$LOGS/edge-kiro-v3-second.log" 2>&1 +) +diff -u <(printf '%s\n' --v3 --resume-id "$kiro_v3_session_id") \ + "$TMP/kiro-v3-second-argv.log" +kiro_v3_second_hits=$("$BIN" --data-dir "$DATA" workstream-search \ + --workstream-id "$kiro_v3_ws_id" --limit 100 --json "AMWS-FAKE-KIRO-V3") +jq -e \ + '([.[] | select(.role == "assistant" and (.content | contains("AMWS-FAKE-KIRO-V3-ONE")))] | length == 1) + and ([.[] | select(.role == "assistant" and (.content | contains("AMWS-FAKE-KIRO-V3-TWO")))] | length == 1)' \ + <<<"$kiro_v3_second_hits" >/dev/null || { + printf 'kiro v3 incremental import duplicated or missed a round sentinel\n' >&2 + tail -80 "$LOGS/edge-kiro-v3-second.log" >&2 + exit 1 +} # Antigravity fake-mode fixture: `agy` owns the conversation id and SQLite # database, while its real PreInvocation hook links that id and accepts any