Automated session retrospectives¶
/rose-session-learn turns what happened in a coding session into durable,
team-shared improvements — a code fix, a skill amendment, a line in the nearest
AGENTS.md. It only runs when someone remembers to run it, so sessions closed
without it lose their learnings.
Autorun does it unattended. It is opt-in per operator and off by default;
with it disabled nothing below happens and the Stop hook only nudges, as before.
How it runs¶
Stop hook (scripts/stop-nudge.sh, once per session_id)
└─ nohup worker.sh detached
├─ digest.sh transcript -> signal, or exit below the gate
├─ claude -p read-only reflect -> candidate JSONL
├─ ledger.sh add bank candidates locally
└─ ledger.sh ripe key recurred in >=N distinct sessions?
├─ Linear MCP file the issue that hosts the work
└─ scratch worktree -> agent edits -> commit -> push -> PR
There is no cron and no LaunchAgent. Claude transcripts
(~/.claude/projects/*.jsonl) never leave the machine, which rules out cloud
routines and GitHub Actions; and a scheduler would have to rediscover which
sessions ended, which the Stop hook already knows — it is handed the exact
transcript path.
Why a ledger sits in the middle¶
A single session cannot tell a real pattern from a one-off. A job that opened a
PR per session would bury the team in noise, so nothing reaches the repo from
one run. Candidates are banked locally and only promoted once the same learning
has recurred across N independent sessions. Recurrence is the filter; the PR is
the approval gate that replaces the interactive apply.
Measured over 222 transcripts across 7 days: 115 sessions pass the gate, and a 15-session sample banked 28 candidates under 22 keys with one key recurring in six workspaces — roughly one promotion per fifteen gated sessions.
The gate¶
digest.sh keeps a session only if it has ≥3 operator turns AND ≥1 failure
(an is_error tool result or a hook_blocking_error), then reduces it to the
learnable part: operator turns, assistant text and thinking, failed tool results,
blocked edits. That is 2.6% of the bytes — 253 MB of transcript becomes
6.7 MB. The discarded remainder is file contents, diffs, search results and tool
parameters, none of which a retrospective needs.
The gate selects on friction, not effort: a long clean session taught nobody anything; a short one that failed twice did.
The ledger¶
~/.claude/rose-session-learn/ledger.jsonl — local and uncommitted. The
transcripts behind it exist only on one machine, so recurrence can only be counted
per-operator, and several detached workers append concurrently, which in a
committed file means a merge conflict per session. The ledger is scratch state on
the way to a PR; the PR is the shared artifact.
Deduplication is by key, a kebab-case slug naming the trigger, emitted by
the reflecting model. Existing keys are fed back into the next reflect prompt with
an instruction to reuse an exact match, so the model does the fuzzy matching and
the ledger only counts. A key's weight is its number of distinct session ids.
ledger.sh pending # key -> distinct-session count
ledger.sh ripe [N] # keys at or above the threshold
ledger.sh show <key> # every record behind a key
worker.sh --preview-issue # what the ledger would file right now
Read by the worker at the end of every run — which is why no second scheduler exists — and by you.
The Linear issue¶
Filed before the branch, so the branch can be Linear's own branchName; that
is what attaches the PR automatically. Title and description come entirely from
the ledger, so no model names the work. It carries the session-learning
label, starts in Triage, and moves to In Review once the PR exists — never
before, since a promotion can still fail at commit or push.
Configure the automation exclusion
Naming the branch after the issue is also what makes Linear's GitHub
automation fire on push: an issue filed as Triage comes back In Progress,
assigned, and pulled into the live cycle. Nothing on the API side can prevent
that. Exclude the session-learning label from the auto-assign and
auto-add-to-cycle rules in Linear, or every unattended proposal inflates the
active sprint.
If Linear is unreachable the worker logs it, falls back to a learnings/<stamp>
branch and opens the PR anyway. Losing the issue must never cost the learning.
What the promoting agent may and may not do¶
It works in a throwaway worktree branched from origin/develop, never a live
workspace — an agent with write access firing while nobody is watching must not
land in a branch someone is mid-work on.
It keeps a shell, and is told to run the tests it touches. Code it cannot test is
code the pre-commit hook rejects after the run has ended, discarding the work;
the prompt tells it to drop any learning it cannot make pass, because a dropped
learning costs nothing and a red branch costs a human. git commit, git push
and gh are denied — the script owns those, and two writers on one branch is a
real bug.
Candidate text is published verbatim into a Linear issue and a GitHub PR, so
secret-scan.sh drops any record matching a credential shape (key prefixes, JWTs,
private-key headers, connection strings with passwords, KEY=<blob>). It rejects
rather than redacts: partial redaction is a bypass surface, and dropping costs
nothing given recurrence.
Enabling it¶
Prefix the hook command in your personal, gitignored
.claude/settings.local.json:
"command": "ROSE_SESSION_LEARN_AUTORUN=1 \"$CLAUDE_PROJECT_DIR\"/.agents/skills/rose-session-learn/scripts/stop-nudge.sh"
Autorun is silent by design — the session ends normally and the retrospective
runs behind it. Watch ~/.claude/rose-session-learn/worker.log.
Start by watching ledger.sh pending for a few days before letting it promote: a
wrong gate or a drifting key scheme shows up there first.
Tunables¶
| Variable | Default | |
|---|---|---|
ROSE_SESSION_LEARN_AUTORUN |
unset | Enables autorun; unset means nudge only. |
ROSE_LEARN_THRESHOLD |
3 |
Distinct sessions before a key is promoted. |
ROSE_LEARN_REFLECT_MODEL |
sonnet |
Runs on every gated session — keep it cheap. |
ROSE_LEARN_PROMOTE_MODEL |
opus |
Runs rarely and writes code. |
ROSE_LEARN_LINEAR_TEAM |
Rose |
|
ROSE_LEARN_LINEAR_LABELS |
session-learning |
|
ROSE_LEARN_LINEAR_REVIEW_STATUS |
In Review |
Set once the PR exists. |
ROSE_LEARN_STATE |
~/.claude/rose-session-learn |
Logs, lock, ledger. |
Logs¶
| File | |
|---|---|
worker.log |
one line per session, plus the reflect stage's stderr |
promote-agent.log |
the promoting agent's stderr |
commit.log |
the pre-commit hook output for the last commit attempt |
unparsed.log |
the model spoke but nothing parsed |
They are separate on purpose. Sharing one stream once made a hook failure indistinguishable from an agent failure and produced two wrong diagnoses.
Checks¶
cd .agents/skills/rose-session-learn/scripts
./test-ledger.sh # counting, promotion, thresholds
./test-parse.sh # model-output parsing and the schema gate
./test-issue.sh # Linear issue fields (renders only, never calls Linear)
./test-secret-scan.sh # credential shapes drop, ordinary errors survive
Known limits¶
A transcript shows what was corrected, not why — "the operator redirected me"
and "the operator changed their mind" are indistinguishable on disk. Recurrence
filters most of that out, not all. This is a safety net, not a replacement for
running /rose-session-learn on a session you just lived.