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
2 changes: 1 addition & 1 deletion plugins/session-flow/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "session-flow",
"version": "0.48.5",
"version": "0.48.6",
"description": "Session lifecycle: workflow (next stage), handoff (save-point, resume prompt), continue-in-background, keep-going (resume after interruption or stall), find-handoff (recover a lost handoff), clean-stop (durable stopping point), retro and running-retro (retrospectives), audit-sessions, orient (where the session stands), orchestrate (delegation imperatives), reanchor (verify assumptions), reconcile (retire finished work), show-options (ranked skill menu), tidy-work (.work tiers), check, setup.",
"author": {
"name": "Melodic Software",
Expand Down
10 changes: 10 additions & 0 deletions plugins/session-flow/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,15 @@
# Changelog: session-flow plugin

## [0.48.6] - 2026-10-04

### Fixed

- **`/session-flow:audit-sessions` takes its medians and findings over interactive sessions only ([#6076](https://github.com/melodic-software/claude-code-plugins/issues/6076)).**
Each store record now carries the transcript's `entrypoint` values in `entrypoints`, or `["unknown"]`. Sweep sets aside a session whose entrypoints are all headless or Agent SDK ones (`sdk-cli`, `sdk-ts`, `sdk-py`), says how many in the report, and keeps a session with no known entrypoint, counted as unclassified; drift still reads every record. A new canary, `key_path:user:entrypoint`, withholds the four metrics whose medians automated sessions were measured to move if Claude Code stops writing the key.
- **One interrupt in a session is no longer a `tools.interrupts` finding.** The threshold is now 1, so a session needs two.
- **Claude Desktop prompts count as typed turns.** Desktop writes the person's prompts with `promptSource: sdk`, which the shared transcript reader rejected. A record whose `origin.kind` is `human` now counts whatever its `promptSource`, after one leading `<system-reminder>` block is dropped; `/session-flow:retro` counts them too. CLI records count as before.
- **A transcript Claude Code set aside as `<session>.orphaned-*.jsonl` is no longer collected as a second session.** Collect skips it and reports the count in `skipped_orphaned`, and deletes a record an earlier version stored for one, even when the transcript is gone, reporting the count in `purged_orphaned`.

## [0.48.5] - 2026-10-04

### Fixed
Expand Down
36 changes: 23 additions & 13 deletions plugins/session-flow/scripts/transcript_reader.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,13 @@
plus `unique_messages`. Non-integer counts read as 0.
- `user_text(record)` is a user record's text, stripped, with text blocks joined by newlines; None
for a tool result or a content shape that carries no text. Injected text is returned as is.
- `is_typed_turn(record)` is true only for a user record the human typed: not a tool result, not
meta, compact-summary or transcript-only, `origin.kind` absent or `human`, `promptSource` absent
or in `TYPED_PROMPT_SOURCES`, not an interrupt, and not starting with an `INJECTED_PREFIXES`
entry. Injected records outnumber typed turns.
- `typed_text(record)` is the text of a user record the human typed, else None: not a tool
result, not meta, compact-summary or transcript-only, `origin.kind` absent or `human`,
`promptSource` absent or in `TYPED_PROMPT_SOURCES` unless `origin.kind` is `human`, not an
interrupt, and not starting with an `INJECTED_PREFIXES` entry. For an `origin.kind: human` record,
one leading `<system-reminder>` block is dropped first: Claude Desktop writes the person's prompts
that way, with `promptSource: sdk`. Injected records outnumber typed turns.
- `is_typed_turn(record)` is `typed_text(record) is not None`.
- `iter_subagents(main_path)` yields a `Subagent(path, meta)` for each
`<session>/subagents/agent-*.jsonl` beside `<session>.jsonl`, in name order; `meta` is the
parsed `agent-*.meta.json` object, or None when it is missing, unreadable or not an object.
Expand Down Expand Up @@ -67,6 +70,7 @@
"<user-prompt-submit-hook>",
)
INTERRUPT_RE = re.compile(r"^\[Request interrupted by user( for tool use)?\]")
LEADING_REMINDER_RE = re.compile(r"<system-reminder>.*?</system-reminder>\s*", re.S)
TYPED_PROMPT_SOURCES = {"typed", "queued"}
RECORD_TYPES = frozenset(
{
Expand Down Expand Up @@ -177,18 +181,24 @@ def user_text(record: dict) -> str | None:
return content.strip() if isinstance(content, str) else None


def is_typed_turn(record: dict) -> bool:
def typed_text(record: dict) -> str | None:
if record.get("type") != "user":
return False
return None
if record.get("isMeta") or record.get("isCompactSummary") or record.get("isVisibleInTranscriptOnly"):
return False
return None
kind = _obj(record.get("origin")).get("kind")
if kind and kind != "human":
return False
return None
source = record.get("promptSource")
if source and not (isinstance(source, str) and source in TYPED_PROMPT_SOURCES):
return False
if kind != "human" and source and not (isinstance(source, str) and source in TYPED_PROMPT_SOURCES):
return None
text = user_text(record)
if not text or INTERRUPT_RE.match(text):
return False
return not text.startswith(INJECTED_PREFIXES)
if text and kind == "human" and (reminder := LEADING_REMINDER_RE.match(text)):
text = text[reminder.end() :]
if not text or INTERRUPT_RE.match(text) or text.startswith(INJECTED_PREFIXES):
return None
return text


def is_typed_turn(record: dict) -> bool:
return typed_text(record) is not None
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"validated_against": "2.1.287",
"verified": "2026-10-02",
"recheck": "a Claude Code release that changes the transcript format, or a drift run that reports new or vanished keys",
"basis": "collect.py census over this machine's 104 sessions (2.1.275-2.1.287): each key below is on every record of its population in 2.1.284, 2.1.285 and 2.1.287, except effort (99.8%) and turn_duration (30-47% of system records); invariant:usage_split counted 16 groups, all in subagents, none in 2.1.287",
"basis": "collect.py census over this machine's 104 sessions (2.1.275-2.1.287): each key below is on every record of its population in 2.1.284, 2.1.285 and 2.1.287, except effort (99.8%) and turn_duration (30-47% of system records); invariant:usage_split counted 16 groups, all in subagents, none in 2.1.287. key_path:user:entrypoint was added 2026-10-04: it decides which sessions sweep sets aside as automated, and its feeds are the four metrics whose pooled and interactive-only medians the 2026-10-03 audit compared (764 sessions), all of which moved; no other metric was compared, so widen the feeds if a later comparison shows one moving; it was on every user record of headless 2.1.288 sessions, not yet checked over this machine's corpus",
"canaries": [
{"key": "key_path:assistant:message.id", "record_type": "assistant", "feeds": ["tokens.main", "tokens.sub"]},
{"key": "usage_key:input_tokens", "record_type": "assistant", "feeds": ["tokens.main", "tokens.sub"]},
Expand All @@ -17,6 +17,7 @@
{"key": "system_subtype:turn_duration", "record_type": "system", "feeds": ["time.active_s"]},
{"key": "key_path:system:durationMs", "record_type": "system", "population": "system_subtype:turn_duration", "feeds": ["time.active_s"]},
{"key": "key_path:system:hookInfos[].durationMs", "record_type": "system", "population": "system_subtype:stop_hook_summary", "feeds": ["hooks.stop_ms"]},
{"key": "key_path:user:message.content", "record_type": "user", "feeds": ["human.turns", "tools.interrupts", "context.clear_commands"]}
{"key": "key_path:user:message.content", "record_type": "user", "feeds": ["human.turns", "tools.interrupts", "context.clear_commands"]},
{"key": "key_path:user:entrypoint", "record_type": "user", "feeds": ["tokens.main", "tokens.sub", "time.active_s", "human.turns"]}
]
}
Original file line number Diff line number Diff line change
Expand Up @@ -43,8 +43,24 @@ D/audit-sessions/
`audit_sessions_retention_days` days ago (0 keeps every record), and never ingests one already
outside that window.

- **How a session was launched.** `entrypoints` lists every `entrypoint` value the main transcript
carries, or `["unknown"]` when it carries none; a record written before the field existed reads
as unknown too. Collect skips a transcript Claude Code set aside as `<session>.orphaned-*.jsonl`
and counts it in `skipped_orphaned`, so it never becomes a second session. A record an earlier
collector stored for such a transcript is deleted on the next collect, whether or not the
transcript still exists and whatever `--session` selects, and counted in `purged_orphaned`.

## Reports

- **Interactive sessions only.** Sweep sets aside a session whose entrypoints are all headless or
Agent SDK ones (the set is `AUTOMATED_ENTRYPOINTS` in `scripts/census.py`) and reports how many;
a session with no known entrypoint stays in and is counted as unclassified. The drift check still
reads every record. A headless run of real work is set aside too.
- **Pointer**: the transcript key is undocumented; its values match the `app.entrypoint`
attribute in <https://code.claude.com/docs/en/monitoring-usage#standard-attributes>.
- **As of**: 2026-10-04
- **Recheck trigger**: that row adds an SDK entrypoint, or a drift run reports
`key_path:user:entrypoint` as a lost canary.
- `sweep.py --write-report` writes one JSON and one markdown report per run, named by a UTC stamp
with microseconds, and appends one line to that scope's `history.jsonl`: the stamp, scope, session
count and each metric's value. The newest 20 report pairs per scope are kept; `history.jsonl` is
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -78,11 +78,11 @@
"metric": "tools.interrupts",
"lens": "interruption",
"unit": "count",
"threshold": 0,
"threshold": 1,
"route": "retro-codify",
"suggested_skill": "/session-flow:retro",
"confidence": "measured",
"basis": "main-session interrupts per session; spike median 0, p90 0, max 2, 3 of 90 sessions nonzero"
"basis": "main-session interrupts per session; spike median 0, p90 0, max 2, 3 of 90 sessions nonzero. Raised from 0 to 1 by operator decision (#6076): the first full store (2026-10-03, 764 sessions) had p90 1, and 57 of the 81 sessions threshold 0 flagged had exactly one interrupt; threshold 1 flags 24"
},
{
"metric": "tools.denials",
Expand Down
21 changes: 20 additions & 1 deletion plugins/session-flow/skills/audit-sessions/scripts/census.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@
- `unknown-record-type`: a record type the collector's reader does not know, with its count.

Only versions holding `min_count` records take part, and the `unknown` version bucket (records
without a `version`) never does. The drift defaults and `write_atomic` live here too, so both
without a `version`) never does. The drift defaults, `session_class` (a record's stored
`entrypoints` read as automated, interactive or unknown) and `write_atomic` live here too, so both
scripts share one store layer without `sweep.py` importing `collect.py` and its reader.
Stdlib only; Python 3.10+.
"""
Expand Down Expand Up @@ -48,6 +49,12 @@
}
)
UNKNOWN_VERSION = "unknown"
UNKNOWN_ENTRYPOINT = "unknown"
# Transcript `entrypoint` values of headless (`claude -p`) and Agent SDK runs. The transcript key is
# undocumented; its values match the `app.entrypoint` telemetry attribute. Pointer:
# https://code.claude.com/docs/en/monitoring-usage#standard-attributes (as of 2026-10-04; recheck
# when that row adds an SDK entrypoint or a drift run reports the entrypoint canary lost).
AUTOMATED_ENTRYPOINTS = frozenset({"sdk-cli", "sdk-ts", "sdk-py"})
DEFAULT_MIN_COUNT = 20
DEFAULT_VERSIONS = 3
# Sessions the newest version needs before a missing canary counts as lost.
Expand Down Expand Up @@ -105,6 +112,18 @@ def load_records(data_dir: Path) -> tuple[list[dict], int]:
return records, skipped


def session_class(record: dict) -> str:
"""`automated` when every stored entrypoint is an SDK one, `unknown` when none is known, else `interactive`.

A session resumed interactively after a headless start carries both and counts as interactive.
"""
values = record.get("entrypoints")
known = [v for v in values if isinstance(v, str) and v != UNKNOWN_ENTRYPOINT] if isinstance(values, list) else []
if not known:
return "unknown"
return "automated" if all(v in AUTOMATED_ENTRYPOINTS for v in known) else "interactive"


def load_canaries(path: Path) -> dict:
canaries = json.loads(Path(path).read_text(encoding="utf-8"))
if not isinstance(canaries, dict) or canaries.get("schema") != CANARY_SCHEMA:
Expand Down
32 changes: 28 additions & 4 deletions plugins/session-flow/skills/audit-sessions/scripts/collect.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,9 @@
collect.py drift --data-dir D [--min-count N] [--versions N] [--session-floor N] [--canaries FILE]

Writes one `session-record/v1` file per main session (the main transcript plus its subagents)
under `D/audit-sessions/store/v1/`, the machine-wide store `sweep.py` reads. A session whose
under `D/audit-sessions/store/v1/`, the machine-wide store `sweep.py` reads; a transcript Claude
Code set aside (`<session>.orphaned-*.jsonl`) is skipped and counted, not ingested, and a record an
earlier collector stored for one is deleted. A session whose
fingerprint matches its stored record is skipped, unless that record was written by another
collector version, under other excerpt limits, or with redaction failing closed where it now
works or the reverse. With a retention window, records of sessions
Expand Down Expand Up @@ -100,6 +102,10 @@
TEXT_CAP_FLOOR = 4096
TOO_LONG = "<too-long>"
SUPPRESSED = "<suppressed>"
# `<session>.orphaned-<timestamp>-<suffix>.jsonl` is an earlier transcript Claude Code set aside, not
# a second session. Pointer: https://code.claude.com/docs/en/claude-directory#cleaned-up-automatically
# (as of 2026-10-04; recheck when that table renames the set-aside transcript).
ORPHANED = ".orphaned-"


def emit(status: str, summary: str, data: dict, code: int, schema: str = SCHEMA) -> int:
Expand Down Expand Up @@ -263,6 +269,7 @@ def __init__(self, excerpt_words: int) -> None:
self.timestamps: list[float] = []
self.human_timestamps: list[float] = []
self.versions: set[str] = set()
self.entrypoints: set[str] = set()
self.branches: set[str] = set()
self.prs: set[tuple[str, int]] = set()
self.permission_modes: Counter = Counter()
Expand Down Expand Up @@ -299,6 +306,8 @@ def add(self, record: dict, side: str) -> None:
for field, values in (("version", self.versions), ("gitBranch", self.branches)):
if isinstance(record.get(field), str) and record[field]:
values.add(record[field])
if isinstance(record.get("entrypoint"), str) and record["entrypoint"]:
self.entrypoints.add(_label(record["entrypoint"]))
handler = getattr(self, "_" + kind.replace("-", "_"), None)
if handler is not None:
handler(record, side, ts)
Expand Down Expand Up @@ -379,8 +388,8 @@ def _user(self, record: dict, side: str, ts: float | None) -> None:
self.slash[command.group(1)] += 1
if command.group(1) == "clear" and self.human_turns == 0 and not self.messages["main"]:
self.started_with_clear = True
elif transcript_reader.is_typed_turn(record):
self._typed_turn(record, text, ts)
elif (typed := transcript_reader.typed_text(record)) is not None:
self._typed_turn(record, typed, ts)

def _typed_turn(self, record: dict, text: str, ts: float | None) -> None:
index = self.human_turns
Expand Down Expand Up @@ -641,6 +650,7 @@ def build_record(
"repo_identity": repo_identity,
"worktree": worktree,
"cc_versions": sorted(scan.versions, key=census.version_key),
"entrypoints": sorted(scan.entrypoints) or [census.UNKNOWN_ENTRYPOINT],
"time": scan.time_block(),
"models": {side: dict(counts) for side, counts in scan.models.items()},
"effort": {side: dict(counts) for side, counts in scan.effort.items()},
Expand Down Expand Up @@ -773,10 +783,22 @@ def cmd_collect(args: argparse.Namespace) -> int:
index = load_store(store)
# A record stored under other settings is re-ingested, so a lowered excerpt limit reaches old records.
policy = (version, {"chars": args.excerpt_chars, "words": args.excerpt_words}, redactor.fail_closed)
scanned = ingested = skipped = expired = too_long = 0
scanned = ingested = skipped = expired = orphaned = purged = too_long = 0
failed: list[dict] = []
unknown_types: Counter = Counter()
# Earlier collectors stored a set-aside transcript as a session, and its source may since be gone.
for path in [p for p in index if ORPHANED in p.stem]:
try:
path.unlink(missing_ok=True)
except OSError as exc:
failed.append({"session_id": path.stem, "reason": str(exc)})
continue
del index[path]
purged += 1
for main in sorted(root.glob("*/*.jsonl")):
if ORPHANED in main.stem:
orphaned += 1
continue
Comment thread
cursor[bot] marked this conversation as resolved.
if wanted and main.stem not in wanted:
continue
try:
Expand Down Expand Up @@ -831,6 +853,8 @@ def cmd_collect(args: argparse.Namespace) -> int:
"ingested": ingested,
"skipped_unchanged": skipped,
"skipped_expired": expired,
"skipped_orphaned": orphaned,
"purged_orphaned": purged,
"failed": failed,
"pruned": pruned,
"store_records": sum(1 for _ in store.glob("p-*/*.json")),
Expand Down
Loading
Loading