diff --git a/plugin/.claude-plugin/plugin.json b/plugin/.claude-plugin/plugin.json index 2407298..86ab450 100644 --- a/plugin/.claude-plugin/plugin.json +++ b/plugin/.claude-plugin/plugin.json @@ -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" }, diff --git a/plugin/hooks/hooks.json b/plugin/hooks/hooks.json index a238000..5c6b7ef 100644 --- a/plugin/hooks/hooks.json +++ b/plugin/hooks/hooks.json @@ -101,6 +101,16 @@ } ] } + ], + "SessionEnd": [ + { + "hooks": [ + { + "type": "command", + "command": "bash \"${CLAUDE_PLUGIN_ROOT}/hooks/scribe_session_end.sh\"" + } + ] + } ] } } diff --git a/plugin/hooks/scribe_session_end.sh b/plugin/hooks/scribe_session_end.sh new file mode 100755 index 0000000..db21695 --- /dev/null +++ b/plugin/hooks/scribe_session_end.sh @@ -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 diff --git a/plugin/hooks/scribe_static_context.md b/plugin/hooks/scribe_static_context.md index d20ddde..062af16 100644 --- a/plugin/hooks/scribe_static_context.md +++ b/plugin/hooks/scribe_static_context.md @@ -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. diff --git a/plugin/skills/using-scribe/SKILL.md b/plugin/skills/using-scribe/SKILL.md index 984a30e..da75f16 100644 --- a/plugin/skills/using-scribe/SKILL.md +++ b/plugin/skills/using-scribe/SKILL.md @@ -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 diff --git a/scripts/check_plugin.py b/scripts/check_plugin.py index cb7fba4..33bac05 100755 --- a/scripts/check_plugin.py +++ b/scripts/check_plugin.py @@ -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": "", diff --git a/src/scribe/mcp/tools/tasks.py b/src/scribe/mcp/tools/tasks.py index 5eebc4a..98dd9ee 100644 --- a/src/scribe/mcp/tools/tasks.py +++ b/src/scribe/mcp/tools/tasks.py @@ -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 diff --git a/src/scribe/routes/plugin.py b/src/scribe/routes/plugin.py index e2791c0..a9cd7de 100644 --- a/src/scribe/routes/plugin.py +++ b/src/scribe/routes/plugin.py @@ -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(): diff --git a/src/scribe/services/task_claims.py b/src/scribe/services/task_claims.py index 8a6af89..5124295 100644 --- a/src/scribe/services/task_claims.py +++ b/src/scribe/services/task_claims.py @@ -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) diff --git a/tests/test_guidance_ownership.py b/tests/test_guidance_ownership.py index 99241be..db86c97 100644 --- a/tests/test_guidance_ownership.py +++ b/tests/test_guidance_ownership.py @@ -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",), diff --git a/tests/test_task_claims.py b/tests/test_task_claims.py index 86254e8..bbb7426 100644 --- a/tests/test_task_claims.py +++ b/tests/test_task_claims.py @@ -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