feat(tasks): the hand-off — SessionEnd releases a session's claims; the practice is written down (milestone 381 step 4)
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 12s
CI & Build / TypeScript typecheck (push) Successful in 52s
CI & Build / integration (push) Successful in 59s
CI & Build / Python tests (push) Successful in 1m40s
CI & Build / Build & push image (push) Successful in 24s

Release is the mechanical half: scribe_session_end.sh sends the ending
session's id to /api/plugin/release-session, which releases the claims it
held. A tidy-up, not the guarantee (no SessionEnd on a crash; the lease
covers that), and skipped on /clear so SessionStart(clear) can still
hand the claimed work back.

Saying what happened is the half only the model can do. It is stated as a
practice where it is read: the using-scribe skill owns it ("Hand off before
this session's context stops existing", pinned in test_guidance_ownership),
the static context points at it for the wrap-up moment, and add_task_log's
docstring says a log claims the task. _INSTRUCTIONS is untouched.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-24 06:43:14 -04:00
co-authored by Claude Opus 5.5
parent e46eea3b52
commit 952e56ee75
11 changed files with 161 additions and 1 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "scribe",
"description": "Scribe for Claude Code: connects the scribe MCP server, adds the hooks that deliver live project state and relevant records at the right moment, ships the shared client-neutral Scribe skills (using-scribe, writing-plans, reporting-back, systematic-debugging, verification, brainstorming, reusing-code, shape-accounting), and syncs your saved Scribe Processes as skills (/scribe:sync).",
"version": "2026.09.24.1041",
"version": "2026.09.24.1042",
"author": {
"name": "Bryan Van Deusen"
},
+10
View File
@@ -101,6 +101,16 @@
}
]
}
],
"SessionEnd": [
{
"hooks": [
{
"type": "command",
"command": "bash \"${CLAUDE_PLUGIN_ROOT}/hooks/scribe_session_end.sh\""
}
]
}
]
}
}
+45
View File
@@ -0,0 +1,45 @@
#!/usr/bin/env bash
# Scribe — release this session's task claims as it ends (milestone 381 step 4).
#
# A claim records a session's attention, and a session that ends has none left
# to give. This is the mechanical half of the hand-off: it needs nothing from
# the model, only the session id the harness reports. The other half — writing
# down where the work stands — only the model can do, and it is stated as a
# practice in the skill and the static context, not here.
#
# A TIDY-UP, NOT THE GUARANTEE. SessionEnd does not fire on a crash, a killed
# terminal or a dropped connection, and those are exactly the cases the claim
# was designed around. The lease is what makes a dead session's claim read as
# dead; this only keeps the ordinary exit from leaving a claim to run out.
#
# NOT ON /clear. A clear ends one conversation and starts the next in the same
# terminal, and SessionStart(source=clear) pushes back the work this session
# had claimed. Releasing here would leave that push with nothing to say. The
# next write moves the claim wherever the work actually continues.
#
# EXIT 0 AND SILENT, ALWAYS. Nobody reads a SessionEnd hook's output, and the
# session is ending whether this succeeds or not.
set -uo pipefail
# shellcheck source=plugin/hooks/scribe_defs.sh
. "$(dirname "${BASH_SOURCE[0]}")/scribe_defs.sh"
command -v curl >/dev/null 2>&1 || exit 0
scribe_config || exit 0
event=$(cat 2>/dev/null || true)
[ -n "$event" ] || exit 0
event_flat=$(printf '%s' "$event" | scribe_json_flat)
reason=$(scribe_json_pick "$event_flat" '.reason')
[ "$reason" = "clear" ] && exit 0
session_id=$(scribe_json_pick "$event_flat" '.session_id')
[ -n "$session_id" ] || exit 0
sid_enc=$(printf '%s' "$session_id" | scribe_urlenc) || exit 0
curl -fsS --max-time 4 \
-H "Authorization: Bearer ${token}" \
"${url%/}/api/plugin/release-session?session_id=${sid_enc}" \
>/dev/null 2>&1 || true
exit 0
+3
View File
@@ -23,6 +23,9 @@ What only Claude Code needs said:
long session, log it to Scribe, then tell the operator it's a good moment to
`/compact` and name what you logged. You can't run it yourself; suggest it at
seams, not every turn.
- **When the operator wraps up, hand off first.** Log where each task you
worked stands before the session ends; the plugin releases your claims at
session end, but only you can say what happened (using-scribe, "Hand off").
- **Stored Processes arrive as skills** (`scribe-proc-*`), refreshed at session
start. After a Process is added or edited, `/scribe:sync` makes it available
straight away.
+10
View File
@@ -175,6 +175,16 @@ Two constraints on *how* that's achieved:
**complete** a task and when you **hit or discover a problem**, so a change
of direction is on the record and not only the successes.
**Hand off before this session's context stops existing.** A compaction, a
`/clear`, the operator wrapping up for the day — each is the last moment the
reasoning behind the work lives anywhere but here. Log on the task you were
holding where it stands, what you tried and ruled out, and the next move:
write down what the next session needs, because it arrives with Scribe's
record and nothing else. Moving a task to `in_progress` or logging on it also
claims it for this session — that claim is what hands the work back to you
after a compaction, and it ends on its own when you stop, so there is nothing
to release by hand.
6. **Fixes are issues, not work-logs.** When you fix a problem — even one solved
in passing — record it as its own issue (`create_task(kind="issue")`) with
symptom → root cause → fix, optionally linked to the task it arose from
+6
View File
@@ -395,6 +395,12 @@ SMOKE_EVENTS: dict[str, str] = {
"tool_input": {"task_id": 1, "content": "smoke"},
"tool_response": {}}
),
# The SessionEnd claim release (milestone 381 step 4). Silent: nobody reads
# a SessionEnd hook's output, and with no instance it must exit first.
"scribe_session_end.sh": json.dumps(
{"session_id": "smoke", "cwd": ".", "hook_event_name": "SessionEnd",
"reason": "prompt_input_exit"}
),
# The shared library is sourced, never run; executed bare it defines
# functions and exits — silent by construction.
"scribe_defs.sh": "",
+5
View File
@@ -483,6 +483,11 @@ async def add_task_log(task_id: int, content: str) -> dict:
cannot get from the body or the diff is what you tried, what you ruled
out, and where it actually stands.
A log on an open task also marks it as being worked by this session — its
`claim` (milestone 381). That is what brings the task and its newest
entries back to you after a compaction; it lapses by itself once the
session stops writing, and a status of done, cancelled or todo clears it.
The response shows the task's `systems` — or, if the task is an untagged
project record, the `systems_hint` question: logging work IS working in
some area, so answer it (update_task with system_ids, or create_system
+19
View File
@@ -388,6 +388,25 @@ async def claim_session():
return jsonify({"claim": claim})
@plugin_bp.get("/release-session")
@login_required
async def release_session():
"""Release the claims a session held, as it ends (milestone 381 step 4).
Called by `scribe_session_end.sh`. Best-effort by design: SessionEnd does
not fire on a crash, so the claim's lease — not this call — is what makes a
dead session's claim read as dead. This only makes the common case tidy.
Query:
session_id (str) — the ending session's id, from the hook event.
"""
session_id = (request.args.get("session_id") or "").strip()
if not session_id:
return jsonify({"error": "session_id is required"}), 400
released = await task_claims_svc.release_session(g.user.id, session_id)
return jsonify({"released": released})
@plugin_bp.get("/processes")
@login_required
async def process_manifest():
+30
View File
@@ -281,3 +281,33 @@ async def claims_for_session_start(
if len(bucket) < _LOGS_PER_TASK:
bucket.append(row)
return render_claims(source, session_id, claims, logs)
async def release_session(user_id: int, session_id: str) -> int:
"""Release every claim the caller holds under `session_id` (milestone 381 step 4).
Called by the plugin's SessionEnd hook: the session's context is about to
stop existing, so the attention its claims record is ending too. A TIDY-UP,
not the guarantee — SessionEnd does not fire on a crash, a killed terminal
or a dropped connection, and those are what the lease is for. Returns how
many were released; 0 is the ordinary answer for a session that claimed
nothing.
"""
from sqlalchemy import select
from scribe.models import async_session
from scribe.models.note import Note
session_id = (session_id or "").strip()[:200]
if not session_id:
return 0
async with async_session() as session:
held = (await session.execute(
select(Note).where(
Note.claimed_by == user_id, Note.claim_session == session_id,
)
)).scalars().all()
for note in held:
release_claim(note)
await session.commit()
return len(held)
+5
View File
@@ -155,6 +155,11 @@ TOPICS: tuple[Topic, ...] = (
Topic("a retrieved rule outranks a default habit", U, ("outranks a default habit",),
"a retrieved rule outranks a default habit"),
Topic("log on completion and on a problem", U, ("hit or discover a problem",), "hit or discover a problem"),
# Milestone 381 step 4: the half of a hand-off only the model can do. The
# release is mechanical (SessionEnd hook); saying what happened is not.
Topic("hand off before the context stops existing", U,
("hand off", "claims it for this session"),
"write down what the next session needs"),
Topic("the project's design system binds ui", U, ("resolve_design_system",),
"building ui: the project's design system binds", index=("resolve_design_system",)),
Topic("name the record, never just its number", U, ("name the record",),
+27
View File
@@ -256,3 +256,30 @@ async def test_session_start_after_a_compaction_carries_the_claimed_task(users):
owner, source="compact", session_id="sess-compact"))["context"]
assert "claimed then compacted" in ctx
assert "the migration is written" in ctx
# --- step 4: the hand-off ----------------------------------------------------
def test_session_end_releases_claims_but_not_on_clear():
"""The mechanical half of the hand-off. A /clear keeps the claim, because
SessionStart(source=clear) pushes the claimed work straight back."""
hooks = json.loads((ROOT / "plugin/hooks/hooks.json").read_text())["hooks"]
commands = [h["command"] for b in hooks.get("SessionEnd", []) for h in b["hooks"]]
assert any("scribe_session_end.sh" in c for c in commands)
text = (ROOT / "plugin/hooks/scribe_session_end.sh").read_text()
assert '[ "$reason" = "clear" ] && exit 0' in text
assert "/api/plugin/release-session" in text
@pytest.mark.integration
async def test_ending_a_session_releases_only_that_sessions_claims(users):
owner, _ = users
kept = await notes_svc.create_note(owner, title="other session's", status="in_progress")
gone = await notes_svc.create_note(owner, title="ending session's", status="in_progress")
await tc.bind_session(owner, kept.id, "sess-stays")
await tc.bind_session(owner, gone.id, "sess-ends")
assert await tc.release_session(owner, "sess-ends") >= 1
assert (await notes_svc.get_note(owner, gone.id)).claimed_at is None
assert (await notes_svc.get_note(owner, kept.id)).claim_session == "sess-stays"
assert await tc.release_session(owner, "") == 0