Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,8 +50,9 @@ inherit 記住讀取進度,重複呼叫同一個命令即自動翻頁:
## 輸出行為

- 每則訊息的 header 只帶時鐘 `[HH:MM:SS]`,日期在換日時以獨立一行 `--- YYYY-MM-DD ---` 標示
- 角色標籤三種:`user:` 是人打的、`assistant:` 是 Claude 的回應、`harness:` 是 Claude Code 自己塞進對話的訊息
(stop hook 目標、背景任務完成通知、壓縮續接摘要、中斷標記等),不是使用者說的話
- 角色標籤四種:`user:` 是人打的、`assistant:` 是 Claude 的回應、`harness:` 是 Claude Code 自己塞進對話的訊息
(stop hook 目標、背景任務完成通知、壓縮續接摘要、中斷標記等),不是使用者說的話;
`user (sdk):` 是透過 SDK 或 `claude -p` 程式化送入的訊息,不是人手打的,但在對話裡仍站在使用者的位置
- tool 摘要行只在失敗時標 `FAILED`,成功不另標記;Bash 行帶 `| <程式名 子命令>` 讓讀者知道實際跑了什麼
- 當 session 內有 `cc-session inherit/read/context` 呼叫時,連續的同 session 呼叫會被壓成一行:
`(cc-session#Y1dg: inherited session 16d06326 here, 1320 lines omitted)`
Expand Down
88 changes: 88 additions & 0 deletions docs/adr-009-prompt-source-field.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
# ADR-009:訊息來源先看 `promptSource` 欄位,字串比對降為備援

**狀態**:已實作(2026-09-02)。`sdk` 來源標 `user (sdk):`;context 格式的短前綴是 `S:`(`U:`/`H:` 之外新增一個)。

判斷 promptSource 是否與字串分類衝突(決定 4)、以及決定 3 的「system 但形狀認不出來」,
兩者的解析都收在 parser 層(`claudecodec.parseLineWithToolCalls`):字串分類先跑一次,
結果不論如何都先掛上 `PromptSource`;只有人的來源對上 harness 形狀時才整則重置成純文字。
render 層因此不需要重新判斷字串分類,只在「字串分類完全沒認出形狀」的兜底分支上,
依 `PromptSource` 決定 `harness:`/`user (sdk):`/`user:`(`session.UserMessage.IsClassifiedAsHarness`
與 `formatter.plainTextRole`)。實測 `b11858cf` 的 8 則 `system` 訊息全是 `<task-notification>`
這種已認得的形狀,`read` 輸出的 md5 改動前後一致。

ADR-008 把 harness 注入的 user 訊息在 parser 層分類,但判斷全靠比對文字。
每次 harness 改措辭或加新的包裝,reader 就漏一批(ADR-008 之後兩天又補了八種)。
CLI 從 2.1.165 起在 user entry 上寫 `promptSource` 欄位,這份 ADR 記錄它能替代多少字串比對。

量測基礎:`~/.claude/projects` 下最近 60 天的主 session 與 `subagents/` 兩層 transcript,
版本 2.1.165 以上的 user 文字 entry 共 11,064 則(2026-09-02)。

## 欄位長什麼樣

| 值 | 則數 | 意思 | 啟動一輪的比例 |
|---|---:|---|---:|
| `typed` | 3,547 | 人在終端打的 | 89.6% |
| `sdk` | 1,213 | SDK 或 `claude -p` 程式化送入 | 98.3% |
| `system` | 1,206 | harness 注入 | 93.9% |
| `queued` | 158 | 人排隊送出的 | 93.0% |
| `suggestion_accepted` | 50 | 人接受了建議 | 96.0% |
| 沒帶 | 4,890 | 見下節 | |

「啟動一輪」的判準同 ADR-008 第 5 項:往後掃到下一則 user 文字為止,中間有帶 usage 的 assistant 訊息。
五種值全都在九成上下,也就是**帶 `promptSource` 的訊息就是啟動 turn 的 prompt**。

