You are viewing a plain text version of this content. The canonical link for it is here.
Posted to notifications@skywalking.apache.org by wu...@apache.org on 2026/10/11 01:55:57 UTC
(skywalking-ai-sessionizer) 01/01: Collect the sessions Kiro's V3 engine writes, for kiro-cli and Kiro Crew
This is an automated email from the ASF dual-hosted git repository.
wu-sheng pushed a commit to branch kiro-v3-adapter
in repository https://gitbox.apache.org/repos/asf/skywalking-ai-sessionizer.git
commit 776a846ed61bdfd95901c527d922149063bba6eb
Author: Wu Sheng <wu...@foxmail.com>
AuthorDate: Sun Oct 11 09:55:40 2026 +0800
Collect the sessions Kiro's V3 engine writes, for kiro-cli and Kiro Crew
A new adapter, kiro-local, reads the sessions Kiro's V3 agent engine
writes under ~/.kiro: kiro-cli on its V3 engine, and Kiro Crew on its
kas backend. It is on by default and needs nothing from Kiro. A session
is read when its header names schema 1.0.0. kiro-cli's V2 sessions and
V3 sessions of another schema are counted and not collected.
Measured with kiro-cli 2.28.0 and its engine @kiro/agent 0.66.26, and
Kiro Crew 0.7.2, over 86 sessions and 3,101 records, and with Kiro Crew
0.8.0, which bundles kiro-cli 2.27.1 and engine 0.66.22, over 13
sessions and 2,069 records.
Kiro ties no record to a provider call, so model output whose record
names no call is now a step of its run, with no llm.call around it. A
sidecar of the main stream names the conversation, because Kiro keeps a
session's title in its header. control.notice joins model.Vocabulary,
so every glossary is checked for it.
A file that fails to land now leaves nothing behind in any collector.
Its records entered the index during the write, and it kept its number,
so a failed rename left index entries for records that never landed and
a gap that every later round reported as a missing file. Records enter
the index once their file has landed, and a file that did not land gives
its number back.
Kiro Crew deletes a task run's step sessions within seconds on kas, so a
collector misses most of them; kirodotdev/KiroCrew#18988 proposes a
setting that keeps them for a while.
---
CLAUDE.md | 2 +
README.md | 1 +
asz.yaml | 26 +
cmd/asz/dialects.go | 2 +
cmd/asz/kiro.go | 101 +++
cmd/asz/main.go | 7 +-
cmd/asz/push.go | 4 +-
cmd/asz/refresh.go | 56 +-
docs/en/adapters/kiro-glossary.md | 105 +++
docs/en/adapters/kiro.md | 186 +++++
docs/en/adapters/langsmith-glossary.md | 3 +-
docs/en/changes/changes.md | 50 ++
.../concepts-and-designs/conversation-assembly.md | 10 +-
.../unified-conversation-model.md | 4 +-
docs/en/formats/otlp.md | 2 +-
docs/en/formats/session-data.md | 6 +-
docs/en/guides/scenario.md | 6 +-
docs/en/setup/configuration.md | 15 +
docs/en/setup/kiro.md | 87 +++
docs/menu.yml | 6 +
internal/adapters/claudecode/collect.go | 12 +-
internal/adapters/claudecode/regress_test.go | 83 ++
internal/adapters/claudecodechanges/collect.go | 6 +-
.../adapters/claudecodechanges/collect_test.go | 73 ++
internal/adapters/claudecodeprovider/collect.go | 6 +-
internal/adapters/kiro/collect.go | 443 +++++++++++
internal/adapters/kiro/convert.go | 457 +++++++++++
internal/adapters/kiro/discover.go | 238 ++++++
internal/adapters/kiro/glossary.go | 130 ++++
internal/adapters/kiro/kiro.go | 86 +++
internal/adapters/kiro/kiro_test.go | 847 +++++++++++++++++++++
internal/adapters/kiro/matcher.go | 127 +++
.../messages.jsonl | 57 ++
.../session.json | 66 ++
.../5d13f3d7-73ae-431d-847f-04729b40503e.jsonl | 11 +
.../messages.jsonl | 17 +
.../session.json | 46 ++
.../128c0b21-7f90-4dca-8b14-986e1eb23dda.jsonl | 8 +
.../a8e4758b-530e-4fe7-9973-5fc227e90881.jsonl | 7 +
.../messages.jsonl | 16 +
.../session.json | 45 ++
.../messages.jsonl | 74 ++
.../session.json | 17 +
.../messages.jsonl | 11 +
.../session.json | 66 ++
.../messages.jsonl | 10 +
.../session.json | 17 +
.../cli/6f0e8c2a-5b1d-4c3e-9a7f-2d4b6e8c0a1f.jsonl | 0
internal/adapters/kiro/turns.go | 140 ++++
internal/adapters/langsmith/glossary.go | 1 +
internal/assemble/emit.go | 41 +
internal/assemble/streams.go | 8 +-
internal/config/config.go | 18 +
internal/config/config_test.go | 4 +-
internal/index/lookup.go | 27 +
internal/scenario/expect/expect.go | 12 +-
internal/scenario/plan.go | 17 +-
internal/scenario/plan_test.go | 19 +
internal/scenario/scenario.go | 68 +-
internal/storage/state.go | 10 +
pkg/model/term.go | 2 +-
tests/scenarios/all-kinds.expect.yaml | 1 +
...expect.yaml => output-without-call.expect.yaml} | 18 +-
tests/scenarios/output-without-call.yaml | 35 +
64 files changed, 4026 insertions(+), 50 deletions(-)
diff --git a/CLAUDE.md b/CLAUDE.md
index eb5ec27..156d957 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -21,6 +21,7 @@ Apache-2.0. A subproject of Apache SkyWalking: `github.com/apache/skywalking-ai-
| `docs/en/formats/` | The three shapes: Session Data (`.sd`), Session Flow (`.sf`), and `asz.view` (one conversation as one document, never a file). asz defines and owns all three; a server that mirrors them, such as the OAP, follows these pages. |
| `docs/en/adapters/claude-code-glossary.md` | What Claude Code calls each of them. Generated by `asz glossary`. |
| `docs/en/adapters/claude-code.md` | How Claude Code maps onto the model, and what it cannot supply. |
+| `docs/en/adapters/kiro.md` | The same for Kiro's V3 engine, kiro-cli and Kiro Crew. |
Every claim in the docs is backed by a measurement against a real corpus. When changing them,
keep that standard: state what was measured, on what sample, and say `unavailable` rather than
@@ -69,6 +70,7 @@ internal/metrics/ derives the runtime's token metric from landed S
internal/repack/ re-cuts landed files into a new root under another budget
internal/adapters/claudecode/ the claude-code-local adapter
internal/adapters/claudecodeprovider/ the claude-code-provider adapter: the request and response bodies Claude Code writes, landed into their sessions
+internal/adapters/kiro/ the kiro-local adapter: the sessions Kiro's V3 engine writes, for kiro-cli and Kiro Crew
internal/adapters/mock/ the mock dialect: Session Data a scenario writes directly
internal/scenario/ scenarios: the model, the clock, the two writers, and each session's marker (collector side)
internal/scenario/expect/ expectation files evaluated over a root (server side)
diff --git a/README.md b/README.md
index 05c1b7a..771dc52 100644
--- a/README.md
+++ b/README.md
@@ -58,6 +58,7 @@ adapter reports it as `unavailable` rather than approximating it.
| --- | --- | --- |
| [Claude Code](docs/en/adapters/claude-code.md) | collection implemented | local files — no configuration required, and it works on history that already exists. The [plugin](docs/en/setup/claude-code-plugin.md) adds which files each shell command changed. |
| Codex | planned | — |
+| [Kiro](docs/en/adapters/kiro.md) | collection implemented | local files of Kiro's V3 agent engine, for kiro-cli and Kiro Crew; no configuration of Kiro beyond running that engine. |
| [LangChain / LangGraph](docs/en/adapters/langsmith.md) | collection implemented | a receiver, `langsmith-ingest`, the tracing client every LangChain application carries is pointed at with four environment variables; nothing changes in the application. It lands the conversation and what each model call was sent. The [plugin](docs/en/setup/langchain-plugin.md) adds which files each tool call changed. |
## Quick start
diff --git a/asz.yaml b/asz.yaml
index 95d5805..08e40c4 100644
--- a/asz.yaml
+++ b/asz.yaml
@@ -197,6 +197,32 @@ adapters:
interval: 10m
max_delta_bytes: 2097152
+ # The sessions Kiro's V3 agent engine writes: kiro-cli started with --v3
+ # or with chat.agentEngine set to v3, and Kiro Crew on its kas backend.
+ # kiro-cli's V2 and V1 sessions are counted and reported, not collected.
+ # On by default because it costs nothing where Kiro is not installed.
+ - name: kiro-local
+ enabled: true
+
+ # The directory that holds Kiro's sessions directory. Leave empty for
+ # ~/.kiro, where the engine writes; it does not read KIRO_HOME. Set it to
+ # collect from a copy.
+ source_root: ""
+
+ # Session filters, judged by the workspace paths in the session's own
+ # header, session.json. A path ending in /** matches that directory and
+ # everything beneath it; anything else is a glob over the whole path. A
+ # session is left out only when every one of its paths is. Kiro records
+ # the real path, so on macOS a session in /tmp is under /private/tmp.
+ include: []
+ exclude:
+ - /private/tmp/**
+
+ collector:
+ mode: watch
+ interval: 10m
+ max_delta_bytes: 2097152
+
parse:
# The largest round file the parser writes, in bytes. A round travels
# whole as one log record, so it is cut at the same budget as a landed
diff --git a/cmd/asz/dialects.go b/cmd/asz/dialects.go
index efe694e..8caded4 100644
--- a/cmd/asz/dialects.go
+++ b/cmd/asz/dialects.go
@@ -22,6 +22,7 @@ import (
"sort"
"github.com/apache/skywalking-ai-sessionizer/internal/adapters/claudecode"
+ "github.com/apache/skywalking-ai-sessionizer/internal/adapters/kiro"
"github.com/apache/skywalking-ai-sessionizer/internal/adapters/langsmith"
"github.com/apache/skywalking-ai-sessionizer/internal/storage"
"github.com/apache/skywalking-ai-sessionizer/pkg/model"
@@ -36,6 +37,7 @@ import (
func glossaries() map[string]*model.Glossary {
return map[string]*model.Glossary{
claudecode.Dialect: claudecode.Glossary(),
+ kiro.Dialect: kiro.Glossary(),
langsmith.Dialect: langsmith.Glossary(),
}
}
diff --git a/cmd/asz/kiro.go b/cmd/asz/kiro.go
new file mode 100644
index 0000000..a759ae9
--- /dev/null
+++ b/cmd/asz/kiro.go
@@ -0,0 +1,101 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package main
+
+import (
+ "fmt"
+ "os"
+ "sort"
+ "strings"
+ "text/tabwriter"
+
+ "github.com/apache/skywalking-ai-sessionizer/internal/adapters/kiro"
+ "github.com/apache/skywalking-ai-sessionizer/internal/config"
+)
+
+// cmdSourcesKiro lists the Kiro sessions the adapter can see, and what it
+// sees and does not collect.
+func cmdSourcesKiro(_ *config.Config, ad config.Adapter, _ bool) error {
+ root, err := kiro.ResolveSourceRoot(ad.SourceRoot)
+ if err != nil {
+ return err
+ }
+ all, skipped, warnings, err := kiro.Discover(root)
+ if err != nil {
+ return err
+ }
+ for _, w := range warnings {
+ fmt.Fprintf(os.Stderr, "warning: %v\n", w)
+ }
+ m := kiro.NewMatcher(ad.Include, ad.Exclude)
+ var sessions []kiro.Session
+ for _, s := range all {
+ if m.Match(s) {
+ sessions = append(sessions, s)
+ }
+ }
+ fmt.Printf("\nkiro root : %s\n", root)
+ if n := len(all) - len(sessions); n > 0 {
+ fmt.Printf("filtered : %d session(s) excluded by config\n", n)
+ }
+ for _, line := range notCollected(skipped) {
+ fmt.Printf("not read : %s\n", line)
+ }
+ if len(sessions) == 0 {
+ fmt.Println("no V3 sessions; Kiro is not installed, or has not run on its V3 engine yet")
+ return nil
+ }
+ fmt.Println()
+ tw := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
+ fmt.Fprintln(tw, "SESSION\tSTREAMS\tWORKSPACE")
+ for _, s := range sessions {
+ streams := 0
+ for _, src := range s.Sources {
+ if src.Kind.Append() {
+ streams++
+ }
+ }
+ fmt.Fprintf(tw, "%s\t%d\t%s\n", s.ID, streams, strings.Join(s.Workspaces, ", "))
+ }
+ if err := tw.Flush(); err != nil {
+ return err
+ }
+ fmt.Printf("\n%d V3 session(s)\n", len(sessions))
+ return nil
+}
+
+// notCollected says, one line each, what Kiro wrote that this adapter does
+// not read and why.
+func notCollected(s kiro.Skipped) []string {
+ var out []string
+ if s.V2 > 0 {
+ out = append(out, fmt.Sprintf("%d session(s) of kiro-cli's V2 engine under sessions/cli; only the V3 engine's sessions are collected: start kiro-cli with --v3, or set chat.agentEngine to v3", s.V2))
+ }
+ var schemas []string
+ for v := range s.Schemas {
+ schemas = append(schemas, v)
+ }
+ sort.Strings(schemas)
+ for _, v := range schemas {
+ out = append(out, fmt.Sprintf("%d V3 session(s) with schema %s; only schema %s is read", s.Schemas[v], v, kiro.Schema))
+ }
+ if s.Unreadable > 0 {
+ out = append(out, fmt.Sprintf("%d V3 session(s) whose session.json is not valid JSON", s.Unreadable))
+ }
+ return out
+}
diff --git a/cmd/asz/main.go b/cmd/asz/main.go
index 1054eea..52b433f 100644
--- a/cmd/asz/main.go
+++ b/cmd/asz/main.go
@@ -179,7 +179,7 @@ func main() {
}
switch ad.Name {
case config.AdapterClaudeCodeLocal, config.AdapterChanges, config.AdapterClaudeCodeChanges,
- config.AdapterClaudeCodeProvider:
+ config.AdapterClaudeCodeProvider, config.AdapterKiroLocal:
local = append(local, ad)
case config.RemovedClaudeCodeOTLP:
fmt.Fprintf(os.Stderr, "skipping %q: asz no longer receives Claude Code's exporter; point the exporter at the receiver that reads it, such as the SkyWalking OAP\n", ad.Name)
@@ -212,7 +212,7 @@ func main() {
case "sources":
local = withoutReceivers(local)
if len(local) == 0 {
- fatal(fmt.Errorf("%s: no enabled %s, %s or %s adapter", cmd, config.AdapterClaudeCodeLocal, config.AdapterChanges, config.AdapterClaudeCodeProvider))
+ fatal(fmt.Errorf("%s: no enabled %s, %s, %s or %s adapter", cmd, config.AdapterClaudeCodeLocal, config.AdapterChanges, config.AdapterClaudeCodeProvider, config.AdapterKiroLocal))
}
for _, ad := range local {
if err := run(cfg, ad, *once); err != nil {
@@ -291,6 +291,9 @@ func cmdSources(cfg *config.Config, ad config.Adapter, once bool) error {
if ad.Name == config.AdapterClaudeCodeProvider {
return cmdSourcesProvider(cfg, ad, once)
}
+ if ad.Name == config.AdapterKiroLocal {
+ return cmdSourcesKiro(cfg, ad, once)
+ }
root, err := claudecode.ResolveSourceRoot(ad.SourceRoot)
if err != nil {
return err
diff --git a/cmd/asz/push.go b/cmd/asz/push.go
index 2979641..cbe27e3 100644
--- a/cmd/asz/push.go
+++ b/cmd/asz/push.go
@@ -27,6 +27,7 @@ import (
"github.com/apache/skywalking-ai-sessionizer/internal/adapters/claudecode"
"github.com/apache/skywalking-ai-sessionizer/internal/adapters/claudecodechanges"
"github.com/apache/skywalking-ai-sessionizer/internal/adapters/claudecodeprovider"
+ "github.com/apache/skywalking-ai-sessionizer/internal/adapters/kiro"
"github.com/apache/skywalking-ai-sessionizer/internal/adapters/langsmith"
"github.com/apache/skywalking-ai-sessionizer/internal/adapters/mock"
"github.com/apache/skywalking-ai-sessionizer/internal/config"
@@ -138,7 +139,8 @@ func newPusher(cfg *config.Config, zoneRoot string) (*otlp.Pusher, func(), error
ServiceName: o.ServiceName,
Runtimes: map[string]string{claudecode.Name: claudecode.RuntimeName, claudecodechanges.Name: claudecodechanges.RuntimeName,
claudecodechanges.NameWas: claudecodechanges.RuntimeName,
- langsmith.Name: langsmith.RuntimeName, claudecodeprovider.Name: claudecodeprovider.RuntimeName, mock.Name: mock.RuntimeName},
+ langsmith.Name: langsmith.RuntimeName, claudecodeprovider.Name: claudecodeprovider.RuntimeName, mock.Name: mock.RuntimeName,
+ kiro.Name: kiro.RuntimeName},
// A session whose transcript has not landed yet is named by asz
// itself when it came through the receiver, and that name says so.
SessionRuntime: func(session string) string {
diff --git a/cmd/asz/refresh.go b/cmd/asz/refresh.go
index ff89bf5..2b41312 100644
--- a/cmd/asz/refresh.go
+++ b/cmd/asz/refresh.go
@@ -30,6 +30,7 @@ import (
"github.com/apache/skywalking-ai-sessionizer/internal/adapters/claudecode"
"github.com/apache/skywalking-ai-sessionizer/internal/adapters/claudecodechanges"
"github.com/apache/skywalking-ai-sessionizer/internal/adapters/claudecodeprovider"
+ "github.com/apache/skywalking-ai-sessionizer/internal/adapters/kiro"
"github.com/apache/skywalking-ai-sessionizer/internal/adapters/langsmith"
"github.com/apache/skywalking-ai-sessionizer/internal/config"
"github.com/apache/skywalking-ai-sessionizer/internal/export/otlp"
@@ -103,10 +104,18 @@ type refresher struct {
// and providerAd holds its filter settings.
provider *claudecodeprovider.Collector
providerAd config.Adapter
+ // kiroCol lands the sessions Kiro's V3 engine writes, and kiroMatch is
+ // its session filter.
+ kiroCol *kiro.Collector
+ kiroMatch func(kiro.Session) bool
+ // kiroSkipped is what the last pass said Kiro wrote and is not read, so
+ // a pass says it again only when it changed.
+ kiroSkipped string
// The directories the collectors read. They are checked on every pass,
// not once, so a source that appears later is picked up.
colSource string
providerSource string
+ kiroSource string
interval time.Duration
maxRound int64
// pushAfter is when sending may resume, set when a receiver asks to be
@@ -238,9 +247,18 @@ func newRefresher(srv *view.Server, zone *storage.Zone, ads []config.Adapter, mc
r.providerAd = ad
r.providerSource = src
names, sources = append(names, ad.Name), append(sources, src)
+ case config.AdapterKiroLocal:
+ src, err := kiro.ResolveSourceRoot(ad.SourceRoot)
+ if err != nil {
+ return nil, err
+ }
+ r.kiroCol = kiro.New(src, zone, ad.Collector.MaxDeltaBytes)
+ r.kiroMatch = kiro.NewMatcher(ad.Include, ad.Exclude).Match
+ r.kiroSource = src
+ names, sources = append(names, ad.Name), append(sources, src)
}
}
- if r.col == nil && len(r.changes) == 0 && r.provider == nil && r.langsmith == nil {
+ if r.col == nil && len(r.changes) == 0 && r.provider == nil && r.langsmith == nil && r.kiroCol == nil {
// Nothing local is enabled and nothing feeds the pipeline, so there
// is nothing to refresh, and the caller refuses to run. A root filled
// somewhere else, such as one copied from another machine, is served
@@ -481,6 +499,42 @@ func (r *refresher) pass() error {
}
}
+ // Kiro may not be installed, or not have run on its V3 engine yet, and
+ // the next pass looks again.
+ kiroHere := false
+ if r.kiroCol != nil {
+ var kerr error
+ if kiroHere, kerr = present(r.kiroSource, true); kerr != nil {
+ errs = append(errs, kerr)
+ }
+ }
+ if kiroHere {
+ collected = true
+ ks, err := r.kiroCol.CollectAll(r.kiroMatch)
+ if err != nil {
+ errs = append(errs, err)
+ ks = &kiro.Stats{}
+ }
+ errs = append(errs, ks.Errors...)
+ landed += ks.SourcesLanded
+ records += ks.Records
+ sessionsSeen += ks.Sessions
+ pending += ks.Pending
+ conflicts += ks.Conflicts
+ busy += ks.Busy
+ for _, id := range ks.Changed {
+ changed[id] = true
+ }
+ // Sessions that are there and are not read are said once, and
+ // again when the count changes, not on every pass.
+ if said := strings.Join(notCollected(ks.Skipped), "\n"); said != r.kiroSkipped {
+ r.kiroSkipped = said
+ for _, line := range notCollected(ks.Skipped) {
+ fmt.Printf("not read : %s: %s\n", config.AdapterKiroLocal, line)
+ }
+ }
+ }
+
// The provider bodies come after the transcripts, so a response the
// transcripts are the only evidence for finds its session in this pass.
providerHere := false
diff --git a/docs/en/adapters/kiro-glossary.md b/docs/en/adapters/kiro-glossary.md
new file mode 100644
index 0000000..96f0999
--- /dev/null
+++ b/docs/en/adapters/kiro-glossary.md
@@ -0,0 +1,105 @@
+# Kiro Glossary
+
+What Kiro's V3 agent engine calls the things the
+[project glossary](../concepts-and-designs/glossary.md) names, and where each one appears in the
+files it writes.
+
+The dialect is **`kiro/1`**. That names the vocabulary, not the adapter: `kiro-local` says how
+records were acquired, the dialect says how they are read.
+
+Two words here mean something other than what a reader might expect. Kiro's **execution** is one
+turn, which is a Run here. And Kiro writes a subagent's records in a file of their own, a
+**sub-execution**, which is a child stream here.
+
+The tables below are copied from what `asz glossary kiro/1` prints. The command reads the adapter's
+own declarations, so where this page and the command differ, the command is right.
+
+```sh
+asz glossary kiro/1 this table
+asz conversation ID talk · run · tool
+asz conversation -terms native ID talk · executionId · tool_call and tool_result
+asz conversation -terms both ID run (executionId)
+```
+
+**61 terms: 24 that Kiro names, 37 this project derives.**
+
+---
+
+## Named by Kiro
+
+Each row says where the thing is recorded.
+
+| Model | Kiro | Where | Note |
+| --- | --- | --- | --- |
+| `agent.call` | `sub_agent_start` | messages.jsonl | no tool call names the spawn; the record is the call |
+| `agent.output` | `sub_agent_complete` | messages.jsonl | the response the parent received; the child's own last record is in its file |
+| `child` | `subSessionId` | sub_agent_start and sub_agent_complete | also the name of the child's file under sub-executions |
+| `context.injection` | `agent_note, session_start, steering_inclusion` | messages.jsonl | and a user record with syntheticUserMessageReason, the engine's text around a steering message |
+| `control.notice` | `turn_end, ContextualHookInvoked` | messages.jsonl | a turn_end whose stopReason is not end_turn; also a record with no type, or a type this adapter does not describe |
+| `conversation` | `id` | session.json | one session is one conversation; Kiro Crew names no chain between the sessions of one chat |
+| `epoch.boundary` | `tombstone` | messages.jsonl | kind summarization or checkpoint_revert |
+| `epoch.summary` | `assistant, operationType Summary` | messages.jsonl | nothing names the tombstone it follows |
+| `error.api` | `session_metadata, key displayError` | messages.jsonl | |
+| `external` | `user` | messages.jsonl | |
+| `id` | `id` | every record | followed by # and the first 12 hex digits of the record's SHA-256; the engine gives one id to an approval it asks twice |
+| `message.assistant` | `assistant, operationType Say` | messages.jsonl | |
+| `message.external` | `user` | messages.jsonl | also a message sent during a turn, source steer; Kiro Crew writes its own prompts here too |
+| `redacted` | `reasoningRedactedContent` | a Reasoning record | |
+| `result_of` | `toolCallId` | tool_result | subSessionId for a subagent's end |
+| `run` | `executionId` | turn_start and the records of the turn | a prompt carries none; it belongs to the first turn named after it in the file |
+| `session` | `session` | sessions/<workspace key>/sess_<id> | |
+| `starts` | `subSessionId` | sub_agent_start | |
+| `stream` | `sub-execution` | sub-executions/<subSessionId>.jsonl | the main stream is messages.jsonl |
+| `thinking` | `assistant, operationType Reasoning` | messages.jsonl | the text ... means the engine kept no reasoning |
+| `time` | `timestamp` | every record | not in line order; line order is the order |
+| `tool` | `tool_call and tool_result` | messages.jsonl and sub-executions | joined by toolCallId |
+| `turn.duration` | `usage_summary` | messages.jsonl | elapsedTime, with the turn's credits; no token counts |
+| `unavailable` | `...` | the content of a Reasoning record | |
+
+---
+
+## Derived by this project
+
+Nothing in the files carries these. They are what assembly works out from the evidence above, and
+saying so is more useful than a plausible name that would send a reader looking for a field that is
+not there.
+
+| Model | Kiro | Where | Note |
+| --- | --- | --- | --- |
+| `agent.launch_ack` | — | derived here | no equivalent: nothing acknowledges a subagent separately |
+| `available` | — | derived here | the engine keeps text and tool results whole, apart from large tool output |
+| `batch` | — | derived here | no equivalent: subagents started together share only their turn |
+| `call` | — | derived here | no equivalent: a turn lists its request ids in usage_summary, and no record names one |
+| `cancels` | — | derived here | no equivalent |
+| `conflict` | — | derived here | derived |
+| `continues` | — | derived here | no equivalent: a tombstone's effectiveFromMessageId names where the dropped records begin, not the last one kept |
+| `control.command` | — | derived here | no equivalent: a slash command leaves no record of its own |
+| `control.interrupt` | — | derived here | not read: a turn the person cancelled ends with stopReason cancelled, which lands as a notice |
+| `control.permission` | — | derived here | not read: pending_interaction and interaction_resolved are landed and are no step |
+| `ends_with` | — | derived here | derived from a child's last response |
+| `epoch` | — | derived here | the span between two tombstones |
+| `exact_ambiguous` | — | derived here | derived |
+| `exact_unique` | — | derived here | one toolCallId or subSessionId on both records |
+| `follows` | — | derived here | derived from line order |
+| `hash_only` | — | derived here | no equivalent |
+| `in_segment` | — | derived here | derived |
+| `input_of` | — | derived here | no equivalent |
+| `llm.call` | — | derived here | no equivalent: the engine records what the model said and not which request said it |
+| `message.synthetic` | — | derived here | not read: records the engine writes on recovery end their id in -synthetic, are a tool result, a child's end or a turn's end, and none was measured |
+| `model` | — | derived here | not read: no record says which model answered; session.json names the model chosen |
+| `notification` | — | derived here | no equivalent |
+| `omitted` | — | derived here | no equivalent |
+| `parent` | — | derived here | no equivalent: a record names no record before it |
+| `reports` | — | derived here | no equivalent |
+| `retries` | — | derived here | no equivalent |
+| `runtime.notification` | — | derived here | no equivalent: a subagent's end is the result of its call |
+| `segment` | — | derived here | an activity window chosen for commit |
+| `size_only` | — | derived here | no equivalent |
+| `strong_inference` | — | derived here | derived |
+| `summarizes` | — | derived here | not read: nothing joins a summary to its tombstone |
+| `talk` | — | derived here | one readable interaction: a prompt and the turn it opened |
+| `trigger` | — | derived here | derived: a user record that is neither a steering note nor written by the engine |
+| `truncated` | — | derived here | not read: the model is sent a preview of a large tool output, and that preview is the result here; the whole output is in tool-outputs, which is not collected |
+| `unknown` | — | derived here | derived |
+| `unresolved` | — | derived here | derived |
+| `weak_inference` | — | derived here | derived |
diff --git a/docs/en/adapters/kiro.md b/docs/en/adapters/kiro.md
new file mode 100644
index 0000000..64d8bff
--- /dev/null
+++ b/docs/en/adapters/kiro.md
@@ -0,0 +1,186 @@
+# Kiro
+
+How Kiro maps onto the [unified conversation model](../concepts-and-designs/unified-conversation-model.md),
+what the `kiro-local` adapter reads, and what Kiro's files cannot supply.
+
+For turning it on, see [Kiro](../setup/kiro.md). For the words, see the
+[Kiro glossary](kiro-glossary.md).
+
+## Where the data comes from
+
+Kiro writes each session to disk as it runs. The adapter reads those files, as the Claude Code
+adapter reads Claude Code's, and needs nothing from Kiro: no setting, no hook, no plugin.
+
+Kiro has three agent engines, and only one is read.
+
+| Engine | Used by | Where it writes | Read |
+| --- | --- | --- | --- |
+| V3, `@kiro/agent` | `kiro-cli --v3`, or `kiro-cli` with `chat.agentEngine` set to `v3`; Kiro Crew on its `kas` backend; the Kiro IDE | `~/.kiro/sessions/<workspace key>/sess_<id>/` | yes |
+| V2 | `kiro-cli` by default in 2.28.0 | `~/.kiro/sessions/cli/<id>.jsonl` | no: counted and reported |
+| V1, `--classic` | `kiro-cli --classic` | a SQLite database beside the login | no |
+
+Measured with kiro-cli 2.28.0 and its engine `@kiro/agent` 0.66.26, and Kiro Crew 0.7.2 on it, on
+one macOS machine: 86 sessions, 56 of them with records, and 3,101 records, from tasks run for the
+purpose and from real use. Kiro Crew 0.8.0, which bundles kiro-cli 2.27.1 and engine 0.66.22, was
+measured on the same machine after it updated itself: 13 sessions, 10 of them with records, and
+2,069 records, from real use. All 10 were collected and verified.
+The engine's code was read where the files did not say enough, and each such point names it. The
+IDE writes the same store; no IDE turn was measured.
+
+A session is read when its header, `session.json`, says `schemaVersion` `1.0.0`, the one schema
+measured. A session that says another is counted and left, as the V2 sessions are, and both appear
+in `asz sources` and once in `asz collect`:
+
+```text
+not read : kiro-local: 24 session(s) of kiro-cli's V2 engine under sessions/cli; only the V3 engine's sessions are collected: start kiro-cli with --v3, or set chat.agentEngine to v3
+```
+
+## What is read
+
+One session directory holds:
+
+| File | Read as |
+| --- | --- |
+| `session.json` | the session's header, replaced whole by the engine, also between turns. It lands as a new snapshot when its bytes change, as an `agent_meta` file of the main stream. Its `title` names the conversation. |
+| `messages.jsonl` | the main stream, `main`. Only appended to. |
+| `sub-executions/<id>.jsonl` | one subagent's stream, named by the file. Only appended to. |
+| `tool-outputs/`, `snapshots/`, `publish*.cursor`, `session.json.tmp.*` | not read: a large tool output the engine spilled, the files it keeps for checkpoints, its upload offsets, and a header copy left by an interrupted replace |
+
+A session with a header and no record is not collected until it has one. Kiro Crew opens sessions
+before it uses them: 30 of the 86 held nothing else.
+
+The engine writes under the home directory and does not read `KIRO_HOME`, so the default source
+root is `~/.kiro`. The workspace key in the path is a digest of the session's workspace paths. The
+paths themselves are in the header, and the [session filters](../setup/configuration.md#the-kiro-adapter)
+match them.
+
+## The mapping
+
+| Model | Kiro |
+| --- | --- |
+| Conversation | the session id, `sess_<uuid>`; supplied, one session is one conversation |
+| ExecutionStream | `main` for `messages.jsonl`; a child stream per `sub-executions/<subSessionId>.jsonl` |
+| Run | `executionId`, one turn |
+| Talk | a prompt and the turn it opened |
+| `llm.call` | none: no record names the model request it came from |
+| `message.external` | a `user` record |
+| `message.assistant` | `assistant` with `operationType` `Say` |
+| `thinking` | `assistant` with `operationType` `Reasoning` |
+| `tool` | `tool_call` and `tool_result`, joined by `toolCallId` |
+| `agent.call` | `sub_agent_start` |
+| `agent.output` | the child's last response, with `sub_agent_complete`, the response the parent received |
+| `context.injection` | `agent_note`, `session_start`, `steering_inclusion`, and a `user` record the engine wrote |
+| `epoch.boundary`, `epoch.summary` | `tombstone`, and `assistant` with `operationType` `Summary` |
+| `error.api` | `session_metadata` with `key` `displayError` |
+| `control.notice` | a `turn_end` whose `stopReason` is not `end_turn`, a `ContextualHookInvoked`, and a record type the adapter does not describe |
+| `turn.duration` | `usage_summary` |
+
+### A prompt belongs to the turn after it
+
+A turn is `turn_start` … `turn_end`, and every record inside names it by `executionId`. A
+person's prompt does not: none of the 210 `user` records carries one, nor does the note the
+engine writes before each prompt. A message sent while a turn runs, which the engine marks
+`source: steer`, carries none either.
+
+Each belongs to the first turn named after it in the file: the prompt to its own turn, and a
+message sent during a turn to that turn. The rule reads line order, which the engine keeps. Its
+timestamps are not in line order: the note before a prompt is stamped later than the prompt.
+
+A record whose turn is not in the file yet waits. A landed record is never written again, so a
+prompt landed before its turn starts would sit outside that turn for good. The adapter reads past
+what it is about to land, and lands the waiting records once the turn is named, which the engine
+does milliseconds later. A prompt whose turn never starts, because the engine stopped first, lands
+with no turn once the file has been still for five minutes.
+
+### Model output without a model call
+
+The engine records what the model said and not which request said it. A turn lists its request ids
+in `usage_summary`, and no record names one. Grouping records into calls, by turn or by record,
+would draw call boundaries Kiro never recorded, so no `llm.call` is written. The agent's text,
+reasoning and tools are steps of the run.
+
+### Record ids
+
+A record's id in Session Data is the engine's `id`, then `#`, then the first 12 hex digits of the
+record's SHA-256. The engine can give one id to two different records: when it asks twice to
+approve one tool call, both requests carry one id and both answers another. In the 3,101 records, 46
+ids did so, for 23 approvals, and no other id was reused. Assembly removes a repeated id as a copy, so the engine's id
+alone would drop the second record. The digest keeps the id the same for the same bytes, so a record
+landed again after a crash is still removed as the copy it is.
+
+### Subagents
+
+The engine writes `sub_agent_start` and `sub_agent_complete` in the parent, and the child's records
+in `sub-executions/<subSessionId>.jsonl`. No tool call in the parent names the spawn, so the start
+record is the call: its id is the child's `subSessionId`, and `sub_agent_complete` is its result.
+The `starts` relation joins the call to the child's stream, and `result_of` joins the child's output
+back to the call.
+
+A child's stream is named by its file. The child's records carry the parent's turn as
+`executionId`, and most carry the child's id as `subExecutionId`, but hook records in a child do
+not.
+
+### Compaction
+
+A `tombstone` takes the records before it out of the context the model is sent: `kind`
+`summarization` after a compaction, `checkpoint_revert` after a return to a checkpoint. It starts a
+new epoch. `effectiveFromMessageId` names where the dropped records begin, not the last record
+kept, so the epoch names no continuation. The summary that follows is `assistant` with
+`operationType` `Summary`, which the engine sends to the model as a person's message, so it is input
+here. Nothing joins it to its tombstone, so no `summarizes` relation is drawn.
+
+### The system prompt
+
+`session_start` holds the system prompt the engine composed, and is named `system_prompt`, so a
+server can [withhold](../formats/asz-view.md#withholding) it. The engine writes it when the first
+turn ends.
+
+Kiro Crew sends its own system prompt as the first prompt of a session, in a `user` record: 24 of
+24 Crew sessions measured begin so. It sends its rules again in the prompt that resumes a session.
+Nothing in the record tells either from a prompt a person typed, so each is a `message.external`
+and is not named `system_prompt`.
+
+### Messages sent during a turn
+
+Three `user` records carry a message sent while a turn runs. The person's message, `source:
+steer`, is a `message.external` of that turn. The engine's text that carries it to the model,
+marked `syntheticUserMessageReason`, is a `context.injection` of the same turn. The note that the
+messages were consumed, `steeringClearedIds`, is kept and is no step. Kiro Crew also sends its own
+notices this way, such as one that it blocked a tool call; those read as the person's messages do.
+
+### Reasoning
+
+A `Reasoning` record whose text is `...` holds no reasoning: the engine wrote that in place of
+reasoning it did not keep, and reads it as none itself. Its `thinking` step is `unavailable`. Of 338
+reasoning records, 179 were so. The signature beside it is named in `dropped`.
+
+## Kiro Crew
+
+Kiro Crew runs every chat through `kiro-cli acp --agent-engine v3` when its `agent.acp_backend` is
+`kas`, so its sessions are V3 sessions under the directory Crew works in. Measured with Crew 0.7.2:
+
+| Crew does | In the store |
+| --- | --- |
+| a chat in the dashboard | one session over many prompts, in the directory the chat works in, by default Crew's own |
+| `kirocrew chat -m` | a new session for each message |
+| a background job | a session under `_bg` in Crew's workspace |
+| a scheduled job | a session kept under `cron_<job id>` in Crew's workspace |
+| a task's steps, `kirocrew run` | temporary sessions: 16 created and 14 removed in one run, lifetimes 1.4 to 358 seconds, median 68 |
+
+A collector that reads every few minutes does not see most task steps: Crew frees the session and
+the engine removes it before the next read. A session found and then removed keeps what was landed.
+Kiro Crew issue [#18988](https://github.com/kirodotdev/KiroCrew/issues/18988) proposes a setting
+that keeps these records for a while before Crew deletes them.
+
+## What this adapter cannot supply
+
+| Not in the files | Why |
+| --- | --- |
+| tokens | the engine records credits per turn in `usage_summary`, and no token counts, so no token metric is derived |
+| model calls | no record names its request; see [above](#model-output-without-a-model-call) |
+| the model per record | no record names the model that answered; the header names the one chosen |
+| a permission decision as a step | `pending_interaction` and `interaction_resolved` land and are kept; the model has a step for them, and nothing draws it from Kiro yet |
+| an interruption as a step | a turn the person cancelled ends with `stopReason` `cancelled`, which lands as a notice |
+| a large tool output whole | the model is sent a preview, which is the result here; the rest is in `tool-outputs`, which is not read |
+| a conversation over several sessions | Kiro Crew names no chain between the sessions of one chat |
+| records the engine writes on recovery | they end their id in `-synthetic`; none was measured, and they land as what they are |
diff --git a/docs/en/adapters/langsmith-glossary.md b/docs/en/adapters/langsmith-glossary.md
index 30927d7..a6dd377 100644
--- a/docs/en/adapters/langsmith-glossary.md
+++ b/docs/en/adapters/langsmith-glossary.md
@@ -21,7 +21,7 @@ asz conversation -terms native ID talk · trace_id · id · run_type: tool
asz conversation -terms both ID run (trace_id)
```
-**60 terms: 17 that LangChain names, 43 this project derives.** That ratio is worth watching against
+**61 terms: 17 that LangChain names, 44 this project derives.** That ratio is worth watching against
the Claude Code one. A framework that records its own graph in detail still has no word for a
conversation boundary, a context reset or a delegated agent — which is the whole reason this model
exists separately from any product's.
@@ -74,6 +74,7 @@ not there.
| `continues` | — | derived here | no equivalent: a marked summary names no last message before it |
| `control.command` | — | derived here | no equivalent |
| `control.interrupt` | — | derived here | no equivalent |
+| `control.notice` | — | derived here | no equivalent |
| `control.permission` | — | derived here | no equivalent: nothing here asks before running a tool |
| `ends_with` | — | derived here | derived from a nested agent's last response |
| `epoch` | — | derived here | the span between two summaries SummarizationMiddleware marked |
diff --git a/docs/en/changes/changes.md b/docs/en/changes/changes.md
index 1779d4f..e691d22 100644
--- a/docs/en/changes/changes.md
+++ b/docs/en/changes/changes.md
@@ -2,6 +2,31 @@
> In development, not yet released. `tools/release/release.sh prepare 0.6.0` removes this note.
+## Kiro
+
+- A new adapter, `kiro-local`, collects the sessions Kiro's V3 agent engine writes under `~/.kiro`:
+ kiro-cli started with `--v3` or with `chat.agentEngine` set to `v3`, and Kiro Crew on its `kas`
+ backend. It is on by default and needs nothing from Kiro. Measured with kiro-cli 2.28.0 and its
+ engine `@kiro/agent` 0.66.26, and Kiro Crew 0.7.2, over 86 sessions and 3,101 records, and with
+ Kiro Crew 0.8.0, which bundles kiro-cli 2.27.1 and engine 0.66.22, over 13 sessions and 2,069
+ records.
+ A session is read when its header names schema `1.0.0`; kiro-cli's V2 sessions and V3 sessions of
+ another schema are counted, in `asz sources` and once in `asz collect`, and not collected. See
+ [Kiro](../setup/kiro.md) and the [Kiro adapter](../adapters/kiro.md).
+- A prompt carries no turn id in Kiro, nor does a message sent while a turn runs. Each belongs to the
+ first turn named after it in the file, and waits to land until that turn is named, since a landed
+ record is never written again. A prompt whose turn never starts lands with no turn once its file
+ has been still for five minutes.
+- A Kiro record's id is the engine's id followed by the record's digest. The engine gives one id to
+ an approval it asks twice, 46 ids in the measured records, and assembly would have removed the
+ second as a copy.
+- Kiro's subagents are joined by the child's id, which the parent's start and end records and the
+ child's own file share. A compaction is a context reset with its summary. A turn's `elapsedTime`
+ is its measured duration. Kiro Crew's system prompt arrives as the first prompt of a session and
+ is not named `system_prompt`, because nothing in the record tells it from a person's prompt.
+- Kiro records no token counts, so no token metric is derived for it. Pushed, its sessions are the
+ service `Kiro`.
+
## LangChain and LangGraph
- Two records that could carry a system prompt unnamed are named. An unfinished model call whose
@@ -157,6 +182,16 @@
title of what it copies, so the whole body and the settings copy while their section is closed.
The renderer is Horizon's, now pinned at `e55f329` (apache/skywalking-horizon-ui#190).
+## Collection
+
+- A file that fails to land leaves nothing behind. Its records entered the index while it was being
+ written, and it kept the number it took, so a failed rename left index entries for records that
+ never landed, and a gap in the landed numbers that every later round reported as a missing file.
+ Claude Code's transcripts and workspace changes and Kiro's sessions did both. Provider bodies
+ stopped the session's pass at the failed file, before anything was saved, and they land in the same
+ order now. Records enter the index only once their file has landed, and a file that did not land
+ gives its number back.
+
## Configuration
- The `view` section is read strictly. A key it does not have is refused, where every other section
@@ -198,12 +233,27 @@
LangChain conversation, for context windows from 8K to 1M tokens, trimmed, summarised or neither.
`make test` and CI only compile it. Its comment gives the command that runs it, the reference
numbers, and the machine they come from.
+- A scenario's `error` step can name no call, `error: {text, unidentified: true}`, and an
+ expectation can check the session's title, `session: {title}`. `all-kinds` checks that a child's
+ meta file does not rename the conversation.
- golangci-lint is v2.14.0. Go 1.27.2 writes export data version 5, which v2.13.2 cannot read, so
under it lint stopped at the first package with a type-check error. CI takes the newest Go 1.27
release, so CI failed as soon as 1.27.2 came out.
## Conversation model
+- Model output whose record names no provider call is a step of its run. It was in no node at all,
+ because only a call held text and reasoning. No Claude Code or LangChain record lacks the call;
+ every Kiro record does, since Kiro ties no record to a request. Grouping such output into calls
+ by turn or by record would draw boundaries the runtime never recorded, so no `llm.call` is
+ written for it. The scenario `output-without-call` holds it.
+- A sidecar of the `main` stream names the conversation: its label is the session's title, as a
+ title record's is. A sidecar used to name only the stream it described. Kiro keeps a session's
+ title in its header, which lands as the main stream's `agent_meta`.
+- `control.notice` is in `model.Vocabulary`, the list every glossary is checked against. It was the
+ one kind the model emits that the list left out, so a glossary could miss it with no test failing,
+ and the Kiro and LangSmith glossaries did.
+
- A stream whose first record is a reset now has one epoch, opened by that reset. It used to get an
empty epoch before the reset, with the reset's own id, so one epoch replaced the other and
followed itself. No Claude Code stream starts with a reset. A LangChain agent whose first model
diff --git a/docs/en/concepts-and-designs/conversation-assembly.md b/docs/en/concepts-and-designs/conversation-assembly.md
index e500931..fc520aa 100644
--- a/docs/en/concepts-and-designs/conversation-assembly.md
+++ b/docs/en/concepts-and-designs/conversation-assembly.md
@@ -38,14 +38,14 @@ Eight stages. The order is forced: each depends on the one before it.
| --- | --- |
| 1. Remove duplicate records | the first copy of each record id wins, and nothing downstream is correct before it |
| 2. Partition streams | one ordered lineage per agent, from the file it was written in |
-| 3. Group provider calls | by message id, in line order |
+| 3. Group provider calls | by message id, in line order; output that names no call is grouped into none |
| 4. Join tools | request and result become one step |
| 5. Join child agents | which call started which child stream |
| 6. Cut context epochs | only from an explicit reset record |
| 7. Build Talks and Runs | the conversation a person reads |
| 8. Propose segments | activity windows, and whether each could be committed |
-Seven rules in here are the opposite of the obvious choice, and each is the opposite because a
+Eight rules in here are the opposite of the obvious choice, and each is the opposite because a
measurement said so.
**A record type is not what a record is.** Every tool result in the corpus sits on a record whose
@@ -73,6 +73,11 @@ again.
which drops a tool use on nearly one call in five; and never by request id, because a record the
client fabricated can carry a real call's request id.
+**Output that names no call is grouped into none.** Kiro's V3 engine lists the request ids of a
+turn and ties no record to one of them, so no record names its call. Grouping such output by turn
+or by record would draw call boundaries the runtime never recorded. So no `llm.call` is written,
+and the text, reasoning and tool uses are steps of the run that holds them.
+
**Take usage from the last fragment in line order.** Not from the fragment with a stop reason — a
parent lineage stamps the same stop reason on every fragment — and never by adding fragments up,
which multiplies the real number by the fragment count.
@@ -183,6 +188,7 @@ session
├ llm.call
│ ├ message.assistant · thinking
│ └ tool · agent.call
+ ├ message.assistant · thinking · tool · agent.call, where no call is named
├ message.external · context.injection
├ agent.launch_ack
└ runtime.notification
diff --git a/docs/en/concepts-and-designs/unified-conversation-model.md b/docs/en/concepts-and-designs/unified-conversation-model.md
index 3174fe2..4e433d1 100644
--- a/docs/en/concepts-and-designs/unified-conversation-model.md
+++ b/docs/en/concepts-and-designs/unified-conversation-model.md
@@ -277,9 +277,7 @@ Three rules keep a glossary complete and checkable:
The model lists the names a glossary is checked against in `model.Vocabulary`. For the
[built-in runtime adapter](../adapters/claude-code-glossary.md), a test fails when an entry breaks
-rule 2 or rule 3, or when a name in that list has no entry. The list leaves out one name the model
-emits, `control.notice`. So no test would fail if the entry for it were missing. The built-in
-glossary has that entry.
+rule 2 or rule 3, or when a name in that list has no entry.
## Non-goals
diff --git a/docs/en/formats/otlp.md b/docs/en/formats/otlp.md
index c735197..642eeef 100644
--- a/docs/en/formats/otlp.md
+++ b/docs/en/formats/otlp.md
@@ -80,7 +80,7 @@ The resource, which names the service a record belongs to:
| Attribute | Value |
| --- | --- |
-| `service.name` | `export.otlp.service_name`, or when empty the runtime that produced each session, read off its landed header's adapter: `Claude Code` for `claude-code-local`, `changes` and `claude-code-provider`, `Mock Agent` for `mock`. One service per kind of agent, and a root that holds both is pushed as both |
+| `service.name` | `export.otlp.service_name`, or when empty the runtime that produced each session, read off its landed header's adapter: `Claude Code` for `claude-code-local`, `changes` and `claude-code-provider`, `Kiro` for `kiro-local`, `Mock Agent` for `mock`. One service per kind of agent, and a root that holds both is pushed as both |
| `service.instance.id` | `export.otlp.instance_id`: who is pushing, in words the people reading the receiver recognise. A receiver lists it under the service as the instance, so put your mailbox, your name, or the machine there. Empty means `user@host` of the machine running `asz push`, which is stable across restarts. The session a record belongs to is on the record as `asz.session` |
| `service.layer` | `export.otlp.layer`, `AI_AGENT` by default, the layer the receiver places the service in. The OAP selects its rules by layer, and a layer name is upper case with underscores |
| `telemetry.sdk.name` | `asz`, so a receiver can tell these records apart from any other source |
diff --git a/docs/en/formats/session-data.md b/docs/en/formats/session-data.md
index 9381e3d..633488b 100644
--- a/docs/en/formats/session-data.md
+++ b/docs/en/formats/session-data.md
@@ -74,7 +74,7 @@ is a directory with one file per body, and each record names its own file.
| `kind` | Collected from | Its records | What refers to it |
| --- | --- | --- | --- |
| `transcript` | one execution stream: the main stream or a child agent's own | the conversation itself: inputs, calls in fragments, results, injections, resets. `stream` on the header is `main` or the agent id. | almost every node of a round |
-| `agent_meta` | what the runtime recorded about a child agent when it started it | one record, `from: runtime`, with `child` and `label`, and a `data` part | the child's `stream` node, for its label |
+| `agent_meta` | what the runtime recorded about an agent: about a child agent when it started it, or, from Kiro, about the session | one record, `from: runtime`, with `child` and `label`, and a `data` part. Kiro's is the session's header, in the `main` stream, with `label` and no `child` | the child's `stream` node, for its label; in the `main` stream, the session node's title |
| `journal` | a workflow run's journal | one record per event, `from: runtime`, with `child` and `batch`; a `child_result` flag on the record that returns a child's value | `agent.output` nodes and `ends_with` relations |
| `workflow_manifest` | a workflow run's manifest | one record with `batch` and `label`, and a `data` part | the run's name |
| `workflow_script` | the program a workflow ran | one record whose part is `unknown`: the source is a program, not data | nothing; kept because it is part of the session |
@@ -84,8 +84,8 @@ is a directory with one file per body, and each record names its own file.
A source that is one document, not a stream of lines, lands as one record with `ord` 1 and `off` 0.
Its `sha` and `bytes` cover the whole document. The Claude Code adapter's `agent_meta`,
-`workflow_manifest` and `workflow_script` files are of this kind, and a new version of the document
-lands as a new file (see [Snapshot cursors](storage-root.md#snapshot-cursors)).
+`workflow_manifest` and `workflow_script` files are of this kind, as is the Kiro adapter's
+`agent_meta`, and a new version of the document lands as a new file (see [Snapshot cursors](storage-root.md#snapshot-cursors)).
Files of every kind are bound by the rounds' `input_digest`, so a session travels or archives as
all of them. A session's `agent_meta`, `workflow_manifest` and `workflow_script` records carry
diff --git a/docs/en/guides/scenario.md b/docs/en/guides/scenario.md
index 39dd563..c557044 100644
--- a/docs/en/guides/scenario.md
+++ b/docs/en/guides/scenario.md
@@ -260,9 +260,9 @@ Every step is exactly one of these, plus an optional `after`, `checkpoint` and `
| `input` | a person's message; `trigger: external`; opens a run and a talk |
| `queued: {text, mode}` | input that exists only as a queued attachment; `mode` is `prompt` or `task-notification` |
| `inject: {type, text}` | material the harness put into context, of any type. `inject: {type: prompt_snapshot, system_prompt, tools, text}` is the snapshot Claude Code and a runtime built on the Agent SDK write, landed with `system_prompt` and, with tools, `tool_schemas`. `text` is prose beside them, which no version measured writes. `inject: {type: deferred_tools_record, tools}` is the record of the tools offered on demand, landed with `tool_schemas` |
-| `call` | fragments in this order: `thinking`, `text`, then one of `tool`, `agent`, `skill`, `workflow`; `usage` on every fragment; the last carries the stop reason |
+| `call` | fragments in this order: `thinking`, `text`, then one of `tool`, `agent`, `skill`, `workflow`; `usage` on every fragment; the last carries the stop reason. `unidentified: true` writes the fragments with no call id and no usage, as a runtime does that records what the model said but not which call said it |
| `result: {of, text, after, failed, string}` | a tool result arriving on its own, for a tool whose call gave none |
-| `error` | a synthetic message |
+| `error` | a synthetic message. `error: {text, unidentified: true}` writes it with no call id, as `call`'s `unidentified` does. A scenario with `provider_bodies` has neither form of `unidentified`: bodies are written per call |
| `reset: {summary}` | a context reset and its summary, the summary timestamped before the boundary as the runtime writes it |
| `replay: N` | the last N main-stream records re-emitted with their run rewritten |
| `system: {subtype, fields}` | a system record of any subtype |
@@ -355,7 +355,7 @@ checkpoints:
relations: {starts: 1, reports: 1}
unresolved: {open: 0, resolved: 0}
unresolved_kinds: {tool_result: none}
- session: {from: +0s, to: +11.1s} # the session node's range, as deltas from --at
+ session: {from: +0s, to: +11.1s} # the session node's range, as deltas from --at; title: its title
view: {state: verified, problems: 0, talks: 3, files: 6, first_talk: {label: run the build, runs: 2}}
# with provider_bodies: the landed bodies, and the calls whose request is captured
# view: {provider_bodies: 14, captured_prompts: 7, provider_files: 5}
diff --git a/docs/en/setup/configuration.md b/docs/en/setup/configuration.md
index 94c1d9e..cebe2cc 100644
--- a/docs/en/setup/configuration.md
+++ b/docs/en/setup/configuration.md
@@ -27,6 +27,7 @@ A list of the sources asz collects from.
| `claude-code-local` | yes | Claude Code's transcripts on this machine |
| `changes` | yes | what a tool call changed on disk, from the [Claude Code](claude-code-plugin.md) or [LangChain](langchain-plugin.md) plugin, and each call to an MCP server the Claude Code plugin saw; see [below](#the-changes-adapter) |
| `claude-code-provider` | yes | the request and response bodies Claude Code writes when asked; see [below](#the-provider-adapter) |
+| `kiro-local` | yes | the sessions Kiro's V3 agent engine writes on this machine, for kiro-cli and Kiro Crew; see [below](#the-kiro-adapter) and [Kiro](kiro.md) |
| `langsmith-ingest` | no | what the LangSmith tracing client sends, from a LangChain or LangGraph application; see [LangChain and LangGraph](langchain.md) |
### More than one agent
@@ -40,6 +41,8 @@ A list of the sources asz collects from.
deployments never share one unless they share both.
- **Several machines.** One asz on each, with its own storage root, exporting to one SkyWalking
OAP. `instance_id` says who is pushing, `user@host` by default, so instances stay apart there.
+- **kiro-cli and Kiro Crew on one machine.** Supported by one `kiro-local` entry. Both write
+ under `~/.kiro`, and each session is collected on its own.
- **Several recorder directories on one machine.** One `changes` entry per directory, as
[below](#the-changes-adapter).
- **Several Claude Code directories on one machine** - two users, or instances started with
@@ -188,3 +191,15 @@ Two entries that read one directory are refused.
`claude-code-provider` collects the request and response bodies Claude Code sends to its model
provider. Claude Code writes them only when you turn it on, as
[Claude Code Provider Bodies](claude-code-provider-bodies.md) shows.
+
+## The Kiro adapter
+
+`kiro-local` collects the sessions Kiro's V3 agent engine writes, as [Kiro](kiro.md) shows how to
+turn on. With no `source_root` it reads `~/.kiro`, the directory that holds Kiro's `sessions`
+directory, and does nothing where Kiro is not installed. Kiro does not read `KIRO_HOME` for these
+sessions, so neither does asz.
+
+Its session filters match the workspace paths in each session's `session.json`, not a directory
+name. A filter ending in `/**` matches that path and everything beneath it; any other filter is a
+glob over the whole path. A session is left out only when every one of its paths is. Kiro records
+the real path, so on macOS a session started in `/tmp` is under `/private/tmp`.
diff --git a/docs/en/setup/kiro.md b/docs/en/setup/kiro.md
new file mode 100644
index 0000000..14f4df8
--- /dev/null
+++ b/docs/en/setup/kiro.md
@@ -0,0 +1,87 @@
+# Kiro
+
+asz assembles conversations from the sessions Kiro writes on this machine: kiro-cli's, and Kiro
+Crew's. It reads Kiro's own files. Kiro only has to run its V3 agent engine.
+
+## Run Kiro on its V3 engine
+
+kiro-cli, for one session:
+
+```sh
+kiro-cli chat --v3
+```
+
+kiro-cli, for every session:
+
+```sh
+kiro-cli settings chat.agentEngine v3
+```
+
+Kiro Crew, then restart it:
+
+```sh
+kirocrew config set agent.acp_backend kas
+```
+
+## Turn the adapter on
+
+`kiro-local` is on by default. It reads `~/.kiro`. To read another directory, or to change which
+sessions are collected, set it in `asz.yaml`:
+
+```yaml
+adapters:
+ - name: kiro-local
+ enabled: true
+ source_root: ""
+ include: []
+ exclude:
+ - /private/tmp/**
+```
+
+A file that lists `adapters` replaces the whole default list, so list every adapter you want. See
+[Configuration](configuration.md#the-kiro-adapter).
+
+## Check what it sees
+
+```sh
+asz sources
+```
+
+```text
+kiro root : /Users/me/.kiro
+not read : 24 session(s) of kiro-cli's V2 engine under sessions/cli; only the V3 engine's sessions are collected: start kiro-cli with --v3, or set chat.agentEngine to v3
+
+SESSION STREAMS WORKSPACE
+sess_5f20ccf4-b254-4bdb-945b-080e051eb7f5 1 /Users/me/.kiro/crew/workspace
+```
+
+Sessions of kiro-cli's V2 engine are counted and not collected.
+
+## Collect
+
+```sh
+asz server
+```
+
+The page lists each Kiro session as a conversation, named by the title Kiro gave it.
+
+## Supported versions
+
+| Kiro | Versions | Collected |
+| --- | --- | --- |
+| kiro-cli on its V3 engine | 2.28.0, engine `@kiro/agent` 0.66.26 | yes |
+| Kiro Crew on `kas` | 0.7.2, on kiro-cli 2.28.0; 0.8.0, with its kiro-cli 2.27.1, engine 0.66.22 | yes |
+| kiro-cli on its V2 or V1 engine | every version | no; V2 sessions are counted |
+
+A session whose `session.json` names a `schemaVersion` other than `1.0.0` is counted and not
+collected.
+
+## Limits
+
+- Kiro records no token counts, so no token metric is derived for Kiro.
+- Kiro Crew removes the sessions of a task's steps, `kirocrew run`, soon after each step ends. In one
+ measured run they lasted 1.4 to 358 seconds, 68 at the median, so a collection every few minutes
+ does not see most of them. See the [Kiro adapter](../adapters/kiro.md#kiro-crew).
+- A large tool output is collected as the preview the model was sent.
+
+What each Kiro record becomes is on the [Kiro adapter](../adapters/kiro.md) page.
diff --git a/docs/menu.yml b/docs/menu.yml
index 5f22452..b67d610 100644
--- a/docs/menu.yml
+++ b/docs/menu.yml
@@ -40,6 +40,8 @@ catalog:
path: /en/setup/langchain
- name: LangChain Plugin
path: /en/setup/langchain-plugin
+ - name: Kiro
+ path: /en/setup/kiro
- name: Claude Code Plugin
path: /en/setup/claude-code-plugin
- name: Claude Code Provider Bodies
@@ -78,6 +80,10 @@ catalog:
path: /en/adapters/claude-code-plugin
- name: Claude Code Provider Bodies Internals
path: /en/adapters/claude-code-provider-bodies
+ - name: Kiro
+ path: /en/adapters/kiro
+ - name: Kiro Glossary
+ path: /en/adapters/kiro-glossary
- name: Guides
catalog:
- name: Contributing
diff --git a/internal/adapters/claudecode/collect.go b/internal/adapters/claudecode/collect.go
index 8d67cb6..60910f0 100644
--- a/internal/adapters/claudecode/collect.go
+++ b/internal/adapters/claudecode/collect.go
@@ -400,6 +400,7 @@ func (c *Collector) landAppend(src Source, cur *storage.Cursor, cursorPath, dir,
}
var written int64
+ var staged index.Staged
err = storage.WriteAtomic(filepath.Join(dir, name), storage.PermLanded, func(w io.Writer) error {
rw, err := sessiondata.NewWriter(w, hdr)
if err != nil {
@@ -415,14 +416,15 @@ func (c *Collector) landAppend(src Source, cur *storage.Cursor, cursorPath, dir,
written += int64(len(ln.Bytes))
// The index is built from the CONVERTED record, so the source is
// parsed once and the index has no dialect in it.
- e, blocks, body := index.FromRecord(p.ix, hdr, rec, uint32(seq), uint32(row+1))
- p.ix.AppendRecord(e, blocks, body)
+ staged.Add(index.FromRecord(p.ix, hdr, rec, uint32(seq), uint32(row+1)))
}
return rw.Close()
})
if err != nil {
+ state.GiveBack(seq)
return false, false, err
}
+ p.ix.AppendStaged(&staged)
// Land before committing the cursor. A crash between the two re-lands the
// same records next pass, which is safe because the assembler must already
@@ -477,6 +479,7 @@ func (c *Collector) landSnapshot(src Source, cur *storage.Cursor, cursorPath, di
Kind: src.Kind.RecordKind(), Adapter: Name + "/" + Version, Dialect: Dialect,
Src: src.Rel, Session: src.Session, Stream: src.Stream, Batch: src.RunID,
}
+ var staged index.Staged
err = storage.WriteAtomic(filepath.Join(dir, name), storage.PermLanded, func(w io.Writer) error {
rw, err := sessiondata.NewWriter(w, hdr)
if err != nil {
@@ -486,13 +489,14 @@ func (c *Collector) landSnapshot(src Source, cur *storage.Cursor, cursorPath, di
if err := rw.Write(rec); err != nil {
return err
}
- e, blocks, body := index.FromRecord(p.ix, hdr, rec, uint32(seq), 1)
- p.ix.AppendRecord(e, blocks, body)
+ staged.Add(index.FromRecord(p.ix, hdr, rec, uint32(seq), 1))
return rw.Close()
})
if err != nil {
+ state.GiveBack(seq)
return false, err
}
+ p.ix.AppendStaged(&staged)
info, _ := statSource(src.Path)
cur.ContentSHA256 = digest
diff --git a/internal/adapters/claudecode/regress_test.go b/internal/adapters/claudecode/regress_test.go
index 3abae52..122f5d7 100644
--- a/internal/adapters/claudecode/regress_test.go
+++ b/internal/adapters/claudecode/regress_test.go
@@ -22,9 +22,11 @@ import (
"fmt"
"os"
"path/filepath"
+ "sort"
"strings"
"sync"
"testing"
+ "time"
"github.com/apache/skywalking-ai-sessionizer/internal/adapters/claudecode"
"github.com/apache/skywalking-ai-sessionizer/internal/index"
@@ -369,6 +371,87 @@ func TestIndexGapIsClosedAfterInterruptedPass(t *testing.T) {
}
}
+// TestAFileThatFailsToLandLeavesNoIndexEntryOrGap: the subagent's file lands
+// in the same pass as the main transcript's fails, so the session's index and
+// state are saved. The failed file's records must not be in that index, and the
+// number it gave back must go to the next file that lands, or every round after
+// it reports that number as a missing file.
+func TestAFileThatFailsToLandLeavesNoIndexEntryOrGap(t *testing.T) {
+ src, zone := t.TempDir(), t.TempDir()
+ mk(t, filepath.Join(src, "-proj-a", tSess+".jsonl"), "{\"uuid\":\"m1\"}\n{\"uuid\":\"m2\"}\n")
+ mk(t, filepath.Join(src, "-proj-a", tSess, "subagents", "agent-"+tAgent+".jsonl"), "{\"uuid\":\"c1\"}\n")
+ mk(t, filepath.Join(src, "-proj-a", tSess, "subagents", "agent-"+tAgent+".meta.json"), `{"agentType":"helper"}`)
+
+ z := storage.NewZone(zone)
+ col := claudecode.New(src, z, 0)
+ at := time.Date(2026, 10, 11, 1, 0, 0, 0, time.UTC)
+ col.Now = func() time.Time { return at }
+ // A directory with something in it where a file would go makes its rename
+ // fail: the main transcript's, a window of a growing file, and the agent's
+ // meta file, a snapshot.
+ var blockers []string
+ for seq := uint64(1); seq <= 4; seq++ {
+ for _, b := range []string{
+ filepath.Join(z.StreamDir(tSess, storage.StreamMain), storage.LandedName("transcript", storage.Stamp(at), seq)),
+ filepath.Join(z.StreamDir(tSess, tAgent), storage.LandedName("meta", storage.Stamp(at), seq)),
+ } {
+ mk(t, filepath.Join(b, "keep"), "")
+ blockers = append(blockers, b)
+ }
+ }
+ st, err := col.CollectAll(nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(st.Errors) != 2 || st.SourcesLanded != 1 {
+ t.Fatalf("pass: %+v, want the agent's transcript landed and the other two failed", st)
+ }
+ ix, ok, err := index.Load(z.IndexDir(tSess), tSess)
+ if err != nil || !ok {
+ t.Fatalf("index: ok=%v err=%v", ok, err)
+ }
+ for _, id := range []string{"m1", "m2"} {
+ if _, found := ix.EntryByRecord(id); found {
+ t.Errorf("record %q is in the index, and its file did not land", id)
+ }
+ }
+ landed := map[string]bool{}
+ for _, seq := range landedSeqs(t, z.SessionDir(tSess)) {
+ landed[seq] = true
+ }
+ for _, e := range ix.Entries {
+ if seq := fmt.Sprintf("%06d", e.Seq); !landed[seq] {
+ t.Errorf("the index names file %s, which did not land", seq)
+ }
+ }
+
+ for _, b := range blockers {
+ if err := os.RemoveAll(b); err != nil {
+ t.Fatal(err)
+ }
+ }
+ col.Now = time.Now
+ if _, err := col.CollectAll(nil); err != nil {
+ t.Fatal(err)
+ }
+ seqs := landedSeqs(t, z.SessionDir(tSess))
+ sort.Strings(seqs)
+ for i, seq := range seqs {
+ if want := fmt.Sprintf("%06d", i+1); seq != want {
+ t.Fatalf("landed numbers %v: %s where %s belongs", seqs, seq, want)
+ }
+ }
+ ix, ok, err = index.Load(z.IndexDir(tSess), tSess)
+ if err != nil || !ok {
+ t.Fatalf("index: ok=%v err=%v", ok, err)
+ }
+ for _, id := range []string{"m1", "m2", "c1"} {
+ if _, found := ix.EntryByRecord(id); !found {
+ t.Errorf("record %q missing from the index after the retry", id)
+ }
+ }
+}
+
// TestIndexRebuildsFromLandedFiles covers a wholesale loss of the index - the
// case that makes it safe to treat the index as derived and disposable.
func TestIndexRebuildsFromLandedFiles(t *testing.T) {
diff --git a/internal/adapters/claudecodechanges/collect.go b/internal/adapters/claudecodechanges/collect.go
index f93c6c2..050147a 100644
--- a/internal/adapters/claudecodechanges/collect.go
+++ b/internal/adapters/claudecodechanges/collect.go
@@ -336,6 +336,7 @@ func (c *Collector) collectSource(src Source, ix *index.Index, state *storage.Se
Src: src.Rel, Session: src.Session, Stream: src.Stream,
}
var written int64
+ var staged index.Staged
err = storage.WriteAtomic(filepath.Join(dir, name), storage.PermLanded, func(w io.Writer) error {
rw, err := sessiondata.NewWriter(w, hdr)
if err != nil {
@@ -347,14 +348,15 @@ func (c *Collector) collectSource(src Source, ix *index.Index, state *storage.Se
return err
}
written += int64(len(ln.Bytes))
- e, blocks, body := index.FromRecord(ix, hdr, rec, uint32(seq), uint32(row+1))
- ix.AppendRecord(e, blocks, body)
+ staged.Add(index.FromRecord(ix, hdr, rec, uint32(seq), uint32(row+1)))
}
return rw.Close()
})
if err != nil {
+ state.GiveBack(seq)
return false, false, err
}
+ ix.AppendStaged(&staged)
// Land before committing the cursor, as the transcript adapter does: a
// crash between the two lands the same lines again, and assembly and
// the view drop the repeat by record id.
diff --git a/internal/adapters/claudecodechanges/collect_test.go b/internal/adapters/claudecodechanges/collect_test.go
index 9ed7ca9..f9267d3 100644
--- a/internal/adapters/claudecodechanges/collect_test.go
+++ b/internal/adapters/claudecodechanges/collect_test.go
@@ -23,6 +23,7 @@ import (
"io"
"os"
"path/filepath"
+ "sort"
"strings"
"testing"
"time"
@@ -185,6 +186,78 @@ func TestCollectLandsEachStreamUnderTheSession(t *testing.T) {
}
}
+// The agent's stream lands in the same pass as the main stream's fails, so the
+// session's index and state are saved. The failed file's record must not be in
+// that index, and the number it gave back must go to the next file that lands,
+// or every round after it reports that number as a missing file.
+func TestAFileThatFailsToLandLeavesNoIndexEntryOrGap(t *testing.T) {
+ src := t.TempDir()
+ out := filepath.Join(src, "asz-changes-inline", "output", session)
+ appendTo(t, filepath.Join(out, "main.jsonl"), line("p1/c1", "main", "toolu_1", "/workspace/project"))
+ appendTo(t, filepath.Join(out, agent+".jsonl"), line("p1/c2", agent, "toolu_2", "/workspace/project"))
+
+ zone := storage.NewZone(t.TempDir())
+ c := claudecodechanges.New(src, zone, 0)
+ at := time.Date(2026, 9, 8, 3, 0, 0, 0, time.UTC)
+ c.Now = func() time.Time { return at }
+ // A directory with something in it where the main stream's file would go
+ // makes its rename fail.
+ stream := zone.StreamDir(session, storage.StreamMain)
+ var blockers []string
+ for seq := uint64(1); seq <= 3; seq++ {
+ b := filepath.Join(stream, storage.LandedName("changes", storage.Stamp(at), seq))
+ appendTo(t, filepath.Join(b, "keep"), "")
+ blockers = append(blockers, b)
+ }
+ st, err := c.CollectAll(nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(st.Errors) == 0 || st.SourcesLanded != 1 {
+ t.Fatalf("pass: %+v, want the agent's stream landed and the main stream's failed", st)
+ }
+ ix, ok, err := index.Load(zone.IndexDir(session), session)
+ if err != nil || !ok {
+ t.Fatalf("index: ok=%v err=%v", ok, err)
+ }
+ if _, found := ix.EntryByRecord("p1/c1"); found {
+ t.Error("the main stream's record is in the index, and its file did not land")
+ }
+
+ for _, b := range blockers {
+ if err := os.RemoveAll(b); err != nil {
+ t.Fatal(err)
+ }
+ }
+ c.Now = time.Now
+ if _, err := c.CollectAll(nil); err != nil {
+ t.Fatal(err)
+ }
+ files, err := storage.LandedFiles(zone, session)
+ if err != nil {
+ t.Fatal(err)
+ }
+ seqs := make([]uint64, 0, len(files))
+ for _, f := range files {
+ seqs = append(seqs, f.Seq)
+ }
+ sort.Slice(seqs, func(i, j int) bool { return seqs[i] < seqs[j] })
+ for i, seq := range seqs {
+ if seq != uint64(i+1) {
+ t.Fatalf("landed numbers %v: %d where %d belongs", seqs, seq, i+1)
+ }
+ }
+ ix, ok, err = index.Load(zone.IndexDir(session), session)
+ if err != nil || !ok {
+ t.Fatalf("index: ok=%v err=%v", ok, err)
+ }
+ for _, id := range []string{"p1/c1", "p1/c2"} {
+ if _, found := ix.EntryByRecord(id); !found {
+ t.Errorf("record %q missing from the index after the retry", id)
+ }
+ }
+}
+
func TestCollectKeepsALineItCannotRead(t *testing.T) {
src := t.TempDir()
out := filepath.Join(src, "asz-changes-inline", "output", session)
diff --git a/internal/adapters/claudecodeprovider/collect.go b/internal/adapters/claudecodeprovider/collect.go
index 3adc19b..a266ba4 100644
--- a/internal/adapters/claudecodeprovider/collect.go
+++ b/internal/adapters/claudecodeprovider/collect.go
@@ -507,6 +507,7 @@ func (c *Collector) collectSession(session string, files []*file, sn *seen, st *
Kind: sessiondata.KindProviderBody, Adapter: Name + "/" + Version, Dialect: Dialect,
Src: ".", Session: session,
}
+ var staged index.Staged
err := storage.WriteAtomic(filepath.Join(c.Zone.ProviderDir(session), name), storage.PermLanded, func(w io.Writer) error {
rw, err := sessiondata.NewWriter(w, hdr)
if err != nil {
@@ -516,14 +517,15 @@ func (c *Collector) collectSession(session string, files []*file, sn *seen, st *
if err := rw.Write(rec); err != nil {
return err
}
- e, blocks, body := index.FromRecord(ix, hdr, rec, uint32(seq), uint32(row+1))
- ix.AppendRecord(e, blocks, body)
+ staged.Add(index.FromRecord(ix, hdr, rec, uint32(seq), uint32(row+1)))
}
return rw.Close()
})
if err != nil {
+ state.GiveBack(seq)
return err
}
+ ix.AppendStaged(&staged)
for _, f := range batchFiles {
st.Bytes += f.size
}
diff --git a/internal/adapters/kiro/collect.go b/internal/adapters/kiro/collect.go
new file mode 100644
index 0000000..f3117cd
--- /dev/null
+++ b/internal/adapters/kiro/collect.go
@@ -0,0 +1,443 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package kiro
+
+import (
+ "crypto/sha256"
+ "encoding/hex"
+ "errors"
+ "fmt"
+ "io"
+ "os"
+ "path/filepath"
+ "time"
+
+ "github.com/apache/skywalking-ai-sessionizer/internal/adapters/claudecode"
+ "github.com/apache/skywalking-ai-sessionizer/internal/index"
+ "github.com/apache/skywalking-ai-sessionizer/internal/storage"
+ "github.com/apache/skywalking-ai-sessionizer/pkg/sessiondata"
+)
+
+// Collector lands Kiro V3 sessions into a storage zone.
+//
+// A session removed whole, as Kiro Crew removes the sessions of a task's
+// steps, is not found again and not visited again. Its landed files stay, and
+// they are the record of it.
+type Collector struct {
+ SourceRoot string
+ Zone *storage.Zone
+ MaxDelta int64
+ Now func() time.Time
+}
+
+// pass carries one session's collection state. The index lives here and not
+// on the Collector because it is one session's mutable state.
+type pass struct {
+ ix *index.Index
+ state *storage.SessionState
+ st *Stats
+ now time.Time
+}
+
+// maxDrainRounds bounds how many windows one source may land in a single pass,
+// so a source that grows faster than it is read cannot starve the others.
+const maxDrainRounds = 64
+
+// Stats summarises one collection pass.
+type Stats struct {
+ Sessions int
+ SourcesSeen int
+ SourcesLanded int
+ Records int
+ Bytes int64
+ // SourcesGone counts files discovery listed that were gone when the pass
+ // came to read them.
+ SourcesGone int
+ Conflicts int
+ // Busy counts sessions skipped because another collector holds their lock.
+ Busy int
+ // Pending counts sources that still had data when the per-pass drain
+ // limit was reached. A non-zero value means the pass was NOT complete.
+ Pending int
+ // Held counts sources whose last records wait for the turn they belong
+ // to. It is not an error: the records land on a later pass.
+ Held int
+ // Indexed is the total index entries across every session touched.
+ // Reindexed counts entries recovered from landed files because the index
+ // had fallen behind them.
+ Indexed int
+ Reindexed int
+ // Skipped is what discovery found and does not collect.
+ Skipped Skipped
+ Errors []error
+ // Changed lists the sessions whose landed data or index moved this pass.
+ Changed []string
+}
+
+// Complete reports whether the pass collected everything available.
+func (s *Stats) Complete() bool {
+ return s.Pending == 0 && s.Conflicts == 0 && len(s.Errors) == 0
+}
+
+// New returns a Collector with the default byte budget when none is given.
+func New(sourceRoot string, zone *storage.Zone, maxDelta int64) *Collector {
+ if maxDelta <= 0 {
+ maxDelta = 2 << 20
+ }
+ return &Collector{SourceRoot: sourceRoot, Zone: zone, MaxDelta: maxDelta, Now: time.Now}
+}
+
+// CollectAll discovers sessions and lands whatever is new in each.
+func (c *Collector) CollectAll(filter func(Session) bool) (*Stats, error) {
+ sessions, skipped, warnings, err := Discover(c.SourceRoot)
+ if err != nil {
+ return nil, err
+ }
+ st := &Stats{Skipped: skipped}
+ // A directory that could not be read hides sessions, so the pass must
+ // not look complete.
+ st.Errors = append(st.Errors, warnings...)
+ for _, s := range sessions {
+ if filter != nil && !filter(s) {
+ continue
+ }
+ st.Sessions++
+ if err := c.collectSession(s, st); err != nil {
+ st.Errors = append(st.Errors, fmt.Errorf("session %s: %w", s.ID, err))
+ }
+ }
+ return st, nil
+}
+
+// collectSession processes one session single-threaded, under its lock, so
+// the landed sequence stays monotonic across every stream of the session.
+func (c *Collector) collectSession(s Session, st *Stats) error {
+ lock, err := storage.LockSession(c.Zone.SessionDir(s.ID))
+ if err != nil {
+ if errors.Is(err, storage.ErrSessionBusy) {
+ st.Busy++
+ return nil
+ }
+ return err
+ }
+ defer func() { _ = lock.Unlock() }()
+
+ indexDir := c.Zone.IndexDir(s.ID)
+ ixState, err := storage.LoadIndexState(c.Zone.IndexStatePath(s.ID), s.ID)
+ if err != nil {
+ return err
+ }
+ statePath := c.Zone.SessionStatePath(s.ID)
+ state, err := storage.LoadSessionState(statePath, s.ID)
+ if err != nil {
+ return err
+ }
+ if err := state.RecoverNextSeq(c.Zone.SessionDir(s.ID)); err != nil {
+ return err
+ }
+ // Only an index that agrees with its saved state is extended; one that
+ // does not is built again.
+ ix, ok, err := index.LoadFor(indexDir, s.ID, ixState)
+ if err != nil {
+ return err
+ }
+ if !ok {
+ ix = index.New(s.ID)
+ ixState = storage.NewIndexState(s.ID)
+ }
+ // Cursors commit per source and the index once per session, so a crash
+ // between them leaves landed records the index does not hold. Nothing
+ // would land them again, so they are read back from the landed files.
+ reindexed := false
+ if landedTo := state.NextSeq - 1; ixState.IndexedSeq < landedTo {
+ n, rerr := index.Rebuild(c.Zone, s.ID, ix, ixState.IndexedSeq)
+ if rerr != nil {
+ return rerr
+ }
+ st.Reindexed += n
+ ixState.IndexedSeq = landedTo
+ reindexed = true
+ }
+
+ p := &pass{ix: ix, state: state, st: st, now: c.Now()}
+ changed := false
+ for _, src := range s.Sources {
+ st.SourcesSeen++
+ // Drain a source rather than land one window per pass, so a single
+ // pass over existing history is complete.
+ var landedAny bool
+ for round := 0; ; round++ {
+ landed, more, err := c.collectSource(src, p)
+ if err != nil {
+ st.Errors = append(st.Errors, err)
+ break
+ }
+ landedAny = landedAny || landed
+ if !more {
+ break
+ }
+ if round >= maxDrainRounds {
+ st.Pending++
+ break
+ }
+ }
+ if landedAny {
+ changed = true
+ st.SourcesLanded++
+ }
+ }
+
+ // A recovered index is saved even when nothing landed, or the rebuild
+ // would repeat on every pass.
+ if changed || reindexed {
+ st.Changed = append(st.Changed, s.ID)
+ ixState.Schema = index.Schema
+ ixState.IndexedSeq = state.NextSeq - 1
+ ixState.Entries = len(ix.Entries)
+ ixState.Blocks = len(ix.Blocks)
+ ixState.Strings = ix.Strings.Len()
+ if err := ix.Write(indexDir); err != nil {
+ return err
+ }
+ if err := ixState.Save(c.Zone.IndexStatePath(s.ID), p.now); err != nil {
+ return err
+ }
+ st.Indexed += ixState.Entries
+ }
+ state.LastScan = p.now.UTC().Format(time.RFC3339Nano)
+ return state.Save(statePath, p.now)
+}
+
+// cursorPaths returns the landing directory, the cursor path and the landed
+// file prefix for a source.
+//
+// The header lands in the main stream's directory under its own prefix and
+// cursor, as a sidecar does, so the main stream's records and the header's
+// snapshots never share a cursor.
+func (c *Collector) cursorPaths(src Source) (dir, cursorPath, prefix string) {
+ dir = c.Zone.StreamDir(src.Session, src.Stream)
+ prefix = "transcript"
+ if src.Kind == SrcHeader {
+ prefix = "meta"
+ }
+ return dir, filepath.Join(dir, prefix+".cursor"), prefix
+}
+
+// recordKind is the landed kind of a source. The header is what the runtime
+// recorded about the session's agent, which is what an agent_meta file holds.
+func recordKind(k SrcKind) sessiondata.Kind {
+ if k == SrcHeader {
+ return sessiondata.KindAgentMeta
+ }
+ return sessiondata.KindTranscript
+}
+
+func (c *Collector) collectSource(src Source, p *pass) (landed, more bool, err error) {
+ dir, cursorPath, prefix := c.cursorPaths(src)
+ kind := storage.CursorSnapshot
+ if src.Kind.Append() {
+ kind = storage.CursorAppend
+ }
+ cur, err := storage.LoadCursor(cursorPath, kind, src.Rel)
+ if err != nil {
+ return false, false, err
+ }
+ if cur.State == storage.CursorConflict {
+ p.st.Conflicts++
+ return false, false, nil // a conflict is sticky until resolved
+ }
+ // Discovery found the file, so it is not gone, whatever its cursor last
+ // said.
+ if cur.State == storage.CursorSourceGone {
+ cur.State = storage.CursorActive
+ if err := cur.Save(cursorPath, p.now); err != nil {
+ return false, false, err
+ }
+ }
+ if src.Kind.Append() {
+ return c.landAppend(src, cur, cursorPath, dir, prefix, p)
+ }
+ landed, err = c.landSnapshot(src, cur, cursorPath, dir, prefix, p)
+ return landed, false, err
+}
+
+func (c *Collector) landAppend(src Source, cur *storage.Cursor, cursorPath, dir, prefix string,
+ p *pass) (bool, bool, error) {
+ st, now := p.st, p.now
+
+ chunk, err := claudecode.TailAppend(src.Path, cur, c.MaxDelta)
+ switch {
+ case errors.Is(err, claudecode.ErrSourceGone):
+ return false, false, c.markGone(cur, cursorPath, st, now)
+ case err != nil:
+ var ce *claudecode.ConflictError
+ if errors.As(err, &ce) {
+ cur.State = storage.CursorConflict
+ st.Conflicts++
+ _ = cur.Save(cursorPath, now)
+ }
+ return false, false, err
+ }
+ if len(chunk.Lines) == 0 {
+ if chunk.Moved {
+ // The same bytes under a new identity. Record it now, or every
+ // later pass verifies the tail window again.
+ cur.Dev, cur.Ino = chunk.Dev, chunk.Ino
+ cur.Size, cur.MTime = chunk.Size, chunk.MTime
+ return false, false, cur.Save(cursorPath, now)
+ }
+ return false, false, nil
+ }
+
+ runs, keep, err := turnFor(chunk.Lines, src.Path, chunk.NewOffset, chunk.MTime, now)
+ if err != nil {
+ return false, false, err
+ }
+ lines, newOffset, newOrd, more := chunk.Lines, chunk.NewOffset, chunk.NewOrd, chunk.More
+ if keep < len(lines) {
+ // The rest waits for its turn. The cursor stops before it, so the
+ // next pass reads it again with whatever followed.
+ st.Held++
+ lines, newOffset, newOrd, more = lines[:keep], lines[keep].Off, lines[keep].Ord-1, false
+ }
+ if len(lines) == 0 {
+ return false, false, nil
+ }
+
+ seq := p.state.Take()
+ name := storage.LandedName(prefix, storage.Stamp(now), seq)
+ hdr := &sessiondata.Header{
+ H: 1, Seq: seq, At: now.UTC().Format(time.RFC3339Nano),
+ Kind: recordKind(src.Kind), Adapter: Name + "/" + Version, Dialect: Dialect,
+ Src: src.Rel, Session: src.Session, Stream: src.Stream,
+ }
+ var written int64
+ var staged index.Staged
+ err = storage.WriteAtomic(filepath.Join(dir, name), storage.PermLanded, func(w io.Writer) error {
+ rw, err := sessiondata.NewWriter(w, hdr)
+ if err != nil {
+ return err
+ }
+ for row, ln := range lines {
+ rec := Convert(src, ln.Ord, ln.Off, ln.Bytes, runs[row])
+ if err := rw.Write(rec); err != nil {
+ return err
+ }
+ written += int64(len(ln.Bytes))
+ // The index is built from the converted record, so it holds no
+ // Kiro field.
+ staged.Add(index.FromRecord(p.ix, hdr, rec, uint32(seq), uint32(row+1)))
+ }
+ return rw.Close()
+ })
+ if err != nil {
+ p.state.GiveBack(seq)
+ return false, false, err
+ }
+ p.ix.AppendStaged(&staged)
+
+ // Land before committing the cursor. A crash between the two lands the
+ // same records again, which assembly removes by record id.
+ digest, err := claudecode.TailDigestAt(src.Path, int64(newOffset))
+ if err != nil && !os.IsNotExist(err) {
+ return true, false, err
+ }
+ cur.Dev, cur.Ino = chunk.Dev, chunk.Ino
+ cur.Offset, cur.Ord = newOffset, newOrd
+ cur.TailSHA256 = digest
+ cur.Size, cur.MTime = chunk.Size, chunk.MTime
+ cur.LastSeq, cur.State = seq, storage.CursorActive
+
+ st.Records += len(lines)
+ st.Bytes += written
+ return true, more, cur.Save(cursorPath, now)
+}
+
+func (c *Collector) landSnapshot(src Source, cur *storage.Cursor, cursorPath, dir, prefix string,
+ p *pass) (bool, error) {
+ st, now := p.st, p.now
+
+ data, err := os.ReadFile(src.Path)
+ if err != nil {
+ if os.IsNotExist(err) {
+ return false, c.markGone(cur, cursorPath, st, now)
+ }
+ return false, err
+ }
+ body := trimTrailingNewline(data)
+ if len(body) == 0 {
+ return false, nil
+ }
+ sum := sha256.Sum256(body)
+ digest := hex.EncodeToString(sum[:])
+ if digest == cur.ContentSHA256 {
+ return false, nil // unchanged
+ }
+
+ seq := p.state.Take()
+ name := storage.LandedName(prefix, storage.Stamp(now), seq)
+ hdr := &sessiondata.Header{
+ H: 1, Seq: seq, At: now.UTC().Format(time.RFC3339Nano),
+ Kind: recordKind(src.Kind), Adapter: Name + "/" + Version, Dialect: Dialect,
+ Src: src.Rel, Session: src.Session, Stream: src.Stream,
+ }
+ var staged index.Staged
+ err = storage.WriteAtomic(filepath.Join(dir, name), storage.PermLanded, func(w io.Writer) error {
+ rw, err := sessiondata.NewWriter(w, hdr)
+ if err != nil {
+ return err
+ }
+ rec := Convert(src, 1, 0, body, "")
+ if err := rw.Write(rec); err != nil {
+ return err
+ }
+ staged.Add(index.FromRecord(p.ix, hdr, rec, uint32(seq), 1))
+ return rw.Close()
+ })
+ if err != nil {
+ p.state.GiveBack(seq)
+ return false, err
+ }
+ p.ix.AppendStaged(&staged)
+
+ cur.ContentSHA256 = digest
+ if fi, err := os.Stat(src.Path); err == nil {
+ cur.Size, cur.MTime = fi.Size(), fi.ModTime().Unix()
+ }
+ cur.LastSeq, cur.State = seq, storage.CursorActive
+
+ st.Records++
+ st.Bytes += int64(len(body))
+ return true, cur.Save(cursorPath, now)
+}
+
+func (c *Collector) markGone(cur *storage.Cursor, cursorPath string, st *Stats, now time.Time) error {
+ st.SourcesGone++
+ if cur.State == storage.CursorSourceGone {
+ return nil
+ }
+ cur.State = storage.CursorSourceGone
+ return cur.Save(cursorPath, now)
+}
+
+func trimTrailingNewline(b []byte) []byte {
+ for len(b) > 0 && (b[len(b)-1] == '\n' || b[len(b)-1] == '\r') {
+ b = b[:len(b)-1]
+ }
+ return b
+}
diff --git a/internal/adapters/kiro/convert.go b/internal/adapters/kiro/convert.go
new file mode 100644
index 0000000..20e66ae
--- /dev/null
+++ b/internal/adapters/kiro/convert.go
@@ -0,0 +1,457 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package kiro
+
+import (
+ "crypto/sha256"
+ "encoding/hex"
+ "encoding/json"
+ "strings"
+
+ "github.com/apache/skywalking-ai-sessionizer/pkg/model"
+ "github.com/apache/skywalking-ai-sessionizer/pkg/sessiondata"
+)
+
+// envelope is what every line of a V3 record file holds.
+type envelope struct {
+ ID string `json:"id"`
+ Timestamp string `json:"timestamp"`
+ Payload json.RawMessage `json:"payload"`
+}
+
+// payload is the part of a record the dialect reads. Fields of different
+// record types share it, because the engine never gives one name two meanings
+// that matter here.
+type payload struct {
+ Type string `json:"type"`
+
+ ExecutionID string `json:"executionId"`
+ ParentExecutionID string `json:"parentExecutionId"`
+ Context struct {
+ ExecutionID string `json:"executionId"`
+ } `json:"context"`
+
+ Content json.RawMessage `json:"content"`
+ Images []json.RawMessage `json:"images"`
+ Documents json.RawMessage `json:"documents"`
+ Meta struct {
+ Kiro struct {
+ SteeringClearedIDs json.RawMessage `json:"steeringClearedIds"`
+ SyntheticReason json.RawMessage `json:"syntheticUserMessageReason"`
+ ServerName string `json:"serverName"`
+ } `json:"kiro"`
+ } `json:"_meta"`
+
+ OperationType string `json:"operationType"`
+ ReasoningSignature string `json:"reasoningSignature"`
+ ReasoningRedacted string `json:"reasoningRedactedContent"`
+ SummaryReasoning json.RawMessage `json:"summaryReasoning"`
+
+ ToolCallID string `json:"toolCallId"`
+ ToolName string `json:"toolName"`
+ Args json.RawMessage `json:"args"`
+ Success *bool `json:"success"`
+
+ StopReason string `json:"stopReason"`
+ Key string `json:"key"`
+ Status string `json:"status"`
+ ElapsedTime json.RawMessage `json:"elapsedTime"`
+
+ SubSessionID string `json:"subSessionId"`
+ SubAgentName string `json:"subAgentName"`
+ Response json.RawMessage `json:"response"`
+}
+
+// reasoningPlaceholder is what the engine writes as the text of a reasoning
+// record whose reasoning it did not keep. The engine itself reads this value,
+// and an empty one, as no reasoning when it replays a session (engine
+// @kiro/agent 0.66.26), so it is no text here either.
+const reasoningPlaceholder = "..."
+
+// Convert turns one Kiro source record into a Session Data record.
+//
+// This is the only function that reads Kiro's shape. Everything above it sees
+// parts and role names. run is the turn the collector found for a record that
+// names none of its own; see turnFor.
+//
+// Two choices are forced by the store and are worth knowing:
+//
+// - The record id is the engine's id, then "#", then the record's digest.
+// The engine can give one id to two different records: when it asks
+// twice to approve one tool call, both requests carry one id and both
+// answers another. Measured over 56 sessions and 3,101 records, 46 ids
+// did so, for 23 approvals, and no other id was reused. Assembly
+// removes a repeated id as a replayed copy, so the engine's id alone
+// would drop the second record. The digest keeps the id stable for the
+// same bytes, which is what lets a record landed twice after a crash
+// still be removed as the copy it is.
+// - Nothing is grouped into a provider call. The engine lists the request
+// ids of a turn and ties no record to one, so a record names no call and
+// assembly draws none.
+func Convert(src Source, ord, off uint64, raw []byte, run string) *sessiondata.Record {
+ sum := sha256.Sum256(raw)
+ rec := &sessiondata.Record{
+ Ord: ord, Off: off,
+ Sha: hex.EncodeToString(sum[:])[:12],
+ Bytes: len(raw),
+ }
+ rec.From = sessiondata.FromRuntime
+ if src.Kind == SrcHeader {
+ return convertHeader(rec, raw)
+ }
+
+ var env envelope
+ if err := json.Unmarshal(raw, &env); err != nil {
+ // A record that will not parse is still evidence, and it is kept
+ // whole. It could be the system prompt, so it is named for one: a
+ // reader withholding the prompt is better kept from it than shown it.
+ rec.Parts = []sessiondata.Part{rawPart(raw, "the record is not valid JSON")}
+ rec.Flags = []string{sessiondata.FlagSystemPrompt}
+ return rec
+ }
+ rec.ID = env.ID + "#" + rec.Sha
+ rec.Time = env.Timestamp
+ var p payload
+ if len(env.Payload) == 0 || json.Unmarshal(env.Payload, &p) != nil || p.Type == "" {
+ rec.Flags = []string{"notice"}
+ rec.Parts = []sessiondata.Part{rawPart(raw, "the record has no payload this dialect can read")}
+ return rec
+ }
+ rec.Run = turnOf(&p)
+ if rec.Run == "" {
+ rec.Run = run
+ }
+
+ switch p.Type {
+ case "user":
+ convertUser(rec, &p, env.Payload)
+ case "agent_note":
+ // What the engine tells the model about the session, such as which
+ // model is current. It is written just before a prompt.
+ rec.Flags = []string{"injected"}
+ rec.Parts = []sessiondata.Part{textOf(p.Content)}
+ case "session_start":
+ // The system prompt the engine composed for the agent. It is written
+ // when the first turn ends, which is why it names no turn.
+ rec.Flags = []string{"injected", sessiondata.FlagSystemPrompt}
+ rec.Parts = []sessiondata.Part{textOf(p.Content)}
+ case "steering_inclusion":
+ // The steering documents the engine put into context for this turn.
+ rec.Flags = []string{"injected"}
+ rec.Parts = []sessiondata.Part{dataPart(env.Payload)}
+ case "assistant":
+ convertAssistant(rec, &p)
+ case "tool_call":
+ rec.From = sessiondata.FromAgent
+ part := sessiondata.Part{Kind: sessiondata.PartCall, ID: p.ToolCallID, Name: p.ToolName,
+ Data: p.Args, State: model.ContentAvailable, Bytes: len(p.Args)}
+ part.Server, part.ServerTool = mcpName(p.ToolName, p.Meta.Kiro.ServerName)
+ rec.Parts = []sessiondata.Part{part}
+ case "tool_result":
+ rec.From = sessiondata.FromExternal
+ part := sessiondata.Part{Kind: sessiondata.PartResult, Of: p.ToolCallID, State: model.ContentAvailable}
+ setResult(&part, p.Content)
+ if p.Success != nil {
+ failed := !*p.Success
+ part.Failed = &failed
+ }
+ rec.Parts = []sessiondata.Part{part}
+ case "sub_agent_start":
+ if p.SubSessionID == "" {
+ unknown(rec, p.Type, env.Payload)
+ break
+ }
+ // The engine records the spawn here and nowhere else: no tool call
+ // names it. The child's id is the only key the parent and the
+ // child's own file share, so it is the call's id, and the record is
+ // the call.
+ rec.From = sessiondata.FromAgent
+ rec.Tool, rec.Child = p.SubSessionID, p.SubSessionID
+ rec.Parts = []sessiondata.Part{{Kind: sessiondata.PartCall, ID: p.SubSessionID, Name: p.SubAgentName,
+ Data: env.Payload, State: model.ContentAvailable, Bytes: len(env.Payload)}}
+ case "sub_agent_complete":
+ if p.SubSessionID == "" {
+ unknown(rec, p.Type, env.Payload)
+ break
+ }
+ // The parent's copy of what the child returned, as the result of the
+ // call that started it.
+ rec.From = sessiondata.FromExternal
+ rec.Child = p.SubSessionID
+ rec.Flags = []string{"child_result"}
+ part := sessiondata.Part{Kind: sessiondata.PartResult, Of: p.SubSessionID, State: model.ContentAvailable}
+ setResult(&part, p.Response)
+ if p.Status != "" {
+ failed := p.Status != "success"
+ part.Failed = &failed
+ }
+ rec.Parts = []sessiondata.Part{part}
+ case "tombstone":
+ // A tombstone takes the records before it out of the context the
+ // model is sent. Its kind says why: a summary, or a checkpoint the
+ // person went back to. Either way the context was reset in place.
+ // Its effectiveFromMessageId names where the dropped records begin,
+ // not the last one before the reset, so it is kept in the data and
+ // not read as a continuation.
+ rec.Flags = []string{"context_reset"}
+ rec.Parts = []sessiondata.Part{dataPart(env.Payload)}
+ case "turn_end":
+ // A turn that ended any other way than the model finishing is
+ // something the requester saw: a turn they cancelled, or one an
+ // error stopped.
+ if p.StopReason != "end_turn" {
+ rec.Flags = []string{"notice"}
+ }
+ rec.Parts = []sessiondata.Part{dataPart(env.Payload)}
+ case "usage_summary":
+ // The engine's own measure of the turn, with its credits and the
+ // request ids it made. It holds no token counts. elapsedTime is the
+ // turn's length in milliseconds, from its start to its end, and is
+ // given first under durationMs, the name a turn's measurement is
+ // read by.
+ rec.Flags = []string{"turn_duration"}
+ rec.Parts = []sessiondata.Part{dataPart(env.Payload)}
+ var ms json.Number
+ if json.Unmarshal(p.ElapsedTime, &ms) == nil && ms != "" && p.ElapsedTime[0] != '"' {
+ d, _ := json.Marshal(map[string]json.Number{"durationMs": ms})
+ rec.Parts = append([]sessiondata.Part{dataPart(d)}, rec.Parts...)
+ }
+ case "session_metadata":
+ // The error the engine showed the requester. The other key measured,
+ // contextUsage, is a percentage of the context window and no step.
+ if p.Key == "displayError" {
+ rec.Flags = []string{"error"}
+ }
+ rec.Parts = []sessiondata.Part{dataPart(env.Payload)}
+ case "ContextualHookInvoked":
+ rec.Flags = []string{"notice"}
+ rec.Parts = []sessiondata.Part{dataPart(env.Payload)}
+ case "turn_start", "session_event", "pending_interaction", "interaction_resolved":
+ // The engine's own bookkeeping: a turn starting, the session pausing
+ // after it, and an approval asked and answered for a tool. Each is
+ // kept, and none is a step the model has a kind for.
+ rec.Parts = []sessiondata.Part{dataPart(env.Payload)}
+ default:
+ unknown(rec, p.Type, env.Payload)
+ }
+ return rec
+}
+
+// convertUser reads a record in the user role. Three different things are
+// written there, and the fields say which.
+func convertUser(rec *sessiondata.Record, p *payload, raw json.RawMessage) {
+ switch {
+ case p.Meta.Kiro.SteeringClearedIDs != nil:
+ // The engine noting that the messages a person sent during a turn
+ // were consumed. It has no content of its own.
+ rec.Parts = []sessiondata.Part{dataPart(raw)}
+ case p.Meta.Kiro.SyntheticReason != nil:
+ // The text the engine wrote to carry such a message to the model.
+ // The person's own words are the steering record before it.
+ rec.From = sessiondata.FromExternal
+ rec.Flags = []string{"injected"}
+ rec.Parts = userParts(p)
+ default:
+ // A prompt, or a message sent while a turn ran. Kiro Crew writes its
+ // own prompts here too, which is input from outside the agent all
+ // the same.
+ rec.From = sessiondata.FromExternal
+ rec.Trigger = model.TriggerExternal
+ rec.Flags = []string{"external_input"}
+ rec.Parts = userParts(p)
+ }
+}
+
+// userParts is a user record's text, then its images, then its documents.
+func userParts(p *payload) []sessiondata.Part {
+ parts := []sessiondata.Part{textOf(p.Content)}
+ for _, raw := range p.Images {
+ var img struct {
+ Data json.RawMessage `json:"data"`
+ MimeType string `json:"mimeType"`
+ }
+ var data string
+ if json.Unmarshal(raw, &img) != nil || json.Unmarshal(img.Data, &data) != nil {
+ parts = append(parts, rawPart(raw, "the image is not one this dialect describes"))
+ continue
+ }
+ parts = append(parts, sessiondata.Part{Kind: sessiondata.PartMedia, Media: img.MimeType,
+ Data: img.Data, State: model.ContentAvailable, Bytes: len(data)})
+ }
+ // Every measured record has an empty list. A document that does arrive
+ // travels as it came.
+ if len(p.Documents) > 0 && string(p.Documents) != "[]" && string(p.Documents) != "null" {
+ parts = append(parts, dataPart(p.Documents))
+ }
+ return parts
+}
+
+// convertAssistant reads a record the agent produced.
+func convertAssistant(rec *sessiondata.Record, p *payload) {
+ switch p.OperationType {
+ case "Summary":
+ // The summary a compaction carried forward. The engine sends it to
+ // the model as a human message, so it is input here, not output.
+ rec.From = sessiondata.FromExternal
+ rec.Flags = []string{"reset_summary"}
+ rec.Parts = []sessiondata.Part{textOf(p.Content)}
+ if len(p.SummaryReasoning) > 0 {
+ rec.Parts = append(rec.Parts, dataPart(p.SummaryReasoning))
+ }
+ case "Reasoning":
+ rec.From = sessiondata.FromAgent
+ rec.Parts, rec.Dropped = reasoningOf(p)
+ default:
+ // Say, and Print, which the engine sends back to the model as the
+ // agent's own text. So does any other operation it does not name.
+ rec.From = sessiondata.FromAgent
+ rec.Parts = []sessiondata.Part{textOf(p.Content)}
+ }
+}
+
+// reasoningOf is a reasoning record's thought, and what it holds that no
+// reader can use.
+func reasoningOf(p *payload) ([]sessiondata.Part, []sessiondata.Drop) {
+ if len(p.Content) > 0 && p.Content[0] != '"' && string(p.Content) != "null" {
+ return []sessiondata.Part{rawPart(p.Content, "the reasoning is not text")}, nil
+ }
+ var text string
+ _ = json.Unmarshal(p.Content, &text)
+ state := model.ContentAvailable
+ if text == "" || text == reasoningPlaceholder {
+ text, state = "", model.ContentUnavailable
+ }
+ if p.ReasoningRedacted != "" {
+ state = model.ContentRedacted
+ }
+ part := sessiondata.Part{Kind: sessiondata.PartReasoning, Text: text, State: state, Bytes: len(text)}
+ var dropped []sessiondata.Drop
+ if p.ReasoningSignature != "" {
+ dropped = append(dropped, sessiondata.Drop{What: "reasoning signature", Bytes: len(p.ReasoningSignature),
+ Why: "a provider verifies it; a reader cannot read it"})
+ }
+ if p.ReasoningRedacted != "" {
+ dropped = append(dropped, sessiondata.Drop{What: "redacted reasoning", Bytes: len(p.ReasoningRedacted),
+ Why: "the provider redacted it; a reader cannot read it"})
+ }
+ return []sessiondata.Part{part}, dropped
+}
+
+// convertHeader reads session.json: the session's title, and the whole header
+// as data.
+func convertHeader(rec *sessiondata.Record, raw []byte) *sessiondata.Record {
+ var h header
+ if err := json.Unmarshal(raw, &h); err != nil {
+ rec.Parts = []sessiondata.Part{rawPart(raw, "the header is not valid JSON")}
+ return rec
+ }
+ rec.Label = h.Title
+ rec.Parts = []sessiondata.Part{dataPart(raw)}
+ return rec
+}
+
+// turnOf is the turn a record names itself: the turn it belongs to, the turn
+// that started a subagent, or the turn a session event reports on.
+func turnOf(p *payload) string {
+ switch {
+ case p.ExecutionID != "":
+ return p.ExecutionID
+ case p.ParentExecutionID != "":
+ return p.ParentExecutionID
+ }
+ return p.Context.ExecutionID
+}
+
+// wantsTurn reports whether a record belongs to a turn it does not name. A
+// person's prompt, the note the engine writes before it, and a message sent
+// during a turn carry no turn id in any measured session. The note that
+// steering messages were consumed belongs to none.
+func wantsTurn(p *payload) bool {
+ if turnOf(p) != "" {
+ return false
+ }
+ switch p.Type {
+ case "user":
+ return p.Meta.Kiro.SteeringClearedIDs == nil
+ case "agent_note":
+ return true
+ }
+ return false
+}
+
+// unknown keeps a record this dialect does not describe, whole, and says the
+// runtime reported something. A later version of the dialect can read it
+// without the source.
+func unknown(rec *sessiondata.Record, typ string, raw json.RawMessage) {
+ rec.From = sessiondata.FromRuntime
+ rec.Flags = []string{"notice"}
+ rec.Parts = []sessiondata.Part{rawPart(raw, "record type \""+typ+"\" is not one this dialect describes")}
+}
+
+// mcpName splits a tool's name into the MCP server and the server's tool. The
+// engine names such a tool "mcp_" + the server's name with "-" written "_" +
+// "_" + the tool's own name, and records the server's name beside it. Both
+// are set only where the name splits exactly that way.
+func mcpName(tool, server string) (string, string) {
+ if server == "" {
+ return "", ""
+ }
+ rest, ok := strings.CutPrefix(tool, "mcp_"+strings.ReplaceAll(server, "-", "_")+"_")
+ if !ok || rest == "" {
+ return "", ""
+ }
+ return server, rest
+}
+
+// textOf is a text part from a field that should hold a string. Anything else
+// keeps its bytes.
+func textOf(raw json.RawMessage) sessiondata.Part {
+ if len(raw) == 0 || string(raw) == "null" {
+ return sessiondata.Part{Kind: sessiondata.PartText, State: model.ContentAvailable}
+ }
+ var s string
+ if raw[0] != '"' || json.Unmarshal(raw, &s) != nil {
+ return rawPart(raw, "the content is not text")
+ }
+ return sessiondata.Part{Kind: sessiondata.PartText, Text: s, State: model.ContentAvailable, Bytes: len(s)}
+}
+
+// setResult puts a result's content on its part: as text when it is a string,
+// and as it came otherwise.
+func setResult(part *sessiondata.Part, raw json.RawMessage) {
+ if len(raw) == 0 {
+ return
+ }
+ var s string
+ if raw[0] == '"' && json.Unmarshal(raw, &s) == nil {
+ part.Text, part.Bytes = s, len(s)
+ return
+ }
+ part.Data, part.Bytes = raw, len(raw)
+}
+
+func dataPart(raw json.RawMessage) sessiondata.Part {
+ return sessiondata.Part{Kind: sessiondata.PartData, Data: raw, State: model.ContentAvailable, Bytes: len(raw)}
+}
+
+// rawPart keeps bytes the dialect could not describe, every one of them.
+func rawPart(b []byte, why string) sessiondata.Part {
+ p := sessiondata.Part{Kind: sessiondata.PartUnknown, Text: why, State: model.ContentAvailable, Bytes: len(b)}
+ p.SetRaw(b)
+ return p
+}
diff --git a/internal/adapters/kiro/discover.go b/internal/adapters/kiro/discover.go
new file mode 100644
index 0000000..0453217
--- /dev/null
+++ b/internal/adapters/kiro/discover.go
@@ -0,0 +1,238 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package kiro
+
+import (
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io/fs"
+ "os"
+ "path"
+ "path/filepath"
+ "sort"
+ "strings"
+
+ "github.com/apache/skywalking-ai-sessionizer/internal/storage"
+)
+
+// SrcKind says what a source file is.
+type SrcKind int
+
+const (
+ // SrcMessages is a session's messages.jsonl, its main stream.
+ SrcMessages SrcKind = iota + 1
+ // SrcChild is sub-executions/<id>.jsonl, the stream of one subagent.
+ SrcChild
+ // SrcHeader is session.json, the session's header. The engine replaces it
+ // whole, also between turns, so it lands as a new snapshot whenever its
+ // bytes change.
+ SrcHeader
+)
+
+// Append reports whether a source only ever grows.
+func (k SrcKind) Append() bool { return k == SrcMessages || k == SrcChild }
+
+// Source is one file of a session.
+type Source struct {
+ Kind SrcKind
+ // Path is absolute. Rel is relative to the source root, with forward
+ // slashes on every platform.
+ Path, Rel string
+ Session string
+ Stream string
+}
+
+// Session is one V3 session directory.
+type Session struct {
+ ID string
+ // Rel is the session directory, relative to the source root.
+ Rel string
+ // Workspaces are the paths the session worked in, from its header. They
+ // are what the include and exclude patterns are matched against.
+ Workspaces []string
+ Sources []Source
+}
+
+// Skipped counts what discovery found and does not collect, so a person is
+// told it is there.
+type Skipped struct {
+ // V2 counts sessions of kiro-cli's V2 engine, sessions/cli/<id>.jsonl.
+ V2 int
+ // Schemas counts V3 sessions by a schema this adapter does not read.
+ Schemas map[string]int
+ // Unreadable counts V3 session headers that could not be read as JSON.
+ Unreadable int
+}
+
+// Total is everything discovery did not collect.
+func (s Skipped) Total() int {
+ n := s.V2 + s.Unreadable
+ for _, c := range s.Schemas {
+ n += c
+ }
+ return n
+}
+
+// header is the part of session.json discovery reads.
+type header struct {
+ SchemaVersion *string `json:"schemaVersion"`
+ Title string `json:"title"`
+ WorkspacePaths []string `json:"workspacePaths"`
+}
+
+const (
+ headerFile = "session.json"
+ messagesFile = "messages.jsonl"
+ childDir = "sub-executions"
+ // v2Dir is where kiro-cli's V2 engine keeps its sessions. It sits among
+ // the V3 workspace directories and is not one.
+ v2Dir = "cli"
+)
+
+// Discover lists the V3 sessions under a source root.
+//
+// The walk is sessions/<workspace key>/sess_<id>/. Nothing else under a
+// session is read: large tool output the engine spilled to tool-outputs/,
+// the file snapshots it keeps for checkpoints, its upload cursors and its
+// temporary header copies are not the conversation.
+//
+// The warnings are directories that could not be read. A pass that met one
+// has not seen every session, and says so rather than look complete.
+func Discover(root string) ([]Session, Skipped, []error, error) {
+ skipped := Skipped{Schemas: map[string]int{}}
+ base := filepath.Join(root, "sessions")
+ keys, err := os.ReadDir(base)
+ if err != nil {
+ if errors.Is(err, fs.ErrNotExist) {
+ return nil, skipped, nil, nil
+ }
+ return nil, skipped, nil, err
+ }
+ var sessions []Session
+ var warnings []error
+ for _, k := range keys {
+ if !k.IsDir() || strings.HasPrefix(k.Name(), ".") {
+ continue
+ }
+ dir := filepath.Join(base, k.Name())
+ items, err := os.ReadDir(dir)
+ if err != nil {
+ warnings = append(warnings, fmt.Errorf("kiro: %s: %w", dir, err))
+ continue
+ }
+ if k.Name() == v2Dir {
+ for _, it := range items {
+ if !it.IsDir() && strings.HasSuffix(it.Name(), ".jsonl") {
+ skipped.V2++
+ }
+ }
+ continue
+ }
+ for _, it := range items {
+ if !it.IsDir() || !IsSessionID(it.Name()) {
+ continue
+ }
+ s, ok, err := readSession(root, path.Join("sessions", k.Name(), it.Name()), &skipped)
+ if err != nil {
+ warnings = append(warnings, err)
+ continue
+ }
+ if ok {
+ sessions = append(sessions, s)
+ }
+ }
+ }
+ sort.Slice(sessions, func(i, j int) bool { return sessions[i].ID < sessions[j].ID })
+ return sessions, skipped, warnings, nil
+}
+
+// readSession reads one session directory. It reports false for a directory
+// that is not a session this adapter collects, counting why in skipped.
+func readSession(root, rel string, skipped *Skipped) (Session, bool, error) {
+ dir := filepath.Join(root, filepath.FromSlash(rel))
+ id := path.Base(rel)
+ data, err := os.ReadFile(filepath.Join(dir, headerFile))
+ if err != nil {
+ // The engine writes the header when it creates the session and
+ // replaces it by renaming, so a missing one is a directory being
+ // created or removed right now. The next pass sees which.
+ if errors.Is(err, fs.ErrNotExist) {
+ return Session{}, false, nil
+ }
+ return Session{}, false, fmt.Errorf("kiro: %s: %w", dir, err)
+ }
+ var h header
+ if err := json.Unmarshal(data, &h); err != nil {
+ skipped.Unreadable++
+ return Session{}, false, nil
+ }
+ if h.SchemaVersion == nil || *h.SchemaVersion != Schema {
+ v := "none"
+ if h.SchemaVersion != nil {
+ v = *h.SchemaVersion
+ }
+ skipped.Schemas[v]++
+ return Session{}, false, nil
+ }
+
+ s := Session{ID: id, Rel: rel, Workspaces: h.WorkspacePaths}
+ add := func(kind SrcKind, name, stream string) {
+ r := path.Join(rel, name)
+ s.Sources = append(s.Sources, Source{Kind: kind, Path: filepath.Join(root, filepath.FromSlash(r)),
+ Rel: r, Session: id, Stream: stream})
+ }
+ add(SrcHeader, headerFile, storage.StreamMain)
+ hasRecord := false
+ if fi, err := os.Stat(filepath.Join(dir, messagesFile)); err == nil && fi.Mode().IsRegular() {
+ add(SrcMessages, messagesFile, storage.StreamMain)
+ hasRecord = fi.Size() > 0
+ }
+ children, err := os.ReadDir(filepath.Join(dir, childDir))
+ if err != nil && !errors.Is(err, fs.ErrNotExist) {
+ return Session{}, false, fmt.Errorf("kiro: %s: %w", filepath.Join(dir, childDir), err)
+ }
+ for _, c := range children {
+ child, ok := childOf(c.Name())
+ if !c.Type().IsRegular() || !ok {
+ continue
+ }
+ add(SrcChild, path.Join(childDir, c.Name()), child)
+ if info, err := c.Info(); err == nil && info.Size() > 0 {
+ hasRecord = true
+ }
+ }
+ // A session with a header and no record is not a conversation yet. Kiro
+ // Crew opens sessions ahead of use, and 30 of 86 measured held nothing
+ // else. It is collected once it has a record, and an empty file holds none.
+ if !hasRecord {
+ return Session{}, false, nil
+ }
+ return s, true, nil
+}
+
+// childOf reads a subagent's stream name from its file name. The file name is
+// the only identity every record of a child shares: some of them do not carry
+// the child's id inside.
+func childOf(name string) (string, bool) {
+ id, ok := strings.CutSuffix(name, ".jsonl")
+ if !ok || !childIDRe.MatchString(id) || id == storage.StreamMain {
+ return "", false
+ }
+ return id, true
+}
diff --git a/internal/adapters/kiro/glossary.go b/internal/adapters/kiro/glossary.go
new file mode 100644
index 0000000..b510a51
--- /dev/null
+++ b/internal/adapters/kiro/glossary.go
@@ -0,0 +1,130 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package kiro
+
+import "github.com/apache/skywalking-ai-sessionizer/pkg/model"
+
+// glossary is what Kiro's V3 engine calls the things the model names.
+//
+// It lives beside the code that reads them, so a rename in the store is made in
+// both places at once. A test checks that the model's whole vocabulary is
+// accounted for. An empty Native says the engine has no word for something,
+// which is more useful than a plausible one that sends a reader looking for a
+// field that is not there.
+//
+// Two words here mean something different from what a reader might expect.
+// Kiro's "execution" is one turn, which is a run here. And Kiro writes a
+// subagent's records in a file of its own, a "sub-execution", which is a
+// child stream here.
+var glossary = model.NewGlossary(Dialect,
+ // ---- the roles a record plays ----
+ model.Term{Unified: model.RoleID, Native: "id", Where: "every record",
+ Note: "followed by # and the first 12 hex digits of the record's SHA-256; the engine gives one id to an approval it asks twice"},
+ model.Term{Unified: model.RoleParent, Note: "no equivalent: a record names no record before it"},
+ model.Term{Unified: model.RoleCall, Note: "no equivalent: a turn lists its request ids in usage_summary, and no record names one"},
+ model.Term{Unified: model.RoleBatch, Note: "no equivalent: subagents started together share only their turn"},
+ model.Term{Unified: model.RoleContinues, Note: "no equivalent: a tombstone's effectiveFromMessageId names where the dropped records begin, not the last one kept"},
+ model.Term{Unified: model.RoleChild, Native: "subSessionId", Where: "sub_agent_start and sub_agent_complete",
+ Note: "also the name of the child's file under sub-executions"},
+ model.Term{Unified: model.RoleTime, Native: "timestamp", Where: "every record",
+ Note: "not in line order; line order is the order"},
+ model.Term{Unified: model.RoleModel, Note: "not read: no record says which model answered; session.json names the model chosen"},
+ model.Term{Unified: model.RoleTrigger, Note: "derived: a user record that is neither a steering note nor written by the engine"},
+
+ // ---- structure ----
+ model.Term{Unified: model.KindConversation, Native: "id", Where: "session.json",
+ Note: "one session is one conversation; Kiro Crew names no chain between the sessions of one chat"},
+ model.Term{Unified: model.KindSegment, Note: "an activity window chosen for commit"},
+ model.Term{Unified: model.KindSession, Native: "session", Where: "sessions/<workspace key>/sess_<id>"},
+ model.Term{Unified: model.KindStream, Native: "sub-execution", Where: "sub-executions/<subSessionId>.jsonl",
+ Note: "the main stream is messages.jsonl"},
+ model.Term{Unified: model.KindEpoch, Note: "the span between two tombstones"},
+ model.Term{Unified: model.KindTalk, Note: "one readable interaction: a prompt and the turn it opened"},
+ model.Term{Unified: model.KindRun, Native: "executionId", Where: "turn_start and the records of the turn",
+ Note: "a prompt carries none; it belongs to the first turn named after it in the file"},
+
+ // ---- steps ----
+ model.Term{Unified: model.KindMessageExternal, Native: "user", Where: "messages.jsonl",
+ Note: "also a message sent during a turn, source steer; Kiro Crew writes its own prompts here too"},
+ model.Term{Unified: model.KindMessageAssistant, Native: "assistant, operationType Say", Where: "messages.jsonl"},
+ model.Term{Unified: model.KindMessageSynthetic, Note: "not read: records the engine writes on recovery end their id in -synthetic, are a tool result, a child's end or a turn's end, and none was measured"},
+ model.Term{Unified: model.KindContextInjection, Native: "agent_note, session_start, steering_inclusion",
+ Where: "messages.jsonl",
+ Note: "and a user record with syntheticUserMessageReason, the engine's text around a steering message"},
+ model.Term{Unified: model.KindLLMCall, Note: "no equivalent: the engine records what the model said and not which request said it"},
+ model.Term{Unified: model.KindThinking, Native: "assistant, operationType Reasoning", Where: "messages.jsonl",
+ Note: "the text ... means the engine kept no reasoning"},
+ model.Term{Unified: model.KindTool, Native: "tool_call and tool_result", Where: "messages.jsonl and sub-executions",
+ Note: "joined by toolCallId"},
+ model.Term{Unified: model.KindAgentCall, Native: "sub_agent_start", Where: "messages.jsonl",
+ Note: "no tool call names the spawn; the record is the call"},
+ model.Term{Unified: model.KindAgentLaunchAck, Note: "no equivalent: nothing acknowledges a subagent separately"},
+ model.Term{Unified: model.KindAgentOutput, Native: "sub_agent_complete", Where: "messages.jsonl",
+ Note: "the response the parent received; the child's own last record is in its file"},
+ model.Term{Unified: model.KindRuntimeNotification, Note: "no equivalent: a subagent's end is the result of its call"},
+ model.Term{Unified: model.KindEpochBoundary, Native: "tombstone", Where: "messages.jsonl",
+ Note: "kind summarization or checkpoint_revert"},
+ model.Term{Unified: model.KindEpochSummary, Native: "assistant, operationType Summary", Where: "messages.jsonl",
+ Note: "nothing names the tombstone it follows"},
+ model.Term{Unified: model.KindErrorAPI, Native: "session_metadata, key displayError", Where: "messages.jsonl"},
+ model.Term{Unified: model.KindControlInterrupt, Note: "not read: a turn the person cancelled ends with stopReason cancelled, which lands as a notice"},
+ model.Term{Unified: model.KindControlPermission, Note: "not read: pending_interaction and interaction_resolved are landed and are no step"},
+ model.Term{Unified: model.KindControlCommand, Note: "no equivalent: a slash command leaves no record of its own"},
+ model.Term{Unified: model.KindTurnDuration, Native: "usage_summary", Where: "messages.jsonl",
+ Note: "elapsedTime, with the turn's credits; no token counts"},
+ model.Term{Unified: model.KindControlNotice, Native: "turn_end, ContextualHookInvoked", Where: "messages.jsonl",
+ Note: "a turn_end whose stopReason is not end_turn; also a record with no type, or a type this adapter does not describe"},
+
+ // ---- relations ----
+ model.Term{Unified: model.RelStarts, Native: "subSessionId", Where: "sub_agent_start"},
+ model.Term{Unified: model.RelReports, Note: "no equivalent"},
+ model.Term{Unified: model.RelEndsWith, Note: "derived from a child's last response"},
+ model.Term{Unified: model.RelResultOf, Native: "toolCallId", Where: "tool_result",
+ Note: "subSessionId for a subagent's end"},
+ model.Term{Unified: model.RelFollows, Note: "derived from line order"},
+ model.Term{Unified: model.RelSummarizes, Note: "not read: nothing joins a summary to its tombstone"},
+ model.Term{Unified: model.RelInSegment, Note: "derived"},
+ model.Term{Unified: model.RelRetries, Note: "no equivalent"},
+ model.Term{Unified: model.RelCancels, Note: "no equivalent"},
+ model.Term{Unified: model.RelInputOf, Note: "no equivalent"},
+
+ // ---- qualification; all derived, none of it in the store ----
+ model.Term{Unified: model.ExactUnique, Note: "one toolCallId or subSessionId on both records"},
+ model.Term{Unified: model.ExactAmbiguous, Note: "derived"},
+ model.Term{Unified: model.StrongInference, Note: "derived"},
+ model.Term{Unified: model.WeakInference, Note: "derived"},
+ model.Term{Unified: model.Unresolved, Note: "derived"},
+ model.Term{Unified: model.Conflict, Note: "derived"},
+
+ // ---- what a part holds ----
+ model.Term{Unified: model.ContentAvailable, Note: "the engine keeps text and tool results whole, apart from large tool output"},
+ model.Term{Unified: model.ContentRedacted, Native: "reasoningRedactedContent", Where: "a Reasoning record"},
+ model.Term{Unified: model.ContentOmitted, Note: "no equivalent"},
+ model.Term{Unified: model.ContentHashOnly, Note: "no equivalent"},
+ model.Term{Unified: model.ContentSizeOnly, Note: "no equivalent"},
+ model.Term{Unified: model.ContentTruncated, Note: "not read: the model is sent a preview of a large tool output, and that preview is the result here; the whole output is in tool-outputs, which is not collected"},
+ model.Term{Unified: model.ContentUnavailable, Native: "...", Where: "the content of a Reasoning record"},
+
+ // ---- what began a turn ----
+ model.Term{Unified: model.TriggerExternal, Native: "user", Where: "messages.jsonl"},
+ model.Term{Unified: model.TriggerNotification, Note: "no equivalent"},
+ model.Term{Unified: model.TriggerUnknown, Note: "derived"},
+)
+
+// Glossary is what this runtime calls the things the model names.
+func Glossary() *model.Glossary { return glossary }
diff --git a/internal/adapters/kiro/kiro.go b/internal/adapters/kiro/kiro.go
new file mode 100644
index 0000000..dd198ea
--- /dev/null
+++ b/internal/adapters/kiro/kiro.go
@@ -0,0 +1,86 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+// Package kiro implements the kiro-local adapter: a pull collector that reads
+// the sessions Kiro's V3 agent engine writes on disk.
+//
+// The V3 engine is what kiro-cli runs with --v3 or with chat.agentEngine set
+// to v3, what Kiro Crew runs on its kas backend, and what the Kiro IDE runs.
+// Its store was measured on kiro-cli 2.28.0 with engine @kiro/agent 0.66.26:
+// one directory per session under ~/.kiro/sessions, holding the session's
+// header, its records as JSON lines, and one file of records per subagent.
+//
+// The older stores are not read. kiro-cli's V2 engine writes
+// sessions/cli/<id>.jsonl, and its V1 engine a SQLite database beside the
+// login. Discovery counts the V2 sessions so a person is told they are there
+// and why they are not collected.
+package kiro
+
+import (
+ "os"
+ "path/filepath"
+ "regexp"
+)
+
+// Name is the adapter identifier used in configuration and in landed headers.
+const Name = "kiro-local"
+
+// Version is the adapter's contract version, recorded in landed headers so a
+// bundle can be interpreted against the adapter that produced it.
+const Version = "0.1.0"
+
+// Dialect names the schema the records were read as.
+const Dialect = "kiro/1"
+
+// RuntimeName is the runtime a landed Kiro session came from, the service a
+// push names it under.
+const RuntimeName = "Kiro"
+
+// Schema is the only session schema this adapter reads. It is the one the
+// engine writes in every measured session. A session that names another is
+// reported and left alone until that schema is measured.
+const Schema = "1.0.0"
+
+// sessionIDRe matches a V3 session directory, which is also the session's own
+// id in its header.
+var sessionIDRe = regexp.MustCompile(`^sess_[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$`)
+
+// IsSessionID reports whether name is a V3 session id.
+func IsSessionID(name string) bool { return sessionIDRe.MatchString(name) }
+
+// childIDRe matches a subagent's file name without its extension. The engine
+// names the file by the child's session id, a UUID in every measured case. The
+// pattern is wider than that, so an id of another shape is still collected,
+// and narrow enough that the name is safe as a directory.
+var childIDRe = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$`)
+
+// ResolveSourceRoot determines where Kiro keeps its sessions.
+//
+// The engine writes under the home directory unless it is started with
+// --home-dir, and it does not read KIRO_HOME. kiro-cli does not say how it
+// starts the engine, so nothing here guesses: the root is the override, or
+// the default.
+func ResolveSourceRoot(override string) (string, error) {
+ if override != "" {
+ return filepath.Abs(override)
+ }
+ home, err := os.UserHomeDir()
+ if err != nil {
+ return "", err
+ }
+ return filepath.Join(home, ".kiro"), nil
+}
diff --git a/internal/adapters/kiro/kiro_test.go b/internal/adapters/kiro/kiro_test.go
new file mode 100644
index 0000000..e59c528
--- /dev/null
+++ b/internal/adapters/kiro/kiro_test.go
@@ -0,0 +1,847 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package kiro_test
+
+import (
+ "bytes"
+ "encoding/json"
+ "errors"
+ "io"
+ "io/fs"
+ "os"
+ "path/filepath"
+ "reflect"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/apache/skywalking-ai-sessionizer/internal/adapters/kiro"
+ "github.com/apache/skywalking-ai-sessionizer/internal/index"
+ "github.com/apache/skywalking-ai-sessionizer/internal/parse"
+ "github.com/apache/skywalking-ai-sessionizer/internal/storage"
+ "github.com/apache/skywalking-ai-sessionizer/internal/verify"
+ "github.com/apache/skywalking-ai-sessionizer/internal/view"
+ "github.com/apache/skywalking-ai-sessionizer/pkg/model"
+ "github.com/apache/skywalking-ai-sessionizer/pkg/sessiondata"
+ "github.com/apache/skywalking-ai-sessionizer/pkg/sessionview"
+)
+
+// The fixtures are measured sessions of kiro-cli 2.28.0 on the V3 engine
+// (@kiro/agent 0.66.26), and of Kiro Crew 0.7.2 on it. The kit's sessions keep
+// their text, which the kit wrote, with paths, the engine's system prompt and
+// reasoning signatures replaced. The sessions from real use keep only their
+// record structure: every text a person, the agent or a tool wrote is
+// replaced, and their headers keep only the fields every header has.
+const fixtures = "testdata"
+
+const (
+ // Two subagents started in parallel in one turn.
+ subagents = "sess_acd7b622-5c65-468e-a288-6bb404e112f2"
+ // Hooks on every step, a failed tool, a subagent whose file has hook
+ // records without the child's id, then a compaction and a turn after it.
+ combined = "sess_d3a5510d-40b8-4a77-be1c-bf31a17f584d"
+ // A turn the person cut with Ctrl-C while a tool ran.
+ cancelled = "sess_03390a84-1c44-4785-bcd2-d4b15891b24a"
+ // A tool the person denied when asked to approve it.
+ denied = "sess_77537f32-41e4-4bfc-b412-e80db5ec1d51"
+ // Kiro Crew: two turns an expired login failed, then messages sent
+ // during two later turns, and the engine's text around them.
+ crew = "sess_5f20ccf4-b254-4bdb-945b-080e051eb7f5"
+ // A Kiro Crew background prompt with two images.
+ images = "sess_8272aa81-fa49-4d34-aa24-8eff99377673"
+)
+
+var allFixtures = []string{subagents, combined, cancelled, denied, crew, images}
+
+// root is a storage root the fixtures, or a copy of some, are landed into.
+type root struct {
+ t *testing.T
+ zone *storage.Zone
+ col *kiro.Collector
+}
+
+func newRoot(t *testing.T, src string) *root {
+ t.Helper()
+ z := storage.NewZone(t.TempDir())
+ return &root{t: t, zone: z, col: kiro.New(src, z, 0)}
+}
+
+func (r *root) collect() *kiro.Stats {
+ r.t.Helper()
+ st, err := r.col.CollectAll(nil)
+ if err != nil {
+ r.t.Fatal(err)
+ }
+ if !st.Complete() {
+ r.t.Fatalf("pass not complete: %+v", st)
+ }
+ return st
+}
+
+func (r *root) parse(id string) *parse.Round {
+ r.t.Helper()
+ res, err := parse.Session(r.zone, parse.Options{Conversation: id, Session: id})
+ if err != nil {
+ r.t.Fatal(err)
+ }
+ return res
+}
+
+func (r *root) doc(id string) *sessionview.Conversation {
+ r.t.Helper()
+ c, err := view.New(r.zone, nil).Load(id)
+ if err != nil {
+ r.t.Fatal(err)
+ }
+ d, err := c.Build()
+ if err != nil {
+ r.t.Fatal(err)
+ }
+ if d.Summary.State != sessionview.StateVerified || len(d.Summary.Problems) > 0 {
+ r.t.Fatalf("%s: %s %v", id, d.Summary.State, d.Summary.Problems)
+ }
+ return d
+}
+
+// landed is every fixture collected and parsed once.
+func landed(t *testing.T) *root {
+ t.Helper()
+ r := newRoot(t, fixtures)
+ r.collect()
+ for _, id := range allFixtures {
+ r.parse(id)
+ }
+ return r
+}
+
+// nodes is every node of a document, flattened.
+func nodes(d *sessionview.Conversation) []sessionview.Node {
+ var out []sessionview.Node
+ var walk func(ns []sessionview.Node)
+ walk = func(ns []sessionview.Node) {
+ for _, n := range ns {
+ out = append(out, n)
+ walk(n.Children)
+ }
+ }
+ walk(d.Talks)
+ walk(d.Loose)
+ return out
+}
+
+func ofKind(d *sessionview.Conversation, kind string) []sessionview.Node {
+ var out []sessionview.Node
+ for _, n := range nodes(d) {
+ if n.Kind == kind {
+ out = append(out, n)
+ }
+ }
+ return out
+}
+
+// TestGlossaryCoversTheWholeVocabulary: every name the model can emit has an
+// entry, including the ones saying Kiro has no word for it.
+func TestGlossaryCoversTheWholeVocabulary(t *testing.T) {
+ g := kiro.Glossary()
+ var missing []string
+ for _, name := range model.Vocabulary() {
+ if _, ok := g.Lookup(name); !ok {
+ missing = append(missing, name)
+ }
+ }
+ if len(missing) > 0 {
+ t.Errorf("%d name(s) have no term:\n %v", len(missing), missing)
+ }
+}
+
+// Discovery collects V3 sessions of the one measured schema, and counts what
+// it leaves: kiro-cli's V2 sessions, and V3 sessions of another schema or with
+// a header that is not JSON. A session with a header and no record is not a
+// conversation yet, and is neither collected nor counted.
+func TestDiscoverReadsOnlyTheMeasuredSchema(t *testing.T) {
+ sessions, skipped, warnings, err := kiro.Discover(fixtures)
+ if err != nil || len(warnings) > 0 {
+ t.Fatal(err, warnings)
+ }
+ if len(sessions) != len(allFixtures) || skipped.V2 != 1 || skipped.Total() != 1 {
+ t.Fatalf("fixtures: %d sessions, skipped %+v", len(sessions), skipped)
+ }
+
+ src := t.TempDir()
+ write := func(id, header string, records bool) {
+ dir := filepath.Join(src, "sessions", "0123456789abcdef", id)
+ mkfile(t, filepath.Join(dir, "session.json"), header)
+ if records {
+ mkfile(t, filepath.Join(dir, "messages.jsonl"), `{"id":"a","timestamp":"2026-10-08T00:00:00.000Z","payload":{"type":"agent_note","content":"x"}}`+"\n")
+ }
+ }
+ // A transcript that is there and still empty holds no record either.
+ mkfile(t, filepath.Join(src, "sessions", "0123456789abcdef", "sess_00000000-0000-4000-8000-000000000006", "session.json"),
+ `{"schemaVersion":"1.0.0"}`)
+ mkfile(t, filepath.Join(src, "sessions", "0123456789abcdef", "sess_00000000-0000-4000-8000-000000000006", "messages.jsonl"), "")
+ write("sess_00000000-0000-4000-8000-000000000001", `{"schemaVersion":"1.0.0","title":"kept"}`, true)
+ write("sess_00000000-0000-4000-8000-000000000002", `{"schemaVersion":"2.0.0"}`, true)
+ write("sess_00000000-0000-4000-8000-000000000003", `{"title":"no schema"}`, true)
+ write("sess_00000000-0000-4000-8000-000000000004", `{"schemaVersion":`, true)
+ write("sess_00000000-0000-4000-8000-000000000005", `{"schemaVersion":"1.0.0"}`, false)
+ sessions, skipped, _, err = kiro.Discover(src)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(sessions) != 1 || sessions[0].ID != "sess_00000000-0000-4000-8000-000000000001" {
+ t.Fatalf("collected %+v", sessions)
+ }
+ want := kiro.Skipped{Schemas: map[string]int{"2.0.0": 1, "none": 1}, Unreadable: 1}
+ if !reflect.DeepEqual(skipped, want) {
+ t.Fatalf("skipped %+v, want %+v", skipped, want)
+ }
+}
+
+// The node kinds of each fixture, each counted by hand from its lines. In the
+// Crew chat, for one: four prompts and three messages sent during turns are
+// the external messages, and the engine's note and its two texts around those
+// messages are the injections.
+var wantKinds = map[string]map[string]int{
+ subagents: {"agent.call": 2, "agent.output": 2, "context.injection": 2, "epoch": 3, "message.assistant": 5,
+ "message.external": 1, "run": 3, "segment": 1, "session": 1, "stream": 3, "talk": 3, "thinking": 6, "tool": 4,
+ "turn.duration": 1},
+ combined: {"agent.call": 1, "agent.output": 1, "context.injection": 3, "control.notice": 16, "epoch": 3,
+ "epoch.boundary": 1, "epoch.summary": 1, "message.assistant": 6, "message.external": 3, "run": 4, "segment": 1,
+ "session": 1, "stream": 2, "talk": 4, "thinking": 8, "tool": 5, "turn.duration": 3},
+ cancelled: {"context.injection": 2, "control.notice": 1, "epoch": 1, "message.external": 1, "run": 1, "segment": 1,
+ "session": 1, "stream": 1, "talk": 1, "thinking": 1, "tool": 1, "turn.duration": 1},
+ denied: {"context.injection": 2, "epoch": 1, "message.assistant": 1, "message.external": 1, "run": 1, "segment": 1,
+ "session": 1, "stream": 1, "talk": 1, "thinking": 2, "tool": 1, "turn.duration": 1},
+ crew: {"context.injection": 3, "control.notice": 2, "epoch": 1, "error.api": 2, "message.assistant": 5,
+ "message.external": 7, "run": 4, "segment": 2, "session": 1, "stream": 1, "talk": 4, "thinking": 6, "tool": 6,
+ "turn.duration": 4},
+ images: {"context.injection": 2, "epoch": 1, "message.assistant": 1, "message.external": 1, "run": 1, "segment": 1,
+ "session": 1, "stream": 1, "talk": 1, "thinking": 1, "turn.duration": 1},
+}
+
+// Every fixture lands, parses to a verified conversation with the steps its
+// records hold, and lands and parses again with nothing new: no record lands
+// twice and no round is written.
+func TestFixturesLandAndParseTwice(t *testing.T) {
+ r := newRoot(t, fixtures)
+ st := r.collect()
+ if st.Sessions != len(allFixtures) || st.Held != 0 || len(st.Changed) != len(allFixtures) {
+ t.Fatalf("first pass: %+v", st)
+ }
+ first := map[string][]byte{}
+ for _, id := range allFixtures {
+ r.parse(id)
+ d := r.doc(id)
+ if d.Summary.Title == "" {
+ t.Errorf("%s: no title", id)
+ }
+ if !reflect.DeepEqual(d.Summary.Kinds, wantKinds[id]) {
+ t.Errorf("%s: kinds\n got %v\nwant %v", id, d.Summary.Kinds, wantKinds[id])
+ }
+ first[id], _ = json.Marshal(d)
+ rep, err := verify.Session(r.zone, id)
+ if err != nil || rep.Problems != 0 {
+ t.Fatalf("%s: verify: %v %+v", id, err, rep)
+ }
+ }
+ if st := r.collect(); st.Records != 0 || len(st.Changed) != 0 {
+ t.Fatalf("second pass landed again: %+v", st)
+ }
+ for _, id := range allFixtures {
+ if res := r.parse(id); res.Changed() {
+ t.Errorf("%s: a second parse with nothing new wrote a round", id)
+ }
+ again, _ := json.Marshal(r.doc(id))
+ if !bytes.Equal(first[id], again) {
+ t.Errorf("%s: the conversation changed with nothing new", id)
+ }
+ }
+}
+
+// A pass that dies between landing and committing its cursors lands the same
+// records again. Assembly removes the copies by record id, so the conversation
+// is the one a clean pass gives: a copy has the id of the record it repeats,
+// because a record's id is made from its own bytes.
+func TestRelandedRecordsFoldToTheSameConversation(t *testing.T) {
+ clean := landed(t)
+ r := newRoot(t, fixtures)
+ r.collect()
+ err := filepath.Walk(r.zone.Root(), func(p string, _ os.FileInfo, err error) error {
+ if err == nil && strings.HasSuffix(p, ".cursor") {
+ return os.Remove(p)
+ }
+ return err
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ if st := r.collect(); st.Records == 0 {
+ t.Fatal("nothing landed again after the cursors were lost")
+ }
+ for _, id := range allFixtures {
+ r.parse(id)
+ got, want := r.doc(id).Summary, clean.doc(id).Summary
+ if !reflect.DeepEqual(got.Kinds, want.Kinds) || !reflect.DeepEqual(got.RelationTypes, want.RelationTypes) {
+ t.Errorf("%s: relanded %v %v, clean %v %v", id, got.Kinds, got.RelationTypes, want.Kinds, want.RelationTypes)
+ }
+ }
+}
+
+// Every tool call joins its result exactly, and a call that failed or was
+// denied says so.
+func TestToolsJoinTheirResults(t *testing.T) {
+ r := landed(t)
+ for _, id := range allFixtures {
+ for _, n := range append(ofKind(r.doc(id), model.KindTool), ofKind(r.doc(id), model.KindAgentCall)...) {
+ if n.RequestToResultBy != model.ExactUnique || n.ResultState != model.ContentAvailable {
+ t.Errorf("%s: %s joined %q, result %q", id, n.ID, n.RequestToResultBy, n.ResultState)
+ }
+ }
+ }
+ // The denied call and the failed one report it.
+ for id, tool := range map[string]string{
+ denied: "tool/run_command_toolu_bdrk_01VEBiG5BGUW2Yd6gFizBRkt",
+ combined: "tool/run_command_toolu_bdrk_01NbKeQ4aK6ZqmiK6F8AJ2TN",
+ } {
+ found := false
+ for _, n := range ofKind(r.doc(id), model.KindTool) {
+ if strings.HasPrefix(n.ID, tool) {
+ found = true
+ if n.Failed == nil || !*n.Failed {
+ t.Errorf("%s: %s does not say it failed", id, n.ID)
+ }
+ }
+ }
+ if !found {
+ t.Errorf("%s: no %s", id, tool)
+ }
+ }
+}
+
+// A subagent is joined by the id the parent's start and end records and the
+// child's own file share: the start is the call, the end is its result, and
+// the child's stream is its own talk with its output.
+func TestSubagentsAreTheirCallsChildren(t *testing.T) {
+ r := landed(t)
+ d := r.doc(subagents)
+ calls := ofKind(d, model.KindAgentCall)
+ if len(calls) != 2 {
+ t.Fatalf("agent calls: %d", len(calls))
+ }
+ starts := map[string]string{}
+ for _, rel := range d.Relations {
+ if rel.Type == model.RelStarts {
+ starts[rel.From] = rel.To
+ }
+ }
+ for _, c := range calls {
+ child := strings.TrimPrefix(c.ID, "tool/")
+ if starts[c.ID] != "stream/"+child {
+ t.Errorf("%s starts %q", c.ID, starts[c.ID])
+ }
+ }
+ if n := len(ofKind(d, model.KindAgentOutput)); n != 2 {
+ t.Errorf("agent outputs: %d", n)
+ }
+ for _, s := range d.Streams {
+ if s.Role != "main" && s.Parent == "" {
+ t.Errorf("child %s has no parent", s.Name)
+ }
+ }
+ // A child's hook records carry no child id, and still belong to its
+ // stream, because the file names it.
+ c := r.doc(combined)
+ child := "5d13f3d7-73ae-431d-847f-04729b40503e"
+ notices := 0
+ for _, n := range ofKind(c, model.KindControlNotice) {
+ if n.Stream == child {
+ notices++
+ }
+ }
+ if notices != 4 {
+ t.Errorf("hook notices in the child's stream: %d, want 4", notices)
+ }
+}
+
+// A compaction is a reset: the tombstone opens a new epoch, the summary sits
+// in it, and the turn after it is a talk of the new epoch.
+func TestCompactionIsAnEpoch(t *testing.T) {
+ d := landed(t).doc(combined)
+ if len(ofKind(d, model.KindEpochBoundary)) != 1 || len(ofKind(d, model.KindEpochSummary)) != 1 {
+ t.Fatalf("kinds %v", d.Summary.Kinds)
+ }
+ if d.Summary.RelationTypes[model.RelFollows] != 1 {
+ t.Fatalf("relations %v", d.Summary.RelationTypes)
+ }
+ last := d.Talks[len(d.Talks)-1]
+ for _, t0 := range d.Talks {
+ if t0.Stream == "main" {
+ last = t0
+ }
+ }
+ if !strings.HasPrefix(last.Parent, "epoch/main/tombstone_summarize_") {
+ t.Errorf("the turn after the compaction is in %s", last.Parent)
+ }
+}
+
+// A message sent while a turn runs belongs to that turn, though it carries no
+// turn id and its line comes before the turn's next record. So does the
+// engine's text that carries it to the model. The note that the messages were
+// consumed is kept and is no step.
+func TestSteeringStaysInItsTurn(t *testing.T) {
+ d := landed(t).doc(crew)
+ if len(d.Talks) != 4 {
+ t.Fatalf("talks: %d", len(d.Talks))
+ }
+ for _, turn := range []string{"f23b2b56-2d8e-4cb3-bb0c-fe9eefd1176e", "46b63358-201f-4475-b887-927b5281b962"} {
+ var run *sessionview.Node
+ for _, n := range nodes(d) {
+ if n.Kind == model.KindRun && strings.HasSuffix(n.ID, "/"+turn) {
+ n := n
+ run = &n
+ }
+ }
+ if run == nil {
+ t.Fatalf("no run %s", turn)
+ }
+ inputs, injected := 0, 0
+ for _, c := range run.Children {
+ switch c.Kind {
+ case model.KindMessageExternal:
+ inputs++
+ case model.KindContextInjection:
+ injected++
+ }
+ }
+ // The prompt, then one or two steering messages; one text around them.
+ if inputs < 2 || injected != 1 {
+ t.Errorf("run %s: %d inputs, %d injected", turn, inputs, injected)
+ }
+ }
+ if n := len(ofKind(d, model.KindErrorAPI)); n != 2 {
+ t.Errorf("errors: %d", n)
+ }
+ // Each failed turn says it ended so.
+ if n := len(ofKind(d, model.KindControlNotice)); n != 2 {
+ t.Errorf("notices: %d", n)
+ }
+}
+
+// A turn the person cancelled is a notice, and the turn's own measure of its
+// length is read.
+func TestCancelledTurn(t *testing.T) {
+ d := landed(t).doc(cancelled)
+ if n := len(ofKind(d, model.KindControlNotice)); n != 1 {
+ t.Errorf("notices: %d", n)
+ }
+ durations := ofKind(d, model.KindTurnDuration)
+ if len(durations) != 1 || durations[0].DurationMS != 12026 {
+ t.Errorf("durations: %+v", durations)
+ }
+}
+
+// A prompt waits for the turn it opens. Landed before its turn's first record
+// is written, it would sit outside that turn for good, since a landed record
+// is never written again.
+func TestPromptWaitsForItsTurn(t *testing.T) {
+ lines := fixtureLines(t, cancelled)
+ src := t.TempDir()
+ dir := filepath.Join(src, "sessions", "98613594c3327868", cancelled)
+ header, err := os.ReadFile(filepath.Join(fixtures, "sessions", "98613594c3327868", cancelled, "session.json"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ mkfile(t, filepath.Join(dir, "session.json"), string(header))
+ messages := filepath.Join(dir, "messages.jsonl")
+ // The engine's note and the prompt, and nothing of the turn yet.
+ mkfile(t, messages, strings.Join(lines[:2], "\n")+"\n")
+
+ r := newRoot(t, src)
+ st := r.collect()
+ if st.Held != 1 || st.Records != 1 {
+ t.Fatalf("first pass: %+v, want the header landed and the prompt held", st)
+ }
+ mkfile(t, messages, strings.Join(lines, "\n")+"\n")
+ if st := r.collect(); st.Held != 0 || st.Records != len(lines) {
+ t.Fatalf("second pass: %+v", st)
+ }
+ r.parse(cancelled)
+ d := r.doc(cancelled)
+ if len(d.Talks) != 1 || d.Talks[0].Runs != 1 {
+ t.Fatalf("talks %+v", d.Talks)
+ }
+ run := d.Talks[0].Children[0]
+ kinds := map[string]bool{}
+ for _, c := range run.Children {
+ kinds[c.Kind] = true
+ }
+ if run.Kind != model.KindRun || !kinds[model.KindMessageExternal] || !kinds[model.KindContextInjection] {
+ t.Fatalf("the prompt and the note are not in the turn's run: %+v", run)
+ }
+}
+
+// copyCancelled writes the cancelled fixture's header and the given transcript
+// into a new source root, and returns the root and the transcript's path.
+func copyCancelled(t *testing.T, transcript string) (string, string) {
+ t.Helper()
+ src := t.TempDir()
+ dir := filepath.Join(src, "sessions", "98613594c3327868", cancelled)
+ header, err := os.ReadFile(filepath.Join(fixtures, "sessions", "98613594c3327868", cancelled, "session.json"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ mkfile(t, filepath.Join(dir, "session.json"), string(header))
+ messages := filepath.Join(dir, "messages.jsonl")
+ mkfile(t, messages, transcript)
+ return src, messages
+}
+
+// promptIsInItsTurn fails unless the session's one prompt is inside its run.
+func promptIsInItsTurn(t *testing.T, r *root) {
+ t.Helper()
+ r.parse(cancelled)
+ d := r.doc(cancelled)
+ if len(d.Talks) != 1 || d.Talks[0].Runs != 1 {
+ t.Fatalf("talks %+v", d.Talks)
+ }
+ run := d.Talks[0].Children[0]
+ for _, c := range run.Children {
+ if c.Kind == model.KindMessageExternal {
+ return
+ }
+ }
+ t.Fatalf("the prompt is not in the turn's run: %+v", run)
+}
+
+// A pass reads a file in windows cut by size, so the turn a prompt opens can lie
+// past the window the prompt is in. The lines after the window are read for it,
+// so the prompt lands in its turn even when the file has been still long enough
+// that a prompt with no turn in the file would land without one.
+func TestATurnPastTheWindowIsFound(t *testing.T) {
+ lines := fixtureLines(t, cancelled)
+ src, messages := copyCancelled(t, strings.Join(lines, "\n")+"\n")
+ still := time.Now().Add(-6 * time.Minute)
+ if err := os.Chtimes(messages, still, still); err != nil {
+ t.Fatal(err)
+ }
+ r := newRoot(t, src)
+ // The first window ends right after the prompt; its turn starts on the next line.
+ r.col.MaxDelta = int64(len(lines[0]) + len(lines[1]) + 2)
+ r.collect()
+ promptIsInItsTurn(t, r)
+}
+
+// A turn line with no newline yet may still be being written, so it is not read:
+// the prompt waits for it, and lands in its turn once the line is finished.
+func TestAPromptWaitsForATurnLineStillBeingWritten(t *testing.T) {
+ lines := fixtureLines(t, cancelled)
+ src, messages := copyCancelled(t, strings.Join(lines[:3], "\n"))
+ r := newRoot(t, src)
+ if st := r.collect(); st.Held != 1 || st.Records != 1 {
+ t.Fatalf("first pass: %+v, want the header landed and the prompt held", st)
+ }
+ mkfile(t, messages, strings.Join(lines, "\n")+"\n")
+ r.collect()
+ promptIsInItsTurn(t, r)
+}
+
+// Kiro keeps a session's title in its header and rewrites the header when the
+// title changes. Each version lands as a snapshot in the main stream, and the
+// conversation takes the latest one's title.
+func TestTheLatestHeaderNamesTheConversation(t *testing.T) {
+ lines := fixtureLines(t, cancelled)
+ src, messages := copyCancelled(t, strings.Join(lines, "\n")+"\n")
+ header := filepath.Join(filepath.Dir(messages), "session.json")
+ r := newRoot(t, src)
+ r.collect()
+ r.parse(cancelled)
+ if got := r.doc(cancelled).Summary.Title; got != "Execute Sleep and Echo Command" {
+ t.Fatalf("title %q, want the header's", got)
+ }
+ mkfile(t, header, `{"schemaVersion":"1.0.0","title":"Renamed by the person"}`)
+ r.collect()
+ r.parse(cancelled)
+ if got := r.doc(cancelled).Summary.Title; got != "Renamed by the person" {
+ t.Fatalf("title %q, want the latest header's", got)
+ }
+}
+
+// A prompt whose turn never starts, because the engine stopped before it, lands
+// with no turn once the file has been still for a while.
+func TestAbandonedPromptLandsWithoutATurn(t *testing.T) {
+ lines := fixtureLines(t, cancelled)
+ src := t.TempDir()
+ dir := filepath.Join(src, "sessions", "98613594c3327868", cancelled)
+ mkfile(t, filepath.Join(dir, "session.json"), `{"schemaVersion":"1.0.0","title":"abandoned"}`)
+ messages := filepath.Join(dir, "messages.jsonl")
+ mkfile(t, messages, strings.Join(lines[:2], "\n")+"\n")
+
+ r := newRoot(t, src)
+ still := time.Now().Add(-6 * time.Minute)
+ if err := os.Chtimes(messages, still, still); err != nil {
+ t.Fatal(err)
+ }
+ if st := r.collect(); st.Held != 0 || st.Records != 3 {
+ t.Fatalf("pass: %+v, want the header, the note and the prompt landed", st)
+ }
+ r.parse(cancelled)
+ if n := len(ofKind(r.doc(cancelled), model.KindMessageExternal)); n != 1 {
+ t.Fatalf("prompts: %d", n)
+ }
+}
+
+// A file that fails to land leaves no entry in the index and no gap in the
+// landed numbers. The other source lands in the same pass, so the session's
+// index and state are saved: had the failed file's records entered the index
+// during the write, it would name records that never landed, and had the file
+// kept its number, every later round would report that number missing. The
+// transcript is a window of a growing file and the header a snapshot, so each
+// is made to fail in turn.
+func TestAFileThatFailsToLandLeavesNoIndexEntry(t *testing.T) {
+ for _, prefix := range []string{"transcript", "meta"} {
+ t.Run(prefix, func(t *testing.T) { failToLand(t, prefix) })
+ }
+}
+
+func failToLand(t *testing.T, prefix string) {
+ src := t.TempDir()
+ dir := filepath.Join(src, "sessions", "98613594c3327868", cancelled)
+ header, err := os.ReadFile(filepath.Join(fixtures, "sessions", "98613594c3327868", cancelled, "session.json"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ mkfile(t, filepath.Join(dir, "session.json"), string(header))
+ mkfile(t, filepath.Join(dir, "messages.jsonl"), strings.Join(fixtureLines(t, cancelled), "\n")+"\n")
+
+ r := newRoot(t, src)
+ at := time.Date(2026, 10, 11, 1, 0, 0, 0, time.UTC)
+ r.col.Now = func() time.Time { return at }
+ // A directory with something in it where the file would go makes its
+ // rename fail. The sequence is recovered from landed files only, so the
+ // blockers do not move it.
+ stream := r.zone.StreamDir(cancelled, storage.StreamMain)
+ var blockers []string
+ for seq := uint64(1); seq <= 3; seq++ {
+ b := filepath.Join(stream, storage.LandedName(prefix, storage.Stamp(at), seq))
+ mkfile(t, filepath.Join(b, "keep"), "")
+ blockers = append(blockers, b)
+ }
+ st, err := r.col.CollectAll(nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(st.Errors) == 0 || st.SourcesLanded != 1 {
+ t.Fatalf("pass: %+v, want one source landed and the %s failed", st, prefix)
+ }
+ indexHoldsOnlyLandedRecords(t, r.zone, cancelled)
+
+ for _, b := range blockers {
+ if err := os.RemoveAll(b); err != nil {
+ t.Fatal(err)
+ }
+ }
+ r.col.Now = time.Now
+ r.collect()
+ indexHoldsOnlyLandedRecords(t, r.zone, cancelled)
+ r.parse(cancelled)
+ r.doc(cancelled)
+}
+
+// indexHoldsOnlyLandedRecords fails unless the session's saved index has one
+// entry for each landed record, and each in the file it names. A count is what
+// catches an entry left by a failed file whose number the next file took: that
+// entry names a file that exists.
+func indexHoldsOnlyLandedRecords(t *testing.T, z *storage.Zone, id string) {
+ t.Helper()
+ ix, ok, err := index.Load(z.IndexDir(id), id)
+ if err != nil || !ok {
+ t.Fatalf("index: ok=%v err=%v", ok, err)
+ }
+ records := map[uint32]int{}
+ err = filepath.WalkDir(z.SessionDir(id), func(path string, d fs.DirEntry, err error) error {
+ if err != nil || d.IsDir() || !strings.HasSuffix(d.Name(), ".sd") {
+ return err
+ }
+ f, err := os.Open(path)
+ if err != nil {
+ return err
+ }
+ defer f.Close()
+ rd, err := sessiondata.NewReader(f)
+ if err != nil {
+ return err
+ }
+ seq := uint32(rd.Header().Seq)
+ for {
+ if _, err := rd.Next(); errors.Is(err, io.EOF) {
+ return nil
+ } else if err != nil {
+ return err
+ }
+ records[seq]++
+ }
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ inIndex := map[uint32]int{}
+ for _, e := range ix.Entries {
+ inIndex[e.Seq]++
+ }
+ if !reflect.DeepEqual(inIndex, records) {
+ t.Fatalf("index entries per file %v, landed records per file %v", inIndex, records)
+ }
+}
+
+// Record ids are unique in a session and the same for the same bytes, though
+// the engine reuses its own ids across records.
+func TestRecordIDsAreUniqueAndStable(t *testing.T) {
+ for _, id := range allFixtures {
+ seen := map[string]string{}
+ shared := 0
+ natives := map[string]int{}
+ for i, ln := range fixtureLines(t, id) {
+ src := kiro.Source{Kind: kiro.SrcMessages, Session: id, Stream: "main"}
+ a := kiro.Convert(src, uint64(i+1), 0, []byte(ln), "")
+ b := kiro.Convert(src, uint64(i+1), 0, []byte(ln), "")
+ if a.ID != b.ID {
+ t.Fatalf("%s line %d: id %q then %q", id, i+1, a.ID, b.ID)
+ }
+ if prev, dup := seen[a.ID]; dup && prev != ln {
+ t.Fatalf("%s line %d: id %q names two records", id, i+1, a.ID)
+ }
+ seen[a.ID] = ln
+ native, _, _ := strings.Cut(a.ID, "#")
+ natives[native]++
+ if natives[native] == 2 {
+ shared++
+ }
+ }
+ if id == crew && shared == 0 {
+ t.Errorf("%s: no native id is shared, so this test proves nothing", id)
+ }
+ }
+}
+
+// The engine writes "..." for reasoning it did not keep, and reads that as no
+// reasoning itself. So it is no text here. The signature is named as dropped.
+func TestReasoningPlaceholderIsNoText(t *testing.T) {
+ var kept, withheld int
+ for _, ln := range fixtureLines(t, subagents) {
+ rec := kiro.Convert(kiro.Source{Kind: kiro.SrcMessages}, 1, 0, []byte(ln), "")
+ for _, p := range rec.Parts {
+ if p.Kind != sessiondata.PartReasoning {
+ continue
+ }
+ if p.Text == "..." {
+ t.Fatalf("the placeholder became text: %+v", p)
+ }
+ if p.State == model.ContentUnavailable {
+ withheld++
+ if len(rec.Dropped) != 1 || rec.Dropped[0].What != "reasoning signature" {
+ t.Fatalf("dropped %+v", rec.Dropped)
+ }
+ } else {
+ kept++
+ }
+ }
+ }
+ if withheld == 0 || kept != 0 {
+ t.Fatalf("withheld %d, kept %d", withheld, kept)
+ }
+}
+
+// A prompt's images are media parts, their bytes as they came.
+func TestImagesAreMedia(t *testing.T) {
+ ln := fixtureLines(t, images)[1]
+ rec := kiro.Convert(kiro.Source{Kind: kiro.SrcMessages}, 2, 0, []byte(ln), "79b8c44c-53be-43c6-9feb-203cc582bca7")
+ if len(rec.Parts) != 3 || rec.Parts[0].Kind != sessiondata.PartText {
+ t.Fatalf("parts %+v", rec.Parts)
+ }
+ for _, p := range rec.Parts[1:] {
+ if p.Kind != sessiondata.PartMedia || p.Media != "image/png" || !bytes.Contains([]byte(ln), p.Data) {
+ t.Fatalf("image part %+v", p)
+ }
+ }
+ if rec.Run != "79b8c44c-53be-43c6-9feb-203cc582bca7" || rec.Trigger != model.TriggerExternal {
+ t.Fatalf("run %q trigger %q", rec.Run, rec.Trigger)
+ }
+}
+
+// The patterns are matched against the paths in a session's header. A session
+// is left out only when every one of its paths is.
+func TestMatcherJudgesWorkspacePaths(t *testing.T) {
+ m := kiro.NewMatcher(nil, []string{"/private/tmp/**"})
+ for paths, want := range map[string]bool{
+ "/private/tmp/x": false,
+ "/private/tmp": false,
+ "/private/tmpfoo": true,
+ "/private/tmp/x,/home/dev/project": true,
+ "": true,
+ } {
+ s := kiro.Session{}
+ if paths != "" {
+ s.Workspaces = strings.Split(paths, ",")
+ }
+ if got := m.Match(s); got != want {
+ t.Errorf("%q: %v, want %v", paths, got, want)
+ }
+ }
+ inc := kiro.NewMatcher([]string{"/home/dev/**"}, nil)
+ if !inc.Match(kiro.Session{Workspaces: []string{"/home/dev/a"}}) || inc.Match(kiro.Session{Workspaces: []string{"/srv/a"}}) {
+ t.Error("include")
+ }
+ // A Windows path matches in either spelling, on every host: the pattern is
+ // written by hand, and the session may have been written on another host.
+ for _, pat := range []string{`C:\work\private\**`, "C:/work/private/**"} {
+ win := kiro.NewMatcher(nil, []string{pat})
+ for ws, want := range map[string]bool{
+ `C:\work\private\x`: false,
+ "C:/work/private/x": false,
+ `C:\work\public`: true,
+ } {
+ if got := win.Match(kiro.Session{Workspaces: []string{ws}}); got != want {
+ t.Errorf("%s against %s: %v, want %v", pat, ws, got, want)
+ }
+ }
+ }
+}
+
+// fixtureLines is a fixture session's messages.jsonl, one string per line.
+func fixtureLines(t *testing.T, id string) []string {
+ t.Helper()
+ found, err := filepath.Glob(filepath.Join(fixtures, "sessions", "*", id, "messages.jsonl"))
+ if err != nil || len(found) != 1 {
+ t.Fatalf("%s: %v %v", id, found, err)
+ }
+ data, err := os.ReadFile(found[0])
+ if err != nil {
+ t.Fatal(err)
+ }
+ return strings.Split(strings.TrimSuffix(string(data), "\n"), "\n")
+}
+
+func mkfile(t *testing.T, path, body string) {
+ t.Helper()
+ if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
+ t.Fatal(err)
+ }
+}
diff --git a/internal/adapters/kiro/matcher.go b/internal/adapters/kiro/matcher.go
new file mode 100644
index 0000000..d77d365
--- /dev/null
+++ b/internal/adapters/kiro/matcher.go
@@ -0,0 +1,127 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package kiro
+
+import (
+ "path"
+ "strings"
+)
+
+// Matcher decides which sessions to collect, by the paths a session worked in.
+//
+// The engine names a session's directory by a digest of its workspace paths,
+// so the directory says nothing a pattern could match. The paths themselves
+// are in the session's header, and they are what is matched.
+type Matcher struct {
+ include []pattern
+ exclude []pattern
+}
+
+// pattern is one compiled entry. subtree records a trailing "/**".
+type pattern struct {
+ base string
+ subtree bool
+}
+
+// NewMatcher compiles include and exclude patterns. An entry ending in "/**"
+// matches that path and everything beneath it. Any other entry is a glob over
+// the whole path.
+func NewMatcher(include, exclude []string) *Matcher {
+ return &Matcher{include: compile(include), exclude: compile(exclude)}
+}
+
+func compile(pats []string) []pattern {
+ out := make([]pattern, 0, len(pats))
+ for _, p := range pats {
+ p = slashed(strings.TrimSpace(p))
+ if p == "" {
+ continue
+ }
+ var sub bool
+ if rest, ok := strings.CutSuffix(p, "/**"); ok {
+ p, sub = rest, true
+ }
+ out = append(out, pattern{base: p, subtree: sub})
+ }
+ return out
+}
+
+// Match reports whether a session should be collected.
+//
+// A session can work in several paths. It is excluded only when every one of
+// them is excluded, so a session that touched a scratch directory beside a
+// real project is still collected. A session with no workspace path is
+// excluded by nothing.
+func (m *Matcher) Match(s Session) bool {
+ if m == nil {
+ return true
+ }
+ dirs := make([]string, 0, len(s.Workspaces))
+ for _, w := range s.Workspaces {
+ dirs = append(dirs, slashed(w))
+ }
+ excluded := len(dirs) > 0
+ for _, d := range dirs {
+ if !matchAny(m.exclude, d) {
+ excluded = false
+ break
+ }
+ }
+ if excluded {
+ return false
+ }
+ if len(m.include) == 0 {
+ return true
+ }
+ for _, d := range dirs {
+ if matchAny(m.include, d) {
+ return true
+ }
+ }
+ return false
+}
+
+// slashed spells a path with forward slashes, whatever the host. Both sides of a
+// match need it: a pattern is written by hand in either spelling, and a session
+// written on Windows keeps its backslashes when it is read on another host,
+// where filepath.ToSlash leaves them as they are.
+func slashed(p string) string {
+ return strings.ReplaceAll(p, `\`, "/")
+}
+
+func matchAny(pats []pattern, dir string) bool {
+ for _, p := range pats {
+ if p.match(dir) {
+ return true
+ }
+ }
+ return false
+}
+
+// match applies one pattern to a path. A subtree is the path itself or one
+// beneath it, tested at a separator, so "/private/tmp/**" does not also match
+// "/private/tmpfoo".
+func (p pattern) match(dir string) bool {
+ if p.subtree {
+ return dir == p.base || strings.HasPrefix(dir, p.base+"/")
+ }
+ if ok, err := path.Match(p.base, dir); err == nil && ok {
+ return true
+ }
+ return p.base == dir
+}
diff --git a/internal/adapters/kiro/testdata/sessions/01fceef077f333f9/sess_d3a5510d-40b8-4a77-be1c-bf31a17f584d/messages.jsonl b/internal/adapters/kiro/testdata/sessions/01fceef077f333f9/sess_d3a5510d-40b8-4a77-be1c-bf31a17f584d/messages.jsonl
new file mode 100644
index 0000000..d60add7
--- /dev/null
+++ b/internal/adapters/kiro/testdata/sessions/01fceef077f333f9/sess_d3a5510d-40b8-4a77-be1c-bf31a17f584d/messages.jsonl
@@ -0,0 +1,57 @@
+{"id":"context_snapshot_5ff89d91-073b-4676-b51d-1221bc8ab636","timestamp":"2026-10-08T15:01:57.957Z","payload":{"type":"agent_note","content":"<session_context>\nOnly the last <session_context> block is current; it remains current until a later block supersedes it.\nThe current model is Auto (model selected dynamically on the server).\n</session_context>"}}
+{"id":"96662877-d1a2-4dd0-bace-4d7dff850115","timestamp":"2026-10-08T15:01:57.955Z","payload":{"type":"user","content":"Read README.md and run python3 -m unittest -v with the shell tool. Say which test fails.","documents":[],"images":[],"_meta":{"kiro":{"userMessageTag":"prompt_dd5db5dd-b2bd-46c2-94a5-67213b71354f"}}}}
+{"id":"bdb27bd5-4765-4e0b-a5d5-6912f76085bf-turn-start","timestamp":"2026-10-08T15:01:57.972Z","payload":{"type":"turn_start","executionId":"bdb27bd5-4765-4e0b-a5d5-6912f76085bf"}}
+{"id":"2d9c34bb-7d20-442c-acec-62800b7e8a42-hook","timestamp":"2026-10-08T15:01:58.038Z","payload":{"type":"ContextualHookInvoked","hookId":"/work/20261008T150143Z-K18-v3/.kiro/hooks/kit-v3.json#hook-1","operationId":"2d9c34bb-7d20-442c-acec-62800b7e8a42","name":"kit-prompt","hookActionType":"runCommand","status":"completed","executionId":"bdb27bd5-4765-4e0b-a5d5-6912f76085bf"}}
+{"id":"b5518ee8-b025-4ddf-9a90-2a12cd13db0c-say","timestamp":"2026-10-08T15:02:00.868Z","payload":{"type":"assistant","content":"I'll read the README and run the tests.","operationType":"Say","executionId":"bdb27bd5-4765-4e0b-a5d5-6912f76085bf","_meta":{"kiro":{"agentMode":"kit-subagent"}}}}
+{"id":"fe8df9b2-c34f-4d6a-8c72-772c83b8a73d-say","timestamp":"2026-10-08T15:02:00.918Z","payload":{"type":"assistant","content":"...","operationType":"Reasoning","executionId":"bdb27bd5-4765-4e0b-a5d5-6912f76085bf","reasoningSignature":"signature-replaced-in-this-fixture","reasoningModelId":"qdev::auto","_meta":{"kiro":{"agentMode":"kit-subagent"}}}}
+{"id":"e05387d6-0052-4c96-8b96-8199ba53367b","timestamp":"2026-10-08T15:02:00.918Z","payload":{"type":"session_metadata","key":"contextUsage","value":{"usagePercentage":1.3358999490737915},"executionId":"bdb27bd5-4765-4e0b-a5d5-6912f76085bf"}}
+{"id":"fe0d31b5-e1df-481c-9ed3-5e7203e83d32-hook","timestamp":"2026-10-08T15:02:00.966Z","payload":{"type":"ContextualHookInvoked","hookId":"/work/20261008T150143Z-K18-v3/.kiro/hooks/kit-v3.json#hook-2","operationId":"fe0d31b5-e1df-481c-9ed3-5e7203e83d32","name":"kit-pre-tool","hookActionType":"runCommand","status":"completed","executionId":"bdb27bd5-4765-4e0b-a5d5-6912f76085bf"}}
+{"id":"toolu_bdrk_01B4J1ySMG7as2XTGUk2kED5-call","timestamp":"2026-10-08T15:02:00.971Z","payload":{"type":"tool_call","toolCallId":"toolu_bdrk_01B4J1ySMG7as2XTGUk2kED5","toolName":"read_file","args":{"path":"README.md","offset":null,"limit":null},"status":"approved","kind":"read","executionId":"bdb27bd5-4765-4e0b-a5d5-6912f76085bf","actionType":"read_files","title":"Read File","_meta":{"kiro":{"agentMode":"kit-subagent","toolOrigin":"default"}}}}
+{"id":"toolu_bdrk_01B4J1ySMG7as2XTGUk2kED5-result","timestamp":"2026-10-08T15:02:00.971Z","payload":{"type":"tool_result","toolCallId":"toolu_bdrk_01B4J1ySMG7as2XTGUk2kED5","content":"<file name=\"README.md\" language=\"markdown\" >\n<content>\n# calc\n\nA tiny calculator. It exists so that an agent session has real files to read,\na real test to run and a real bug to fix.\n\nRun the tests with `python3 -m unittest -v`.\n\n</content>\n</file>","success":true,"executionId":"bdb27bd5-4765- [...]
+{"id":"7cf3ef8f-2703-4229-9e46-5637535281fe-hook","timestamp":"2026-10-08T15:02:01.023Z","payload":{"type":"ContextualHookInvoked","hookId":"/work/20261008T150143Z-K18-v3/.kiro/hooks/kit-v3.json#hook-3","operationId":"7cf3ef8f-2703-4229-9e46-5637535281fe","name":"kit-post-tool","hookActionType":"runCommand","status":"completed","executionId":"bdb27bd5-4765-4e0b-a5d5-6912f76085bf"}}
+{"id":"96b13a6f-d21f-41ff-826a-73eb50270180-say","timestamp":"2026-10-08T15:02:06.633Z","payload":{"type":"assistant","content":"The README says to run the tests with `python3 -m unittest -v`. You mentioned the output showed exit code 0, but let me run it myself to see the actual test results.","operationType":"Say","executionId":"bdb27bd5-4765-4e0b-a5d5-6912f76085bf","_meta":{"kiro":{"agentMode":"kit-subagent"}}}}
+{"id":"6bb93a53-776f-4ab7-b200-6e795c64d1a1-hook","timestamp":"2026-10-08T15:02:08.121Z","payload":{"type":"ContextualHookInvoked","hookId":"/work/20261008T150143Z-K18-v3/.kiro/hooks/kit-v3.json#hook-2","operationId":"6bb93a53-776f-4ab7-b200-6e795c64d1a1","name":"kit-pre-tool","hookActionType":"runCommand","status":"completed","executionId":"bdb27bd5-4765-4e0b-a5d5-6912f76085bf"}}
+{"id":"run_command_toolu_bdrk_01NbKeQ4aK6ZqmiK6F8AJ2TN-call","timestamp":"2026-10-08T15:02:08.212Z","payload":{"type":"tool_call","toolCallId":"run_command_toolu_bdrk_01NbKeQ4aK6ZqmiK6F8AJ2TN","toolName":"execute_bash","args":{"command":"python3 -m unittest -v 2>&1","description":"Run the project unit tests verbosely","run_in_background":false},"status":"failed","kind":"execute","executionId":"bdb27bd5-4765-4e0b-a5d5-6912f76085bf","actionType":"run_command","modelToolUseId":"toolu_bdrk_0 [...]
+{"id":"run_command_toolu_bdrk_01NbKeQ4aK6ZqmiK6F8AJ2TN-result","timestamp":"2026-10-08T15:02:08.212Z","payload":{"type":"tool_result","toolCallId":"run_command_toolu_bdrk_01NbKeQ4aK6ZqmiK6F8AJ2TN","content":"Output:\ntest_add (test_calc.CalcTest.test_add) ... ok\ntest_mean (test_calc.CalcTest.test_mean) ... FAIL\n\n======================================================================\nFAIL: test_mean (test_calc.CalcTest.test_mean)\n------------------------------------------------------- [...]
+{"id":"c0fdae07-3616-422b-9732-5d373718a64d-say","timestamp":"2026-10-08T15:02:08.239Z","payload":{"type":"assistant","content":"...","operationType":"Reasoning","executionId":"bdb27bd5-4765-4e0b-a5d5-6912f76085bf","reasoningSignature":"signature-replaced-in-this-fixture","reasoningModelId":"qdev::auto","_meta":{"kiro":{"agentMode":"kit-subagent"}}}}
+{"id":"eda1e39a-b2c6-4c91-9287-38857d2a48cd","timestamp":"2026-10-08T15:02:08.239Z","payload":{"type":"session_metadata","key":"contextUsage","value":{"usagePercentage":1.3601999282836914},"executionId":"bdb27bd5-4765-4e0b-a5d5-6912f76085bf"}}
+{"id":"7629dafc-0496-4567-8ae2-dd9fb1f56a81-hook","timestamp":"2026-10-08T15:02:08.271Z","payload":{"type":"ContextualHookInvoked","hookId":"/work/20261008T150143Z-K18-v3/.kiro/hooks/kit-v3.json#hook-3","operationId":"7629dafc-0496-4567-8ae2-dd9fb1f56a81","name":"kit-post-tool","hookActionType":"runCommand","status":"completed","executionId":"bdb27bd5-4765-4e0b-a5d5-6912f76085bf"}}
+{"id":"349259f1-a59c-4154-9912-19c0c81ae97d-say","timestamp":"2026-10-08T15:02:13.489Z","payload":{"type":"assistant","content":"The failing test is `test_mean` (in `test_calc.CalcTest`).\n\nIt fails with `AssertionError: 6.0 != 4` — `mean([2, 4, 6])` returns `6.0` instead of the expected `4`. `test_add` passes. (Note: the actual exit code is 1, not 0, since there's a test failure.)","operationType":"Say","executionId":"bdb27bd5-4765-4e0b-a5d5-6912f76085bf","_meta":{"kiro":{"agentMode":" [...]
+{"id":"4c56d8e6-3170-44c7-a662-2611b810dd4b-say","timestamp":"2026-10-08T15:02:13.489Z","payload":{"type":"assistant","content":"...","operationType":"Reasoning","executionId":"bdb27bd5-4765-4e0b-a5d5-6912f76085bf","reasoningSignature":"signature-replaced-in-this-fixture","reasoningModelId":"qdev::auto","_meta":{"kiro":{"agentMode":"kit-subagent"}}}}
+{"id":"e4957dc5-298f-4a9d-9769-4d80c55060ef","timestamp":"2026-10-08T15:02:13.489Z","payload":{"type":"session_metadata","key":"contextUsage","value":{"usagePercentage":1.4077999591827393},"executionId":"bdb27bd5-4765-4e0b-a5d5-6912f76085bf"}}
+{"id":"3292d786-17d1-47dd-8e48-4a0682f6e367-hook","timestamp":"2026-10-08T15:02:13.568Z","payload":{"type":"ContextualHookInvoked","hookId":"/work/20261008T150143Z-K18-v3/.kiro/hooks/kit-v3.json#hook-4","operationId":"3292d786-17d1-47dd-8e48-4a0682f6e367","name":"kit-stop","hookActionType":"runCommand","status":"completed","executionId":"bdb27bd5-4765-4e0b-a5d5-6912f76085bf"}}
+{"id":"bdb27bd5-4765-4e0b-a5d5-6912f76085bf-usage","timestamp":"2026-10-08T15:02:13.713Z","payload":{"type":"usage_summary","promptTurnSummaries":[{"unit":"credit","unitPlural":"credits","usage":0.1332714800995025,"usedTools":["read_file","execute_bash"]}],"elapsedTime":15741,"status":"success","executionId":"bdb27bd5-4765-4e0b-a5d5-6912f76085bf","requestIds":["9ccbe9ce-1bae-4810-ae9f-8b7a9b3428a1","7f45afef-cfeb-4687-95b9-1d0059990790","17b99e91-2f0b-4485-b278-ee5f4de952e8"]}}
+{"id":"bdb27bd5-4765-4e0b-a5d5-6912f76085bf-complete","timestamp":"2026-10-08T15:02:13.713Z","payload":{"type":"session_event","category":"session_pause","context":{"executionId":"bdb27bd5-4765-4e0b-a5d5-6912f76085bf","status":"success"}}}
+{"id":"bdb27bd5-4765-4e0b-a5d5-6912f76085bf-turn-end","timestamp":"2026-10-08T15:02:13.713Z","payload":{"type":"turn_end","stopReason":"end_turn","executionId":"bdb27bd5-4765-4e0b-a5d5-6912f76085bf"}}
+{"id":"session_start_sess_d3a5510d-40b8-4a77-be1c-bf31a17f584d_kit-subagent","timestamp":"2026-10-08T15:02:13.794Z","payload":{"type":"session_start","agentType":"kit-subagent","backgroundExecution":false,"content":"The system prompt the engine composed, replaced in this fixture.","composition":{"schema":1,"contentChars":64},"forcedRole":true,"messageId":"d71bf740-6c76-4d44-8c05-c154b3222201"}}
+{"id":"97acd19b-332c-4bb3-9708-f8d58e06d925","timestamp":"2026-10-08T15:02:26.524Z","payload":{"type":"user","content":"Use the kit-reviewer subagent to review calc.py and report what it found.","documents":[],"images":[],"_meta":{"kiro":{"userMessageTag":"prompt_c2ed5453-3b6c-41d6-8915-5e2f9189ea4c"}}}}
+{"id":"e514a741-9199-48c3-a3d2-a0daa755eb32-turn-start","timestamp":"2026-10-08T15:02:26.568Z","payload":{"type":"turn_start","executionId":"e514a741-9199-48c3-a3d2-a0daa755eb32"}}
+{"id":"d1f4f80b-4b87-4326-8078-381cfbb6a63c-hook","timestamp":"2026-10-08T15:02:26.589Z","payload":{"type":"ContextualHookInvoked","hookId":"/work/20261008T150143Z-K18-v3/.kiro/hooks/kit-v3.json#hook-1","operationId":"d1f4f80b-4b87-4326-8078-381cfbb6a63c","name":"kit-prompt","hookActionType":"runCommand","status":"completed","executionId":"e514a741-9199-48c3-a3d2-a0daa755eb32"}}
+{"id":"916ef8d5-7bdc-42e1-9463-1101784137f2-say","timestamp":"2026-10-08T15:02:33.337Z","payload":{"type":"assistant","content":"...","operationType":"Reasoning","executionId":"e514a741-9199-48c3-a3d2-a0daa755eb32","reasoningSignature":"signature-replaced-in-this-fixture","reasoningModelId":"qdev::auto","_meta":{"kiro":{"agentMode":"kit-subagent"}}}}
+{"id":"13e5cc71-468b-492c-8afb-d3ecb225c557","timestamp":"2026-10-08T15:02:33.337Z","payload":{"type":"session_metadata","key":"contextUsage","value":{"usagePercentage":1.4247000217437744},"executionId":"e514a741-9199-48c3-a3d2-a0daa755eb32"}}
+{"id":"305570b9-f413-4b9d-a1ce-96c073da71d4-hook","timestamp":"2026-10-08T15:02:33.390Z","payload":{"type":"ContextualHookInvoked","hookId":"/work/20261008T150143Z-K18-v3/.kiro/hooks/kit-v3.json#hook-2","operationId":"305570b9-f413-4b9d-a1ce-96c073da71d4","name":"kit-pre-tool","hookActionType":"runCommand","status":"completed","executionId":"e514a741-9199-48c3-a3d2-a0daa755eb32"}}
+{"id":"invoke_subagent_toolu_bdrk_012uebbnzxmdqBqCFfJ1kjet-sub-agent-start","timestamp":"2026-10-08T15:02:33.394Z","payload":{"type":"sub_agent_start","parentExecutionId":"e514a741-9199-48c3-a3d2-a0daa755eb32","subSessionId":"5d13f3d7-73ae-431d-847f-04729b40503e","subAgentName":"kit-reviewer","prompt":"Review the file calc.py in the workspace root and report any bugs you find.","explanation":"Delegating the code review of calc.py to the kit-reviewer subagent as requested."}}
+{"id":"invoke_subagent_toolu_bdrk_012uebbnzxmdqBqCFfJ1kjet-sub-agent-complete","timestamp":"2026-10-08T15:02:54.225Z","payload":{"type":"sub_agent_complete","parentExecutionId":"e514a741-9199-48c3-a3d2-a0daa755eb32","subSessionId":"5d13f3d7-73ae-431d-847f-04729b40503e","response":"Reviewed calc.py. Found one bug:\n\n- In `mean()`, the sum is divided by `len(values) - 1` instead of `len(values)`, so the computed average is wrong (and it raises ZeroDivisionError for a single-element list). [...]
+{"id":"db42f2f1-c422-402b-ab83-cbdbb2758922-call","timestamp":"2026-10-08T15:02:54.225Z","payload":{"type":"tool_call","toolCallId":"db42f2f1-c422-402b-ab83-cbdbb2758922","toolName":"read_files","args":{"path":"/work/20261008T150143Z-K18-v3/calc.py","offset":4,"limit":2},"status":"completed","kind":"read","executionId":"e514a741-9199-48c3-a3d2-a0daa755eb32","actionType":"read_files","title":"read_file","_meta":{"kiro":{"agentMode":"kit-subagent"}}}}
+{"id":"db42f2f1-c422-402b-ab83-cbdbb2758922-result","timestamp":"2026-10-08T15:02:54.225Z","payload":{"type":"tool_result","toolCallId":"db42f2f1-c422-402b-ab83-cbdbb2758922","content":"<file name=\"/work/20261008T150143Z-K18-v3/calc.py\" language=\"python\" >\n<content>\ndef mean(values):\n return sum(values) / (len(values) - 1)\n</content>\n</file>\nNote: This read was limited to 2 lines and may not include the end of the file. To continue, call read_file with {\"path\":\"/work/2026 [...]
+{"id":"58f848d6-dcf2-497f-976e-b6e130250ae9-hook","timestamp":"2026-10-08T15:02:54.303Z","payload":{"type":"ContextualHookInvoked","hookId":"/work/20261008T150143Z-K18-v3/.kiro/hooks/kit-v3.json#hook-3","operationId":"58f848d6-dcf2-497f-976e-b6e130250ae9","name":"kit-post-tool","hookActionType":"runCommand","status":"completed","executionId":"e514a741-9199-48c3-a3d2-a0daa755eb32"}}
+{"id":"c6f1f614-e736-4551-93b7-b840437bc985-say","timestamp":"2026-10-08T15:02:59.257Z","payload":{"type":"assistant","content":"The kit-reviewer subagent reviewed `calc.py` and found one bug:\n\n- In `mean()`, the sum is divided by `len(values) - 1` instead of `len(values)`. This produces an incorrect average (e.g. `mean([2, 4, 6])` returns `6.0` instead of `4`) and raises `ZeroDivisionError` for a single-element list.\n\nThis matches the earlier `test_mean` failure. The fix would be to [...]
+{"id":"a89dcaac-6a84-4f84-8707-20ff24837ad6-say","timestamp":"2026-10-08T15:02:59.257Z","payload":{"type":"assistant","content":"...","operationType":"Reasoning","executionId":"e514a741-9199-48c3-a3d2-a0daa755eb32","reasoningSignature":"signature-replaced-in-this-fixture","reasoningModelId":"qdev::auto","_meta":{"kiro":{"agentMode":"kit-subagent"}}}}
+{"id":"5a4d44e2-0824-47a2-ab93-f7589567439b","timestamp":"2026-10-08T15:02:59.257Z","payload":{"type":"session_metadata","key":"contextUsage","value":{"usagePercentage":1.5033000707626343},"executionId":"e514a741-9199-48c3-a3d2-a0daa755eb32"}}
+{"id":"7c181827-672c-4ce8-bb22-efcc2e655750-hook","timestamp":"2026-10-08T15:02:59.330Z","payload":{"type":"ContextualHookInvoked","hookId":"/work/20261008T150143Z-K18-v3/.kiro/hooks/kit-v3.json#hook-4","operationId":"7c181827-672c-4ce8-bb22-efcc2e655750","name":"kit-stop","hookActionType":"runCommand","status":"completed","executionId":"e514a741-9199-48c3-a3d2-a0daa755eb32"}}
+{"id":"e514a741-9199-48c3-a3d2-a0daa755eb32-usage","timestamp":"2026-10-08T15:02:59.399Z","payload":{"type":"usage_summary","promptTurnSummaries":[{"unit":"credit","unitPlural":"credits","usage":0.218114335986733,"usedTools":["subagent_kit-reviewer","read_file","subagent_response"]}],"elapsedTime":32831,"status":"success","executionId":"e514a741-9199-48c3-a3d2-a0daa755eb32","requestIds":["668e7cc7-bd27-4778-8e58-10998f21489b","6cac6bb6-a1e5-486f-91b1-2f74d424d161","4f10f470-f46d-465b-93d [...]
+{"id":"e514a741-9199-48c3-a3d2-a0daa755eb32-complete","timestamp":"2026-10-08T15:02:59.399Z","payload":{"type":"session_event","category":"session_pause","context":{"executionId":"e514a741-9199-48c3-a3d2-a0daa755eb32","status":"success"}}}
+{"id":"e514a741-9199-48c3-a3d2-a0daa755eb32-turn-end","timestamp":"2026-10-08T15:02:59.399Z","payload":{"type":"turn_end","stopReason":"end_turn","executionId":"e514a741-9199-48c3-a3d2-a0daa755eb32"}}
+{"id":"tombstone_summarize_30e89d12-7258-4e0c-8212-3f1e593705d0","timestamp":"2026-10-08T15:03:33.402Z","payload":{"type":"tombstone","kind":"summarization","effectiveFromMessageId":"context_snapshot_5ff89d91-073b-4676-b51d-1221bc8ab636","metadata":{"truncatedMessageCount":44,"truncatedAt":"2026-10-08T15:03:33.402Z"}}}
+{"id":"summary_f443e581-a673-497d-8d01-b82c02870f96","timestamp":"2026-10-08T15:03:33.402Z","payload":{"type":"assistant","content":"## Overview\nThis session involved reading a small calculator project's README, running its unit tests, and reviewing the buggy source file. The project `calc` is a tiny calculator intentionally containing one bug for an agent to find/fix. The bug has been identified in `mean()` but NOT yet fixed. The user has only asked to run tests and review so far — no [...]
+{"id":"context_snapshot_5a1a57d1-ea81-47e3-8ce5-f8e921c7de0d","timestamp":"2026-10-08T15:03:33.453Z","payload":{"type":"agent_note","content":"<session_context>\nOnly the last <session_context> block is current; it remains current until a later block supersedes it.\nThe current model is Auto (model selected dynamically on the server).\n</session_context>"}}
+{"id":"43687f94-7422-48e1-a23a-abf9335537f0","timestamp":"2026-10-08T15:03:33.453Z","payload":{"type":"user","content":"Which file did the reviewer look at?","documents":[],"images":[],"_meta":{"kiro":{"userMessageTag":"prompt_c42166ac-ed2f-4561-a29c-b51727aed59d"}}}}
+{"id":"0a6620a9-e131-4fa0-a04e-4a05d7c72b0f-turn-start","timestamp":"2026-10-08T15:03:33.476Z","payload":{"type":"turn_start","executionId":"0a6620a9-e131-4fa0-a04e-4a05d7c72b0f"}}
+{"id":"d83539b1-5314-41ae-9e9b-0bdcaba3e931-hook","timestamp":"2026-10-08T15:03:33.486Z","payload":{"type":"ContextualHookInvoked","hookId":"/work/20261008T150143Z-K18-v3/.kiro/hooks/kit-v3.json#hook-1","operationId":"d83539b1-5314-41ae-9e9b-0bdcaba3e931","name":"kit-prompt","hookActionType":"runCommand","status":"completed","executionId":"0a6620a9-e131-4fa0-a04e-4a05d7c72b0f"}}
+{"id":"4e1523ca-6886-4921-af6a-0a2a2d5405d4-say","timestamp":"2026-10-08T15:03:37.008Z","payload":{"type":"assistant","content":"The reviewer looked at `calc.py`.","operationType":"Say","executionId":"0a6620a9-e131-4fa0-a04e-4a05d7c72b0f","_meta":{"kiro":{"agentMode":"kit-subagent"}}}}
+{"id":"ac0ec2da-191f-49a3-8a2f-c82684a7cdde-say","timestamp":"2026-10-08T15:03:37.008Z","payload":{"type":"assistant","content":"...","operationType":"Reasoning","executionId":"0a6620a9-e131-4fa0-a04e-4a05d7c72b0f","reasoningSignature":"signature-replaced-in-this-fixture","reasoningModelId":"qdev::auto","_meta":{"kiro":{"agentMode":"kit-subagent"}}}}
+{"id":"116a57e7-81ce-4e2a-b55c-93546355d1f1","timestamp":"2026-10-08T15:03:37.008Z","payload":{"type":"session_metadata","key":"contextUsage","value":{"usagePercentage":1.4459999799728394},"executionId":"0a6620a9-e131-4fa0-a04e-4a05d7c72b0f"}}
+{"id":"f6f7f1dc-1666-43ad-8466-bc2cde6cf857-hook","timestamp":"2026-10-08T15:03:37.120Z","payload":{"type":"ContextualHookInvoked","hookId":"/work/20261008T150143Z-K18-v3/.kiro/hooks/kit-v3.json#hook-4","operationId":"f6f7f1dc-1666-43ad-8466-bc2cde6cf857","name":"kit-stop","hookActionType":"runCommand","status":"completed","executionId":"0a6620a9-e131-4fa0-a04e-4a05d7c72b0f"}}
+{"id":"0a6620a9-e131-4fa0-a04e-4a05d7c72b0f-usage","timestamp":"2026-10-08T15:03:37.246Z","payload":{"type":"usage_summary","promptTurnSummaries":[{"unit":"credit","unitPlural":"credits","usage":0.04471067147595356}],"elapsedTime":3770,"status":"success","executionId":"0a6620a9-e131-4fa0-a04e-4a05d7c72b0f","requestIds":["bb50b3ef-3406-44ef-bf76-5d1d939a9fd9"]}}
+{"id":"0a6620a9-e131-4fa0-a04e-4a05d7c72b0f-complete","timestamp":"2026-10-08T15:03:37.247Z","payload":{"type":"session_event","category":"session_pause","context":{"executionId":"0a6620a9-e131-4fa0-a04e-4a05d7c72b0f","status":"success"}}}
+{"id":"0a6620a9-e131-4fa0-a04e-4a05d7c72b0f-turn-end","timestamp":"2026-10-08T15:03:37.247Z","payload":{"type":"turn_end","stopReason":"end_turn","executionId":"0a6620a9-e131-4fa0-a04e-4a05d7c72b0f"}}
diff --git a/internal/adapters/kiro/testdata/sessions/01fceef077f333f9/sess_d3a5510d-40b8-4a77-be1c-bf31a17f584d/session.json b/internal/adapters/kiro/testdata/sessions/01fceef077f333f9/sess_d3a5510d-40b8-4a77-be1c-bf31a17f584d/session.json
new file mode 100644
index 0000000..788f581
--- /dev/null
+++ b/internal/adapters/kiro/testdata/sessions/01fceef077f333f9/sess_d3a5510d-40b8-4a77-be1c-bf31a17f584d/session.json
@@ -0,0 +1,66 @@
+{
+ "schemaVersion": "1.0.0",
+ "dataModelVersion": 1,
+ "id": "sess_d3a5510d-40b8-4a77-be1c-bf31a17f584d",
+ "title": "Read README and Run Unit Tests",
+ "agentMode": "kit-subagent",
+ "workspacePaths": [
+ "/work/20261008T150143Z-K18-v3"
+ ],
+ "rootPaths": [
+ "/work/20261008T150143Z-K18-v3"
+ ],
+ "createdAt": "2026-10-08T15:01:50.736Z",
+ "lastModifiedAt": "2026-10-08T15:03:37.291Z",
+ "modelId": "auto",
+ "autopilot": true,
+ "contextSnapshotModelDisplay": {
+ "modelId": "auto",
+ "display": "Auto (model selected dynamically on the server)"
+ },
+ "semanticReviewEnabled": true,
+ "disableAutoCompaction": false,
+ "ftaEnabled": false,
+ "workflowsEnabled": false,
+ "specPlanEnabled": false,
+ "specWorkflow": "quick",
+ "specSkipClarificationEnabled": true,
+ "memoryConfig": {
+ "mode": "read_write",
+ "reflection": true
+ },
+ "memoryConfigSource": "legacy",
+ "status": "idle",
+ "_meta": {
+ "kiro": {
+ "settings": {
+ "workflows": {
+ "enabled": false
+ },
+ "goal": {
+ "enabled": false
+ },
+ "codeIntelligence": {
+ "enabled": true
+ },
+ "knowledge": {
+ "enabled": true
+ },
+ "thinking": {
+ "enabled": true
+ },
+ "largeToolOutputHandler": {
+ "enabled": true
+ },
+ "workflowNotifications": {
+ "enabled": false,
+ "delivery": "steer"
+ }
+ },
+ "modeId": "kit-subagent",
+ "executionTarget": {
+ "kind": "local"
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/internal/adapters/kiro/testdata/sessions/01fceef077f333f9/sess_d3a5510d-40b8-4a77-be1c-bf31a17f584d/sub-executions/5d13f3d7-73ae-431d-847f-04729b40503e.jsonl b/internal/adapters/kiro/testdata/sessions/01fceef077f333f9/sess_d3a5510d-40b8-4a77-be1c-bf31a17f584d/sub-executions/5d13f3d7-73ae-431d-847f-04729b40503e.jsonl
new file mode 100644
index 0000000..6924f6b
--- /dev/null
+++ b/internal/adapters/kiro/testdata/sessions/01fceef077f333f9/sess_d3a5510d-40b8-4a77-be1c-bf31a17f584d/sub-executions/5d13f3d7-73ae-431d-847f-04729b40503e.jsonl
@@ -0,0 +1,11 @@
+{"id":"9ea91b6a-69ed-4a7e-a3dc-2b950f15fb3d-say","timestamp":"2026-10-08T15:02:39.689Z","payload":{"type":"assistant","content":"...","operationType":"Reasoning","executionId":"e514a741-9199-48c3-a3d2-a0daa755eb32","subExecutionId":"5d13f3d7-73ae-431d-847f-04729b40503e","reasoningSignature":"signature-replaced-in-this-fixture","reasoningModelId":"qdev::auto","_meta":{"kiro":{"agentMode":"kit-subagent","agentSubtaskId":"5d13f3d7-73ae-431d-847f-04729b40503e"}}}}
+{"id":"17623d0d-a064-45e2-af84-4c1ddf77646f-hook","timestamp":"2026-10-08T15:02:39.719Z","payload":{"type":"ContextualHookInvoked","hookId":"/work/20261008T150143Z-K18-v3/.kiro/hooks/kit-v3.json#hook-2","operationId":"17623d0d-a064-45e2-af84-4c1ddf77646f","name":"kit-pre-tool","hookActionType":"runCommand","status":"completed","executionId":"e514a741-9199-48c3-a3d2-a0daa755eb32"}}
+{"id":"toolu_bdrk_01PtwBJNbY9wsywG6i5uQoGU-call","timestamp":"2026-10-08T15:02:39.722Z","payload":{"type":"tool_call","toolCallId":"toolu_bdrk_01PtwBJNbY9wsywG6i5uQoGU","toolName":"read_file","args":{"path":"/work/20261008T150143Z-K18-v3/calc.py","offset":null,"limit":null},"status":"approved","kind":"read","executionId":"e514a741-9199-48c3-a3d2-a0daa755eb32","actionType":"read_files","title":"Read File","_meta":{"kiro":{"agentMode":"kit-subagent","agentSubtaskId":"5d13f3d7-73ae-431d-847 [...]
+{"id":"toolu_bdrk_01PtwBJNbY9wsywG6i5uQoGU-result","timestamp":"2026-10-08T15:02:39.722Z","payload":{"type":"tool_result","toolCallId":"toolu_bdrk_01PtwBJNbY9wsywG6i5uQoGU","content":"<file name=\"/work/20261008T150143Z-K18-v3/calc.py\" language=\"python\" >\n<content>\ndef add(a, b):\n return a + b\n\n\ndef mean(values):\n return sum(values) / (len(values) - 1)\n\n</content>\n</file>","success":true,"executionId":"e514a741-9199-48c3-a3d2-a0daa755eb32","_meta":{"kiro":{"agentMode": [...]
+{"id":"df06501a-e667-4e05-a02a-8cd20d8989eb-hook","timestamp":"2026-10-08T15:02:39.785Z","payload":{"type":"ContextualHookInvoked","hookId":"/work/20261008T150143Z-K18-v3/.kiro/hooks/kit-v3.json#hook-3","operationId":"df06501a-e667-4e05-a02a-8cd20d8989eb","name":"kit-post-tool","hookActionType":"runCommand","status":"completed","executionId":"e514a741-9199-48c3-a3d2-a0daa755eb32"}}
+{"id":"592cebfd-53fa-4d85-a407-536caa189cc2-say","timestamp":"2026-10-08T15:02:46.529Z","payload":{"type":"assistant","content":"The file has one bug.","operationType":"Say","executionId":"e514a741-9199-48c3-a3d2-a0daa755eb32","subExecutionId":"5d13f3d7-73ae-431d-847f-04729b40503e","_meta":{"kiro":{"agentMode":"kit-subagent","agentSubtaskId":"5d13f3d7-73ae-431d-847f-04729b40503e"}}}}
+{"id":"be4a9995-02bc-4369-80f7-66e1d3b8081b-say","timestamp":"2026-10-08T15:02:53.782Z","payload":{"type":"assistant","content":"...","operationType":"Reasoning","executionId":"e514a741-9199-48c3-a3d2-a0daa755eb32","subExecutionId":"5d13f3d7-73ae-431d-847f-04729b40503e","reasoningSignature":"signature-replaced-in-this-fixture","reasoningModelId":"qdev::auto","_meta":{"kiro":{"agentMode":"kit-subagent","agentSubtaskId":"5d13f3d7-73ae-431d-847f-04729b40503e"}}}}
+{"id":"91bc3b44-092c-4739-a937-53683de7c928-hook","timestamp":"2026-10-08T15:02:54.155Z","payload":{"type":"ContextualHookInvoked","hookId":"/work/20261008T150143Z-K18-v3/.kiro/hooks/kit-v3.json#hook-2","operationId":"91bc3b44-092c-4739-a937-53683de7c928","name":"kit-pre-tool","hookActionType":"runCommand","status":"completed","executionId":"e514a741-9199-48c3-a3d2-a0daa755eb32"}}
+{"id":"toolu_bdrk_018WapuGQZeHb3Fr9P3T8UwJ-call","timestamp":"2026-10-08T15:02:54.155Z","payload":{"type":"tool_call","toolCallId":"toolu_bdrk_018WapuGQZeHb3Fr9P3T8UwJ","toolName":"subagent_response","args":{"response":"Reviewed calc.py. Found one bug:\n\n- In `mean()`, the sum is divided by `len(values) - 1` instead of `len(values)`, so the computed average is wrong (and it raises ZeroDivisionError for a single-element list).","files":[{"path":"/work/20261008T150143Z-K18-v3/calc.py","st [...]
+{"id":"toolu_bdrk_018WapuGQZeHb3Fr9P3T8UwJ-result","timestamp":"2026-10-08T15:02:54.155Z","payload":{"type":"tool_result","toolCallId":"toolu_bdrk_018WapuGQZeHb3Fr9P3T8UwJ","content":"Response recorded.","success":true,"executionId":"e514a741-9199-48c3-a3d2-a0daa755eb32","_meta":{"kiro":{"agentMode":"kit-subagent"}},"subExecutionId":"5d13f3d7-73ae-431d-847f-04729b40503e"}}
+{"id":"9bfdbdfa-4f02-48f9-8e28-f25267e68863-hook","timestamp":"2026-10-08T15:02:54.220Z","payload":{"type":"ContextualHookInvoked","hookId":"/work/20261008T150143Z-K18-v3/.kiro/hooks/kit-v3.json#hook-3","operationId":"9bfdbdfa-4f02-48f9-8e28-f25267e68863","name":"kit-post-tool","hookActionType":"runCommand","status":"completed","executionId":"e514a741-9199-48c3-a3d2-a0daa755eb32"}}
diff --git a/internal/adapters/kiro/testdata/sessions/0454e3ae83e187ed/sess_acd7b622-5c65-468e-a288-6bb404e112f2/messages.jsonl b/internal/adapters/kiro/testdata/sessions/0454e3ae83e187ed/sess_acd7b622-5c65-468e-a288-6bb404e112f2/messages.jsonl
new file mode 100644
index 0000000..766b08c
--- /dev/null
+++ b/internal/adapters/kiro/testdata/sessions/0454e3ae83e187ed/sess_acd7b622-5c65-468e-a288-6bb404e112f2/messages.jsonl
@@ -0,0 +1,17 @@
+{"id":"context_snapshot_b47b466e-df62-4b5a-8348-85c5f2ead7dc","timestamp":"2026-10-08T14:33:29.657Z","payload":{"type":"agent_note","content":"<session_context>\nOnly the last <session_context> block is current; it remains current until a later block supersedes it.\nThe current model is Auto (model selected dynamically on the server).\n</session_context>"}}
+{"id":"03b42e32-3bab-4417-a385-8b0fa6c0ba87","timestamp":"2026-10-08T14:33:29.317Z","payload":{"type":"user","content":"Start two kit-reviewer subagents in parallel: one reviews calc.py, the other reviews test_calc.py. Then combine what they found.","documents":[],"images":[],"_meta":{"kiro":{"userMessageTag":"prompt_8d43f122-a79e-4f38-8506-24a562155cdd"}}}}
+{"id":"76bc7b84-4fd3-428b-8a78-5d81f855bbcb-turn-start","timestamp":"2026-10-08T14:33:32.501Z","payload":{"type":"turn_start","executionId":"76bc7b84-4fd3-428b-8a78-5d81f855bbcb"}}
+{"id":"7af964fe-11f5-47ba-b122-cd6fa1c57bdd-say","timestamp":"2026-10-08T14:33:36.020Z","payload":{"type":"assistant","content":"I'll start both reviewers in parallel.","operationType":"Say","executionId":"76bc7b84-4fd3-428b-8a78-5d81f855bbcb","_meta":{"kiro":{"agentMode":"kit-subagent"}}}}
+{"id":"invoke_subagent_toolu_bdrk_013QF468AmdAr6A3KUGRfAGN-sub-agent-start","timestamp":"2026-10-08T14:33:37.522Z","payload":{"type":"sub_agent_start","parentExecutionId":"76bc7b84-4fd3-428b-8a78-5d81f855bbcb","subSessionId":"a8e4758b-530e-4fe7-9973-5fc227e90881","subAgentName":"kit-reviewer","prompt":"Review the file calc.py in the workspace root. Report any bugs, correctness issues, edge cases, or code quality problems you find.","explanation":"Reviewing calc.py in parallel with the te [...]
+{"id":"invoke_subagent_toolu_bdrk_018JY8Vxr4PwyneBAecWGM8B-sub-agent-start","timestamp":"2026-10-08T14:33:38.118Z","payload":{"type":"sub_agent_start","parentExecutionId":"76bc7b84-4fd3-428b-8a78-5d81f855bbcb","subSessionId":"128c0b21-7f90-4dca-8b14-986e1eb23dda","subAgentName":"kit-reviewer","prompt":"Review the file test_calc.py in the workspace root. Report any bugs, missing test coverage, incorrect assertions, or test quality problems you find.","explanation":"Reviewing test_calc.py [...]
+{"id":"8f762ebc-6564-423c-948e-b91c6c88e210-say","timestamp":"2026-10-08T14:33:38.131Z","payload":{"type":"assistant","content":"...","operationType":"Reasoning","executionId":"76bc7b84-4fd3-428b-8a78-5d81f855bbcb","reasoningSignature":"signature-replaced-in-this-fixture","reasoningModelId":"qdev::auto","_meta":{"kiro":{"agentMode":"kit-subagent"}}}}
+{"id":"24859e01-ee62-4fb1-ad03-55d2dd1eedd3","timestamp":"2026-10-08T14:33:38.131Z","payload":{"type":"session_metadata","key":"contextUsage","value":{"usagePercentage":1.3730000257492065},"executionId":"76bc7b84-4fd3-428b-8a78-5d81f855bbcb"}}
+{"id":"invoke_subagent_toolu_bdrk_013QF468AmdAr6A3KUGRfAGN-sub-agent-complete","timestamp":"2026-10-08T14:33:48.991Z","payload":{"type":"sub_agent_complete","parentExecutionId":"76bc7b84-4fd3-428b-8a78-5d81f855bbcb","subSessionId":"a8e4758b-530e-4fe7-9973-5fc227e90881","response":"Reviewed calc.py. Bugs found:\n\n1. `mean` divides the sum by `len(values) - 1` instead of `len(values)`, so it returns an incorrect (inflated) average.\n\nRelated edge cases from that same line:\n2. `mean` on [...]
+{"id":"invoke_subagent_toolu_bdrk_018JY8Vxr4PwyneBAecWGM8B-sub-agent-complete","timestamp":"2026-10-08T14:33:59.895Z","payload":{"type":"sub_agent_complete","parentExecutionId":"76bc7b84-4fd3-428b-8a78-5d81f855bbcb","subSessionId":"128c0b21-7f90-4dca-8b14-986e1eb23dda","response":"Reviewed test_calc.py (I read only this file, as instructed; I did not read calc.py, so I cannot verify assertions against the actual implementation).\n\nBugs / incorrect assertions:\n- Cannot confirm assertion [...]
+{"id":"7207c414-486c-40c7-a664-97962d450fd4-say","timestamp":"2026-10-08T14:34:10.533Z","payload":{"type":"assistant","content":"Both reviews are in. Here's the combined picture.\n\nThe two reviews reinforce each other: calc.py has a real bug, and test_calc.py is too shallow to catch it.\n\nCore bug (calc.py)\n- `mean` divides by `len(values) - 1` instead of `len(values)`, so every average comes out inflated. Same line causes two edge-case failures: a single-element list divides by zero [...]
+{"id":"e0a4436d-11d2-4748-bc1d-468bb4b1fb47-say","timestamp":"2026-10-08T14:34:10.534Z","payload":{"type":"assistant","content":"...","operationType":"Reasoning","executionId":"76bc7b84-4fd3-428b-8a78-5d81f855bbcb","reasoningSignature":"signature-replaced-in-this-fixture","reasoningModelId":"qdev::auto","_meta":{"kiro":{"agentMode":"kit-subagent"}}}}
+{"id":"48d488d0-9a08-4789-98ed-64c7cf11d7af","timestamp":"2026-10-08T14:34:10.534Z","payload":{"type":"session_metadata","key":"contextUsage","value":{"usagePercentage":1.5047999620437622},"executionId":"76bc7b84-4fd3-428b-8a78-5d81f855bbcb"}}
+{"id":"76bc7b84-4fd3-428b-8a78-5d81f855bbcb-usage","timestamp":"2026-10-08T14:34:10.547Z","payload":{"type":"usage_summary","promptTurnSummaries":[{"unit":"credit","unitPlural":"credits","usage":0.44092019203980093,"usedTools":["subagent_kit-reviewer","read_file","read_file","subagent_response","subagent_response"]}],"elapsedTime":38047,"status":"success","executionId":"76bc7b84-4fd3-428b-8a78-5d81f855bbcb","requestIds":["a7aeb34c-167a-4c42-895e-7532344e907e","1b542617-258a-4171-a4ff-172 [...]
+{"id":"76bc7b84-4fd3-428b-8a78-5d81f855bbcb-complete","timestamp":"2026-10-08T14:34:10.548Z","payload":{"type":"session_event","category":"session_pause","context":{"executionId":"76bc7b84-4fd3-428b-8a78-5d81f855bbcb","status":"success"}}}
+{"id":"76bc7b84-4fd3-428b-8a78-5d81f855bbcb-turn-end","timestamp":"2026-10-08T14:34:10.548Z","payload":{"type":"turn_end","stopReason":"end_turn","executionId":"76bc7b84-4fd3-428b-8a78-5d81f855bbcb"}}
+{"id":"session_start_sess_acd7b622-5c65-468e-a288-6bb404e112f2_kit-subagent","timestamp":"2026-10-08T14:34:10.551Z","payload":{"type":"session_start","agentType":"kit-subagent","backgroundExecution":false,"content":"The system prompt the engine composed, replaced in this fixture.","composition":{"schema":1,"contentChars":64},"forcedRole":true,"messageId":"1601f72d-6e2f-476b-9be4-6cf09781a1b1"}}
diff --git a/internal/adapters/kiro/testdata/sessions/0454e3ae83e187ed/sess_acd7b622-5c65-468e-a288-6bb404e112f2/session.json b/internal/adapters/kiro/testdata/sessions/0454e3ae83e187ed/sess_acd7b622-5c65-468e-a288-6bb404e112f2/session.json
new file mode 100644
index 0000000..c541da7
--- /dev/null
+++ b/internal/adapters/kiro/testdata/sessions/0454e3ae83e187ed/sess_acd7b622-5c65-468e-a288-6bb404e112f2/session.json
@@ -0,0 +1,46 @@
+{
+ "schemaVersion": "1.0.0",
+ "dataModelVersion": 1,
+ "id": "sess_acd7b622-5c65-468e-a288-6bb404e112f2",
+ "title": "Parallel Code Review for Calc Module",
+ "agentMode": "kit-subagent",
+ "workspacePaths": [
+ "/work/20261008T143326Z-K09-v3"
+ ],
+ "rootPaths": [
+ "/work/20261008T143326Z-K09-v3"
+ ],
+ "createdAt": "2026-10-08T14:33:29.243Z",
+ "lastModifiedAt": "2026-10-08T14:34:10.552Z",
+ "modelId": "auto",
+ "autopilot": true,
+ "contextSnapshotModelDisplay": {
+ "modelId": "auto",
+ "display": "Auto (model selected dynamically on the server)"
+ },
+ "semanticReviewEnabled": true,
+ "disableAutoCompaction": false,
+ "ftaEnabled": false,
+ "workflowsEnabled": false,
+ "specPlanEnabled": false,
+ "specWorkflow": "quick",
+ "specSkipClarificationEnabled": true,
+ "memoryConfig": {
+ "mode": "read_write",
+ "reflection": true
+ },
+ "memoryConfigSource": "legacy",
+ "status": "idle",
+ "_meta": {
+ "kiro": {
+ "settings": {
+ "workflows": {
+ "enabled": false
+ },
+ "goal": {
+ "enabled": false
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/internal/adapters/kiro/testdata/sessions/0454e3ae83e187ed/sess_acd7b622-5c65-468e-a288-6bb404e112f2/sub-executions/128c0b21-7f90-4dca-8b14-986e1eb23dda.jsonl b/internal/adapters/kiro/testdata/sessions/0454e3ae83e187ed/sess_acd7b622-5c65-468e-a288-6bb404e112f2/sub-executions/128c0b21-7f90-4dca-8b14-986e1eb23dda.jsonl
new file mode 100644
index 0000000..256d1bc
--- /dev/null
+++ b/internal/adapters/kiro/testdata/sessions/0454e3ae83e187ed/sess_acd7b622-5c65-468e-a288-6bb404e112f2/sub-executions/128c0b21-7f90-4dca-8b14-986e1eb23dda.jsonl
@@ -0,0 +1,8 @@
+{"id":"a1a1b61e-0c8c-4371-b3fd-30ea0fa56836-say","timestamp":"2026-10-08T14:33:45.252Z","payload":{"type":"assistant","content":"I'll review the test file. To evaluate assertion correctness, I need to see what it's testing against, but my instructions limit me to only the file I was given.","operationType":"Say","executionId":"76bc7b84-4fd3-428b-8a78-5d81f855bbcb","subExecutionId":"128c0b21-7f90-4dca-8b14-986e1eb23dda","_meta":{"kiro":{"agentMode":"kit-subagent","agentSubtaskId":"128c0b2 [...]
+{"id":"toolu_bdrk_01Mj3bG3Du9t1t7kHeBtxHFZ-call","timestamp":"2026-10-08T14:33:46.646Z","payload":{"type":"tool_call","toolCallId":"toolu_bdrk_01Mj3bG3Du9t1t7kHeBtxHFZ","toolName":"read_file","args":{"path":"/work/20261008T143326Z-K09-v3/test_calc.py","offset":null,"limit":null},"status":"approved","kind":"read","executionId":"76bc7b84-4fd3-428b-8a78-5d81f855bbcb","actionType":"read_files","title":"Read File","_meta":{"kiro":{"agentMode":"kit-subagent","agentSubtaskId":"128c0b21-7f90-4dc [...]
+{"id":"toolu_bdrk_01Mj3bG3Du9t1t7kHeBtxHFZ-result","timestamp":"2026-10-08T14:33:46.646Z","payload":{"type":"tool_result","toolCallId":"toolu_bdrk_01Mj3bG3Du9t1t7kHeBtxHFZ","content":"<file name=\"/work/20261008T143326Z-K09-v3/test_calc.py\" language=\"python\" >\n<content>\nimport unittest\n\nfrom calc import add, mean\n\n\nclass CalcTest(unittest.TestCase):\n def test_add(self):\n self.assertEqual(add(2, 3), 5)\n\n def test_mean(self):\n self.assertEqual(mean([2, 4, [...]
+{"id":"084d0d4d-c6e0-4fc2-96ba-7393e70a229b-say","timestamp":"2026-10-08T14:33:46.803Z","payload":{"type":"assistant","content":"...","operationType":"Reasoning","executionId":"76bc7b84-4fd3-428b-8a78-5d81f855bbcb","subExecutionId":"128c0b21-7f90-4dca-8b14-986e1eb23dda","reasoningSignature":"signature-replaced-in-this-fixture","reasoningModelId":"qdev::auto","_meta":{"kiro":{"agentMode":"kit-subagent","agentSubtaskId":"128c0b21-7f90-4dca-8b14-986e1eb23dda"}}}}
+{"id":"0839f686-a128-40d6-a384-8be52467d758-say","timestamp":"2026-10-08T14:33:50.077Z","payload":{"type":"assistant","content":"Here's my review of `test_calc.py`.","operationType":"Say","executionId":"76bc7b84-4fd3-428b-8a78-5d81f855bbcb","subExecutionId":"128c0b21-7f90-4dca-8b14-986e1eb23dda","_meta":{"kiro":{"agentMode":"kit-subagent","agentSubtaskId":"128c0b21-7f90-4dca-8b14-986e1eb23dda"}}}}
+{"id":"toolu_bdrk_01TkvYmodmBf8bFMb5xRqxNG-call","timestamp":"2026-10-08T14:33:59.816Z","payload":{"type":"tool_call","toolCallId":"toolu_bdrk_01TkvYmodmBf8bFMb5xRqxNG","toolName":"subagent_response","args":{"response":"Reviewed test_calc.py (I read only this file, as instructed; I did not read calc.py, so I cannot verify assertions against the actual implementation).\n\nBugs / incorrect assertions:\n- Cannot confirm assertion correctness without seeing calc.py; `add(2, 3) == 5` and `mea [...]
+{"id":"toolu_bdrk_01TkvYmodmBf8bFMb5xRqxNG-result","timestamp":"2026-10-08T14:33:59.816Z","payload":{"type":"tool_result","toolCallId":"toolu_bdrk_01TkvYmodmBf8bFMb5xRqxNG","content":"Response recorded.","success":true,"executionId":"76bc7b84-4fd3-428b-8a78-5d81f855bbcb","_meta":{"kiro":{"agentMode":"kit-subagent"}},"subExecutionId":"128c0b21-7f90-4dca-8b14-986e1eb23dda"}}
+{"id":"8a9b7d24-d5a6-4870-bd87-2da105458cc7-say","timestamp":"2026-10-08T14:33:59.891Z","payload":{"type":"assistant","content":"...","operationType":"Reasoning","executionId":"76bc7b84-4fd3-428b-8a78-5d81f855bbcb","subExecutionId":"128c0b21-7f90-4dca-8b14-986e1eb23dda","reasoningSignature":"signature-replaced-in-this-fixture","reasoningModelId":"qdev::auto","_meta":{"kiro":{"agentMode":"kit-subagent","agentSubtaskId":"128c0b21-7f90-4dca-8b14-986e1eb23dda"}}}}
diff --git a/internal/adapters/kiro/testdata/sessions/0454e3ae83e187ed/sess_acd7b622-5c65-468e-a288-6bb404e112f2/sub-executions/a8e4758b-530e-4fe7-9973-5fc227e90881.jsonl b/internal/adapters/kiro/testdata/sessions/0454e3ae83e187ed/sess_acd7b622-5c65-468e-a288-6bb404e112f2/sub-executions/a8e4758b-530e-4fe7-9973-5fc227e90881.jsonl
new file mode 100644
index 0000000..363e0f5
--- /dev/null
+++ b/internal/adapters/kiro/testdata/sessions/0454e3ae83e187ed/sess_acd7b622-5c65-468e-a288-6bb404e112f2/sub-executions/a8e4758b-530e-4fe7-9973-5fc227e90881.jsonl
@@ -0,0 +1,7 @@
+{"id":"8b3295cc-e841-4726-b51e-27ae575faf92-say","timestamp":"2026-10-08T14:33:41.383Z","payload":{"type":"assistant","content":"...","operationType":"Reasoning","executionId":"76bc7b84-4fd3-428b-8a78-5d81f855bbcb","subExecutionId":"a8e4758b-530e-4fe7-9973-5fc227e90881","reasoningSignature":"signature-replaced-in-this-fixture","reasoningModelId":"qdev::auto","_meta":{"kiro":{"agentMode":"kit-subagent","agentSubtaskId":"a8e4758b-530e-4fe7-9973-5fc227e90881"}}}}
+{"id":"toolu_bdrk_01SsNUh7GLZvGnpyHU1UNNsL-call","timestamp":"2026-10-08T14:33:41.386Z","payload":{"type":"tool_call","toolCallId":"toolu_bdrk_01SsNUh7GLZvGnpyHU1UNNsL","toolName":"read_file","args":{"path":"/work/20261008T143326Z-K09-v3/calc.py","offset":null,"limit":null},"status":"approved","kind":"read","executionId":"76bc7b84-4fd3-428b-8a78-5d81f855bbcb","actionType":"read_files","title":"Read File","_meta":{"kiro":{"agentMode":"kit-subagent","agentSubtaskId":"a8e4758b-530e-4fe7-997 [...]
+{"id":"toolu_bdrk_01SsNUh7GLZvGnpyHU1UNNsL-result","timestamp":"2026-10-08T14:33:41.386Z","payload":{"type":"tool_result","toolCallId":"toolu_bdrk_01SsNUh7GLZvGnpyHU1UNNsL","content":"<file name=\"/work/20261008T143326Z-K09-v3/calc.py\" language=\"python\" >\n<content>\ndef add(a, b):\n return a + b\n\n\ndef mean(values):\n return sum(values) / (len(values) - 1)\n\n</content>\n</file>","success":true,"executionId":"76bc7b84-4fd3-428b-8a78-5d81f855bbcb","_meta":{"kiro":{"agentMode": [...]
+{"id":"2bce42f0-f49b-4701-80ee-aa47bb5f5171-say","timestamp":"2026-10-08T14:33:45.049Z","payload":{"type":"assistant","content":"I reviewed calc.py and found one bug.","operationType":"Say","executionId":"76bc7b84-4fd3-428b-8a78-5d81f855bbcb","subExecutionId":"a8e4758b-530e-4fe7-9973-5fc227e90881","_meta":{"kiro":{"agentMode":"kit-subagent","agentSubtaskId":"a8e4758b-530e-4fe7-9973-5fc227e90881"}}}}
+{"id":"toolu_bdrk_0196APfifm4b9exV2n8rqdXh-call","timestamp":"2026-10-08T14:33:48.986Z","payload":{"type":"tool_call","toolCallId":"toolu_bdrk_0196APfifm4b9exV2n8rqdXh","toolName":"subagent_response","args":{"response":"Reviewed calc.py. Bugs found:\n\n1. `mean` divides the sum by `len(values) - 1` instead of `len(values)`, so it returns an incorrect (inflated) average.\n\nRelated edge cases from that same line:\n2. `mean` on a single-element list divides by zero (len 1 - 1 = 0), raising [...]
+{"id":"toolu_bdrk_0196APfifm4b9exV2n8rqdXh-result","timestamp":"2026-10-08T14:33:48.986Z","payload":{"type":"tool_result","toolCallId":"toolu_bdrk_0196APfifm4b9exV2n8rqdXh","content":"Response recorded.","success":true,"executionId":"76bc7b84-4fd3-428b-8a78-5d81f855bbcb","_meta":{"kiro":{"agentMode":"kit-subagent"}},"subExecutionId":"a8e4758b-530e-4fe7-9973-5fc227e90881"}}
+{"id":"2f4ac693-6b8a-4ecf-b5da-aa55de63d958-say","timestamp":"2026-10-08T14:33:48.987Z","payload":{"type":"assistant","content":"...","operationType":"Reasoning","executionId":"76bc7b84-4fd3-428b-8a78-5d81f855bbcb","subExecutionId":"a8e4758b-530e-4fe7-9973-5fc227e90881","reasoningSignature":"signature-replaced-in-this-fixture","reasoningModelId":"qdev::auto","_meta":{"kiro":{"agentMode":"kit-subagent","agentSubtaskId":"a8e4758b-530e-4fe7-9973-5fc227e90881"}}}}
diff --git a/internal/adapters/kiro/testdata/sessions/31561e57e123be61/sess_77537f32-41e4-4bfc-b412-e80db5ec1d51/messages.jsonl b/internal/adapters/kiro/testdata/sessions/31561e57e123be61/sess_77537f32-41e4-4bfc-b412-e80db5ec1d51/messages.jsonl
new file mode 100644
index 0000000..ecdb57d
--- /dev/null
+++ b/internal/adapters/kiro/testdata/sessions/31561e57e123be61/sess_77537f32-41e4-4bfc-b412-e80db5ec1d51/messages.jsonl
@@ -0,0 +1,16 @@
+{"id":"context_snapshot_735c8944-95dc-4734-a99f-420d7181ab93","timestamp":"2026-10-08T14:35:51.526Z","payload":{"type":"agent_note","content":"<session_context>\nOnly the last <session_context> block is current; it remains current until a later block supersedes it.\nThe current model is Auto (model selected dynamically on the server).\n</session_context>"}}
+{"id":"ca86e4d5-935f-48fd-9a60-c180c64fb562","timestamp":"2026-10-08T14:35:51.525Z","payload":{"type":"user","content":"Use the shell tool to run exactly: echo denied-check. Report what happened.","documents":[],"images":[],"_meta":{"kiro":{"userMessageTag":"prompt_ed7e1818-df85-4b2a-a90a-2a2a7701b013"}}}}
+{"id":"b7d6475e-e53b-43db-b363-254bcebbcce0-turn-start","timestamp":"2026-10-08T14:35:54.075Z","payload":{"type":"turn_start","executionId":"b7d6475e-e53b-43db-b363-254bcebbcce0"}}
+{"id":"run_command_toolu_bdrk_01VEBiG5BGUW2Yd6gFizBRkt-pending","timestamp":"2026-10-08T14:36:00.125Z","payload":{"type":"pending_interaction","interactionType":"tool_approval","toolCallId":"run_command_toolu_bdrk_01VEBiG5BGUW2Yd6gFizBRkt","question":"echo denied-check","options":[{"optionId":"accept","name":"Allow","kind":"allow_once"},{"optionId":"always-accept","name":"Always allow","kind":"allow_always"},{"optionId":"reject","name":"Deny","kind":"reject_once"},{"optionId":"always-rej [...]
+{"id":"run_command_toolu_bdrk_01VEBiG5BGUW2Yd6gFizBRkt-resolved","timestamp":"2026-10-08T14:36:00.150Z","payload":{"type":"interaction_resolved","toolCallId":"run_command_toolu_bdrk_01VEBiG5BGUW2Yd6gFizBRkt","outcome":"cancelled","executionId":"b7d6475e-e53b-43db-b363-254bcebbcce0"}}
+{"id":"run_command_toolu_bdrk_01VEBiG5BGUW2Yd6gFizBRkt-call","timestamp":"2026-10-08T14:36:00.177Z","payload":{"type":"tool_call","toolCallId":"run_command_toolu_bdrk_01VEBiG5BGUW2Yd6gFizBRkt","toolName":"execute_bash","args":{"command":"echo denied-check","description":"Run a simple echo command","run_in_background":false},"status":"denied","kind":"execute","executionId":"b7d6475e-e53b-43db-b363-254bcebbcce0","actionType":"run_command","modelToolUseId":"toolu_bdrk_01VEBiG5BGUW2Yd6gFizBR [...]
+{"id":"run_command_toolu_bdrk_01VEBiG5BGUW2Yd6gFizBRkt-result","timestamp":"2026-10-08T14:36:00.177Z","payload":{"type":"tool_result","toolCallId":"run_command_toolu_bdrk_01VEBiG5BGUW2Yd6gFizBRkt","content":"Output:\nThe user rejected this tool call.\n\nExit Code: -1","success":false,"executionId":"b7d6475e-e53b-43db-b363-254bcebbcce0","_meta":{"kiro":{"agentMode":"vibe"}}}}
+{"id":"be6a73c8-a654-4263-9fad-195047fb729a-say","timestamp":"2026-10-08T14:36:00.191Z","payload":{"type":"assistant","content":"...","operationType":"Reasoning","executionId":"b7d6475e-e53b-43db-b363-254bcebbcce0","reasoningSignature":"signature-replaced-in-this-fixture","reasoningModelId":"qdev::auto","_meta":{"kiro":{"agentMode":"vibe"}}}}
+{"id":"54449638-9f42-4d2d-b3e6-339f14d2777d","timestamp":"2026-10-08T14:36:00.191Z","payload":{"type":"session_metadata","key":"contextUsage","value":{"usagePercentage":3.4363999366760254},"executionId":"b7d6475e-e53b-43db-b363-254bcebbcce0"}}
+{"id":"bd625086-51da-4740-9da9-65b3469d49b5-say","timestamp":"2026-10-08T14:36:05.488Z","payload":{"type":"assistant","content":"The command did not run. The tool call to execute `echo denied-check` was rejected, so nothing was executed in the shell. Exit code was -1 and no output was produced.","operationType":"Say","executionId":"b7d6475e-e53b-43db-b363-254bcebbcce0","_meta":{"kiro":{"agentMode":"vibe"}}}}
+{"id":"89fed232-39b7-4eaa-908e-489f9c8b80ed-say","timestamp":"2026-10-08T14:36:05.488Z","payload":{"type":"assistant","content":"...","operationType":"Reasoning","executionId":"b7d6475e-e53b-43db-b363-254bcebbcce0","reasoningSignature":"signature-replaced-in-this-fixture","reasoningModelId":"qdev::auto","_meta":{"kiro":{"agentMode":"vibe"}}}}
+{"id":"22b7af7a-f53a-4ea8-a19c-aaabfa55429b","timestamp":"2026-10-08T14:36:05.488Z","payload":{"type":"session_metadata","key":"contextUsage","value":{"usagePercentage":3.444700002670288},"executionId":"b7d6475e-e53b-43db-b363-254bcebbcce0"}}
+{"id":"b7d6475e-e53b-43db-b363-254bcebbcce0-usage","timestamp":"2026-10-08T14:36:05.559Z","payload":{"type":"usage_summary","promptTurnSummaries":[{"unit":"credit","unitPlural":"credits","usage":0.18186185555555556,"usedTools":["execute_bash"]}],"elapsedTime":11484,"status":"success","executionId":"b7d6475e-e53b-43db-b363-254bcebbcce0","requestIds":["8bb46c87-bd09-4e90-a133-7db30ced6fb6","5fe9bb74-16f0-4c57-8d14-9e74c29c1a3c"]}}
+{"id":"b7d6475e-e53b-43db-b363-254bcebbcce0-complete","timestamp":"2026-10-08T14:36:05.559Z","payload":{"type":"session_event","category":"session_pause","context":{"executionId":"b7d6475e-e53b-43db-b363-254bcebbcce0","status":"success"}}}
+{"id":"b7d6475e-e53b-43db-b363-254bcebbcce0-turn-end","timestamp":"2026-10-08T14:36:05.559Z","payload":{"type":"turn_end","stopReason":"end_turn","executionId":"b7d6475e-e53b-43db-b363-254bcebbcce0"}}
+{"id":"session_start_sess_77537f32-41e4-4bfc-b412-e80db5ec1d51_vibe","timestamp":"2026-10-08T14:36:05.577Z","payload":{"type":"session_start","agentType":"vibe","backgroundExecution":false,"content":"The system prompt the engine composed, replaced in this fixture.","composition":{"schema":1,"contentChars":64,"fileTreeChars":2063},"forcedRole":true,"messageId":"1f35df93-e228-40d7-9dac-8e21a6035690"}}
diff --git a/internal/adapters/kiro/testdata/sessions/31561e57e123be61/sess_77537f32-41e4-4bfc-b412-e80db5ec1d51/session.json b/internal/adapters/kiro/testdata/sessions/31561e57e123be61/sess_77537f32-41e4-4bfc-b412-e80db5ec1d51/session.json
new file mode 100644
index 0000000..8095913
--- /dev/null
+++ b/internal/adapters/kiro/testdata/sessions/31561e57e123be61/sess_77537f32-41e4-4bfc-b412-e80db5ec1d51/session.json
@@ -0,0 +1,45 @@
+{
+ "schemaVersion": "1.0.0",
+ "dataModelVersion": 1,
+ "id": "sess_77537f32-41e4-4bfc-b412-e80db5ec1d51",
+ "title": "Run Echo Denied-Check Command",
+ "agentMode": "vibe",
+ "workspacePaths": [
+ "/work/20261008T143531Z-K05-v3"
+ ],
+ "rootPaths": [
+ "/work/20261008T143531Z-K05-v3"
+ ],
+ "createdAt": "2026-10-08T14:35:51.438Z",
+ "lastModifiedAt": "2026-10-08T14:36:05.595Z",
+ "modelId": "auto",
+ "contextSnapshotModelDisplay": {
+ "modelId": "auto",
+ "display": "Auto (model selected dynamically on the server)"
+ },
+ "semanticReviewEnabled": true,
+ "disableAutoCompaction": false,
+ "ftaEnabled": false,
+ "workflowsEnabled": false,
+ "specPlanEnabled": false,
+ "specWorkflow": "quick",
+ "specSkipClarificationEnabled": true,
+ "memoryConfig": {
+ "mode": "read_write",
+ "reflection": true
+ },
+ "memoryConfigSource": "legacy",
+ "status": "idle",
+ "_meta": {
+ "kiro": {
+ "settings": {
+ "workflows": {
+ "enabled": false
+ },
+ "goal": {
+ "enabled": false
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/internal/adapters/kiro/testdata/sessions/53e72fb3603217da/sess_5f20ccf4-b254-4bdb-945b-080e051eb7f5/messages.jsonl b/internal/adapters/kiro/testdata/sessions/53e72fb3603217da/sess_5f20ccf4-b254-4bdb-945b-080e051eb7f5/messages.jsonl
new file mode 100644
index 0000000..c285067
--- /dev/null
+++ b/internal/adapters/kiro/testdata/sessions/53e72fb3603217da/sess_5f20ccf4-b254-4bdb-945b-080e051eb7f5/messages.jsonl
@@ -0,0 +1,74 @@
+{"id":"context_snapshot_9032e8c9-8c12-4753-9e95-b6afb13771f0","timestamp":"2026-10-10T00:11:32.627Z","payload":{"type":"agent_note","content":"<session_context>\nOnly the last <session_context> block is current; it remains current until a later block supersedes it.\nThe current model is Claude Opus 5.5.\n</session_context>"}}
+{"id":"ee300b0f-e829-4d5a-8839-6b323c3d6fff","timestamp":"2026-10-10T00:11:32.626Z","payload":{"type":"user","content":"A prompt, replaced in this fixture.","documents":[],"images":[],"_meta":{"kiro":{"userMessageTag":"prompt_f9c33923-b4ae-4416-807a-c3f9dd455753"}}}}
+{"id":"751b9d4b-49d5-4c8c-b0ab-f47b96a35b85-turn-start","timestamp":"2026-10-10T00:11:32.638Z","payload":{"type":"turn_start","executionId":"751b9d4b-49d5-4c8c-b0ab-f47b96a35b85"}}
+{"id":"912555e6-3e98-4035-86b3-247fbdbb204b-say","timestamp":"2026-10-10T00:12:10.814Z","payload":{"type":"assistant","content":"Reasoning, replaced in this fixture.","operationType":"Reasoning","executionId":"751b9d4b-49d5-4c8c-b0ab-f47b96a35b85","reasoningSignature":"signature-replaced-in-this-fixture","reasoningModelId":"qdev::claude-opus-5.5","_meta":{"kiro":{"agentMode":"kirocrew"}}}}
+{"id":"facb01e2-7e3a-4c37-a0fc-404d36ab8707","timestamp":"2026-10-10T00:12:18.010Z","payload":{"type":"session_metadata","key":"contextUsage","value":{"usagePercentage":7.9593000411987305},"executionId":"751b9d4b-49d5-4c8c-b0ab-f47b96a35b85"}}
+{"id":"run_command_toolu_bdrk_014Y9go1r47eXHpNWKeV4dLe-pending","timestamp":"2026-10-10T00:12:18.117Z","payload":{"type":"pending_interaction","interactionType":"tool_approval","toolCallId":"run_command_toolu_bdrk_014Y9go1r47eXHpNWKeV4dLe","question":"replaced","options":[{"optionId":"replaced","name":"replaced","kind":"replaced"},{"optionId":"replaced","name":"replaced","kind":"replaced"},{"optionId":"replaced","name":"replaced","kind":"replaced"},{"optionId":"replaced","name":"replaced [...]
+{"id":"run_command_toolu_bdrk_014Y9go1r47eXHpNWKeV4dLe-resolved","timestamp":"2026-10-10T00:12:18.460Z","payload":{"type":"interaction_resolved","toolCallId":"run_command_toolu_bdrk_014Y9go1r47eXHpNWKeV4dLe","outcome":"selected","selectedOption":"replaced","executionId":"751b9d4b-49d5-4c8c-b0ab-f47b96a35b85"}}
+{"id":"run_command_toolu_bdrk_014Y9go1r47eXHpNWKeV4dLe-call","timestamp":"2026-10-10T00:12:42.994Z","payload":{"type":"tool_call","toolCallId":"run_command_toolu_bdrk_014Y9go1r47eXHpNWKeV4dLe","toolName":"execute_bash","args":{"command":"replaced","description":"replaced","run_in_background":false,"timeout":180000},"status":"completed","kind":"execute","executionId":"751b9d4b-49d5-4c8c-b0ab-f47b96a35b85","actionType":"run_command","title":"replaced","_meta":{"kiro":{"agentMode":"kirocrew [...]
+{"id":"run_command_toolu_bdrk_014Y9go1r47eXHpNWKeV4dLe-result","timestamp":"2026-10-10T00:12:42.994Z","payload":{"type":"tool_result","toolCallId":"run_command_toolu_bdrk_014Y9go1r47eXHpNWKeV4dLe","content":"Output, replaced in this fixture.","success":true,"executionId":"751b9d4b-49d5-4c8c-b0ab-f47b96a35b85","_meta":{"kiro":{"agentMode":"kirocrew"}}}}
+{"id":"fd601006-2f18-4a3c-8787-6e4a5a4202d0","timestamp":"2026-10-10T00:13:08.231Z","payload":{"type":"session_metadata","key":"displayError","value":{"message":"Claude Opus 5.5 is experiencing high traffic. Try again, or select another model. (Request ID: 8391a922-bd4b-469a-927b-f728a6e0e493)","errorType":"ModelThrottleError","retryErrorType":"THROTTLING"},"executionId":"751b9d4b-49d5-4c8c-b0ab-f47b96a35b85"}}
+{"id":"751b9d4b-49d5-4c8c-b0ab-f47b96a35b85-usage","timestamp":"2026-10-10T00:13:08.268Z","payload":{"type":"usage_summary","promptTurnSummaries":[{"unit":"credit","unitPlural":"credits","usage":1.228650672968491,"usedTools":["tool"]}],"elapsedTime":95629,"status":"failed","executionId":"751b9d4b-49d5-4c8c-b0ab-f47b96a35b85","requestIds":["49747540-c7fa-47fc-9b4d-e0ad51c879ef"]}}
+{"id":"751b9d4b-49d5-4c8c-b0ab-f47b96a35b85-complete","timestamp":"2026-10-10T00:13:08.268Z","payload":{"type":"session_event","category":"session_pause","context":{"executionId":"751b9d4b-49d5-4c8c-b0ab-f47b96a35b85","status":"failed"}}}
+{"id":"751b9d4b-49d5-4c8c-b0ab-f47b96a35b85-turn-end","timestamp":"2026-10-10T00:13:08.268Z","payload":{"type":"turn_end","stopReason":"error","executionId":"751b9d4b-49d5-4c8c-b0ab-f47b96a35b85"}}
+{"id":"f3ba7bd7-ee77-4b8f-b325-bb336c285791","timestamp":"2026-10-10T00:13:10.698Z","payload":{"type":"user","content":"A prompt, replaced in this fixture.","documents":[],"images":[],"_meta":{"kiro":{"userMessageTag":"prompt_615c5cad-62d1-48be-8260-a6604d82d215"}}}}
+{"id":"4852f3e1-4cc9-4826-9da7-b28ac580cbd8-turn-start","timestamp":"2026-10-10T00:13:10.726Z","payload":{"type":"turn_start","executionId":"4852f3e1-4cc9-4826-9da7-b28ac580cbd8"}}
+{"id":"62365e4a-5acf-4635-b3a1-2b35ce4f1c26","timestamp":"2026-10-10T00:13:22.304Z","payload":{"type":"session_metadata","key":"displayError","value":{"message":"Claude Opus 5.5 is experiencing high traffic. Try again, or select another model. (Request ID: 1c783112-ec9a-4868-acc2-35ad21dccc30)","errorType":"ModelThrottleError","retryErrorType":"THROTTLING"},"executionId":"4852f3e1-4cc9-4826-9da7-b28ac580cbd8"}}
+{"id":"4852f3e1-4cc9-4826-9da7-b28ac580cbd8-usage","timestamp":"2026-10-10T00:13:22.524Z","payload":{"type":"usage_summary","promptTurnSummaries":[],"elapsedTime":11798,"status":"failed","executionId":"4852f3e1-4cc9-4826-9da7-b28ac580cbd8"}}
+{"id":"4852f3e1-4cc9-4826-9da7-b28ac580cbd8-complete","timestamp":"2026-10-10T00:13:22.524Z","payload":{"type":"session_event","category":"session_pause","context":{"executionId":"4852f3e1-4cc9-4826-9da7-b28ac580cbd8","status":"failed"}}}
+{"id":"4852f3e1-4cc9-4826-9da7-b28ac580cbd8-turn-end","timestamp":"2026-10-10T00:13:22.524Z","payload":{"type":"turn_end","stopReason":"error","executionId":"4852f3e1-4cc9-4826-9da7-b28ac580cbd8"}}
+{"id":"c9bfbd7f-1049-416a-91b1-77879f1f7089","timestamp":"2026-10-10T00:51:52.497Z","payload":{"type":"user","content":"A prompt, replaced in this fixture.","documents":[],"images":[],"_meta":{"kiro":{"userMessageTag":"prompt_ce05a1b1-6385-4fe8-b793-cb7b0bc8df0e"}}}}
+{"id":"f23b2b56-2d8e-4cb3-bb0c-fe9eefd1176e-turn-start","timestamp":"2026-10-10T00:51:52.504Z","payload":{"type":"turn_start","executionId":"f23b2b56-2d8e-4cb3-bb0c-fe9eefd1176e"}}
+{"id":"run_command_toolu_bdrk_01FEZHWxTptYC1cEA6JNNvyb-call","timestamp":"2026-10-10T00:56:56.608Z","payload":{"type":"tool_call","toolCallId":"run_command_toolu_bdrk_01FEZHWxTptYC1cEA6JNNvyb","toolName":"execute_bash","args":{"command":"replaced","description":"replaced","cwd":"replaced","run_in_background":false,"timeout":240000},"status":"completed","kind":"execute","executionId":"f23b2b56-2d8e-4cb3-bb0c-fe9eefd1176e","actionType":"run_command","title":"replaced","_meta":{"kiro":{"age [...]
+{"id":"run_command_toolu_bdrk_01FEZHWxTptYC1cEA6JNNvyb-result","timestamp":"2026-10-10T00:56:56.608Z","payload":{"type":"tool_result","toolCallId":"run_command_toolu_bdrk_01FEZHWxTptYC1cEA6JNNvyb","content":"Output, replaced in this fixture.","success":true,"executionId":"f23b2b56-2d8e-4cb3-bb0c-fe9eefd1176e","_meta":{"kiro":{"agentMode":"kirocrew"}}}}
+{"id":"steer-594dd4da-f439-4f37-be97-1bc0947091bd","timestamp":"2026-10-10T00:56:58.879Z","payload":{"type":"user","content":"<user_message>\nA message sent during the turn, replaced in this fixture.\n</user_message>","source":"steer"}}
+{"id":"steer-21d06fbb-8fcf-4775-b2b8-7097c8697181","timestamp":"2026-10-10T00:57:11.845Z","payload":{"type":"user","content":"<user_message>\nA message sent during the turn, replaced in this fixture.\n</user_message>","source":"steer"}}
+{"id":"d59e3845-2ea8-4b75-93a2-249c32ce5077-say","timestamp":"2026-10-10T00:57:26.882Z","payload":{"type":"assistant","content":"Reasoning, replaced in this fixture.","operationType":"Reasoning","executionId":"f23b2b56-2d8e-4cb3-bb0c-fe9eefd1176e","reasoningSignature":"signature-replaced-in-this-fixture","reasoningModelId":"qdev::claude-opus-5.5","_meta":{"kiro":{"agentMode":"kirocrew"}}}}
+{"id":"87712429-a0af-4ad4-9963-4ac035b7fe72-say","timestamp":"2026-10-10T00:57:27.699Z","payload":{"type":"assistant","content":"The agent's reply, replaced in this fixture.","operationType":"Say","executionId":"f23b2b56-2d8e-4cb3-bb0c-fe9eefd1176e","_meta":{"kiro":{"agentMode":"kirocrew"}}}}
+{"id":"run_command_toolu_bdrk_01HmvwXTfcguPiaJ78tzuD7U-pending","timestamp":"2026-10-10T00:58:02.123Z","payload":{"type":"pending_interaction","interactionType":"tool_approval","toolCallId":"run_command_toolu_bdrk_01HmvwXTfcguPiaJ78tzuD7U","question":"replaced","options":[{"optionId":"replaced","name":"replaced","kind":"replaced"},{"optionId":"replaced","name":"replaced","kind":"replaced"},{"optionId":"replaced","name":"replaced","kind":"replaced"},{"optionId":"replaced","name":"replaced [...]
+{"id":"cacd8dde-6c94-4e3d-abe5-0321f9166b86","timestamp":"2026-10-10T00:58:02.355Z","payload":{"type":"session_metadata","key":"contextUsage","value":{"usagePercentage":12.030900001525879},"executionId":"f23b2b56-2d8e-4cb3-bb0c-fe9eefd1176e"}}
+{"id":"run_command_toolu_bdrk_01HmvwXTfcguPiaJ78tzuD7U-resolved","timestamp":"2026-10-10T00:58:02.807Z","payload":{"type":"interaction_resolved","toolCallId":"run_command_toolu_bdrk_01HmvwXTfcguPiaJ78tzuD7U","outcome":"selected","selectedOption":"replaced","executionId":"f23b2b56-2d8e-4cb3-bb0c-fe9eefd1176e"}}
+{"id":"run_command_toolu_bdrk_01HmvwXTfcguPiaJ78tzuD7U-pending","timestamp":"2026-10-10T00:58:02.811Z","payload":{"type":"pending_interaction","interactionType":"tool_approval","toolCallId":"run_command_toolu_bdrk_01HmvwXTfcguPiaJ78tzuD7U","question":"replaced","options":[{"optionId":"replaced","name":"replaced","kind":"replaced"},{"optionId":"replaced","name":"replaced","kind":"replaced"},{"optionId":"replaced","name":"replaced","kind":"replaced"},{"optionId":"replaced","name":"replaced [...]
+{"id":"run_command_toolu_bdrk_01HmvwXTfcguPiaJ78tzuD7U-resolved","timestamp":"2026-10-10T00:58:03.040Z","payload":{"type":"interaction_resolved","toolCallId":"run_command_toolu_bdrk_01HmvwXTfcguPiaJ78tzuD7U","outcome":"selected","selectedOption":"replaced","executionId":"f23b2b56-2d8e-4cb3-bb0c-fe9eefd1176e"}}
+{"id":"run_command_toolu_bdrk_01HmvwXTfcguPiaJ78tzuD7U-call","timestamp":"2026-10-10T00:58:08.823Z","payload":{"type":"tool_call","toolCallId":"run_command_toolu_bdrk_01HmvwXTfcguPiaJ78tzuD7U","toolName":"execute_bash","args":{"command":"replaced","description":"replaced","cwd":"replaced","run_in_background":false},"status":"completed","kind":"execute","executionId":"f23b2b56-2d8e-4cb3-bb0c-fe9eefd1176e","actionType":"run_command","title":"replaced","_meta":{"kiro":{"agentMode":"kirocrew [...]
+{"id":"run_command_toolu_bdrk_01HmvwXTfcguPiaJ78tzuD7U-result","timestamp":"2026-10-10T00:58:08.823Z","payload":{"type":"tool_result","toolCallId":"run_command_toolu_bdrk_01HmvwXTfcguPiaJ78tzuD7U","content":"Output, replaced in this fixture.","success":true,"executionId":"f23b2b56-2d8e-4cb3-bb0c-fe9eefd1176e","_meta":{"kiro":{"agentMode":"kirocrew"}}}}
+{"id":"8b3173ad-ebd4-48ee-a447-f2793d21182b","timestamp":"2026-10-10T00:58:08.828Z","payload":{"type":"user","content":"[LIVE STEERING - New message from user]\n\nThe engine's text around the message, replaced in this fixture.\n\n<user_message id=\"steer-594dd4da-f439-4f37-be97-1bc0947091bd\">\nA message, replaced.\n</user_message>","_meta":{"kiro":{"syntheticUserMessageReason":"steering-injection","steeringConsumedIds":["steer-594dd4da-f439-4f37-be97-1bc0947091bd","steer-21d06fbb-8fcf-4 [...]
+{"id":"d93155bf-08da-4041-80eb-4386b9ab182f-say","timestamp":"2026-10-10T00:58:16.532Z","payload":{"type":"assistant","content":"Reasoning, replaced in this fixture.","operationType":"Reasoning","executionId":"f23b2b56-2d8e-4cb3-bb0c-fe9eefd1176e","reasoningSignature":"signature-replaced-in-this-fixture","reasoningModelId":"qdev::claude-opus-5.5","_meta":{"kiro":{"agentMode":"kirocrew"}}}}
+{"id":"413c355e-9791-4328-a8e3-3206098056c7-say","timestamp":"2026-10-10T00:58:16.580Z","payload":{"type":"assistant","content":"The agent's reply, replaced in this fixture.","operationType":"Say","executionId":"f23b2b56-2d8e-4cb3-bb0c-fe9eefd1176e","_meta":{"kiro":{"agentMode":"kirocrew"}}}}
+{"id":"run_command_toolu_bdrk_01R1tTbZVdtN5wB74VyWCBXf-pending","timestamp":"2026-10-10T00:58:21.543Z","payload":{"type":"pending_interaction","interactionType":"tool_approval","toolCallId":"run_command_toolu_bdrk_01R1tTbZVdtN5wB74VyWCBXf","question":"replaced","options":[{"optionId":"replaced","name":"replaced","kind":"replaced"},{"optionId":"replaced","name":"replaced","kind":"replaced"},{"optionId":"replaced","name":"replaced","kind":"replaced"},{"optionId":"replaced","name":"replaced [...]
+{"id":"403756df-5c74-4126-bde6-6e90b8ff4da5","timestamp":"2026-10-10T00:58:21.544Z","payload":{"type":"session_metadata","key":"contextUsage","value":{"usagePercentage":12.214499473571777},"executionId":"f23b2b56-2d8e-4cb3-bb0c-fe9eefd1176e"}}
+{"id":"run_command_toolu_bdrk_01R1tTbZVdtN5wB74VyWCBXf-resolved","timestamp":"2026-10-10T00:58:21.710Z","payload":{"type":"interaction_resolved","toolCallId":"run_command_toolu_bdrk_01R1tTbZVdtN5wB74VyWCBXf","outcome":"selected","selectedOption":"replaced","executionId":"f23b2b56-2d8e-4cb3-bb0c-fe9eefd1176e"}}
+{"id":"run_command_toolu_bdrk_01R1tTbZVdtN5wB74VyWCBXf-pending","timestamp":"2026-10-10T00:58:21.714Z","payload":{"type":"pending_interaction","interactionType":"tool_approval","toolCallId":"run_command_toolu_bdrk_01R1tTbZVdtN5wB74VyWCBXf","question":"replaced","options":[{"optionId":"replaced","name":"replaced","kind":"replaced"},{"optionId":"replaced","name":"replaced","kind":"replaced"},{"optionId":"replaced","name":"replaced","kind":"replaced"},{"optionId":"replaced","name":"replaced [...]
+{"id":"run_command_toolu_bdrk_01R1tTbZVdtN5wB74VyWCBXf-resolved","timestamp":"2026-10-10T00:58:21.773Z","payload":{"type":"interaction_resolved","toolCallId":"run_command_toolu_bdrk_01R1tTbZVdtN5wB74VyWCBXf","outcome":"selected","selectedOption":"replaced","executionId":"f23b2b56-2d8e-4cb3-bb0c-fe9eefd1176e"}}
+{"id":"run_command_toolu_bdrk_01R1tTbZVdtN5wB74VyWCBXf-call","timestamp":"2026-10-10T00:58:26.760Z","payload":{"type":"tool_call","toolCallId":"run_command_toolu_bdrk_01R1tTbZVdtN5wB74VyWCBXf","toolName":"execute_bash","args":{"command":"replaced","description":"replaced","cwd":"replaced","run_in_background":false},"status":"completed","kind":"execute","executionId":"f23b2b56-2d8e-4cb3-bb0c-fe9eefd1176e","actionType":"run_command","title":"replaced","_meta":{"kiro":{"agentMode":"kirocrew [...]
+{"id":"run_command_toolu_bdrk_01R1tTbZVdtN5wB74VyWCBXf-result","timestamp":"2026-10-10T00:58:26.760Z","payload":{"type":"tool_result","toolCallId":"run_command_toolu_bdrk_01R1tTbZVdtN5wB74VyWCBXf","content":"Output, replaced in this fixture.","success":true,"executionId":"f23b2b56-2d8e-4cb3-bb0c-fe9eefd1176e","_meta":{"kiro":{"agentMode":"kirocrew"}}}}
+{"id":"5aff469f-bd56-4797-8d24-4d9d76385adb-say","timestamp":"2026-10-10T00:58:33.035Z","payload":{"type":"assistant","content":"Reasoning, replaced in this fixture.","operationType":"Reasoning","executionId":"f23b2b56-2d8e-4cb3-bb0c-fe9eefd1176e","reasoningSignature":"signature-replaced-in-this-fixture","reasoningModelId":"qdev::claude-opus-5.5","_meta":{"kiro":{"agentMode":"kirocrew"}}}}
+{"id":"run_command_toolu_bdrk_017XpY3rEdq1d2hG2wWQgMPF-pending","timestamp":"2026-10-10T00:58:33.111Z","payload":{"type":"pending_interaction","interactionType":"tool_approval","toolCallId":"run_command_toolu_bdrk_017XpY3rEdq1d2hG2wWQgMPF","question":"replaced","options":[{"optionId":"replaced","name":"replaced","kind":"replaced"},{"optionId":"replaced","name":"replaced","kind":"replaced"},{"optionId":"replaced","name":"replaced","kind":"replaced"},{"optionId":"replaced","name":"replaced [...]
+{"id":"f1019080-2155-4cca-8008-1a6a91e30e93","timestamp":"2026-10-10T00:58:33.112Z","payload":{"type":"session_metadata","key":"contextUsage","value":{"usagePercentage":12.297599792480469},"executionId":"f23b2b56-2d8e-4cb3-bb0c-fe9eefd1176e"}}
+{"id":"run_command_toolu_bdrk_017XpY3rEdq1d2hG2wWQgMPF-resolved","timestamp":"2026-10-10T00:58:33.136Z","payload":{"type":"interaction_resolved","toolCallId":"run_command_toolu_bdrk_017XpY3rEdq1d2hG2wWQgMPF","outcome":"selected","selectedOption":"replaced","executionId":"f23b2b56-2d8e-4cb3-bb0c-fe9eefd1176e"}}
+{"id":"run_command_toolu_bdrk_017XpY3rEdq1d2hG2wWQgMPF-call","timestamp":"2026-10-10T00:58:33.159Z","payload":{"type":"tool_call","toolCallId":"run_command_toolu_bdrk_017XpY3rEdq1d2hG2wWQgMPF","toolName":"execute_bash","args":{"command":"replaced","description":"replaced","run_in_background":false},"status":"failed","kind":"execute","executionId":"f23b2b56-2d8e-4cb3-bb0c-fe9eefd1176e","actionType":"run_command","title":"replaced","_meta":{"kiro":{"agentMode":"kirocrew","toolOrigin":"defa [...]
+{"id":"run_command_toolu_bdrk_017XpY3rEdq1d2hG2wWQgMPF-result","timestamp":"2026-10-10T00:58:33.159Z","payload":{"type":"tool_result","toolCallId":"run_command_toolu_bdrk_017XpY3rEdq1d2hG2wWQgMPF","content":"Output, replaced in this fixture.","success":false,"executionId":"f23b2b56-2d8e-4cb3-bb0c-fe9eefd1176e","_meta":{"kiro":{"agentMode":"kirocrew"}}}}
+{"id":"5e3fe4fd-7cd6-4164-9b95-a086e3e7f963-say","timestamp":"2026-10-10T00:59:07.962Z","payload":{"type":"assistant","content":"The agent's reply, replaced in this fixture.","operationType":"Say","executionId":"f23b2b56-2d8e-4cb3-bb0c-fe9eefd1176e","_meta":{"kiro":{"agentMode":"kirocrew"}}}}
+{"id":"5f2c225c-8e30-462b-9ab5-56952679545f","timestamp":"2026-10-10T00:59:07.962Z","payload":{"type":"session_metadata","key":"contextUsage","value":{"usagePercentage":12.384699821472168},"executionId":"f23b2b56-2d8e-4cb3-bb0c-fe9eefd1176e"}}
+{"id":"f23b2b56-2d8e-4cb3-bb0c-fe9eefd1176e-usage","timestamp":"2026-10-10T00:59:07.983Z","payload":{"type":"usage_summary","promptTurnSummaries":[{"unit":"credit","unitPlural":"credits","usage":9.569431625207297,"usedTools":["tool","tool","tool","tool","tool","tool","tool","tool","tool","tool","tool","tool","tool"]}],"elapsedTime":435479,"status":"success","executionId":"f23b2b56-2d8e-4cb3-bb0c-fe9eefd1176e","requestIds":["68f99a07-69b4-4332-ae56-8c5203578a0f","b238e291-4825-4486-85ee-0 [...]
+{"id":"f23b2b56-2d8e-4cb3-bb0c-fe9eefd1176e-complete","timestamp":"2026-10-10T00:59:07.984Z","payload":{"type":"session_event","category":"session_pause","context":{"executionId":"f23b2b56-2d8e-4cb3-bb0c-fe9eefd1176e","status":"success"}}}
+{"id":"f23b2b56-2d8e-4cb3-bb0c-fe9eefd1176e-turn-end","timestamp":"2026-10-10T00:59:07.984Z","payload":{"type":"turn_end","stopReason":"end_turn","executionId":"f23b2b56-2d8e-4cb3-bb0c-fe9eefd1176e"}}
+{"id":"steering_boundary_92f5f205-ae96-4a89-947d-6fce6f881b52","timestamp":"2026-10-10T00:59:07.993Z","payload":{"type":"user","content":"","source":"steer","_meta":{"kiro":{"steeringClearedIds":["steer-594dd4da-f439-4f37-be97-1bc0947091bd","steer-21d06fbb-8fcf-4775-b2b8-7097c8697181"]}}}}
+{"id":"458224ee-a900-4be5-b305-2ec2f747d366","timestamp":"2026-10-10T01:06:43.509Z","payload":{"type":"user","content":"A prompt, replaced in this fixture.","documents":[],"images":[],"_meta":{"kiro":{"userMessageTag":"prompt_6495bc4f-6ea4-4e1b-927c-1394bf15c3f1"}}}}
+{"id":"46b63358-201f-4475-b887-927b5281b962-turn-start","timestamp":"2026-10-10T01:06:43.512Z","payload":{"type":"turn_start","executionId":"46b63358-201f-4475-b887-927b5281b962"}}
+{"id":"c7c5fdc8-5bd8-4fa6-92c1-2ac28a5149a9-say","timestamp":"2026-10-10T01:12:42.136Z","payload":{"type":"assistant","content":"Reasoning, replaced in this fixture.","operationType":"Reasoning","executionId":"46b63358-201f-4475-b887-927b5281b962","reasoningSignature":"signature-replaced-in-this-fixture","reasoningModelId":"qdev::claude-opus-5.5","_meta":{"kiro":{"agentMode":"kirocrew"}}}}
+{"id":"run_command_toolu_bdrk_01Nv7VS6tNuZZtUTD3u9F6FX-pending","timestamp":"2026-10-10T01:12:44.156Z","payload":{"type":"pending_interaction","interactionType":"tool_approval","toolCallId":"run_command_toolu_bdrk_01Nv7VS6tNuZZtUTD3u9F6FX","question":"replaced","options":[{"optionId":"replaced","name":"replaced","kind":"replaced"},{"optionId":"replaced","name":"replaced","kind":"replaced"},{"optionId":"replaced","name":"replaced","kind":"replaced"},{"optionId":"replaced","name":"replaced [...]
+{"id":"2800e7b5-d01d-48f5-9fc0-ca4c44b65bc7","timestamp":"2026-10-10T01:12:44.171Z","payload":{"type":"session_metadata","key":"contextUsage","value":{"usagePercentage":14.763599395751953},"executionId":"46b63358-201f-4475-b887-927b5281b962"}}
+{"id":"steer-f7ac2aa1-97be-4273-b11f-05763394646e","timestamp":"2026-10-10T01:12:44.178Z","payload":{"type":"user","content":"<user_message>\nA message sent during the turn, replaced in this fixture.\n</user_message>","source":"steer"}}
+{"id":"run_command_toolu_bdrk_01Nv7VS6tNuZZtUTD3u9F6FX-resolved","timestamp":"2026-10-10T01:12:44.182Z","payload":{"type":"interaction_resolved","toolCallId":"run_command_toolu_bdrk_01Nv7VS6tNuZZtUTD3u9F6FX","outcome":"selected","selectedOption":"replaced","executionId":"46b63358-201f-4475-b887-927b5281b962"}}
+{"id":"run_command_toolu_bdrk_01Nv7VS6tNuZZtUTD3u9F6FX-call","timestamp":"2026-10-10T01:12:44.184Z","payload":{"type":"tool_call","toolCallId":"run_command_toolu_bdrk_01Nv7VS6tNuZZtUTD3u9F6FX","toolName":"execute_bash","args":{"command":"replaced","description":"replaced","cwd":"replaced","run_in_background":false},"status":"denied","kind":"execute","executionId":"46b63358-201f-4475-b887-927b5281b962","actionType":"run_command","title":"replaced","_meta":{"kiro":{"agentMode":"kirocrew"," [...]
+{"id":"run_command_toolu_bdrk_01Nv7VS6tNuZZtUTD3u9F6FX-result","timestamp":"2026-10-10T01:12:44.184Z","payload":{"type":"tool_result","toolCallId":"run_command_toolu_bdrk_01Nv7VS6tNuZZtUTD3u9F6FX","content":"Output, replaced in this fixture.","success":false,"executionId":"46b63358-201f-4475-b887-927b5281b962","_meta":{"kiro":{"agentMode":"kirocrew"}}}}
+{"id":"5b65f074-d68c-4bec-945f-2cc1d40e7589","timestamp":"2026-10-10T01:12:44.190Z","payload":{"type":"user","content":"[LIVE STEERING - New message from user]\n\nThe engine's text around the message, replaced in this fixture.\n\n<user_message id=\"steer-f7ac2aa1-97be-4273-b11f-05763394646e\">\nA message, replaced.\n</user_message>","_meta":{"kiro":{"syntheticUserMessageReason":"steering-injection","steeringConsumedIds":["steer-f7ac2aa1-97be-4273-b11f-05763394646e"]}}}}
+{"id":"e28c2afb-86cc-4823-933c-4bd62365ef73-say","timestamp":"2026-10-10T01:12:58.242Z","payload":{"type":"assistant","content":"Reasoning, replaced in this fixture.","operationType":"Reasoning","executionId":"46b63358-201f-4475-b887-927b5281b962","reasoningSignature":"signature-replaced-in-this-fixture","reasoningModelId":"qdev::claude-opus-5.5","_meta":{"kiro":{"agentMode":"kirocrew"}}}}
+{"id":"29742d38-b027-41dd-ba22-4610245e0f75-say","timestamp":"2026-10-10T01:12:58.542Z","payload":{"type":"assistant","content":"The agent's reply, replaced in this fixture.","operationType":"Say","executionId":"46b63358-201f-4475-b887-927b5281b962","_meta":{"kiro":{"agentMode":"kirocrew"}}}}
+{"id":"cf433d9a-2a5d-45af-b759-5629782edd26-say","timestamp":"2026-10-10T01:24:27.706Z","payload":{"type":"assistant","content":"The agent's reply, replaced in this fixture.","operationType":"Say","executionId":"46b63358-201f-4475-b887-927b5281b962","_meta":{"kiro":{"agentMode":"kirocrew"}}}}
+{"id":"98fd90b7-513a-4733-82c7-8cb6c72a1f8c","timestamp":"2026-10-10T01:24:27.706Z","payload":{"type":"session_metadata","key":"contextUsage","value":{"usagePercentage":17.399700164794922},"executionId":"46b63358-201f-4475-b887-927b5281b962"}}
+{"id":"46b63358-201f-4475-b887-927b5281b962-usage","timestamp":"2026-10-10T01:24:27.717Z","payload":{"type":"usage_summary","promptTurnSummaries":[{"unit":"credit","unitPlural":"credits","usage":17.72874833698176,"usedTools":["tool","tool","tool","tool","tool","tool","tool","tool","tool","tool","tool","tool","tool","tool","tool","tool","tool","tool","tool","tool"]}],"elapsedTime":1064205,"status":"success","executionId":"46b63358-201f-4475-b887-927b5281b962","requestIds":["ac3940d7-97f3- [...]
+{"id":"46b63358-201f-4475-b887-927b5281b962-complete","timestamp":"2026-10-10T01:24:27.717Z","payload":{"type":"session_event","category":"session_pause","context":{"executionId":"46b63358-201f-4475-b887-927b5281b962","status":"success"}}}
+{"id":"46b63358-201f-4475-b887-927b5281b962-turn-end","timestamp":"2026-10-10T01:24:27.718Z","payload":{"type":"turn_end","stopReason":"end_turn","executionId":"46b63358-201f-4475-b887-927b5281b962"}}
+{"id":"steering_boundary_6f8f1216-8702-4f65-bfd6-1c7511ad7442","timestamp":"2026-10-10T01:24:27.719Z","payload":{"type":"user","content":"","source":"steer","_meta":{"kiro":{"steeringClearedIds":["steer-f7ac2aa1-97be-4273-b11f-05763394646e","steer-d2ef28c0-6aae-4f0a-ad23-f02845cf2c0b"]}}}}
diff --git a/internal/adapters/kiro/testdata/sessions/53e72fb3603217da/sess_5f20ccf4-b254-4bdb-945b-080e051eb7f5/session.json b/internal/adapters/kiro/testdata/sessions/53e72fb3603217da/sess_5f20ccf4-b254-4bdb-945b-080e051eb7f5/session.json
new file mode 100644
index 0000000..4cbc952
--- /dev/null
+++ b/internal/adapters/kiro/testdata/sessions/53e72fb3603217da/sess_5f20ccf4-b254-4bdb-945b-080e051eb7f5/session.json
@@ -0,0 +1,17 @@
+{
+ "schemaVersion": "1.0.0",
+ "dataModelVersion": 1,
+ "id": "sess_5f20ccf4-b254-4bdb-945b-080e051eb7f5",
+ "title": "A Kiro Crew chat",
+ "agentMode": "kirocrew",
+ "workspacePaths": [
+ "/home/dev/workspace"
+ ],
+ "createdAt": "2026-10-10T00:11:30.682Z",
+ "lastModifiedAt": "2026-10-10T08:22:20.282Z",
+ "modelId": "claude-opus-5.5",
+ "status": "idle",
+ "rootPaths": [
+ "/home/dev/workspace"
+ ]
+}
\ No newline at end of file
diff --git a/internal/adapters/kiro/testdata/sessions/98613594c3327868/sess_03390a84-1c44-4785-bcd2-d4b15891b24a/messages.jsonl b/internal/adapters/kiro/testdata/sessions/98613594c3327868/sess_03390a84-1c44-4785-bcd2-d4b15891b24a/messages.jsonl
new file mode 100644
index 0000000..cbe6a8f
--- /dev/null
+++ b/internal/adapters/kiro/testdata/sessions/98613594c3327868/sess_03390a84-1c44-4785-bcd2-d4b15891b24a/messages.jsonl
@@ -0,0 +1,11 @@
+{"id":"context_snapshot_c3faa2dc-5d1d-4c62-b92a-6ce2ae366675","timestamp":"2026-10-08T14:57:20.478Z","payload":{"type":"agent_note","content":"<session_context>\nOnly the last <session_context> block is current; it remains current until a later block supersedes it.\nThe current model is Auto (model selected dynamically on the server).\n</session_context>"}}
+{"id":"546e4b26-4519-4d6f-9523-1cb3fa02557f","timestamp":"2026-10-08T14:57:20.476Z","payload":{"type":"user","content":"Run this with the shell tool: sleep 30; echo finished","documents":[],"images":[],"_meta":{"kiro":{"userMessageTag":"prompt_b8b826dc-92a9-4c42-b264-64c9aacd35d0"}}}}
+{"id":"b7998cd0-b79b-4e59-950c-3bd20c8fdf0a-turn-start","timestamp":"2026-10-08T14:57:20.490Z","payload":{"type":"turn_start","executionId":"b7998cd0-b79b-4e59-950c-3bd20c8fdf0a"}}
+{"id":"7030065e-d40e-47fe-b071-c647fd28a0c5-say","timestamp":"2026-10-08T14:57:29.435Z","payload":{"type":"assistant","content":"...","operationType":"Reasoning","executionId":"b7998cd0-b79b-4e59-950c-3bd20c8fdf0a","reasoningSignature":"signature-replaced-in-this-fixture","reasoningModelId":"qdev::auto","_meta":{"kiro":{"agentMode":"kit-tools"}}}}
+{"id":"1bc32b87-16fb-4cea-af31-0415c50c48fc","timestamp":"2026-10-08T14:57:29.436Z","payload":{"type":"session_metadata","key":"contextUsage","value":{"usagePercentage":1.4199999570846558},"executionId":"b7998cd0-b79b-4e59-950c-3bd20c8fdf0a"}}
+{"id":"run_command_toolu_bdrk_01Kjux4cTJQxdNR2ZxejmBFu-call","timestamp":"2026-10-08T14:57:32.477Z","payload":{"type":"tool_call","toolCallId":"run_command_toolu_bdrk_01Kjux4cTJQxdNR2ZxejmBFu","toolName":"execute_bash","args":{"command":"sleep 30; echo finished","description":"Sleep then print finished","run_in_background":false,"timeout":40000},"status":"failed","kind":"execute","executionId":"b7998cd0-b79b-4e59-950c-3bd20c8fdf0a","actionType":"run_command","modelToolUseId":"toolu_bdrk_ [...]
+{"id":"run_command_toolu_bdrk_01Kjux4cTJQxdNR2ZxejmBFu-result","timestamp":"2026-10-08T14:57:32.477Z","payload":{"type":"tool_result","toolCallId":"run_command_toolu_bdrk_01Kjux4cTJQxdNR2ZxejmBFu","content":"This tool was interrupted before it reported a result, so it may or may not have taken effect.","success":false,"executionId":"b7998cd0-b79b-4e59-950c-3bd20c8fdf0a","_meta":{"kiro":{"agentMode":"kit-tools"}}}}
+{"id":"b7998cd0-b79b-4e59-950c-3bd20c8fdf0a-usage","timestamp":"2026-10-08T14:57:32.516Z","payload":{"type":"usage_summary","promptTurnSummaries":[{"unit":"credit","unitPlural":"credits","usage":0.03554919900497513,"usedTools":["execute_bash"]}],"elapsedTime":12026,"status":"aborted","executionId":"b7998cd0-b79b-4e59-950c-3bd20c8fdf0a","requestIds":["62e73516-bb97-4ea7-86e7-ec08ceca5526"]}}
+{"id":"b7998cd0-b79b-4e59-950c-3bd20c8fdf0a-complete","timestamp":"2026-10-08T14:57:32.516Z","payload":{"type":"session_event","category":"session_pause","context":{"executionId":"b7998cd0-b79b-4e59-950c-3bd20c8fdf0a","status":"aborted"}}}
+{"id":"b7998cd0-b79b-4e59-950c-3bd20c8fdf0a-turn-end","timestamp":"2026-10-08T14:57:32.516Z","payload":{"type":"turn_end","stopReason":"cancelled","executionId":"b7998cd0-b79b-4e59-950c-3bd20c8fdf0a"}}
+{"id":"session_start_sess_03390a84-1c44-4785-bcd2-d4b15891b24a_kit-tools","timestamp":"2026-10-08T14:57:32.536Z","payload":{"type":"session_start","agentType":"kit-tools","backgroundExecution":false,"content":"The system prompt the engine composed, replaced in this fixture.","composition":{"schema":1,"contentChars":64},"forcedRole":true,"messageId":"8d8a335d-be2d-413e-9c00-bdbc71c2512d"}}
diff --git a/internal/adapters/kiro/testdata/sessions/98613594c3327868/sess_03390a84-1c44-4785-bcd2-d4b15891b24a/session.json b/internal/adapters/kiro/testdata/sessions/98613594c3327868/sess_03390a84-1c44-4785-bcd2-d4b15891b24a/session.json
new file mode 100644
index 0000000..31c983a
--- /dev/null
+++ b/internal/adapters/kiro/testdata/sessions/98613594c3327868/sess_03390a84-1c44-4785-bcd2-d4b15891b24a/session.json
@@ -0,0 +1,66 @@
+{
+ "schemaVersion": "1.0.0",
+ "dataModelVersion": 1,
+ "id": "sess_03390a84-1c44-4785-bcd2-d4b15891b24a",
+ "title": "Execute Sleep and Echo Command",
+ "agentMode": "kit-tools",
+ "workspacePaths": [
+ "/work/20261008T145636Z-K14-v3"
+ ],
+ "rootPaths": [
+ "/work/20261008T145636Z-K14-v3"
+ ],
+ "createdAt": "2026-10-08T14:57:12.901Z",
+ "lastModifiedAt": "2026-10-08T14:57:32.520Z",
+ "modelId": "auto",
+ "autopilot": true,
+ "contextSnapshotModelDisplay": {
+ "modelId": "auto",
+ "display": "Auto (model selected dynamically on the server)"
+ },
+ "semanticReviewEnabled": true,
+ "disableAutoCompaction": false,
+ "ftaEnabled": false,
+ "workflowsEnabled": false,
+ "specPlanEnabled": false,
+ "specWorkflow": "quick",
+ "specSkipClarificationEnabled": true,
+ "memoryConfig": {
+ "mode": "read_write",
+ "reflection": true
+ },
+ "memoryConfigSource": "legacy",
+ "status": "in_progress",
+ "_meta": {
+ "kiro": {
+ "settings": {
+ "workflows": {
+ "enabled": false
+ },
+ "goal": {
+ "enabled": false
+ },
+ "codeIntelligence": {
+ "enabled": true
+ },
+ "knowledge": {
+ "enabled": true
+ },
+ "thinking": {
+ "enabled": true
+ },
+ "largeToolOutputHandler": {
+ "enabled": true
+ },
+ "workflowNotifications": {
+ "enabled": false,
+ "delivery": "steer"
+ }
+ },
+ "modeId": "kit-tools",
+ "executionTarget": {
+ "kind": "local"
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/internal/adapters/kiro/testdata/sessions/b261dced7fa127f6/sess_8272aa81-fa49-4d34-aa24-8eff99377673/messages.jsonl b/internal/adapters/kiro/testdata/sessions/b261dced7fa127f6/sess_8272aa81-fa49-4d34-aa24-8eff99377673/messages.jsonl
new file mode 100644
index 0000000..5228df3
--- /dev/null
+++ b/internal/adapters/kiro/testdata/sessions/b261dced7fa127f6/sess_8272aa81-fa49-4d34-aa24-8eff99377673/messages.jsonl
@@ -0,0 +1,10 @@
+{"id":"context_snapshot_7fe493ea-d917-4c84-bf32-ccbc1783a3e0","timestamp":"2026-10-09T14:21:20.584Z","payload":{"type":"agent_note","content":"<session_context>\nOnly the last <session_context> block is current; it remains current until a later block supersedes it.\nThe current model is Auto (model selected dynamically on the server).\n</session_context>"}}
+{"id":"0dbfe5cc-1961-4be4-8942-726e5167fcc4","timestamp":"2026-10-09T14:21:20.583Z","payload":{"type":"user","content":"A prompt, replaced in this fixture.","documents":[],"images":[{"data":"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==","mimeType":"image/png"},{"data":"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==","mimeType":"image/png"}],"_meta":{"kiro":{"userMessageTag":"prompt_953fa [...]
+{"id":"79b8c44c-53be-43c6-9feb-203cc582bca7-turn-start","timestamp":"2026-10-09T14:21:20.607Z","payload":{"type":"turn_start","executionId":"79b8c44c-53be-43c6-9feb-203cc582bca7"}}
+{"id":"c97b843e-ac7f-4848-951c-79be387f73de-say","timestamp":"2026-10-09T14:21:54.421Z","payload":{"type":"assistant","content":"The agent's reply, replaced in this fixture.","operationType":"Say","executionId":"79b8c44c-53be-43c6-9feb-203cc582bca7","_meta":{"kiro":{"agentMode":"kirocrew-lite"}}}}
+{"id":"f57ff4c8-00ed-4090-93b1-df2bdd21906a-say","timestamp":"2026-10-09T14:21:54.421Z","payload":{"type":"assistant","content":"...","operationType":"Reasoning","executionId":"79b8c44c-53be-43c6-9feb-203cc582bca7","reasoningSignature":"signature-replaced-in-this-fixture","reasoningModelId":"qdev::auto","_meta":{"kiro":{"agentMode":"kirocrew-lite"}}}}
+{"id":"0ecd39e0-2a1e-496f-82cc-468987d6e363","timestamp":"2026-10-09T14:21:54.421Z","payload":{"type":"session_metadata","key":"contextUsage","value":{"usagePercentage":6.926599979400635},"executionId":"79b8c44c-53be-43c6-9feb-203cc582bca7"}}
+{"id":"79b8c44c-53be-43c6-9feb-203cc582bca7-usage","timestamp":"2026-10-09T14:21:54.444Z","payload":{"type":"usage_summary","promptTurnSummaries":[{"unit":"credit","unitPlural":"credits","usage":0.43859858059701484}],"elapsedTime":33837,"status":"success","executionId":"79b8c44c-53be-43c6-9feb-203cc582bca7","requestIds":["88fad964-e4a0-469d-bf5e-d85cc26f9558"]}}
+{"id":"79b8c44c-53be-43c6-9feb-203cc582bca7-complete","timestamp":"2026-10-09T14:21:54.444Z","payload":{"type":"session_event","category":"session_pause","context":{"executionId":"79b8c44c-53be-43c6-9feb-203cc582bca7","status":"success"}}}
+{"id":"79b8c44c-53be-43c6-9feb-203cc582bca7-turn-end","timestamp":"2026-10-09T14:21:54.444Z","payload":{"type":"turn_end","stopReason":"end_turn","executionId":"79b8c44c-53be-43c6-9feb-203cc582bca7"}}
+{"id":"session_start_sess_8272aa81-fa49-4d34-aa24-8eff99377673_kirocrew-lite","timestamp":"2026-10-09T14:21:54.455Z","payload":{"type":"session_start","agentType":"kirocrew-lite","backgroundExecution":false,"content":"The system prompt the engine composed, replaced in this fixture.","forcedRole":true,"messageId":"2fc7c695-4b5a-4124-97f5-05e34bfc6797","composition":null}}
diff --git a/internal/adapters/kiro/testdata/sessions/b261dced7fa127f6/sess_8272aa81-fa49-4d34-aa24-8eff99377673/session.json b/internal/adapters/kiro/testdata/sessions/b261dced7fa127f6/sess_8272aa81-fa49-4d34-aa24-8eff99377673/session.json
new file mode 100644
index 0000000..612efb4
--- /dev/null
+++ b/internal/adapters/kiro/testdata/sessions/b261dced7fa127f6/sess_8272aa81-fa49-4d34-aa24-8eff99377673/session.json
@@ -0,0 +1,17 @@
+{
+ "schemaVersion": "1.0.0",
+ "dataModelVersion": 1,
+ "id": "sess_8272aa81-fa49-4d34-aa24-8eff99377673",
+ "title": "A background task",
+ "agentMode": "kirocrew-lite",
+ "workspacePaths": [
+ "/home/dev/workspace"
+ ],
+ "createdAt": "2026-10-09T14:13:41.882Z",
+ "lastModifiedAt": "2026-10-09T19:26:28.916Z",
+ "modelId": "auto",
+ "status": "idle",
+ "rootPaths": [
+ "/home/dev/workspace"
+ ]
+}
\ No newline at end of file
diff --git a/internal/adapters/kiro/testdata/sessions/cli/6f0e8c2a-5b1d-4c3e-9a7f-2d4b6e8c0a1f.jsonl b/internal/adapters/kiro/testdata/sessions/cli/6f0e8c2a-5b1d-4c3e-9a7f-2d4b6e8c0a1f.jsonl
new file mode 100644
index 0000000..e69de29
diff --git a/internal/adapters/kiro/turns.go b/internal/adapters/kiro/turns.go
new file mode 100644
index 0000000..36e42d7
--- /dev/null
+++ b/internal/adapters/kiro/turns.go
@@ -0,0 +1,140 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package kiro
+
+import (
+ "bufio"
+ "bytes"
+ "encoding/json"
+ "errors"
+ "io"
+ "os"
+ "time"
+
+ "github.com/apache/skywalking-ai-sessionizer/internal/adapters/claudecode"
+)
+
+// holdLimit is how long the last records of a file wait for the record that
+// names their turn. The engine writes a turn's start a few milliseconds after
+// the prompt, so a prompt still waiting after this long belongs to a turn that
+// never started, such as one the engine died before. It lands with no turn.
+const holdLimit = 5 * time.Minute
+
+// turnFor finds the turn of each record that belongs to a turn without naming
+// it, and says how many of the lines may land now.
+//
+// A person's prompt carries no turn id in any measured session, nor does the
+// note the engine writes just before it, nor a message sent while a turn ran.
+// Each belongs to the first turn named after it in the file: the prompt's own
+// turn, or the turn the message arrived in. The rule reads only line order,
+// which the engine keeps; its timestamps are not in line order.
+//
+// A record whose turn is not in the file yet waits, because a landed record is
+// never written again: landing a prompt with no turn would leave it outside the
+// turn it opened for good. The lines after the chunk are read first, since the
+// turn is usually one line further on. Only when the file has nothing more, and
+// has been still for holdLimit, does the record land with no turn.
+//
+// runs holds the turn found for each line, or "" where the record names its
+// own or belongs to none. keep is how many lines land now.
+func turnFor(lines []claudecode.Line, path string, end uint64, mtime int64, now time.Time) (runs []string, keep int, err error) {
+ runs = make([]string, len(lines))
+ wants := make([]bool, len(lines))
+ next := ""
+ for i := len(lines) - 1; i >= 0; i-- {
+ turn, want := turnRole(lines[i].Bytes)
+ switch {
+ case turn != "":
+ next = turn
+ case want:
+ wants[i], runs[i] = true, next
+ }
+ }
+ first := -1
+ for i := range lines {
+ if wants[i] && runs[i] == "" {
+ first = i
+ break
+ }
+ }
+ if first < 0 {
+ return runs, len(lines), nil
+ }
+ turn, err := peekTurn(path, end)
+ if err != nil {
+ return nil, 0, err
+ }
+ if turn != "" {
+ for i := first; i < len(lines); i++ {
+ if wants[i] {
+ runs[i] = turn
+ }
+ }
+ return runs, len(lines), nil
+ }
+ if now.Sub(time.Unix(mtime, 0)) >= holdLimit {
+ return runs, len(lines), nil
+ }
+ return runs, first, nil
+}
+
+// turnRole reads the turn a record names, and whether it wants one it does not
+// name.
+func turnRole(raw []byte) (turn string, wants bool) {
+ var env envelope
+ if json.Unmarshal(raw, &env) != nil {
+ return "", false
+ }
+ var p payload
+ if json.Unmarshal(env.Payload, &p) != nil {
+ return "", false
+ }
+ return turnOf(&p), wantsTurn(&p)
+}
+
+// peekTurn reads the complete lines after offset, up to the first that names a
+// turn, and returns that turn. An unterminated last line is a write in
+// progress and is not read.
+func peekTurn(path string, offset uint64) (string, error) {
+ f, err := os.Open(path)
+ if err != nil {
+ if errors.Is(err, os.ErrNotExist) {
+ return "", nil
+ }
+ return "", err
+ }
+ defer f.Close()
+ if _, err := f.Seek(int64(offset), io.SeekStart); err != nil {
+ return "", err
+ }
+ // A record can be several megabytes, a prompt with images, so lines are
+ // read whole rather than through a scanner with a fixed limit.
+ r := bufio.NewReader(f)
+ for {
+ ln, err := r.ReadBytes('\n')
+ if err != nil {
+ if errors.Is(err, io.EOF) {
+ return "", nil
+ }
+ return "", err
+ }
+ if turn, _ := turnRole(bytes.TrimSuffix(ln, []byte("\n"))); turn != "" {
+ return turn, nil
+ }
+ }
+}
diff --git a/internal/adapters/langsmith/glossary.go b/internal/adapters/langsmith/glossary.go
index d8b9bab..8a777fd 100644
--- a/internal/adapters/langsmith/glossary.go
+++ b/internal/adapters/langsmith/glossary.go
@@ -91,6 +91,7 @@ var glossary = model.NewGlossary(Dialect,
model.Term{Unified: model.KindControlPermission, Note: "no equivalent: nothing here asks before running a tool"},
model.Term{Unified: model.KindControlCommand, Note: "no equivalent"},
model.Term{Unified: model.KindTurnDuration, Note: "no equivalent as a record; a run's own start and end give it"},
+ model.Term{Unified: model.KindControlNotice, Note: "no equivalent"},
// ---- relations ----
model.Term{Unified: model.RelStarts, Note: "derived from a delegating tool and the runs beneath it"},
diff --git a/internal/assemble/emit.go b/internal/assemble/emit.go
index 3cdea91..17ac7ca 100644
--- a/internal/assemble/emit.go
+++ b/internal/assemble/emit.go
@@ -33,6 +33,7 @@ func (b *builder) emitSteps() {
for _, c := range b.calls {
b.emitCall(c, b.containerOf(c.Fragments[0]))
}
+ b.emitOutputWithoutCall()
for _, t := range b.tools {
if t.Use == nil {
continue
@@ -53,6 +54,46 @@ func (b *builder) emitSteps() {
b.emitSpawnRelations()
}
+// emitOutputWithoutCall writes the output of agent records that name no
+// provider call.
+//
+// Some runtimes record what the model said without saying which provider call
+// said it: Kiro's V3 engine lists a turn's request ids and nothing ties a record
+// to one of them. Grouping such records into one call, per turn or per record,
+// would draw a call boundary the runtime never stated. So no call node is
+// written for them, and their text and reasoning become steps of the record's
+// own container, the run that holds them. A reader still sees every word the
+// agent said; it is only the call around them that is unavailable.
+func (b *builder) emitOutputWithoutCall() {
+ for _, i := range b.canonical {
+ e := &b.ix.Entries[i]
+ if e.Kind != index.KindAssistant || e.Call != 0 {
+ continue
+ }
+ kind := model.KindMessageAssistant
+ if e.Flags.Has(index.FlagSynthetic) {
+ kind = model.KindMessageSynthetic
+ }
+ parent := b.containerOf(e)
+ for _, blk := range b.blocksOf(e) {
+ var id, k string
+ switch blk.Kind {
+ case index.BlockText:
+ id, k = sessionflow.RefID("msg", blockRef(e, blk.Ord)), kind
+ case index.BlockThinking:
+ id, k = sessionflow.RefID("think", blockRef(e, blk.Ord)), model.KindThinking
+ default:
+ continue
+ }
+ b.node(sessionflow.Node{
+ Entity: sessionflow.Entity{ID: id}, Kind: k, Parent: parent,
+ Stream: b.streamName(e), Ref: refPtr(blockRef(e, blk.Ord)),
+ })
+ b.stats.Steps++
+ }
+ }
+}
+
// emitInputSteps writes the records that carry input into the agent.
//
// Two of these are easy to lose. Some input from outside the agent exists ONLY
diff --git a/internal/assemble/streams.go b/internal/assemble/streams.go
index 1734f4f..2f47694 100644
--- a/internal/assemble/streams.go
+++ b/internal/assemble/streams.go
@@ -211,9 +211,11 @@ func (b *builder) latestTitle() string {
e := &b.ix.Entries[i]
// A title record carries a name and no identity of its own. A sidecar
// and a manifest also carry names, but those name a stream and a
- // workflow rather than the conversation.
- if e.Label == 0 || e.Record != 0 ||
- e.Kind == index.KindMeta || e.Kind == index.KindManifest {
+ // workflow rather than the conversation. The main stream's sidecar
+ // is the exception: the stream it names is the conversation. Kiro
+ // keeps the title in the session's header, which lands as one.
+ if e.Label == 0 || e.Record != 0 || e.Kind == index.KindManifest ||
+ (e.Kind == index.KindMeta && b.str(e.Stream) != "main") {
continue
}
title = b.str(e.Label)
diff --git a/internal/config/config.go b/internal/config/config.go
index 75c4a0c..349798a 100644
--- a/internal/config/config.go
+++ b/internal/config/config.go
@@ -322,6 +322,10 @@ const (
// local adapter: Claude Code writes the files, and this adapter finds
// each body's session and lands it.
AdapterClaudeCodeProvider = "claude-code-provider"
+ // AdapterKiroLocal reads the sessions Kiro's V3 agent engine writes
+ // under ~/.kiro: kiro-cli on the V3 engine, and Kiro Crew on its kas
+ // backend. Pull posture, like the Claude Code local adapter.
+ AdapterKiroLocal = "kiro-local"
// AdapterLangSmithIngest receives what the LangSmith tracing client
// sends. Push posture: an application built on LangChain or LangGraph
// already carries that client, so four environment variables are the
@@ -378,6 +382,17 @@ func Default() *Config {
Interval: DefaultInterval,
MaxDeltaBytes: 2 << 20,
},
+ }, {
+ // Kiro's V3 sessions. On by default because it costs nothing
+ // where Kiro is not installed: there is nothing to discover.
+ Name: AdapterKiroLocal,
+ Enabled: true,
+ Exclude: []string{"/private/tmp/**"},
+ Collector: Collector{
+ Mode: ModeWatch,
+ Interval: DefaultInterval,
+ MaxDeltaBytes: 2 << 20,
+ },
}},
Parse: Parse{MaxRoundBytes: 2 << 20},
// Written out, as every default is, so the file says what the first
@@ -619,6 +634,9 @@ func (c *Config) Validate() error {
if a.Name == AdapterClaudeCodeProvider && a.Listen != "" {
return fmt.Errorf("config: adapter %q reads provider bodies only: it takes source_root, include, exclude and collector, not listen", a.Name)
}
+ if a.Name == AdapterKiroLocal && a.Listen != "" {
+ return fmt.Errorf("config: adapter %q reads Kiro's files: it takes source_root, include, exclude and collector, not listen", a.Name)
+ }
if a.Collector.Mode != ModeWatch && a.Collector.Mode != ModeOnce {
return fmt.Errorf("config: adapter %q: unknown collector mode %q", a.Name, a.Collector.Mode)
}
diff --git a/internal/config/config_test.go b/internal/config/config_test.go
index a9b7ba4..b9c262c 100644
--- a/internal/config/config_test.go
+++ b/internal/config/config_test.go
@@ -86,8 +86,8 @@ func TestRepoConfigSpellsOutEveryValue(t *testing.T) {
if err != nil {
t.Fatal(err)
}
- if len(got.Adapters) != 4 {
- t.Fatalf("adapters: got %d, want the local adapter, the receiver, the changes adapter and the provider adapter", len(got.Adapters))
+ if len(got.Adapters) != 5 {
+ t.Fatalf("adapters: got %d, want the local adapter, the receiver, the changes adapter, the provider adapter and the Kiro adapter", len(got.Adapters))
}
for _, a := range got.Adapters {
if a.Collector.Mode == "" || a.Collector.Interval == 0 || a.Collector.MaxDeltaBytes == 0 {
diff --git a/internal/index/lookup.go b/internal/index/lookup.go
index c9927ae..9b84591 100644
--- a/internal/index/lookup.go
+++ b/internal/index/lookup.go
@@ -59,6 +59,33 @@ func (ix *Index) AppendRecord(e Entry, blocks []Block, body *Body) {
ix.Append(e, blocks...)
}
+// Staged holds what FromRecord returned for the records of a file that is
+// still being written. A collector appends them with AppendStaged only once the
+// file has landed: appended during the write, a file that then failed to land
+// would leave entries for records that do not exist, and the index state saved
+// after it would keep them.
+type Staged struct {
+ rows []stagedRow
+}
+
+type stagedRow struct {
+ e Entry
+ blocks []Block
+ body *Body
+}
+
+// Add keeps one record until its file has landed.
+func (s *Staged) Add(e Entry, blocks []Block, body *Body) {
+ s.rows = append(s.rows, stagedRow{e: e, blocks: blocks, body: body})
+}
+
+// AppendStaged adds the staged records in the order they were staged.
+func (ix *Index) AppendStaged(s *Staged) {
+ for _, r := range s.rows {
+ ix.AppendRecord(r.e, r.blocks, r.body)
+ }
+}
+
// Append adds one record and its blocks, wiring the block range.
func (ix *Index) Append(e Entry, blocks ...Block) {
ei := uint32(len(ix.Entries))
diff --git a/internal/scenario/expect/expect.go b/internal/scenario/expect/expect.go
index c0c53f8..ea5befd 100644
--- a/internal/scenario/expect/expect.go
+++ b/internal/scenario/expect/expect.go
@@ -259,10 +259,12 @@ type Node struct {
Absent bool `yaml:"absent"`
}
-// Session is the session node's range, as deltas from the base time.
+// Session is the session node's range, as deltas from the base time, and
+// its title.
type Session struct {
- From string `yaml:"from"`
- To string `yaml:"to"`
+ From string `yaml:"from"`
+ To string `yaml:"to"`
+ Title string `yaml:"title"`
}
// View is what the asz.view document must say.
@@ -490,10 +492,14 @@ func Evaluate(root string, cp *Checkpoint, ctx *Context) ([]string, error) {
var attrs struct {
From string `json:"from_time"`
Through string `json:"through_time"`
+ Title string `json:"title"`
}
if sn != nil {
_ = json.Unmarshal(sn.Attrs, &attrs)
}
+ if cp.Session.Title != "" && attrs.Title != cp.Session.Title {
+ bad("session.title is %q, want %q", attrs.Title, cp.Session.Title)
+ }
if cp.Session.From != "" {
if want, err := delta(ctx.At, cp.Session.From); err != nil {
bad("session.from: %v", err)
diff --git a/internal/scenario/plan.go b/internal/scenario/plan.go
index 4ba2bdc..5171b79 100644
--- a/internal/scenario/plan.go
+++ b/internal/scenario/plan.go
@@ -408,10 +408,13 @@ func (b *planner) step(l *lane, s *Step, id string) error {
Run: b.runOf(l), Of: r.Of, Text: r.Text, Failed: r.Failed, StringEnrichment: r.String, Lost: lost || r.Lost}
b.emit(e)
l.last = e.ID
- case s.Error != "":
+ case s.Error.Text != "":
at := b.tick(l, s.After)
e := Event{Kind: EvSynthetic, Stream: l.stream, Batch: l.batch, At: at, ID: id + "-synthetic", Parent: l.last,
- Call: id + "-synthetic-call", Req: id + "-req", Text: s.Error, Lost: lost}
+ Call: id + "-synthetic-call", Req: id + "-req", Text: s.Error.Text, Lost: lost}
+ if s.Error.Unidentified {
+ e.Call, e.Req = "", ""
+ }
b.emit(e)
l.last = e.ID
case s.Reset != nil:
@@ -480,6 +483,11 @@ func (b *planner) call(l *lane, s *Step, id string) error {
tag := hex.EncodeToString(sum[:6])
call, req = call+"-"+tag, req+"-"+tag
}
+ if c.Unidentified {
+ // Usage is reported per call, so a runtime that names no call has
+ // none to report either.
+ call, req, usage = "", "", Usage{}
+ }
var frags []Event
if c.Thinking != "" {
text := c.Thinking
@@ -694,7 +702,10 @@ func derivedSession(sc *Scenario) string {
h := sha256.New()
fmt.Fprintf(h, "%s|%s|%d", sc.Title, sc.Project, len(sc.Steps))
for _, s := range sc.Steps {
- fmt.Fprintf(h, "|%s%v%v%v%s%v%d%v", s.Input, s.Queued, s.Inject, s.Call != nil, s.Error, s.Reset != nil, s.Replay, s.System)
+ fmt.Fprintf(h, "|%s%v%v%v%s%v%d%v", s.Input, s.Queued, s.Inject, s.Call != nil, s.Error.Text, s.Reset != nil, s.Replay, s.System)
+ if s.Error.Unidentified {
+ fmt.Fprint(h, "|unidentified")
+ }
}
x := hex.EncodeToString(h.Sum(nil))
return fmt.Sprintf("%s-%s-4%s-8%s-%s", x[0:8], x[8:12], x[13:16], x[17:20], x[20:32])
diff --git a/internal/scenario/plan_test.go b/internal/scenario/plan_test.go
index ddc5430..b78a537 100644
--- a/internal/scenario/plan_test.go
+++ b/internal/scenario/plan_test.go
@@ -161,3 +161,22 @@ func TestWorkflowRunNameCollision(t *testing.T) {
}
}
}
+
+// Provider bodies are written per call, so a scenario that writes them cannot
+// also write output that names no call, in its own stream or a child's.
+func TestProviderBodiesNeedEveryCallNamed(t *testing.T) {
+ for _, step := range []string{
+ "{call: {unidentified: true, text: done}}",
+ "{error: {text: refused, unidentified: true}}",
+ "{call: {agent: {name: helper, steps: [{call: {unidentified: true, text: done}}]}}}",
+ } {
+ path := filepath.Join(t.TempDir(), "bodies.yaml")
+ body := "provider_bodies: true\nsteps:\n - input: check\n - " + step + "\n"
+ if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := Load(path); err == nil || !strings.Contains(err.Error(), "provider_bodies") {
+ t.Fatalf("%s: %v", step, err)
+ }
+ }
+}
diff --git a/internal/scenario/scenario.go b/internal/scenario/scenario.go
index adac3aa..b3feaa3 100644
--- a/internal/scenario/scenario.go
+++ b/internal/scenario/scenario.go
@@ -123,7 +123,7 @@ type Step struct {
// gave none: the late result a test watches resolve.
Result *Result `yaml:"result"`
// Error is an assistant-role message the client fabricated.
- Error string `yaml:"error"`
+ Error Synthetic `yaml:"error"`
// Reset is a context reset and the summary that replaced the context.
Reset *Reset `yaml:"reset"`
// Replay re-emits the last N records of the main stream, as the runtime
@@ -175,6 +175,10 @@ type Call struct {
// script filed with the run.
Workflow *Workflow `yaml:"workflow"`
Usage *Usage `yaml:"usage"`
+ // Unidentified writes the fragments with no provider-call id, as a
+ // runtime does that records what the model said but not which call said
+ // it.
+ Unidentified bool `yaml:"unidentified"`
}
// Usage is what the provider reported. Zero fields take the defaults a
@@ -229,6 +233,30 @@ type Result struct {
Lost bool `yaml:"lost"`
}
+// Synthetic is an assistant-role message the client fabricated. It may be
+// written as a plain string, its text.
+type Synthetic struct {
+ Text string `yaml:"text"`
+ // Unidentified writes it with no call id, as a call's Unidentified does:
+ // a runtime that records what was said but not which call said it.
+ Unidentified bool `yaml:"unidentified"`
+}
+
+// UnmarshalYAML lets a synthetic message be a plain string.
+func (m *Synthetic) UnmarshalYAML(n *yaml.Node) error {
+ if n.Kind == yaml.ScalarNode {
+ m.Text = n.Value
+ return nil
+ }
+ type plain Synthetic
+ var p plain
+ if err := n.Decode(&p); err != nil {
+ return err
+ }
+ *m = Synthetic(p)
+ return nil
+}
+
// UnmarshalYAML lets a result be a plain string.
func (r *Result) UnmarshalYAML(n *yaml.Node) error {
if n.Kind == yaml.ScalarNode {
@@ -345,10 +373,46 @@ func (sc *Scenario) Validate() error {
if err := bothOrNeither(sc.SystemPrompt, sc.Tools, "the scenario"); err != nil {
return err
}
+ if sc.ProviderBodies && namesNoCall(sc.Steps) {
+ return errors.New("provider_bodies: a body is written for each call, and an unidentified call or error names none")
+ }
seen := map[string]bool{}
return validateSteps(sc.Steps, "steps", seen, true, advertised(sc.Tools))
}
+// namesNoCall reports whether a step, in any stream, writes model output with no
+// call id. Provider bodies are written per call and joined by the call's ids, so
+// such output has no body, and the two formats would not agree on what is left.
+func namesNoCall(steps []Step) bool {
+ for i := range steps {
+ s := &steps[i]
+ if s.Error.Unidentified {
+ return true
+ }
+ c := s.Call
+ if c == nil {
+ continue
+ }
+ if c.Unidentified {
+ return true
+ }
+ if c.Agent != nil && namesNoCall(c.Agent.Steps) {
+ return true
+ }
+ if c.Skill != nil && namesNoCall(c.Skill.Steps) {
+ return true
+ }
+ if c.Workflow != nil {
+ for _, child := range c.Workflow.Children {
+ if namesNoCall(child.Steps) {
+ return true
+ }
+ }
+ }
+ }
+ return false
+}
+
// validateTools checks one advertised tool list. A name is required, and no
// name is advertised twice: a provider is sent one definition per name, and a
// repeat would quietly shadow the other.
@@ -444,7 +508,7 @@ func validateSteps(steps []Step, where string, seen map[string]bool, main bool,
at := fmt.Sprintf("%s[%d]", where, i)
n := 0
for _, set := range []bool{s.Input != "", s.Queued != nil, s.Inject != nil, s.Call != nil,
- s.Result != nil, s.Error != "", s.Reset != nil, s.Replay > 0, s.System != nil} {
+ s.Result != nil, s.Error.Text != "", s.Reset != nil, s.Replay > 0, s.System != nil} {
if set {
n++
}
diff --git a/internal/storage/state.go b/internal/storage/state.go
index c7ceefd..b13ecf5 100644
--- a/internal/storage/state.go
+++ b/internal/storage/state.go
@@ -168,3 +168,13 @@ func (s *SessionState) Take() uint64 {
s.NextSeq++
return seq
}
+
+// GiveBack returns the number Take just allocated, for a file that did not
+// land. The pass goes on to other sources and saves the state, so a number kept
+// by a failed write would leave a gap in the landed files that every round
+// after it reports as a missing file.
+func (s *SessionState) GiveBack(seq uint64) {
+ if seq+1 == s.NextSeq {
+ s.NextSeq = seq
+ }
+}
diff --git a/pkg/model/term.go b/pkg/model/term.go
index 0dbb070..8563992 100644
--- a/pkg/model/term.go
+++ b/pkg/model/term.go
@@ -121,7 +121,7 @@ func Vocabulary() []string {
KindAgentCall, KindAgentLaunchAck, KindAgentOutput, KindRuntimeNotification,
KindEpochBoundary, KindEpochSummary,
KindErrorAPI, KindControlInterrupt, KindControlPermission, KindControlCommand,
- KindTurnDuration,
+ KindControlNotice, KindTurnDuration,
// relations
RelStarts, RelReports, RelEndsWith, RelResultOf, RelFollows, RelSummarizes,
RelInSegment, RelRetries, RelCancels, RelInputOf,
diff --git a/tests/scenarios/all-kinds.expect.yaml b/tests/scenarios/all-kinds.expect.yaml
index 48313ce..7e29084 100644
--- a/tests/scenarios/all-kinds.expect.yaml
+++ b/tests/scenarios/all-kinds.expect.yaml
@@ -25,6 +25,7 @@ checkpoints:
delta: true
kinds: {epoch.boundary: 1, epoch.summary: 1, talk: 5}
unresolved: {open: 0}
+ session: {title: every kind of file} # the session's own title; a child's meta names only the child
view: {state: verified, rounds: 2, files: 12} # main twice, helper, its meta, two children, journal, manifest, script, the execution record; and two rounds
push:
kinds: [transcript, agent_meta, journal, workflow_manifest, workflow_script, execution, round]
diff --git a/tests/scenarios/all-kinds.expect.yaml b/tests/scenarios/output-without-call.expect.yaml
similarity index 59%
copy from tests/scenarios/all-kinds.expect.yaml
copy to tests/scenarios/output-without-call.expect.yaml
index 48313ce..60c299e 100644
--- a/tests/scenarios/all-kinds.expect.yaml
+++ b/tests/scenarios/output-without-call.expect.yaml
@@ -15,16 +15,12 @@
# specific language governing permissions and limitations
# under the License.
+
+# The run holds every step the agent produced, and there is no call node. The
+# talk's reply is read from its run, so it is there only if the messages are.
checkpoints:
- launched:
- rounds: 1
- kinds: {stream: 4, agent.call: 2, agent.launch_ack: 2, agent.output: 3, runtime.notification: 1}
- relations: {starts: 3, ends_with: 3, reports: 1}
final:
- rounds: 2
- delta: true
- kinds: {epoch.boundary: 1, epoch.summary: 1, talk: 5}
- unresolved: {open: 0}
- view: {state: verified, rounds: 2, files: 12} # main twice, helper, its meta, two children, journal, manifest, script, the execution record; and two rounds
-push:
- kinds: [transcript, agent_meta, journal, workflow_manifest, workflow_script, execution, round]
+ kinds: {message.assistant: 2, message.synthetic: 1, thinking: 1, tool: 1, llm.call: 0, run: 1}
+ view: {first_talk: {label: find the failing test, reply: test_mean fails., runs: 1}}
+ nodes:
+ tool/t1: {kind: tool, refs: 2, attrs: {result: available, result_join: exact_unique}}
diff --git a/tests/scenarios/output-without-call.yaml b/tests/scenarios/output-without-call.yaml
new file mode 100644
index 0000000..2ad062c
--- /dev/null
+++ b/tests/scenarios/output-without-call.yaml
@@ -0,0 +1,35 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+
+# A runtime that records what the model said but not which provider call said
+# it. Kiro's V3 engine is one: a turn lists its request ids, and no record names
+# one of them.
+#
+# Every word stays. The text, the reasoning and the tool use become steps of the
+# run that holds them, and no call node is written, because grouping the records
+# into calls would draw a boundary the runtime never stated. A message the client
+# made, with no call named either, stays a synthetic message in the same run.
+steps:
+ - input: find the failing test
+ - call:
+ unidentified: true
+ thinking: The test file has one assertion that can fail.
+ text: I will run the tests.
+ tool: {id: t1, name: Bash, input: {command: python3 -m unittest}, result: FAILED (failures=1)}
+ - call: {unidentified: true, text: test_mean fails.}
+ - error: {text: "API Error: the request was refused.", unidentified: true}