Three threads, all reaching a session only from main.
#4085 — a directory says which project it belongs to, git repo or not
All six hooks scoped their requests one way: git remote get-url origin. That key does not exist outside a git repo, so a session in a plain directory was unscoped in every hook at once — no project context, no prior-art scoping, no project rules — and silently, because a missing remote is indistinguishable from a remote nobody bound.
A .scribe file is the second key, read by the shared scribe_scope_query so a directory scopes the same way everywhere:
instance is why the file is not just a number: an id is a different project on every Scribe, so a marker that travels — a copied directory, a shared machine, a repo someone else clones — would otherwise scope the session to the wrong project without a word. Compared host-only; a mismatch drops the id, because no project beats the wrong project. A bare integer is accepted too, since that is what a person writes by hand. The marker beats a git remote, which is also how a directory overrides its binding.
Two bugs found on the way:
An explicit project_id that did not resolve rendered no message at all — the branch hung off if project_id: as an elif. Harmless while that arg was only used for curl testing; the first thing a marker would hit.
The refusal reason was a global set inside a function every caller reads through $( ). The assignment died with the subshell, leaving the caller to read an unset variable under set -u — which aborts the hook and costs the session its whole SessionStart context, to fetch a warning about a file.
#3680 — a PreCompact hook tells the summarizer what must survive
Read out of the installed Claude Code build (2.1.273), not the docs, which describe a different mechanism. hookSpecificOutput.additionalContext is never read on PreCompact — the schema has no variant for it. But a PreCompact hook's stdout becomes the compaction's custom instructions, merged into the prompt that writes the summary, on every path: manual, auto and partial.
So the hook does not interrupt the compaction — it steers the summary, which is what the next turn reads. Blocking is the thing not to do: it skips compaction, tells the model nothing, and leaves the session running on uncompacted with no summary at all.
scribe_precompact_preserve.sh names what the summary is the only copy of: Scribe record ids with titles, the in-progress task and its milestone, work done but not yet recorded, governing rules, and unfinished operator asks. No network, no config, no block on any path.
#4061 — list tools return rows that say what a record is, not what it says
list_tasks was overflowing the client at 93k characters, because listing rows carried full bodies. Rows now say what a record is and where it sits.
Verification
CI run 6923 on dd08f98: Python lint, TypeScript typecheck, Python tests, Plugin hooks and integration all green.
Not verified live, and cannot be until this merges: the plugin ships from main, so the marker and the compaction hook both need a /plugin update before a real session exercises them.
Three threads, all reaching a session only from `main`.
## #4085 — a directory says which project it belongs to, git repo or not
All six hooks scoped their requests one way: `git remote get-url origin`. That key does not exist outside a git repo, so a session in a plain directory was unscoped in **every hook at once** — no project context, no prior-art scoping, no project rules — and silently, because a missing remote is indistinguishable from a remote nobody bound.
A `.scribe` file is the second key, read by the shared `scribe_scope_query` so a directory scopes the same way everywhere:
```json
{"instance": "https://scribe.example.com", "project_id": 2, "project": "FabledScribe"}
```
`instance` is why the file is not just a number: an id is a different project on every Scribe, so a marker that travels — a copied directory, a shared machine, a repo someone else clones — would otherwise scope the session to the wrong project without a word. Compared host-only; a mismatch drops the id, because no project beats the wrong project. A bare integer is accepted too, since that is what a person writes by hand. The marker beats a git remote, which is also how a directory overrides its binding.
Two bugs found on the way:
- An explicit `project_id` that did not resolve rendered **no message at all** — the branch hung off `if project_id:` as an `elif`. Harmless while that arg was only used for curl testing; the first thing a marker would hit.
- The refusal reason was a global set inside a function every caller reads through `$( )`. The assignment died with the subshell, leaving the caller to read an unset variable under `set -u` — which aborts the hook and costs the session its whole SessionStart context, to fetch a warning about a file.
## #3680 — a PreCompact hook tells the summarizer what must survive
Read out of the installed Claude Code build (2.1.273), not the docs, which describe a different mechanism. `hookSpecificOutput.additionalContext` is **never read** on PreCompact — the schema has no variant for it. But a PreCompact hook's **stdout becomes the compaction's custom instructions**, merged into the prompt that writes the summary, on every path: manual, auto and partial.
So the hook does not interrupt the compaction — it steers the summary, which is what the next turn reads. Blocking is the thing not to do: it skips compaction, tells the model nothing, and leaves the session running on uncompacted with no summary at all.
`scribe_precompact_preserve.sh` names what the summary is the only copy of: Scribe record ids with titles, the in-progress task and its milestone, work done but not yet recorded, governing rules, and unfinished operator asks. No network, no config, no block on any path.
## #4061 — list tools return rows that say what a record is, not what it says
`list_tasks` was overflowing the client at 93k characters, because listing rows carried full bodies. Rows now say what a record is and where it sits.
## Verification
CI run 6923 on `dd08f98`: Python lint, TypeScript typecheck, Python tests, Plugin hooks and integration all green.
Not verified live, and cannot be until this merges: the plugin ships from `main`, so the marker and the compaction hook both need a `/plugin` update before a real session exercises them.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
https://claude.ai/code/session_01821k5B3Ysecp9fNYs92Kuy
list_tasks returned every row's to_dict(), body included: a project's todo list
came to 93-165k characters, past what an MCP client accepts inline, so the list
arrived as a file to page through (the #4045 failure, one call over).
- notes.brief_row: id, title, type, project, tags, updated_at; for tasks,
status, kind, priority, milestone id and title; description, parent and due
date only when set.
- milestones.titles_for: one query for the milestone titles a page of rows
names.
- Brief rows on list_tasks, list_notes, get_milestone's steps, get_system and
list_system_records. get_task / get_note / get_snippet read a record in full,
and each docstring says so.
- tests/test_list_rows_brief.py pins the ceiling: 100 rows of ~5k-character
step plans stay under 40k characters.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01821k5B3Ysecp9fNYs92Kuy
Spike #3680 asked whether a PreCompact hook can reach the model. Read out of
the installed Claude Code build (2.1.273), the answer is yes — through a
different channel than note #3679 assumed:
* `hookSpecificOutput.additionalContext` is NEVER read on PreCompact. The
hook-output schema has no PreCompact variant; the field is honoured for
SessionStart, SubagentStart and Stop, and silently dropped here.
* A PreCompact hook's STDOUT becomes `newCustomInstructions`, merged with
the operator's own `/compact` instructions and passed into the prompt that
writes the summary. Manual, auto and partial compaction all do this.
So the hook does not interrupt the compaction — it steers the summary, which
is what the next turn reads. Blocking is the thing not to do: a PreCompact
block SKIPS compaction, tells the model nothing, and leaves the session
running on uncompacted with no summary at all.
scribe_precompact_preserve.sh names what the summary is the only copy of:
Scribe record ids WITH titles, the in-progress task and its milestone, work
done but not yet recorded, governing rules, and unfinished operator asks.
No network and no config — what must survive is already in the conversation
being summarized; the hook only says which parts are load-bearing.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01821k5B3Ysecp9fNYs92Kuy
The hook's header quotes `{"decision":"block"}` while explaining why this hook
must never emit one, and the instructions it prints use "decision" in a
sentence. Grepping the whole file caught both and failed the guard on the file
doing its job.
Strips comments and the heredoc first, and guards the stripper: `_code()`
returning nothing would make all three static checks pass against an empty
string, which is the circularity rule 167 is about.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01821k5B3Ysecp9fNYs92Kuy
All six hooks scoped their requests one way: `git remote get-url origin`,
resolved server-side through the repo bindings. That key does not exist
outside a git repo, so a session in a plain directory was unscoped in every
hook at once — no project context, no prior-art scoping, no project rules —
and silently, because a missing remote is indistinguishable from a remote
nobody bound.
A `.scribe` file is the second key, read by the shared scribe_scope_query so a
directory scopes the same way everywhere:
{"instance": "https://scribe.example.com", "project_id": 2, "project": "…"}
`instance` is why the file is not just a number: an id is a different project
on every Scribe, so a marker that travels — a copied directory, a shared
machine, a repo someone else clones — would otherwise scope the session to the
wrong project without a word. Compared host-only, and a mismatch drops the id:
no project beats the wrong project. A bare integer is accepted too, since it
is what a person writes by hand. The marker beats a git remote — someone put
the file there on purpose — which is also how a directory overrides its
binding.
Two things it found on the way:
* An explicit project_id that did not resolve rendered NO message at all —
the branch hung off `if project_id` as an `elif`, so a caller holding a
pointer it believed in got a context that silently omitted the project it
had asked for. Now reported.
* The refusal reason was a global set inside a function every caller reads
through `$( )`. The assignment died with the subshell, leaving the caller
to read an unset variable under `set -u` — which aborts the hook and costs
the whole session's SessionStart context, to fetch a warning about a file.
It comes back through stdout with the id instead, and a test pins it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01821k5B3Ysecp9fNYs92Kuy
Two interface-contract tests read each hook's own source for the query args it
sends. The project-scope key now comes from scribe_scope_query, so `repo=` was
no longer spelled in the hook and both read that as the hook having stopped
sending it.
They now pin the pair: the hook delegates to the helper, and the helper emits
exactly `repo=` and `project_id=`. That is a stronger assertion than the one it
replaces — it catches a hook rolling its own scope resolution again, and it
pins the new key, which the route must read for a non-git session to reach its
project at all.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01821k5B3Ysecp9fNYs92Kuy
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Three threads, all reaching a session only from
main.#4085 — a directory says which project it belongs to, git repo or not
All six hooks scoped their requests one way:
git remote get-url origin. That key does not exist outside a git repo, so a session in a plain directory was unscoped in every hook at once — no project context, no prior-art scoping, no project rules — and silently, because a missing remote is indistinguishable from a remote nobody bound.A
.scribefile is the second key, read by the sharedscribe_scope_queryso a directory scopes the same way everywhere:instanceis why the file is not just a number: an id is a different project on every Scribe, so a marker that travels — a copied directory, a shared machine, a repo someone else clones — would otherwise scope the session to the wrong project without a word. Compared host-only; a mismatch drops the id, because no project beats the wrong project. A bare integer is accepted too, since that is what a person writes by hand. The marker beats a git remote, which is also how a directory overrides its binding.Two bugs found on the way:
project_idthat did not resolve rendered no message at all — the branch hung offif project_id:as anelif. Harmless while that arg was only used for curl testing; the first thing a marker would hit.$( ). The assignment died with the subshell, leaving the caller to read an unset variable underset -u— which aborts the hook and costs the session its whole SessionStart context, to fetch a warning about a file.#3680 — a PreCompact hook tells the summarizer what must survive
Read out of the installed Claude Code build (2.1.273), not the docs, which describe a different mechanism.
hookSpecificOutput.additionalContextis never read on PreCompact — the schema has no variant for it. But a PreCompact hook's stdout becomes the compaction's custom instructions, merged into the prompt that writes the summary, on every path: manual, auto and partial.So the hook does not interrupt the compaction — it steers the summary, which is what the next turn reads. Blocking is the thing not to do: it skips compaction, tells the model nothing, and leaves the session running on uncompacted with no summary at all.
scribe_precompact_preserve.shnames what the summary is the only copy of: Scribe record ids with titles, the in-progress task and its milestone, work done but not yet recorded, governing rules, and unfinished operator asks. No network, no config, no block on any path.#4061 — list tools return rows that say what a record is, not what it says
list_taskswas overflowing the client at 93k characters, because listing rows carried full bodies. Rows now say what a record is and where it sits.Verification
CI run 6923 on
dd08f98: Python lint, TypeScript typecheck, Python tests, Plugin hooks and integration all green.Not verified live, and cannot be until this merges: the plugin ships from
main, so the marker and the compaction hook both need a/pluginupdate before a real session exercises them.🤖 Generated with Claude Code
https://claude.ai/code/session_01821k5B3Ysecp9fNYs92Kuy
The hook's header quotes `{"decision":"block"}` while explaining why this hook must never emit one, and the instructions it prints use "decision" in a sentence. Grepping the whole file caught both and failed the guard on the file doing its job. Strips comments and the heredoc first, and guards the stripper: `_code()` returning nothing would make all three static checks pass against an empty string, which is the circularity rule 167 is about. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01821k5B3Ysecp9fNYs92KuyAll six hooks scoped their requests one way: `git remote get-url origin`, resolved server-side through the repo bindings. That key does not exist outside a git repo, so a session in a plain directory was unscoped in every hook at once — no project context, no prior-art scoping, no project rules — and silently, because a missing remote is indistinguishable from a remote nobody bound. A `.scribe` file is the second key, read by the shared scribe_scope_query so a directory scopes the same way everywhere: {"instance": "https://scribe.example.com", "project_id": 2, "project": "…"} `instance` is why the file is not just a number: an id is a different project on every Scribe, so a marker that travels — a copied directory, a shared machine, a repo someone else clones — would otherwise scope the session to the wrong project without a word. Compared host-only, and a mismatch drops the id: no project beats the wrong project. A bare integer is accepted too, since it is what a person writes by hand. The marker beats a git remote — someone put the file there on purpose — which is also how a directory overrides its binding. Two things it found on the way: * An explicit project_id that did not resolve rendered NO message at all — the branch hung off `if project_id` as an `elif`, so a caller holding a pointer it believed in got a context that silently omitted the project it had asked for. Now reported. * The refusal reason was a global set inside a function every caller reads through `$( )`. The assignment died with the subshell, leaving the caller to read an unset variable under `set -u` — which aborts the hook and costs the whole session's SessionStart context, to fetch a warning about a file. It comes back through stdout with the id instead, and a test pins it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01821k5B3Ysecp9fNYs92Kuy