## 沒帶的那 44% 不是缺資料,是另一類訊息

沒帶 `promptSource` 的 4,890 則裡,前 22 種形狀全是 harness 注入或附屬訊息:

| 形狀 | 則數 |
|---|---:|
| teammate message(開場白起頭 1,080 + 標籤直接起頭 769) | 1,849 |
| skill 注入 `Base directory for this skill:` | 464 |
| `<system-reminder>` | 439 |
| `[Request interrupted by user]` | 244 |
| `[Image: …]` 佔位符 | 237 |
| `<local-command-caveat>` / `<local-command-stdout>` / `<command-*>` | 394 |
| 壓縮續接、`[SYSTEM NOTIFICATION]`、coordinator、fork-boilerplate、nudge、mid-turn 使用者訊息 | 271 |

所以這個欄位的語意是:**有帶 = 這則是啟動 turn 的 prompt;沒帶 = 這則是注入或附屬**。
「沒帶」本身就是訊號,不是缺值。

覆蓋率依版本在 25% 到 89% 之間浮動,但那反映的是各版本注入訊息的比例不同,不是欄位時有時無。

## 與字串比對的衝突

| 情況 | 則數 |
|---|---:|
| 人的來源(`typed`/`queued`/`suggestion_accepted`)但字串比對判成 harness | **0** |
| `system` 但字串比對判不出來 | 15 |

那 15 則是排程或迴圈觸發的 prompt(`Check if background task …`、`Background agent "…"`、
`檢查 /qa skill fork…`),內容是自然語言,沒有任何可比對的形狀。
只有 `promptSource` 抓得到它們。

## 決定

1. **有 `promptSource` 時以它為準。** `system` 歸 harness 角色;`typed`、`queued`、`suggestion_accepted` 歸 user;
`sdk` 歸 user(它是呼叫方下的指令,在那份 transcript 裡就是使用者的位置),但在 header 標 `user (sdk):`,
讓讀者知道不是人手打的。
2. **`CountsAsTurn()` 對帶 `promptSource` 的訊息一律回 true**,不分值。五種值實測都啟動一輪。
ADR-008 第 5 項那張逐種類的表只剩「沒帶」的訊息需要。
3. **字串比對留著,只處理沒帶的訊息。** 沒帶的訊息才需要知道是哪一種注入(決定 compact 形式)。
帶 `promptSource=system` 但字串比對判不出形狀的(上面那 15 則),渲染全文、標 `harness:`。
4. **`promptSource` 與字串比對結論相反時以 `promptSource` 為準。** 實測 0 例,這條是預先定好,避免以後各寫各的。
5. **2.1.165 以前的 transcript 走現行路徑**,不變。

## 不做的

**不把「沒帶 `promptSource`」直接當成 harness。** `[Image: …]` 佔位符沒帶但屬於使用者訊息,
`The user sent a new message while you were working:` 沒帶但 body 是人打的。
沒帶只代表「不是啟動 turn 的 prompt」,是不是人講的還要看內容。

**不用 `isMeta` 當第二來源。** 它在 skill 注入、圖片佔位符、stop hook feedback 上都出現,語意是「附屬」而不是「harness」。

## 預期效果

- `harness:` 標籤多抓到 15 則現在判不出來的排程 prompt
- 之後 harness 改措辭,影響的只剩 compact 形式(會退化成全文渲染),不再影響角色標籤與 K
- K 不變:帶 `promptSource` 的訊息在現行政策下本來就幾乎都算 turn(`system` 那組 91% 是 task-notification,ADR-008 已算)
6 changes: 6 additions & 0 deletions internal/claudecodec/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,12 @@ type rawEntry struct {
// preceding tool_use's name, not treated as a marker on its own.
IsMeta bool `json:"isMeta"`
SourceToolUseID string `json:"sourceToolUseID"`

// PromptSource is written by Claude Code (CLI >= 2.1.165) on some user
// entries: "typed", "sdk", "system", "queued", "suggestion_accepted".
// Absent on older transcripts and on harness injections/mid-turn relays
// even under current CLI (ADR-009).
PromptSource string `json:"promptSource"`
}

