diff --git a/frontend/src/types/note.ts b/frontend/src/types/note.ts index 2850ea3..5bcc33b 100644 --- a/frontend/src/types/note.ts +++ b/frontend/src/types/note.ts @@ -29,6 +29,10 @@ export interface Note { due_date: string | null; started_at: string | null; completed_at: string | null; + // Which session is working this task now (milestone 381). Null when no + // session ever claimed it; `live` false when the claim's lease ran out — + // a session that went quiet mid-task, which is worth seeing, not hiding. + claim?: TaskClaim | null; recurrence_rule: Record | null; recurrence_next_spawn_at: string | null; is_task: boolean; @@ -53,3 +57,11 @@ export interface NoteListResponse { notes: Note[]; total: number; } + +export interface TaskClaim { + held_by: number | null; + session: string | null; + since: string | null; + touched: string | null; + live: boolean; +} diff --git a/frontend/src/views/TaskEditorView.vue b/frontend/src/views/TaskEditorView.vue index 0f0ca64..c984a73 100644 --- a/frontend/src/views/TaskEditorView.vue +++ b/frontend/src/views/TaskEditorView.vue @@ -15,7 +15,8 @@ import type { TaskStatus, TaskPriority } from "@/types/task"; import type { TaskKind } from "@/types/note"; import { useSystemsStore } from "@/stores/systems"; import type { System } from "@/api/systems"; -import type { Note } from "@/types/note"; +import type { Note, TaskClaim } from "@/types/note"; +import { relativeTime } from "@/composables/useRelativeTime"; import type { Editor } from "@tiptap/vue-3"; import MarkdownToolbar from "@/components/MarkdownToolbar.vue"; import TiptapEditor from "@/components/TiptapEditor.vue"; @@ -54,6 +55,7 @@ const parentId = ref(null); const parentTitle = ref(""); const startedAt = ref(null); const completedAt = ref(null); +const claim = ref(null); const recurrenceRule = ref | null>(null); const parentSearchQuery = ref(""); const parentSearchResults = ref<{ id: number; title: string }[]>([]); @@ -318,6 +320,7 @@ onMounted(async () => { const noteTask = store.currentTask as unknown as Note; startedAt.value = noteTask.started_at ?? null; completedAt.value = noteTask.completed_at ?? null; + claim.value = noteTask.claim ?? null; recurrenceRule.value = noteTask.recurrence_rule ?? null; savedTitle = title.value; savedBody = body.value; @@ -592,7 +595,21 @@ useEditorGuards(dirty, save); -
+
+
+ Being worked + + by a session{{ claim.session ? ` (${claim.session.slice(0, 8)})` : "" }}, + last active {{ claim.touched ? relativeTime(claim.touched) : "recently" }} + +
+
+ Went quiet + + the session working this stopped + {{ claim.touched ? relativeTime(claim.touched) : "" }} without finishing it + +
Started {{ new Date(startedAt).toLocaleString() }} diff --git a/plugin/.claude-plugin/plugin.json b/plugin/.claude-plugin/plugin.json index 3f998a4..2407298 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.23.2309", + "version": "2026.09.24.1041", "author": { "name": "Bryan Van Deusen" }, diff --git a/plugin/hooks/scribe_session_context.sh b/plugin/hooks/scribe_session_context.sh index d269c4d..d672e31 100755 --- a/plugin/hooks/scribe_session_context.sh +++ b/plugin/hooks/scribe_session_context.sh @@ -187,8 +187,14 @@ if [ -n "$url" ] && [ -n "$token" ] && command -v curl >/dev/null 2>&1; then marker_why=${marker_read#*$'\t'} repo=$(git -C "$repo_dir" remote get-url origin 2>/dev/null || true) scope=$(scribe_scope_query "$repo_dir") - q="" - [ -n "$scope" ] && q="?${scope}" + # The source and the session id decide what the claim section says + # (milestone 381): a compaction gets back the work this session had claimed, + # with its latest logs; a new session hears about other sessions' claims. + sid_now=$(scribe_json_pick "$event_flat" '.session_id') + q="source=$(printf '%s' "$source" | scribe_urlenc)" + [ -n "$sid_now" ] && q="${q}&session_id=$(printf '%s' "$sid_now" | scribe_urlenc)" + [ -n "$scope" ] && q="${q}&${scope}" + q="?${q}" # ONE FETCH, NAMED WHEN IT FAILS (#4366). This used to be `curl -f … || # body=""`, which folded a timeout, an HTTP error and a refused key into one # sentence — so a session that started blind could not say why, and neither diff --git a/src/scribe/routes/plugin.py b/src/scribe/routes/plugin.py index ef8aff2..e2791c0 100644 --- a/src/scribe/routes/plugin.py +++ b/src/scribe/routes/plugin.py @@ -70,10 +70,16 @@ async def session_context(): send it when a `.scribe` marker file names a project. Takes precedence over `repo`. Access-checked like any other read — an id this account cannot read loads no project rather than failing. + source (optional str) — the host's SessionStart source (startup, + resume, compact, clear, fork); decides what the claim section says. + session_id (optional str) — the session's id, so a claim bound to it + reads as this session's own (milestone 381). """ project_id, _repo, unbound_repo = await _project_scope() result = await plugin_ctx_svc.build_session_context( - g.user.id, project_id, unbound_repo=unbound_repo + g.user.id, project_id, unbound_repo=unbound_repo, + source=(request.args.get("source") or "").strip()[:20], + session_id=(request.args.get("session_id") or "").strip()[:200], ) return jsonify(result) diff --git a/src/scribe/services/plugin_context.py b/src/scribe/services/plugin_context.py index cda7e84..dccf733 100644 --- a/src/scribe/services/plugin_context.py +++ b/src/scribe/services/plugin_context.py @@ -26,6 +26,7 @@ from scribe.services import notes as notes_svc from scribe.services import projects as projects_svc from scribe.services import shape_ledger as shape_ledger_svc from scribe.services import snippets as snippets_svc +from scribe.services import task_claims as task_claims_svc from scribe.services.access import label_shared_items, owner_names_for from scribe.services.embeddings import ( document_title, @@ -3055,7 +3056,8 @@ def _goal_line(goal: str, project_id: int) -> str: async def build_session_context( - user_id: int, project_id: int = 0, unbound_repo: str = "" + user_id: int, project_id: int = 0, unbound_repo: str = "", + source: str = "", session_id: str = "", ) -> dict: """Render the SessionStart context for a user, optionally project-scoped. @@ -3069,6 +3071,12 @@ async def build_session_context( unbound_repo: when the hook sent a repo remote that maps to no project, its normalized key — triggers a one-line "bind this repo" hint so the binding is self-healing. + source / session_id: the host's SessionStart `source` and the + session's id, when the adapter sends them. They decide what the + claim section says (milestone 381 step 3, `task_claims. + render_claims`): a compaction gets back the work it had claimed + with its latest logs; a new session hears about other sessions' + live and abandoned claims; a resume hears nothing. Returns {"context": str, "project": dict | None}. @@ -3135,6 +3143,17 @@ async def build_session_context( f"`get_design_system({design['id']})` → " f"`resolved_guidance`.", ] + # The claim section goes after the project block and before any "nothing + # loaded" note: claimed work is the most specific thing this session can be + # told, and it is true whether or not a project resolved. Best-effort — a + # session start never fails on it. + try: + lines += await task_claims_svc.claims_for_session_start( + user_id, project_dict["id"] if project_dict else 0, source, session_id, + ) + except Exception: # noqa: BLE001 - context is best-effort + logger.warning("claim section skipped", exc_info=True) + # Nothing loaded — say which nothing (#4085). This used to hang off the # `if project_id:` above as an `elif`, which meant an id that was SENT and # did not resolve produced no message at all: the outer branch was taken, diff --git a/src/scribe/services/task_claims.py b/src/scribe/services/task_claims.py index b8044bd..8a6af89 100644 --- a/src/scribe/services/task_claims.py +++ b/src/scribe/services/task_claims.py @@ -123,3 +123,161 @@ async def bind_session(user_id: int, task_id: int, session_id: str) -> dict | No note.claim_touched_at = now await session.commit() return claim_state(note, now) + + +# --- The readers (milestone 381 step 3) --------------------------------------- +# +# A claim nobody reads is the state before this milestone. SessionStart is the +# reader that pays rent to the session that set it, and it branches on the +# `source` the host sends, because the same claim means different things +# depending on what just happened to the context: +# +# compact the context was summarised away and the claim is certainly ours. +# Push the claimed work AND its latest log entries — the state a +# compaction destroys, which the record already holds. A count of +# open tasks cannot answer "where were we". +# clear the context was wiped, so the same push; and other sessions' claims +# are worth knowing about, as on a startup. +# startup a new session. Claims held by OTHER sessions are the news: live +# ones may be running right now, dead ones were abandoned mid-task. +# fork the session carries a conversation that held claims under another +# id. Two sessions now believe they hold the same work, so the +# live claims are named as possibly-the-parent's, with what a write +# does about it. +# resume the context was restored intact. Say nothing. + +# What a session is told, per source. Pure data so the branch is one lookup. +_PUSH_OWN = {"compact", "clear"} +_NAME_OTHERS = {"startup", "clear", "fork"} + +# Caps: a session-start block is read by every session, so it is sized for the +# few claims that matter rather than for the worst case. +_OWN_CAP = 5 +_OTHERS_CAP = 5 +_LOGS_PER_TASK = 2 +_LOG_CHARS = 600 + + +def _age(when: datetime | None, now: datetime) -> str: + if when is None: + return "at an unknown time" + secs = max(0, int((now - when).total_seconds())) + if secs < 90: + return "just now" + if secs < 5400: + return f"{secs // 60}m ago" + if secs < 2 * 86400: + return f"{secs // 3600}h ago" + return f"{secs // 86400}d ago" + + +def render_claims( + source: str, + session_id: str, + claims: list, + logs: dict[int, list], + now: datetime | None = None, +) -> list[str]: + """The claim section of the SessionStart context, as markdown lines. + + `claims` are the caller's claimed tasks (objects with id, title, status and + the claim columns); `logs` maps a task id to its newest log entries + (objects with `created_at` and `content`), newest first. Empty when there is + nothing this source should say — silence is the right answer on a resume, + and on any start with no claims. + """ + from scribe.services.text import elide + + now = now or _now() + source = (source or "").strip() + session_id = (session_id or "").strip() + ours = [c for c in claims if claim_is_live(c, now) + and (c.claim_session == session_id or c.claim_session is None)] + others_live = [c for c in claims if claim_is_live(c, now) + and c.claim_session not in (None, session_id)] + abandoned = [c for c in claims if not claim_is_live(c, now) + and c.status == "in_progress"] + + lines: list[str] = [] + if source in _PUSH_OWN and session_id and ours: + lines += [ + "", + "## In flight — the work this session had claimed", + "Scribe's record of what you were doing before the context was " + "lost. Carry on from here; the full log is `get_task(id)`.", + ] + for c in ours[:_OWN_CAP]: + lines.append( + f"- #{c.id} \"{c.title}\" ({c.status}) — claimed " + f"{_age(c.claimed_at, now)}, last touched {_age(c.claim_touched_at, now)}" + ) + for entry in logs.get(c.id, [])[:_LOGS_PER_TASK]: + text, _ = elide(" ".join((entry.content or "").split()), _LOG_CHARS) + lines.append(f" - log {_age(entry.created_at, now)}: {text}") + if source in _NAME_OTHERS and (others_live or abandoned): + lines += ["", "## Work other sessions were doing"] + if source == "fork": + lines.append( + "This session was forked, so a live claim below may be the " + "session you were forked from — two sessions now think they " + "hold it. Your next log or status change on a task moves its " + "claim here; leave it alone if the other session is still on it." + ) + for c in others_live[:_OTHERS_CAP]: + lines.append( + f"- #{c.id} \"{c.title}\" — claimed by another session, last " + f"touched {_age(c.claim_touched_at, now)}. It may still be " + "running; check before working the same task." + ) + for c in abandoned[:_OTHERS_CAP]: + lines.append( + f"- #{c.id} \"{c.title}\" — in progress, but the session " + f"working it went quiet {_age(c.claim_touched_at, now)} without " + "finishing. Read its log and continue it, or set it back to todo." + ) + return lines + + +async def claims_for_session_start( + user_id: int, project_id: int, source: str, session_id: str, +) -> list[str]: + """Load the caller's claims (in the active project, when one resolved) and + their newest logs, and render them for this `source`. + + "The caller's claims" is `claimed_by == user_id` — a statement about whose + attention a claim records, not an access filter: a claim is only ever + stamped by a write the caller was already allowed to make. + """ + from sqlalchemy import select + + from scribe.models import async_session + from scribe.models.note import Note + from scribe.models.task_log import TaskLog + + # No source means the caller did not ask — another client, or an adapter + # older than this section — and a resume restored everything already. + if (source or "") in ("", "resume"): + return [] + async with async_session() as session: + q = select(Note).where( + Note.claimed_by == user_id, + Note.claimed_at.is_not(None), + Note.deleted_at.is_(None), + ) + if project_id: + q = q.where(Note.project_id == project_id) + claims = list((await session.execute( + q.order_by(Note.claim_touched_at.desc()).limit(_OWN_CAP + 2 * _OTHERS_CAP) + )).scalars().all()) + logs: dict[int, list] = {} + if claims: + rows = (await session.execute( + select(TaskLog) + .where(TaskLog.task_id.in_([c.id for c in claims])) + .order_by(TaskLog.created_at.desc()) + )).scalars().all() + for row in rows: + bucket = logs.setdefault(row.task_id, []) + if len(bucket) < _LOGS_PER_TASK: + bucket.append(row) + return render_claims(source, session_id, claims, logs) diff --git a/tests/test_task_claims.py b/tests/test_task_claims.py index 392d7c8..86254e8 100644 --- a/tests/test_task_claims.py +++ b/tests/test_task_claims.py @@ -168,3 +168,91 @@ async def test_a_closed_task_binds_nothing(users): task = await notes_svc.create_note(owner, title="claim closed", status="in_progress") await notes_svc.update_note(owner, task.id, status="done") assert await tc.bind_session(owner, task.id, "sess") is None + + +# --- step 3: the readers ---------------------------------------------------- + +from types import SimpleNamespace # noqa: E402 + +_NOW = datetime(2026, 9, 24, 12, 0, tzinfo=timezone.utc) + + +def _claimed(id, session, touched_ago, status="in_progress", title=None): + t = _NOW - touched_ago + return SimpleNamespace( + id=id, title=title or f"task {id}", status=status, + claimed_by=1, claimed_at=t, claim_touched_at=t, claim_session=session, + ) + + +def _log(content, ago=timedelta(minutes=5)): + return SimpleNamespace(content=content, created_at=_NOW - ago) + + +def _render(source, claims, logs=None, sid="me"): + return "\n".join(tc.render_claims(source, sid, claims, logs or {}, now=_NOW)) + + +def test_a_compaction_gets_back_its_own_claimed_work_and_latest_logs(): + """The measurement the milestone names: a compacted session comes back + holding its own state, without being told to go looking.""" + mine = _claimed(10, "me", timedelta(minutes=3), title="wire the reader") + out = _render("compact", [mine], {10: [_log("ruled out the cache theory")]}) + assert "#10" in out and "wire the reader" in out + assert "ruled out the cache theory" in out + + +def test_a_resume_says_nothing(): + mine = _claimed(10, "me", timedelta(minutes=3)) + assert _render("resume", [mine]) == "" + + +def test_a_startup_names_other_sessions_live_and_abandoned_claims(): + live = _claimed(11, "other", timedelta(minutes=10)) + gone = _claimed(12, "older", timedelta(days=3)) + out = _render("startup", [live, gone]) + assert "#11" in out and "may still be running" in out + assert "#12" in out and "went quiet" in out + + +def test_a_startup_does_not_push_this_sessions_own_work(): + """A new session id owns nothing yet; the own-work push is for a context + that was lost, not one that never existed.""" + mine = _claimed(10, "me", timedelta(minutes=3)) + assert "In flight" not in _render("startup", [mine]) + + +def test_a_fork_is_told_two_sessions_may_hold_the_same_claim(): + parent = _claimed(13, "parent", timedelta(minutes=2)) + out = _render("fork", [parent], sid="child") + assert "forked" in out and "#13" in out + + +def test_a_dead_claim_on_finished_work_is_not_news(): + done = _claimed(14, "older", timedelta(days=3), status="done") + assert _render("startup", [done]) == "" + + +def test_the_session_start_hook_sends_the_source_and_the_session(): + """The reader branches on what the hook sends; a hook that stopped sending + either would leave every session on the no-claims path, silently.""" + text = (ROOT / "plugin/hooks/scribe_session_context.sh").read_text() + assert re.search(r'q="source=\$\(printf', text) + assert "session_id=$(printf" in text + + +@pytest.mark.integration +async def test_session_start_after_a_compaction_carries_the_claimed_task(users): + from scribe.services import task_logs + from scribe.services.plugin_context import build_session_context + + owner, _ = users + task = await notes_svc.create_note(owner, title="claimed then compacted", + status="in_progress") + await task_logs.create_log(owner, task.id, "halfway: the migration is written") + await tc.bind_session(owner, task.id, "sess-compact") + + ctx = (await build_session_context( + owner, source="compact", session_id="sess-compact"))["context"] + assert "claimed then compacted" in ctx + assert "the migration is written" in ctx