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
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:
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "scribe",
|
"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).",
|
"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": {
|
"author": {
|
||||||
"name": "Bryan Van Deusen"
|
"name": "Bryan Van Deusen"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -101,6 +101,16 @@
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
],
|
||||||
|
"SessionEnd": [
|
||||||
|
{
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "bash \"${CLAUDE_PLUGIN_ROOT}/hooks/scribe_session_end.sh\""
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Executable
+45
@@ -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
|
||||||
@@ -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
|
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
|
`/compact` and name what you logged. You can't run it yourself; suggest it at
|
||||||
seams, not every turn.
|
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
|
- **Stored Processes arrive as skills** (`scribe-proc-*`), refreshed at session
|
||||||
start. After a Process is added or edited, `/scribe:sync` makes it available
|
start. After a Process is added or edited, `/scribe:sync` makes it available
|
||||||
straight away.
|
straight away.
|
||||||
|
|||||||
@@ -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
|
**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.
|
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
|
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
|
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
|
symptom → root cause → fix, optionally linked to the task it arose from
|
||||||
|
|||||||
@@ -395,6 +395,12 @@ SMOKE_EVENTS: dict[str, str] = {
|
|||||||
"tool_input": {"task_id": 1, "content": "smoke"},
|
"tool_input": {"task_id": 1, "content": "smoke"},
|
||||||
"tool_response": {}}
|
"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
|
# The shared library is sourced, never run; executed bare it defines
|
||||||
# functions and exits — silent by construction.
|
# functions and exits — silent by construction.
|
||||||
"scribe_defs.sh": "",
|
"scribe_defs.sh": "",
|
||||||
|
|||||||
@@ -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
|
cannot get from the body or the diff is what you tried, what you ruled
|
||||||
out, and where it actually stands.
|
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
|
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
|
project record, the `systems_hint` question: logging work IS working in
|
||||||
some area, so answer it (update_task with system_ids, or create_system
|
some area, so answer it (update_task with system_ids, or create_system
|
||||||
|
|||||||
@@ -388,6 +388,25 @@ async def claim_session():
|
|||||||
return jsonify({"claim": claim})
|
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")
|
@plugin_bp.get("/processes")
|
||||||
@login_required
|
@login_required
|
||||||
async def process_manifest():
|
async def process_manifest():
|
||||||
|
|||||||
@@ -281,3 +281,33 @@ async def claims_for_session_start(
|
|||||||
if len(bucket) < _LOGS_PER_TASK:
|
if len(bucket) < _LOGS_PER_TASK:
|
||||||
bucket.append(row)
|
bucket.append(row)
|
||||||
return render_claims(source, session_id, claims, logs)
|
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)
|
||||||
|
|||||||
@@ -155,6 +155,11 @@ TOPICS: tuple[Topic, ...] = (
|
|||||||
Topic("a retrieved rule outranks a default habit", U, ("outranks a default habit",),
|
Topic("a retrieved rule outranks a default habit", U, ("outranks a default habit",),
|
||||||
"a retrieved rule 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"),
|
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",),
|
Topic("the project's design system binds ui", U, ("resolve_design_system",),
|
||||||
"building ui: the project's design system binds", index=("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",),
|
Topic("name the record, never just its number", U, ("name the record",),
|
||||||
|
|||||||
@@ -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"]
|
owner, source="compact", session_id="sess-compact"))["context"]
|
||||||
assert "claimed then compacted" in ctx
|
assert "claimed then compacted" in ctx
|
||||||
assert "the migration is written" 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
|
||||||
|
|||||||
Reference in New Issue
Block a user