type rawMessage struct {
Expand Down
116 changes: 116 additions & 0 deletions internal/claudecodec/prompt_source_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
package claudecodec

import (
"testing"

"github.com/Mapleeeeeeeeeee/cc-session-reader/internal/session"
)

// ADR-009: Claude Code (CLI >= 2.1.165) writes a top-level "promptSource"
// field on some user entries. These cases pin how it is carried onto
// session.UserMessage and how it interacts with the pre-existing text
// classifiers in classify.go.

func userMessageEventFor(t *testing.T, line string) *session.UserMessage {
t.Helper()
event, ok, err := ParseLine([]byte(line))
if err != nil {
t.Fatalf("ParseLine returned error: %v", err)
}
if !ok {
t.Fatalf("ParseLine dropped the entry")
}
if event.User == nil {
t.Fatalf("event.User is nil")
}
return event.User
}

func TestParseLine_GivenPromptSource_WhenParsed_ThenCarriesItOntoUserMessage(t *testing.T) {
line := `{"type":"user","timestamp":"2026-09-02T00:00:00Z","message":{"role":"user","content":"為什麼 K 會被高估?"},"promptSource":"typed"}`

got := userMessageEventFor(t, line)

if got.PromptSource != session.PromptSourceTyped {
t.Errorf("PromptSource = %q, want %q", got.PromptSource, session.PromptSourceTyped)
}
if got.Text != "為什麼 K 會被高估?" {
t.Errorf("Text = %q, want the original body", got.Text)
}
}

func TestParseLine_GivenNoPromptSourceField_WhenParsed_ThenLeavesItEmpty(t *testing.T) {
line := `{"type":"user","timestamp":"2026-09-02T00:00:00Z","message":{"role":"user","content":"為什麼 K 會被高估?"}}`

got := userMessageEventFor(t, line)

if got.PromptSource != "" {
t.Errorf("PromptSource = %q, want empty (field absent)", got.PromptSource)
}
}

// ADR-009 decision 3: a promptSource="system" message that the string
// classifier does recognize keeps its existing compact form (the classified
// domain field survives; only PromptSource is added on top of it).
func TestParseLine_GivenSystemPromptSourceOnRecognizedShape_WhenParsed_ThenKeepsTheClassifiedForm(t *testing.T) {
line := `{"type":"user","timestamp":"2026-09-02T00:00:00Z",` +
`"message":{"role":"user","content":"7 background agents were stopped by the user: \"工作區\"."},` +
`"promptSource":"system"}`

got := userMessageEventFor(t, line)

if !got.IsAgentsStopped {
t.Fatalf("IsAgentsStopped = false, want true (classified form preserved)")
}
if got.StoppedAgentCount != 7 {
t.Errorf("StoppedAgentCount = %d, want 7", got.StoppedAgentCount)
}
if got.PromptSource != session.PromptSourceSystem {
t.Errorf("PromptSource = %q, want %q", got.PromptSource, session.PromptSourceSystem)
}
}

// ADR-009 decision 3: the 15 observed messages a scheduled/looped prompt
// with promptSource="system" but a shape none of the text classifiers
// recognize — only promptSource itself flags these as harness, at the
// render layer (see harness_role_test.go), not by reclassifying the body
// here.
func TestParseLine_GivenSystemPromptSourceOnUnrecognizedShape_WhenParsed_ThenLeavesTextUnclassified(t *testing.T) {
line := `{"type":"user","timestamp":"2026-09-02T00:00:00Z",` +
`"message":{"role":"user","content":"Check if background task abc123 finished."},` +
`"promptSource":"system"}`

got := userMessageEventFor(t, line)

if got.IsClassifiedAsHarness() {
t.Errorf("IsClassifiedAsHarness() = true, want false: no text classifier recognizes this shape")
}
if got.Text != "Check if background task abc123 finished." {
t.Errorf("Text = %q, want the original body kept verbatim", got.Text)
}
if got.PromptSource != session.PromptSourceSystem {
t.Errorf("PromptSource = %q, want %q", got.PromptSource, session.PromptSourceSystem)
}
}

// ADR-009 decision 4: 0 observed conflicts, but the rule is pinned so a
// future rewording of a harness message can't silently mislabel a message a
// human actually sent (e.g. a person pasting harness-looking text while
// programmatically driving the CLI via the SDK).
func TestParseLine_GivenHumanPromptSourceOnHarnessShapedBody_WhenParsed_ThenPromptSourceWins(t *testing.T) {
line := `{"type":"user","timestamp":"2026-09-02T00:00:00Z",` +
`"message":{"role":"user","content":"[Request interrupted by user]"},` +
`"promptSource":"sdk"}`

got := userMessageEventFor(t, line)

if got.IsInterrupted {
t.Errorf("IsInterrupted = true, want false: a human promptSource overrules the harness-shaped body")
}
if got.Text != "[Request interrupted by user]" {
t.Errorf("Text = %q, want the original body kept verbatim", got.Text)
}
if got.PromptSource != session.PromptSourceSDK {
t.Errorf("PromptSource = %q, want %q", got.PromptSource, session.PromptSourceSDK)
}
}
8 changes: 8 additions & 0 deletions internal/claudecodec/reader.go
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,14 @@ func parseLineWithToolCalls(line []byte, toolCalls map[string]toolCallInfo) (ses
} else {
event.User = &session.UserMessage{Text: text}
}
event.User.PromptSource = raw.PromptSource
// ADR-009 decision 4: a human promptSource overrules a harness verdict
// from the text classifiers above (0 observed conflicts, but the rule
// is pinned so a future rewording can't quietly flip the label). The
// message reverts to a plain user message under its original text.
if session.IsHumanPromptSource(raw.PromptSource) && event.User.IsClassifiedAsHarness() {
event.User = &session.UserMessage{Text: text, PromptSource: raw.PromptSource}
}
return event, true, nil
case "assistant":
assistant := raw.Message.Assistant()
Expand Down
8 changes: 7 additions & 1 deletion internal/formatter/context.go
Original file line number Diff line number Diff line change
Expand Up @@ -54,8 +54,14 @@ func renderContextEvents(events []session.Event, agentIDs map[string]bool, opts
}
flush()
prefix := "U"
if rendered.role == RoleHarness {
switch rendered.role {
case RoleHarness:
prefix = "H"
case RoleUserSDK:
// ADR-009: "S" sits next to "U"/"H" for a promptSource="sdk"
// message, which is still the user's position but not typed
// by a person.
prefix = "S"
}
fmt.Fprintf(out, "%s: %s\n\n", prefix, rendered.body)

Expand Down
98 changes: 98 additions & 0 deletions internal/formatter/harness_role_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"strings"
"testing"

"github.com/Mapleeeeeeeeeee/cc-session-reader/internal/claudecodec"
"github.com/Mapleeeeeeeeeee/cc-session-reader/internal/session"
)

Expand Down Expand Up @@ -131,6 +132,103 @@ func TestFormatReadEvents_GivenMidTurnUserMessage_WhenRendered_ThenLabelsItUserW
}
}

