A directory names its project; a compaction is told what to keep; list rows say what a record is #161

Merged
bvandeusen merged 5 commits from dev into main 2026-09-16 08:57:46 -04:00
Owner

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": "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.ai/code/session_01821k5B3Ysecp9fNYs92Kuy

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
bvandeusen added 5 commits 2026-09-16 08:57:40 -04:00
fix(mcp): list tools return rows that say what a record is, not what it says (#4061)
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 8s
CI & Build / integration (push) Successful in 49s
CI & Build / TypeScript typecheck (push) Successful in 53s
CI & Build / Python tests (push) Successful in 1m31s
CI & Build / Build & push image (push) Successful in 22s
5e4fd017ae
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
feat(plugin): a PreCompact hook tells the summarizer what must survive (#3680)
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 9s
CI & Build / integration (push) Successful in 45s
CI & Build / TypeScript typecheck (push) Successful in 53s
CI & Build / Python tests (push) Failing after 1m0s
CI & Build / Build & push image (push) Skipped
4b8d22e3ec
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
fix(tests): the no-block guard reads the shell, not the comment explaining it (#3680)
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 9s
CI & Build / integration (push) Successful in 42s
CI & Build / TypeScript typecheck (push) Successful in 54s
CI & Build / Python tests (push) Successful in 1m33s
CI & Build / Build & push image (push) Successful in 19s
47388eda36
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
feat(plugin): a directory says which project it belongs to, git repo or not (#4085)
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 10s
CI & Build / integration (push) Successful in 47s
CI & Build / TypeScript typecheck (push) Successful in 53s
CI & Build / Python tests (push) Failing after 1m1s
CI & Build / Build & push image (push) Skipped
aa94c73d9e
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
fix(tests): the rule-33 contract follows the scope key into the shared helper (#4085)
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 9s
CI & Build / integration (push) Successful in 43s
CI & Build / TypeScript typecheck (push) Successful in 52s
CI & Build / Python tests (push) Successful in 1m29s
CI & Build / Build & push image (push) Successful in 21s
dd08f9858c
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
bvandeusen merged commit 8f3f36e6fc into main 2026-09-16 08:57:46 -04:00
Sign in to join this conversation.
No Reviewers
No labels
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: bvandeusen/FabledScribe#161