// ADR-009: promptSource="sdk" means the message is still the user's
// position in the transcript, but wasn't typed by a person, so it gets its
// own label rather than folding into either "user:" or "harness:".
func TestFormatReadEvents_GivenSDKPromptSource_WhenRendered_ThenLabelsItUserSDK(t *testing.T) {
events := []session.Event{{
Kind: session.EventUserMessage,
Timestamp: "2026-09-02T00:00:00Z",
User: &session.UserMessage{Text: "run the full test suite", PromptSource: session.PromptSourceSDK},
}}

var out bytes.Buffer
if err := FormatReadEvents(events, nil, 0, 0, FormatOptions{}, &out); err != nil {
t.Fatalf("FormatReadEvents returned error: %v", err)
}

want := "[00:00:00] user (sdk):\nrun the full test suite"
if got := out.String(); !strings.Contains(got, want) {
t.Errorf("output missing %q\ngot:\n%s", want, got)
}
}

// ADR-009 decision 3: the 15 observed promptSource="system" messages whose
// body no text classifier recognizes still render in full (not compacted or
// dropped), but under the harness role rather than plain "user:".
func TestFormatReadEvents_GivenSystemPromptSourceOnUnrecognizedBody_WhenRendered_ThenLabelsItHarnessInFull(t *testing.T) {
events := []session.Event{{
Kind: session.EventUserMessage,
Timestamp: "2026-09-02T00:00:00Z",
User: &session.UserMessage{
Text: "Check if background task abc123 finished.",
PromptSource: session.PromptSourceSystem,
},
}}

var out bytes.Buffer
if err := FormatReadEvents(events, nil, 0, 0, FormatOptions{}, &out); err != nil {
t.Fatalf("FormatReadEvents returned error: %v", err)
}

want := "[00:00:00] harness:\nCheck if background task abc123 finished."
if got := out.String(); !strings.Contains(got, want) {
t.Errorf("output missing %q\ngot:\n%s", want, got)
}
}

// ADR-009 decision 4: 0 observed conflicts, but pinned so a future harness
// rewording can't silently mislabel a message a human actually sent — a
// human promptSource overrules a harness-shaped body and renders under the
// user role with the body kept verbatim (no compact form applied). The
// override happens in the parser (claudecodec.ParseLine), so this exercises
// the full pipeline rather than hand-assembling a UserMessage state the
// parser would never actually produce.
func TestFormatReadEvents_GivenHumanPromptSourceOnHarnessShapedBody_WhenRendered_ThenLabelsItUser(t *testing.T) {
line := `{"type":"user","timestamp":"2026-09-02T00:00:00Z",` +
`"message":{"role":"user","content":"[Request interrupted by user]"},` +
`"promptSource":"typed"}`
event, ok, err := claudecodec.ParseLine([]byte(line))
if err != nil || !ok {
t.Fatalf("ParseLine(%q) = %v, %v, %v", line, event, ok, err)
}

var out bytes.Buffer
if err := FormatReadEvents([]session.Event{event}, nil, 0, 0, FormatOptions{}, &out); err != nil {
t.Fatalf("FormatReadEvents returned error: %v", err)
}

got := out.String()
if want := "[00:00:00] user:\n[Request interrupted by user]"; !strings.Contains(got, want) {
t.Errorf("output missing %q\ngot:\n%s", want, got)
}
if strings.Contains(got, "[interrupted]") {
t.Errorf("output still shows the compact harness marker\ngot:\n%s", got)
}
}

// ADR-009 decision 5: a message with no promptSource field (older CLI, or a
// harness injection/mid-turn relay under current CLI) keeps today's
// behavior exactly — this pins that the new field is additive, not a
// default that changes existing output.
func TestFormatReadEvents_GivenNoPromptSource_WhenRendered_ThenBehavesAsBeforeADR009(t *testing.T) {
events := []session.Event{{
Kind: session.EventUserMessage,
Timestamp: "2026-09-02T00:00:00Z",
User: &session.UserMessage{Text: "為什麼 K 會被高估?"},
}}

var out bytes.Buffer
if err := FormatReadEvents(events, nil, 0, 0, FormatOptions{}, &out); err != nil {
t.Fatalf("FormatReadEvents returned error: %v", err)
}

want := "[00:00:00] user:\n為什麼 K 會被高估?"
if got := out.String(); !strings.Contains(got, want) {
t.Errorf("output missing %q\ngot:\n%s", want, got)
}
}

func TestFormatReadEvents_GivenTypedMessage_WhenRendered_ThenStillLabelsItUser(t *testing.T) {
events := []session.Event{
{
Expand Down
Loading
Loading