diff --git a/docs/development.md b/docs/development.md
index 5777d238..fd11a0a1 100644
--- a/docs/development.md
+++ b/docs/development.md
@@ -49,7 +49,7 @@ The hooks must run on a bare machine: POSIX shell, awk, sed and the usual coreut
## CI and releases
-CI runs on Forgejo Actions in the shared `ci-python:3.14` image ([`ci-requirements.md`](../ci-requirements.md) lists what it relies on). Six jobs: TypeScript typecheck, plugin hooks, Python lint (plus design-token and dangling-style checks), unit tests, integration tests, and the image build, which waits on all of them except the plugin job.
+CI runs on Gitea Actions (workflow in `.forgejo/workflows/ci.yml`) in the shared `ci-python:3.14` image ([`ci-requirements.md`](../ci-requirements.md) lists what it relies on). Six jobs: TypeScript typecheck, plugin hooks, Python lint (plus design-token and dangling-style checks), unit tests, integration tests, and the image build, which waits on all of them except the plugin job.
| Push | Image tags |
|------|-----------|
diff --git a/plugin/.claude-plugin/plugin.json b/plugin/.claude-plugin/plugin.json
index ef105383..98cf14ab 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, family-canon), and syncs your saved Scribe Processes as skills (/scribe:sync).",
- "version": "2026.10.09.1955",
+ "version": "2026.10.21.2344",
"author": {
"name": "Bryan Van Deusen"
},
diff --git a/plugin/PACKAGING.md b/plugin/PACKAGING.md
index e44c79bc..ff8d8e9b 100644
--- a/plugin/PACKAGING.md
+++ b/plugin/PACKAGING.md
@@ -2,7 +2,7 @@
This is the maintainer contract for shipping Scribe into an agent client:
what every client package shares, what each one adds, and where a new client's
-files go. It follows from decision #4027 (milestone 410): **the server orients,
+files go. One principle governs it: **the server orients,
the skills hold the depth, and a client adapter only times delivery and says
what that one client needs said.**
@@ -14,9 +14,9 @@ another one means adding files, not moving or rewriting any.
| Piece | Where | What it is |
|---|---|---|
| **The skills** | `plugin/skills/*/SKILL.md` | Agent Skills (the open SKILL.md format). They state every Scribe reflex in full and name no client. `tests/test_guidance_ownership.py` fails if a skill names a particular client, or references anything outside its own folder. Every client package ships this folder verbatim. |
-| **The MCP server** | `/mcp` | HTTP, `Authorization: Bearer `. Its `_INSTRUCTIONS` is a client-neutral orientation — the workflow across tools (≤1,600 chars, #4389); each tool's description carries its contract; in-band responses (`placement`, `report_back`, `systems_hint`, the duplicate gate, the guessed-id refusal) fire in every client. |
-| **The adapter API** | `/api/plugin/*` | Plain `GET` endpoints any client's hooks can call with the same key (read scope is enough): `context` (live session state), `retrieve` (rules, preferences and notes for a message), `prior-art` (records and shape-ledger hints for code being written), `tool-rules` (rules for a command about to run), `reply-rules` (a `POST`: the finished reply, held for an unopened rule or — when the turn closed a task — for missing completion sections), `processes` (stored Processes to expose as skills). |
-| **The API key** | Scribe → Settings → API Keys | One `fmcp_` key per install. Read scope for hooks; write scope for the MCP tools. |
+| **The MCP server** | `/mcp` | HTTP, `Authorization: Bearer `. Its `_INSTRUCTIONS` is a client-neutral orientation — the workflow across tools (≤1,600 chars); each tool's description carries its contract; in-band responses (`placement`, `report_back`, `systems_hint`, the duplicate gate, the guessed-id refusal) fire in every client. |
+| **The adapter API** | `/api/plugin/*` | Endpoints any client's hooks can call with the same key. `GET`, so read scope is enough: `context` (live session state), `retrieve` (rules, preferences and notes for a message), `prior-art` (records and shape-ledger hints for code being written), `tool-rules` (rules for a command about to run), `moment-tools` (which tools reach a mounted moment), `shape-check` (definitions written this turn that nobody has judged), `claim-session` / `release-session` (task claims), `processes` (stored Processes to expose as skills). `POST`, so write scope: `moment` (rules mounted on the moments a tool call reaches), `rule-opened` (a rule was read), `reply-rules` (the finished reply, held for an unopened rule or — when the turn closed a task — for missing completion sections). |
+| **The API key** | Scribe → Settings → MCP Access | One `fmcp_` key per install. Write scope covers the MCP tools and every hook; a read key runs only the `GET` hooks. |
## Added by each client
@@ -42,15 +42,20 @@ another one means adding files, not moving or rewriting any.
| `hooks/scribe_moment.sh` | PreToolUse on every tool: the rules mounted on the moments the call reaches, `POST /api/plugin/moment`; skips tools `GET /api/plugin/moment-tools` says reach nothing mounted, and Scribe's own (their responses carry `moment_rules`). |
| `hooks/scribe_reply_check.sh` | Stop: sends the finished reply to `POST /api/plugin/reply-rules` — the rules mounted on the reply moments, and the reply against every rule's trigger, and — when the turn closed a task — the completion-section check; holds once per rule, with the reason the server returns, and never holds the rewrite. |
| `hooks/scribe_shape_check.sh` | Stop: sends the definitions the turn wrote (the write hooks' `.written.ids` ledger) to `GET /api/plugin/shape-check`; blocks once, with the reason the server returns, so the agent judges what it built. |
+| `hooks/scribe_record_opened.sh` | PostToolUse on `get_rule`: notes the rule as read in the session ledger and `POST /api/plugin/rule-opened`. |
+| `hooks/scribe_record_outcome.sh` | PostToolUse on `rule_outcome`: notes the rule as acted on, locally, for the session-scoped readout. |
+| `hooks/scribe_claim_session.sh` | PostToolUse on `update_task` / `add_task_log`: `GET /api/plugin/claim-session`, so the task comes back after a compaction. |
+| `hooks/scribe_precompact_preserve.sh` | PreCompact: tells the summarizer what to keep literally. Local, and never blocks. |
+| `hooks/scribe_session_end.sh` | SessionEnd: `GET /api/plugin/release-session` releases the session's claims. |
| `hooks/scribe_sync_processes.sh` + `commands/sync.md` | `GET /api/plugin/processes` → `~/.claude/skills/scribe-proc-*` stubs; `/scribe:sync` on demand. |
| `hooks/scribe_defs.sh` | Shared shell helpers: config, dedup ledgers, outage line. |
| `hooks/scribe_static_context.md` | The adapter static text. |
-Hook config arrives as `CLAUDE_PLUGIN_OPTION_API_ENDPOINT` / `CLAUDE_PLUGIN_OPTION_API_TOKEN` (uppercased by Claude Code, #2198), with `SCRIBE_URL` / `SCRIBE_TOKEN` as an override.
+Hook config arrives as `CLAUDE_PLUGIN_OPTION_API_ENDPOINT` / `CLAUDE_PLUGIN_OPTION_API_TOKEN` (uppercased by Claude Code), with `SCRIBE_URL` / `SCRIBE_TOKEN` as an override.
## How other clients would map
-**Researched, not tested** (spike #4023, September 2026). Re-check each client's current docs before building.
+**Researched, not tested.** Re-check each client's current docs before building.
- **Agent Plugins 1.0** (Codex, Cursor, GitHub Copilot / VS Code, Kiro, ChatGPT): a root `plugin.json`, a `skills/` folder, and an `mcp.json`. That is the same `plugin/skills/` plus two small files. Hooks, commands and rules are not part of v1; each client adds its own under a reverse-domain directory (e.g. `com./`) that other clients ignore.
- **Gemini CLI**: `gemini-extension.json` configuring the MCP server, a context file, and bundled skills.
diff --git a/plugin/README.md b/plugin/README.md
index 1c3de12c..38d0cb6a 100644
--- a/plugin/README.md
+++ b/plugin/README.md
@@ -17,7 +17,8 @@ instance into a first-class Claude Code extension:
any client's package would ship: using-scribe, writing-plans, reporting-back
(reply to the operator in a shape that says where the work stands),
systematic-debugging, verification, brainstorming, reusing-code (record and
- recall reusable code as snippets). Replaces superpowers.
+ recall reusable code as snippets), shape-accounting and family-canon.
+ Replaces superpowers.
- **Your Scribe Processes as skills** — saved Processes are synced into local
`~/.claude/skills/scribe-proc-*` stubs that auto-surface by relevance; the
stub fetches the live procedure via `get_process`. Refreshed each session and
@@ -27,7 +28,7 @@ It is designed so you can uninstall `superpowers` and depend on Scribe instead
of auto-memory — leave auto-memory at its default; Scribe replaces its job by
holding the one copy, not by switching it off.
-**How the pieces divide the work** (decision #4027): the Scribe server orients
+**How the pieces divide the work**: the Scribe server orients
every MCP client and serves live state; the skills in `skills/` state every
reflex in full and name no client; this plugin is the Claude Code adapter —
hooks that deliver at the right moment, `/scribe:sync`, and the few things only
@@ -49,7 +50,7 @@ On install you'll be asked for:
| Setting | What |
|---|---|
| **Scribe base URL** | e.g. `https://scribe.example.com` (no trailing slash) |
-| **Scribe API key** | an `fmcp_` key from **Settings → API Keys** (stored in your OS keychain) |
+| **Scribe API key** | a write-scoped `fmcp_` key from **Settings → MCP Access** (stored in your OS keychain) |
| **Active project id** | optional — numeric project id to scope the session-start context |
## What gets wired
@@ -71,7 +72,23 @@ On install you'll be asked for:
in time is said, once per outage ("Scribe did not answer … this write went
UNCHECKED"), so a session can tell "checked, nothing there" from "never
checked"; an answer clears the marker. The local by-name arm needs no
- server and always runs. Toggle in **Settings → Knowledge auto-inject**.
+ server and always runs. Toggle in **Settings → General → Knowledge
+ auto-inject**.
+- `hooks/hooks.json` → UserPromptSubmit hook (`hooks/scribe_autoinject.sh`) →
+ `GET /api/plugin/retrieve`: the rules, preferences and records that match the
+ message, as a short menu of titles and matched passages.
+- `hooks/hooks.json` → PreToolUse hook on `Bash` (`hooks/scribe_tool_rules.sh`)
+ → `GET /api/plugin/tool-rules`: the rules that match the command about to run.
+- `hooks/hooks.json` → PreToolUse hook on every tool (`hooks/scribe_moment.sh`)
+ → `POST /api/plugin/moment`: the rules mounted on the moments the call
+ reaches. It skips tools that `GET /api/plugin/moment-tools` says reach
+ nothing mounted, and Scribe's own tools, whose responses carry
+ `moment_rules`.
+- `hooks/hooks.json` → PostToolUse hooks on Scribe's `get_rule`,
+ `rule_outcome`, `update_task` and `add_task_log`: they keep the session's
+ ledgers of rules opened and acted on (`get_rule` also reports to
+ `POST /api/plugin/rule-opened`), and claim the task being worked
+ (`GET /api/plugin/claim-session`) so it comes back after a compaction.
- `hooks/hooks.json` → PostToolUse hook on `Bash`
(`hooks/scribe_after_write.sh`): code written through sed/heredocs/scripts
never reaches the PreToolUse hook, so this one diffs the working tree after
@@ -106,16 +123,22 @@ On install you'll be asked for:
command → generate `~/.claude/skills/scribe-proc-*` stubs from your Scribe
Processes (via `GET /api/plugin/processes`); also **fail-open**, and pruned to
match what exists in Scribe.
+- `hooks/hooks.json` → PreCompact hook (`hooks/scribe_precompact_preserve.sh`):
+ tells the summarizer which parts of the conversation to keep literally. Local
+ only, and it never blocks, because a blocked compaction is skipped outright.
+- `hooks/hooks.json` → SessionEnd hook (`hooks/scribe_session_end.sh`) →
+ `GET /api/plugin/release-session`: releases the session's task claims.
## Notes
- **Do not hand-edit `version` in `.claude-plugin/plugin.json`.** It is minted
from the clock — run `python3 scripts/mint_plugin_version.py` (or `make
mint-plugin`, where `make` is installed) after changing anything under
- `plugin/`, and commit the result. The installer decides whether to refresh the cache it
- executes from by comparing that string, so content that ships without a new
- version reaches the repo and stops there (#2209). CI fails the lane if you
- forget.
-- The session-start, auto-inject and prior-art hooks need only a **read**-scoped
- key; the MCP tools need **write** scope to create/update. Every hook is a GET
- for that reason — a read key cannot POST.
+ `plugin/`, and commit the result. The installer decides whether to refresh
+ the cache it executes from by comparing that string, so content that ships
+ without a new version reaches the repo and stops there. CI fails the lane if
+ you forget.
+- The plugin uses one key for its MCP tools and its hooks, so give it **write**
+ scope: the MCP tools create and update records, and the moment, rule-opened
+ and reply hooks POST. A read-scoped key runs the GET hooks only; the others
+ fail open and add nothing.
diff --git a/plugin/hooks/scribe_after_write.sh b/plugin/hooks/scribe_after_write.sh
index 4c647bdb..650905bd 100644
--- a/plugin/hooks/scribe_after_write.sh
+++ b/plugin/hooks/scribe_after_write.sh
@@ -1,14 +1,13 @@
#!/usr/bin/env bash
-# Scribe plugin — PostToolUse write-path trigger on Bash (#2901).
+# Scribe plugin — PostToolUse write-path trigger on Bash.
#
# scribe_prior_art.sh fires before a Write/Edit TOOL CALL. Code written any
-# other way — sed, heredocs, python edit scripts, `cat > file` — never reached
-# it, so a whole class of edits (the ones a long session makes most) got no
-# prior-art hint, no ledger feed and no duplicate-family warning. This hook
-# closes that: after EVERY Bash call it asks git what changed in the working
+# other way — sed, heredocs, python edit scripts, `cat > file` — never reaches
+# it, and those are the edits a long session makes most. So after EVERY Bash
+# call it asks git what changed in the working
# tree since it last looked, and runs the same arms on the definitions that
# were just written — the local by-name duplicate arm, the recorded prior-art
-# arms and the ledger's derive/divergence checks (#2900/#2793), via the same
+# arms and the ledger's derive/divergence checks, via the same
# /api/plugin/prior-art endpoint the pre-write hook uses.
#
# Post-hoc by a few seconds, in the same moment and the same session: "the
@@ -137,9 +136,9 @@ while IFS= read -r rel_path; do
fresh="new"
fi
[ -n "$code" ] || continue
- # `|| true` inside: an early-exiting `head` must not void its own output (#4042).
+ # `|| true` inside: an early-exiting `head` must not void its own output.
names=$(printf '%s' "$code" | scribe_defs | sort -u | head -12 || true)
- # What this write defined, for the end-of-turn question (milestone 439) —
+ # What this write defined, for the end-of-turn question —
# recorded before the arms below, which skip a write that defines nothing.
printf '%s\n' "$names" | scribe_written_append "$safe_sid" "$rel_path" "$fresh" "$code"
# Nothing DEFINED in what was written (prose, data, a call-site edit) →
@@ -178,23 +177,17 @@ while IFS= read -r rel_path; do
derive_seen=$(tr '\n' ',' < "$derivefile" 2>/dev/null | sed 's/,$//' | scribe_urlenc) || derive_seen=""
[ -n "$derive_seen" ] && derive_exclude_q="&exclude_derive=${derive_seen}"
fi
- # The rules marker is gone with the resident set it aged (milestone 394).
- # A session no longer holds a fixed set of rules from turn zero, so there
- # is nothing that can have drifted since it loaded them — each rule is
- # retrieved at the moment it applies.
- etag_q=""
if [ -n "$path_enc" ]; then
# 8s, not the pre-write hook's 5: this hook runs AFTER the tool, so it
# gates nothing the session is waiting on, and the first prior-art call
- # after a redeploy is a cold start (embedding warm-up, ~4.6s observed)
- # that a 4s cap turned into a silent fail-open — the one write a
- # session most wants the ledger's word on lost it.
+ # after a redeploy is a cold start (embedding warm-up takes several
+ # seconds) that a tighter cap would turn into a silent fail-open.
reached=1
body=$(curl -fsS --max-time 8 \
-H "Authorization: Bearer ${token}" \
- "${url%/}/api/plugin/prior-art?path=${path_enc}&code=${code_enc}${repo_q}${exclude_q}${sync_exclude_q}${derive_exclude_q}${shapes_q}${etag_q}" 2>/dev/null) || { body=""; reached=0; }
- # A call that was owed and didn't come back is said, once per outage
- # (#2932) — shared marker with the pre-write hook, so one outage is one
+ "${url%/}/api/plugin/prior-art?path=${path_enc}&code=${code_enc}${repo_q}${exclude_q}${sync_exclude_q}${derive_exclude_q}${shapes_q}" 2>/dev/null) || { body=""; reached=0; }
+ # A call that was owed and didn't come back is said, once per outage —
+ # shared marker with the pre-write hook, so one outage is one
# line however the code was written.
if [ "$reached" = 1 ]; then
scribe_reached "$state_dir" "$safe_sid"
@@ -219,9 +212,9 @@ while IFS= read -r rel_path; do
fi
fi
- # The record nudge (#2664), same gate as the pre-write hook: duplication
- # demonstrated locally AND nothing recorded for it — and (#2932) never on a
- # call that did not answer; "nothing recorded" is a claim only an answer
+ # The record nudge, same gate as the pre-write hook: duplication
+ # demonstrated locally AND nothing recorded for it — and never on a call
+ # that did not answer; "nothing recorded" is a claim only an answer
# can back.
if [ -n "$local_lines" ] && [ "$reached" != 0 ]; then
n_recorded=$(scribe_json_len "$body_flat" '.note_ids')
diff --git a/plugin/hooks/scribe_autoinject.sh b/plugin/hooks/scribe_autoinject.sh
index 61a871fe..66a3cd28 100755
--- a/plugin/hooks/scribe_autoinject.sh
+++ b/plugin/hooks/scribe_autoinject.sh
@@ -7,12 +7,12 @@
# only — never bodies; the agent calls get_note(id) to pull anything it judges
# relevant. Most turns inject nothing.
#
-# TWO ARMS SINCE #3852, on one request. Rules and preferences are retrieved
+# TWO ARMS, on one request. Rules and preferences are retrieved
# against the same prompt and returned in the same payload, ahead of the notes
# menu. That arm exists because the two act arms are keyed on a file write or
# a command, so a rule governing what to SAY — extract intent from loose
-# phrasing, raise a conflict before acting, end a finding with an offer — had
-# no moment to fire at. The operator's message is the only query that exists
+# phrasing, raise a conflict before acting, end a finding with an offer —
+# would have no moment to fire at. The operator's message is the only query that exists
# before a response is composed.
#
# The two arms are gated separately server-side: turning the notes menu off
@@ -24,8 +24,8 @@
# fails, the hook stays SILENT and exits 0 — it must never block a prompt.
#
# Config (same as scribe_session_context.sh), exported to the hook by Claude Code
-# with the userConfig key UPPERCASED (see #2198 — reading the lowercase spelling
-# silently disables this hook, and silence is indistinguishable from "nothing
+# with the userConfig key UPPERCASED (reading the lowercase spelling silently
+# disables this hook, and silence is indistinguishable from "nothing
# cleared the threshold"):
# CLAUDE_PLUGIN_OPTION_API_ENDPOINT base URL, no trailing slash
# CLAUDE_PLUGIN_OPTION_API_TOKEN fmcp_ API key (sensitive)
@@ -41,7 +41,7 @@ set -uo pipefail
command -v curl >/dev/null 2>&1 || exit 0
# UserPromptSubmit delivers a JSON event on stdin: { prompt, session_id, cwd, ... }
-# Parsed ONCE into flat lines and then queried three times (#4107): a prompt is
+# Parsed ONCE into flat lines and then queried three times: a prompt is
# the largest payload any hook reads, and re-parsing it per field is three
# passes over the same text.
event=$(cat 2>/dev/null || true)
@@ -54,12 +54,12 @@ transcript=$(scribe_json_pick "$event_flat" '.transcript_path')
# Nothing to retrieve against.
[ -n "$prompt" ] || exit 0
-# A turn CLAUDE CODE wrote, not the operator (#4200) — a task notification, a
+# A turn CLAUDE CODE wrote, not the operator — a task notification, a
# slash-command echo, a local command's caveat banner. Leaving before the
# request matters more for the LOG than for the noise: a retrieval call
# recorded against a notification inflates this surface's denominator and
# seeds `near_misses` with demand nobody expressed. scribe_skip_prompt carries
-# the measurement.
+# the reasoning.
scribe_skip_prompt "$prompt" && exit 0
# Unconfigured install → silent (auto-inject is pure enrichment).
@@ -67,20 +67,17 @@ scribe_config || exit 0
# Cap the query length — a giant prompt makes a giant URL for no extra signal.
# `head -c`, not `cut -c1-2000`: cut is line-oriented and caps EACH LINE, so a
-# long multi-line prompt sailed past the budget entirely. Same defect as the
-# prior-art hook's code cap; this copy was missed when that one was fixed, and
-# scripts/check_plugin.py caught it.
+# long multi-line prompt would sail past the budget. scripts/check_plugin.py
+# checks every hook for this.
q=$(printf '%s' "$prompt" | head -c 2000)
-# Encoded whole, never line by line. The predecessor here was `jq -rR`, which
-# reads a line at a time: a multi-line prompt came back as several separately
-# encoded lines joined by raw newlines and the request died. Single-line prompts
-# worked, which is why this looked healthy — the long, substantial prompts most
-# worth retrieving against were exactly the ones silently dropped. scribe_urlenc
-# reads bytes and has no notion of a line.
+# Encoded whole, never line by line: per-line encoding joins the pieces with
+# raw newlines, an invalid URL, and the long multi-line prompts most worth
+# retrieving against would be the ones dropped. scribe_urlenc reads bytes and
+# has no notion of a line.
q_enc=$(printf '%s' "$q" | scribe_urlenc) || exit 0
[ -n "$q_enc" ] || exit 0
-# What the conversation is about, for a prompt too short to say (#4364). Sent
+# What the conversation is about, for a prompt too short to say. Sent
# beside `q`, never folded into it: the server decides whether the prompt is
# thin enough to need it, for the notes and rule arms alike. Capped at
# 600 chars here as well as there, so the URL stays small whatever the reply.
@@ -104,8 +101,8 @@ mkdir -p "$state_dir" 2>/dev/null || true
# hooks already keep it under scribe-priorart — so that one session keeps ONE
# list and a rule named here is not re-announced before the next Bash call.
# A private copy here would make each arm's "already seen" mean something
-# different, which is the state #3749/#3750 exist to keep coherent. The
-# directory name is the prior-art hook's history, not a scope claim.
+# different. The directory is named for the prior-art hook, which is not a
+# scope claim.
rule_state_dir="${TMPDIR:-/tmp}/scribe-priorart"
mkdir -p "$rule_state_dir" 2>/dev/null || true
idfile=""
@@ -126,9 +123,9 @@ if [ -n "$session_id" ]; then
# stops holding what it was told. scribe_rules_live carries the reasoning.
rule_seen=$(scribe_rules_live "$rulefile")
[ -n "$rule_seen" ] && exclude_q="${exclude_q}&exclude_rule_ids=${rule_seen}"
- # What the session actually OPENED, as against what it was shown (#4100).
+ # What the session actually OPENED, as against what it was shown.
exclude_q="${exclude_q}$(scribe_held_query "$rule_state_dir/${safe_sid}.opened.ids")"
- # Which reply shapes it already holds in full (milestone 500): the core is
+ # Which reply shapes it already holds in full: the core is
# sent whole once, and as its one-line reminder on every later turn.
shapefile=$(scribe_shapes_file "$safe_sid")
shapes_seen=$(scribe_shapes_seen "$shapefile")
@@ -149,7 +146,7 @@ if [ -n "$idfile" ]; then
scribe_json_list "$body_flat" '.note_ids' >> "$idfile" || true
fi
# Rules onto the SHARED ledger, stamped so they can age out. Only FRESH ids
-# come back in rule_ids (#3752) — a rule rendered as a repeat is already on
+# come back in rule_ids — a rule rendered as a repeat is already on
# the ledger, and re-appending it would keep pushing its stamp forward so it
# never aged at all.
if [ -n "$rulefile" ]; then
diff --git a/plugin/hooks/scribe_claim_session.sh b/plugin/hooks/scribe_claim_session.sh
index c765a561..b02aff34 100755
--- a/plugin/hooks/scribe_claim_session.sh
+++ b/plugin/hooks/scribe_claim_session.sh
@@ -1,5 +1,5 @@
#!/usr/bin/env bash
-# Scribe — say WHICH session holds a task's claim (milestone 381 step 2).
+# Scribe — say WHICH session holds a task's claim.
#
# The server stamps a claim on the write that is the work — a task reaching
# `in_progress`, a work log on an open task — so every MCP client gets a claim
diff --git a/plugin/hooks/scribe_defs.sh b/plugin/hooks/scribe_defs.sh
index 61d7c838..5e2ce7c7 100644
--- a/plugin/hooks/scribe_defs.sh
+++ b/plugin/hooks/scribe_defs.sh
@@ -1,6 +1,6 @@
#!/usr/bin/env bash
# shellcheck shell=bash
-# Scribe plugin — the pieces the hooks share (#2901, #2278).
+# Scribe plugin — the pieces the hooks share.
#
# scribe_prior_art.sh fires BEFORE a Write/Edit tool call; scribe_after_write.sh
# fires AFTER a Bash tool call and diffs the working tree, so code written by
@@ -9,24 +9,24 @@
#
# scribe_skip_path PATH formats that hold prose or data, not shapes
# scribe_skip_prompt TEXT the turn was written by the CLIENT, not the
-# operator — skip it entirely (#4200)
+# operator — skip it entirely
# scribe_defs stdin code → "kindname" per definition
# scribe_local_dups ROOT REL "kindname" lines on stdin → the by-name
-# local-duplicate lines (ARM 1, #2280)
+# local-duplicate lines (ARM 1)
# scribe_unreached STATE SID SECS REL the "Scribe didn't answer" line, once
-# per outage (#2932) — or nothing, if said lately
+# per outage — or nothing, if said lately
# scribe_reached STATE SID the server answered: the next outage speaks again
# scribe_config sets `url` + `token` from the env, returns 0
-# only if BOTH are usable (#2278)
+# only if BOTH are usable
# scribe_rules_live FILE live rule ids from the exclusion ledger,
-# comma-joined; entries age out (#3751)
+# comma-joined; entries age out
# scribe_rules_append FILE stdin ids -> the ledger, timestamped
# scribe_json_flat stdin JSON -> IDXPATHVALUE lines,
# queried with scribe_json_pick / _list / _len.
# scribe_json_out writes the hook envelope,
-# scribe_urlenc percent-encodes. No jq (#4107).
+# scribe_urlenc percent-encodes. No jq.
# scribe_scope_query DIR `project_id=N` or `repo=` for DIR — the
-# project-scope key EVERY hook sends (#4085).
+# project-scope key EVERY hook sends.
# Helpers: scribe_marker_file, scribe_url_host,
# scribe_marker_read (idwhy-not),
# scribe_marker_project (the id alone)
@@ -45,23 +45,21 @@ scribe_skip_path() {
return 1
}
-# Skip a turn the CLIENT wrote rather than the operator (#4200). Claude Code
+# Skip a turn the CLIENT wrote rather than the operator. Claude Code
# submits several kinds of machine-written text through UserPromptSubmit — a
# task notification, the echo of a slash command, the caveat banner a local
# command prints — and they reach `.prompt` indistinguishable from typed words
# unless something looks.
#
-# THIS IS A TELEMETRY FIX FIRST AND A NOISE FIX SECOND, which is the part
-# worth keeping straight: retrieving against a notification usually returns
-# nothing, so the visible cost looks like one wasted embedding. The real cost
-# is that the call is LOGGED. It inflates the denominator of every
-# prompt-boundary surface, so delivery rate reads low for a reason that has
-# nothing to do with retrieval; and the refusal lands in `near_misses`, where
-# a later tuning decision reads it as unmet demand. Measured on #3898: 15 of
-# the top 20 preference near-misses were `` blocks, ALL
-# matching one record, ALL within thousandths of the floor. A floor lowered to
-# serve that apparent demand would inject that record into every notification
-# — the instrument arguing for the wrong fix, which is #379's lesson again.
+# THIS PROTECTS TELEMETRY FIRST AND CUTS NOISE SECOND. Retrieving against a
+# notification usually returns nothing, so the visible cost looks like one
+# wasted embedding. The real cost is that the call is LOGGED. It inflates the
+# denominator of every prompt-boundary surface, so delivery rate reads low for
+# a reason that has nothing to do with retrieval; and the refusal lands in
+# `near_misses`, where a tuning decision reads it as unmet demand. Notification
+# blocks resemble each other, so they pile up just under a floor, and a floor
+# lowered to serve that apparent demand would inject the same record into
+# every notification.
#
# A PREFIX TEST, NOT A SUBSTRING ONE, and that distinction is the whole safety
# argument. A real prompt may well CONTAIN one of these tags — an operator
@@ -88,16 +86,12 @@ scribe_skip_prompt() {
}
# ---------------------------------------------------------------------------
-# JSON AND URL-ENCODING, WITHOUT jq (#4107).
+# JSON AND URL-ENCODING, WITHOUT jq.
#
-# Until this section every hook opened `command -v jq >/dev/null 2>&1 || exit 0`
-# and a machine without jq got no session context, no rules, no prior art and
-# no process sync — silently, because `exit 0` is indistinguishable from "ran
-# fine, nothing to say". That is the plugin pushing its own packaging problem
-# onto whoever installs it, and it is the same silence #4085 existed to remove.
# jq is absent by default on macOS, on the Debian/Ubuntu slim images, on Alpine
-# and in most CI containers. awk, sed, tr and od are POSIX; every one of them
-# is already required by code above this line.
+# and in most CI containers, and a hook that exits quietly without it looks
+# exactly like one that ran and had nothing to say. awk, sed, tr and od are
+# POSIX; every one of them is already required by code above this line.
#
# scribe_json.awk does the parsing and carries the format. These are the four
# jobs the hooks actually had jq for:
@@ -129,13 +123,13 @@ scribe_json_flat_lines() {
}
# $1 transcript path → the tail of the last assistant REPLY text, decoded, on
-# one line; "" when there is none (#4364). The auto-inject query pairs it with
+# one line; "" when there is none. The auto-inject query pairs it with
# a short prompt, so "yes do that" still says what it is about.
#
# Cheap by construction, because it runs on every prompt: the last 512 KB only,
# and grep narrows to assistant records carrying a text block BEFORE anything is
-# parsed — a transcript is mostly tool results, and parsing those in awk is the
-# multi-second cost the completion-report check measured (#4107). Fixed strings
+# parsed — a transcript is mostly tool results, and parsing those in awk costs
+# seconds. Fixed strings
# with UNESCAPED quotes can only match at a record's own top level.
# A sidechain is a subagent talking, not this session, so it is skipped.
scribe_recent_context() {
@@ -160,9 +154,8 @@ scribe_recent_context() {
# $1 flat text, $2 exact path → the decoded scalar, or "" if absent.
#
-# `null` READS AS ABSENT, matching the `// empty` every call site used to carry.
-# A field the server left null and one it never sent mean the same thing to
-# every caller here, and the shells downstream test `[ -n "$x" ]`.
+# `null` READS AS ABSENT. A field the server left null and one it never sent
+# mean the same thing to every caller here, and the shells downstream test `[ -n "$x" ]`.
scribe_json_pick() {
printf '%s\n' "${1:-}" \
| awk -F'\t' -v p="$2" '$2 == p { if ($3 != "null") print $3; exit }' 2>/dev/null \
@@ -186,8 +179,7 @@ scribe_json_list() {
# $1 flat text, $2 array path, $3 array path to SUBTRACT → the elements of $2
# that are not in $3, in order. The reuse and sync channels are two classes of
# one answer and each has its own session ledger, so the ids that go in the
-# reuse file are precisely `note_ids` minus `sync_note_ids` — jq wrote that as
-# `(.note_ids // []) - (.sync_note_ids // [])`.
+# reuse file are precisely `note_ids` minus `sync_note_ids`.
scribe_json_list_minus() {
local keep
keep=$(scribe_json_list "$1" "$3")
@@ -210,10 +202,8 @@ scribe_json_len() {
# The server escapes every non-ASCII character this way (the JSON default),
# and `sprintf("%c", n)` for n > 127 means a different thing in every awk and
# locale: gawk in a UTF-8 locale writes the character, gawk in the C locale
-# and mawk write the single byte n % 256. So "·" (U+00B7) arrived as a lone
-# 0xB7 and "—" (U+2014) as 0x14 wherever the hook ran without a UTF-8 locale —
-# found by a test that ran the hook with a bare environment (milestone 500).
-# Under LC_ALL=C every awk writes exactly the byte asked for, so the encoding
+# and mawk write the single byte n % 256, so "·" (U+00B7) would arrive as a
+# lone 0xB7 wherever the hook runs without a UTF-8 locale. Under LC_ALL=C every awk writes exactly the byte asked for, so the encoding
# below is the only one that happens.
scribe_json_unescape() {
LC_ALL=C awk '
@@ -348,12 +338,12 @@ scribe_urlenc() {
# ---------------------------------------------------------------------------
# kindname for each thing a piece of code DEFINES, in source order. One
# program, two consumers: the local duplicate arm (every definition in the
-# payload) and the ledger feed (#2791, below: the definitions being written,
+# payload) and the ledger feed (below: the definitions being written,
# or the one enclosing an Edit). Rule-for-rule mirrored by the server's
# services/coverage.py extract_shapes — ledger rows are keyed by what THAT
# sees, so the two must agree on what counts as a definition.
#
-# THE WHOLE INPUT IS BUFFERED (#4222) so the span scan below can look ahead.
+# THE WHOLE INPUT IS BUFFERED so the span scan below can look ahead.
# The matchers are line-oriented and know nothing about what a line is INSIDE:
# a wrapped docstring beginning "class AND the …" announces a shape called
# `AND`, which reaches the session mid-edit as a divergence prompt about a
@@ -482,7 +472,7 @@ scribe_defs() {
sub(/[^A-Za-z0-9_$].*$/, "", t)
# `type` defines only when something follows the name (= or {); an
# import specifier `type Foo,` is the same two words and defines
- # nothing (mirror of coverage.py, #2904).
+ # nothing (mirror of coverage.py).
if (line ~ /^type[[:space:]]/) {
rest = line; sub(/^type[[:space:]]+[A-Za-z_$][A-Za-z0-9_$]*/, "", rest)
if (rest !~ /[={]/) return
@@ -508,12 +498,11 @@ scribe_defs() {
# ---------------------------------------------------------------------------
-# ARM 1 — BY NAME, LOCALLY (#2280). Does a definition of this already exist?
+# ARM 1 — BY NAME, LOCALLY. Does a definition of this already exist?
#
-# The recorded arms ask Scribe what was RECORDED; the ledger arm (#2900) asks
-# what a BOUND repo's ledger knows. A helper nobody recorded, in a repo nobody
-# bound, is invisible to both — which is how `.btn-primary` came to be defined
-# four times, already diverged. This arm asks the one question only the
+# The recorded arms ask Scribe what was RECORDED; the ledger arm asks what a
+# BOUND repo's ledger knows. A helper nobody recorded, in a repo nobody bound,
+# is invisible to both, so it gets defined again in the next file. This arm asks the one question only the
# developer's machine can answer, inside the repo, holding the code about to
# be written: no index, no storage, no server — it runs even on an install
# that has never configured Scribe.
@@ -521,23 +510,22 @@ scribe_defs() {
# Definition-shaped patterns only. Grepping for bare occurrences would match
# every CALL site and drown the real finding — and a hint that is mostly noise
# is one people learn to skip, which is worse than none. ALL code, not a
-# language shortlist (#2682): the same keyword family scribe_defs announces.
+# language shortlist: the same keyword family scribe_defs announces.
#
# $1 repo root, $2 repo-relative path of the file being written (excluded from
# the grep — it would always match itself on an Edit). Definitions on stdin.
# Prints one "> - `name` is already defined in N other file(s): …" per hit.
-# ── The contract around a change (#4215, milestone 419) ───────────────────
+# ── The contract around a change ───────────────────────────────────────
#
# WHAT THIS ANSWERS. "You altered the arity, name or shape of something — here
-# is everything that reads it." Rule 33's interface-contract check, one scope
-# down: not between layers but between a definition and its callers.
+# is everything that reads it." An interface-contract check between a
+# definition and its callers.
#
-# WHY IT IS A CHECK AND NOT A LESSON. #4207 was written — "widening a tuple is
-# an interface change to every unpack site, and the compiler will not tell
-# you" — hours before a structurally identical mistake was made by its author,
-# and it was surfaced twice in the turns before. Text delivered at the moment
-# of acting is too weak a carrier for a reflex that has to change what the act
-# IS. This looks it up instead.
+# WHY IT IS A CHECK AND NOT ADVICE. "Widening a tuple is an interface change to
+# every unpack site, and the compiler will not tell you" is easy to know and
+# easy to forget mid-edit. Text delivered at the moment of acting is too weak a
+# carrier for a reflex that has to change what the act IS, so this looks the
+# callers up instead.
#
# `scribe_exposed` — the names a CALLER can depend on, from a blob of code.
# Three kinds, because a contract breaks three ways and they look nothing
@@ -547,18 +535,14 @@ scribe_defs() {
# arg its parameter names arity and order
# key quoted keys of dict literals the shape of what it RETURNS
#
-# The third is here because of the miss that produced this step. A config
-# function gained one dict key; three arms read that dict inside a fail-open
-# `except`, so every one of them silently became a no-op and ten tests went
-# red at once with nothing pointing at the cause. No signature changed. A
-# check that only watched signatures would have watched the wrong thing.
+# The third matters because a returned dict can change shape with no
+# signature changing: callers that read it inside a fail-open `except` go
+# silently inert, with nothing pointing at the cause.
scribe_exposed() {
# The `sym` half DELEGATES to scribe_defs rather than repeating its patterns.
- # Those patterns cover nine languages and have been corrected several times
- # (the Go receiver form, the `type` import-specifier false positive, the
- # dunder skip); a second copy here would inherit today's version and then
- # quietly stop agreeing with it, which is #3497's history for the two rule
- # arms. One reader, called twice.
+ # Those patterns cover nine languages and carry their own edge cases (the Go
+ # receiver form, the `type` import specifier, the dunder skip); a second copy
+ # here would drift from them. One reader, called twice.
local blob
blob=$(cat)
{
@@ -612,7 +596,7 @@ scribe_exposed() {
# not match `cfg_path`, and the defining file is excluded — a definition is
# always its own first mention and listing it says nothing.
#
-# `|| true` INSIDE the substitution for the reason #4042 records at
+# `|| true` INSIDE the substitution, for the reason given at
# scribe_local_dups: under `pipefail` a `head` that exits early kills the
# still-writing git grep, and an outer fallback then wipes the hits head had
# already printed.
@@ -679,7 +663,7 @@ scribe_contract_block() {
[ -n "$gained" ] && printf '> gained: %s\n' "$gained"
[ -n "$lost" ] && printf '> lost: %s\n' "$lost"
printf '> read by: %s\n' "$(printf '%s' "$readers" | tr '\n' ' ' | sed 's/ $//')"
- printf '> A caller that passes or reads the old shape keeps compiling and fails only when that line runs (lesson #4207). Read them before moving on.\n'
+ printf '> A caller that passes or reads the old shape keeps compiling and fails only when that line runs. Read them before moving on.\n'
[ -n "$ledger" ] && printf '%s\n' "$subject" >> "$ledger" 2>/dev/null
return 0
}
@@ -705,8 +689,7 @@ scribe_slippage_lines() {
# is the moment the exclusion ledgers are about to be cleared and the moment
# their TTL has usually already eaten the early part of a long session. A
# readout built on them would report only the last stretch of the session
- # and read as though it had reported all of it — the #3311 shape, and the
- # one this milestone exists to stop producing.
+ # and read as though it had reported all of it.
named=$(scribe_ledger_kept "$dir/${sid}.rules.keep.ids")
opened=$(scribe_ledger_kept "$dir/${sid}.opened.keep.ids")
acted=$(scribe_ledger_kept "$dir/${sid}.acted.keep.ids")
@@ -751,38 +734,33 @@ scribe_ids_minus() {
' /dev/null
}
-# ARM 1, BY NAME (#2280) — and the confirmation pass that makes it mean
-# something (#4227).
+# ARM 1, BY NAME — and the confirmation pass that makes it mean something.
#
# WHY A GREP IS NOT ENOUGH. The pattern below looks for a definition keyword
-# followed by the name. A grep sees LINES, not spans, so the sentence
+# followed by the name. A grep sees LINES, not spans, so a docstring sentence
+# like
#
# class with only modifier rules is a deletion that went half-way.
#
-# — real prose, from the module docstring of scripts/check_dangling_styles.py —
-# matches it for `name=with`. #4222 fixed the other end of this same defect, in
-# the extractors that decide what a payload DEFINES; this is the end that
-# decides which other files already define it, and it was still a plain grep.
+# matches it for `name=with`.
#
# SO EVERY HIT IS CONFIRMED by running the real extractor over the candidate
# file and keeping only names it actually reports. That is the honest check and
# the only one that cannot disagree with the other end of the pipe.
#
-# TIGHTENING THE PATTERN WOULD HAVE BEEN CHEAPER AND WRONG. Requiring `(` or
+# TIGHTENING THE PATTERN WOULD BE CHEAPER AND WRONG. Requiring `(` or
# `{` or `:` after the name rejects `class with only…` — and also rejects
# `class Foo extends Bar {`, `class Foo : Base()` and `type Foo struct {`. This
-# arm's whole justification (#2280, #2682) is that it works with no server, no
+# arm's whole justification is that it works with no server, no
# index and no binding, which makes a miss here invisible. Trading a visible
# false positive for an invisible false negative is a bad trade.
#
# ONCE PER DISTINCT FILE, not once per (name, file) pair: the same file is
# usually a candidate for several names at once, and the extractor reads the
-# whole file either way. Measured on this repo, a deliberately pathological
-# payload — nine names that are ordinary English words — produced 28 distinct
-# candidate files totalling 947KB, and `scribe_defs` runs at roughly 33ms per
-# 250KB, so the confirmation costs about 200ms in the worst case anyone has
-# been able to construct here. The per-(name, file) shape would have paid that
-# several times over for the same answer.
+# whole file either way. `scribe_defs` runs at roughly 33ms per 250KB, so
+# even a pathological payload of ordinary-word names costs a few hundred ms to
+# confirm; the per-(name, file) shape would pay that several times over for
+# the same answer.
_SCRIBE_DUP_CANDIDATES=12
_SCRIBE_DUP_SHOWN=4
_SCRIBE_DUP_BUDGET=48
@@ -794,12 +772,10 @@ scribe_local_dups() {
# PASS 1 — candidates. One grep per name, unchanged except for the cap.
#
- # THE CAP IS RAISED FROM FOUR, and that is not a detail. Confirmation REMOVES
- # hits, so capping before it runs lets three phantom matches crowd out a real
- # definition in the fourth file — hits dropped before anyone looked at them,
- # which is #4042's bug wearing a different hat. The display cap stays at four
- # (_SCRIBE_DUP_SHOWN); it now applies to CONFIRMED hits, which is where a cap
- # belongs.
+ # THE CANDIDATE CAP IS WIDER THAN THE DISPLAY CAP. Confirmation REMOVES
+ # hits, so capping before it runs would let phantom matches crowd out a real
+ # definition. The display cap (_SCRIBE_DUP_SHOWN) applies to CONFIRMED hits,
+ # which is where a cap belongs.
while IFS=$'\t' read -r kind name; do
[ -n "${name:-}" ] || continue
case "$kind" in
@@ -807,11 +783,11 @@ scribe_local_dups() {
*) pat="(function|def|class|func|fun|fn|sub|struct|trait|interface|enum|object|protocol|type)[[:space:]]+${name}[^A-Za-z0-9_]|func[[:space:]]*\([^)]*\)[[:space:]]*${name}[[:space:]]*\(|(const|let)[[:space:]]+${name}[[:space:]]*=" ;;
esac
# -I skips binaries; :(exclude) drops the file being written.
- # `|| true` INSIDE the substitution, not `|| hits=""` outside it (#4042):
- # under the hooks' `pipefail`, `head` exiting early kills a git grep that
- # is still writing, the pipeline reports SIGPIPE, and an outer fallback
- # then wipes the hits head had already printed. The name most duplicated —
- # the one this arm exists for — was the one it dropped.
+ # `|| true` INSIDE the substitution, not `|| hits=""` outside it: under
+ # the hooks' `pipefail`, `head` exiting early kills a git grep that is
+ # still writing, the pipeline reports SIGPIPE, and an outer fallback would
+ # wipe the hits head had already printed — dropping exactly the most
+ # duplicated name.
hits=$(git -C "$root" grep -I -l -E -e "$pat" -- . ":(exclude)${rel}" 2>/dev/null \
| head -"$_SCRIBE_DUP_CANDIDATES" || true)
[ -n "$hits" ] || continue
@@ -868,19 +844,9 @@ scribe_local_dups() {
}
# ---------------------------------------------------------------------------
-# The blind spot made visible (#2932). Both write-path hooks fail OPEN when the
-# instance is slow or down — right for noise, wrong for silence: a session
-# cannot tell "the ledger checked and found nothing" from "the ledger never
-# answered", and a self-surfacing system cannot afford an invisible miss (the
-# first write after a redeploy lost its derive line to a 4s cold start and
-# nobody knew). So a failed call says so — ONCE per outage: the marker holds
-# the time it last spoke; within ten minutes of that it stays quiet, and a
-# successful call clears it so the next outage announces itself afresh.
-# Unconfigured installs never reach this: no URL/token means no call was owed.
-# Where every hook gets its endpoint and credential. Four lines, and each of
-# the five hooks carried its own copy until #2278 — which is exactly the
-# missing-sibling shape: the `${...}` guard below is a correctness detail a
-# sixth hook would have forgotten, and nothing would have failed loudly.
+# Where every hook gets its endpoint and credential. One copy, because the
+# `${...}` guard below is a correctness detail a hook with its own copy would
+# forget, and nothing would fail loudly.
#
# Sets `url` and `token` as globals rather than echoing them: a token must not
# pass through a subshell's output, where it could land in a log or an `xtrace`
@@ -905,6 +871,14 @@ scribe_config() {
[ -n "$url" ] && [ -n "$token" ]
}
+# The blind spot made visible. Both write-path hooks fail OPEN when the
+# instance is slow or down — right for noise, wrong for silence: a session
+# cannot tell "the ledger checked and found nothing" from "the ledger never
+# answered" (a cold start after a redeploy is enough to miss the budget). So a
+# failed call says so — ONCE per outage: the marker holds the time it last
+# spoke; within ten minutes of that it stays quiet, and a successful call
+# clears it so the next outage announces itself afresh. Unconfigured installs
+# never reach this: no URL/token means no call was owed.
_SCRIBE_UNREACHED_QUIET=600
scribe_unreached() {
@@ -924,19 +898,18 @@ scribe_reached() {
}
# ---------------------------------------------------------------------------
-# THE RULE EXCLUSION LEDGER, and how entries in it AGE (#3751).
+# THE RULE EXCLUSION LEDGER, and how entries in it AGE.
#
# Two hooks write this file and two read it, which is the whole reason the
# parsing lives here: `scribe_prior_art.sh` and `scribe_tool_rules.sh` share
# one ledger so a rule named by one arm is not re-offered by the other, and a
# format only one of them understood would break that on the first read.
#
-# WHAT PROBLEM AGEING SOLVES. #3749 clears the ledger when an EVENT destroys
+# WHAT PROBLEM AGEING SOLVES. The ledger is cleared when an EVENT destroys
# context — a compaction or a /clear. This is the case with no event at all: a
# long session where the rule was named two hundred turns ago and has simply
-# fallen out of attention. It is #3702's argument at the tier level (present in
-# context and salient at the moment are different properties) applied to time
-# instead of to tier.
+# fallen out of attention. Present in context and salient at the moment are
+# different properties.
#
# PER-ENTRY TIMESTAMPS, NOT A FILE MTIME. Clearing the whole ledger when the
# file is old is one line of shell and wrong in exactly the session that needs
@@ -952,12 +925,10 @@ scribe_reached() {
# which produces one extra full line per rule and no other harm.
_SCRIBE_RULE_TTL=2700
-# 45 MINUTES, and the reasoning rather than the number (rule 32).
+# 45 MINUTES, and the reasoning rather than the number.
#
-# There is no data on this yet, so it is a judgement made to be revised — the
-# telemetry that would settle it is the one #3807 just built, and a reading of
-# how often an aged-out rule gets PULLED after it returns is what should move
-# this.
+# It is a judgement made to be revised: how often an aged-out rule gets PULLED
+# after it returns is the reading that should move it.
#
# Too short and the exclusion stops existing and the repetition it prevents
# comes back. Too long and it never fires at all in a session short enough to
@@ -966,11 +937,10 @@ _SCRIBE_RULE_TTL=2700
# it governs, short enough that a multi-hour session gets a genuine refresh
# rather than one 9am mention.
#
-# Being wrong on the short side is now the cheaper error, which is why this
-# leans short. Since #3750 an excluded rule is REFERENCED rather than withheld,
-# so the ledger is no longer the only thing standing between a session and a
-# rule it has forgotten — an expired entry costs one full line instead of one
-# short one, and the exclusion re-arms the moment it is spent.
+# Being wrong on the short side is the cheaper error, which is why this leans
+# short. An excluded rule is REFERENCED rather than withheld, so an expired
+# entry costs one full line instead of one short one, and the exclusion
+# re-arms the moment it is spent.
# Live ids from a ledger, comma-joined for `exclude_rule_ids`. Empty output for
# a missing, empty or fully-aged file — the callers already treat "" as "send
@@ -983,13 +953,10 @@ _SCRIBE_RULE_TTL=2700
# the loudest possible failure, from the mechanism meant to quieten things.
# Appends are chronological, so the last line for an id is its most recent.
#
-# A BARE ID — no tab, no timestamp — IS LIVE. That is the pre-#3751 format, and
-# a session in flight when this ships has a ledger full of them. Treating
-# unknown as expired would make every one of those sessions re-announce every
-# rule it had already been told, all at once, which is precisely the noise this
-# exists to prevent. Unknown means "not measured" and never "old" — the same
-# null discipline the retrieval_logs columns use. Those entries simply never
-# age, which is bounded: the session ends.
+# A BARE ID — no tab, no timestamp — IS LIVE. Unknown means "not measured" and
+# never "old": treating it as expired would re-announce every such rule at
+# once, which is precisely the noise this exists to prevent. Those entries
+# simply never age, which is bounded: the session ends.
scribe_rules_live() {
local f="$1" now
[ -n "$f" ] && [ -f "$f" ] || return 0
@@ -1017,7 +984,7 @@ scribe_rules_live() {
# Append surfaced ids, stamped. Reads ids on stdin, one per line — the shape
# `scribe_json_list "$flat" '.rule_ids'` already produces at both call sites.
scribe_rules_append() {
- # Writes TWO files, and the second one is the point (#4217).
+ # Writes TWO files, and the second one is the point.
#
# `$f` is an EXCLUSION ledger: it answers "does this context already hold
# this?", so it is aged by TTL and swept at every compaction — after a
@@ -1027,13 +994,11 @@ scribe_rules_append() {
# The twin is an EVIDENCE ledger: it answers "did this happen?", which no
# compaction can make untrue. It is never aged and never swept.
#
- # These are opposite lifetimes and `.opened.ids` was serving both, which is
- # how a session with 45 `get_rule` calls came to report none: the compaction
- # cleared the ledger that was also the record. Measured across six sessions
- # on this instance — 208 opens, 3 surviving — before the split.
+ # These are opposite lifetimes, so one file cannot serve both: a compaction
+ # would clear the ledger that is also the record.
#
- # DERIVED HERE rather than listed at the call sites, because a list is what
- # broke this before (see `scribe_clear_session_ledgers`). Every ledger
+ # DERIVED HERE rather than listed at the call sites, because a list goes
+ # stale (see `scribe_clear_session_ledgers`). Every ledger
# written through this function gets its twin, including the next one
# somebody adds. The cost is a second small file per ledger per session.
local f="$1" now
@@ -1055,19 +1020,7 @@ scribe_ledger_kept() {
} END { if (out != "") print out }' "$f" 2>/dev/null || true
}
-# The OPENED ledger's contribution to a rule arm's query string (#4100).
-#
-# TWO LEDGERS, BECAUSE THEY RECORD TWO DIFFERENT FACTS. `.rules.ids` holds
-# every id an arm has NAMED; `.opened.ids` holds the ids the session actually
-# read, written by scribe_record_opened.sh from the `get_rule` call itself.
-# Named is not read: the injected line is a teaser, and one skimmed past
-# leaves nothing behind — least of all across a compaction. Sending both lets
-# the server tell a reader who opened a rule from one who was only shown it,
-# instead of telling them both the same untrue thing.
-#
-# Same reader as the naming ledger on purpose, so ageing, the last-entry-wins
-# rule and the bare-id format are defined once and cannot drift apart.
-# ── The pre-act checkpoint's session ledger (#4214, milestone 419) ─────────
+# ── The pre-act checkpoint's session ledger ──────────────────────────────
#
# A checkpoint STOPS an act rather than annotating it, so unlike every other
# ledger here its job is to make sure the same stop cannot happen twice. Two
@@ -1106,7 +1059,7 @@ scribe_checkpoint_allowed() {
n=$(grep -c '^[0-9][0-9]*$' "$f" 2>/dev/null || printf '0')
# `grep -c` over a missing file can print nothing; a bare arithmetic test
# on an empty string is a syntax error in some shells and silently true in
- # others, which is how a cap comes to cap nothing (#3191's shape).
+ # others, which is how a cap comes to cap nothing.
n=$(printf '%s' "$n" | tr -cd '0-9')
[ -n "$n" ] || n=0
[ "$n" -ge "$_SCRIBE_CHECKPOINT_CAP" ] && return 1
@@ -1135,6 +1088,18 @@ scribe_json_deny() {
"$1" "$esc"
}
+# The OPENED ledger's contribution to a rule arm's query string.
+#
+# TWO LEDGERS, BECAUSE THEY RECORD TWO DIFFERENT FACTS. `.rules.ids` holds
+# every id an arm has NAMED; `.opened.ids` holds the ids the session actually
+# read, written by scribe_record_opened.sh from the `get_rule` call itself.
+# Named is not read: the injected line is a teaser, and one skimmed past
+# leaves nothing behind — least of all across a compaction. Sending both lets
+# the server tell a reader who opened a rule from one who was only shown it,
+# instead of telling them both the same untrue thing.
+#
+# Same reader as the naming ledger on purpose, so ageing, the last-entry-wins
+# rule and the bare-id format are defined once and cannot drift apart.
scribe_held_query() {
local ids
ids=$(scribe_rules_live "$1")
@@ -1142,8 +1107,8 @@ scribe_held_query() {
return 0
}
-# The Systems whose rulings this session was already shown in full (milestone
-# 444), as the `seen_ruling_systems` query both PreToolUse hooks send. A flat
+# The Systems whose rulings this session was already shown in full, as the
+# `seen_ruling_systems` query both PreToolUse hooks send. A flat
# read, not aged like the rule file: a ruling binds for the whole session, and
# the server already answers a repeat with one short reference line. The file
# follows the ledger name convention, so a compaction clears it and the
@@ -1156,20 +1121,11 @@ scribe_rulings_query() {
return 0
}
-# Drop EVERY per-session ledger, matched by convention rather than listed (#4101).
+# Drop EVERY per-session ledger, matched by convention rather than listed.
#
-# A LIST IS THE BUG. Until now the compact/clear branch named its files one at
-# a time, and it named two of the five: `.rules.ids` and `.opened.ids` were
-# cleared while `.ids` (notes), `.sync.ids` (shape signals) and `.derive.ids`
-# survived. So milestone 386's defect — "a compaction destroys the context but
-# not the ledger, so the most applicable records become permanently
-# unreachable mid-session" — was fixed for rules and left standing on the note
-# and snippet surfaces, which are the ones that fire most often.
-#
-# Nobody decided that. The list was written when rules were the only ledger
-# that mattered and was never revisited when the others arrived, which is what
-# a hand-maintained list of "things to remember to clean up" does. Adding three
-# more `rm` lines would rebuild the same trap for the sixth ledger.
+# A compaction destroys the context, so a ledger that survives it makes the
+# records it lists unreachable for the rest of the session. A hand-kept list
+# of files to clear misses whichever ledger was added after it was written.
#
# So the rule is the NAME: a per-session ledger is `[.].ids`, and
# everything matching that goes. A new ledger following the convention is
@@ -1180,21 +1136,19 @@ scribe_rulings_query() {
# compact rather than discarded can still live in these directories without
# being swept away by a glob that was never told about it. `.unreached`
# is the live example: it records that the instance could not be reached, not
-# what the session holds, and #2932 needs it to outlive a compaction.
+# what the session holds, and the once-per-outage line needs it to outlive a
+# compaction.
#
-# TWO DIRECTORIES, WHICH IS ITS OWN LESSON. The first cut of this swept only
-# `scribe-priorart` — every ledger named in the hooks was there, so the list
-# looked complete. `scribe_autoinject.sh` keeps its note ledger in
-# `scribe-autoinject`, so the arm that fires most (598 calls in five days) was
-# the one the clear could not reach, and the fix read as finished. Hence the
-# roster here rather than a path at the call site: one place to add to, and
-# the tests read THIS string rather than a copy of it.
+# TWO DIRECTORIES: `scribe_autoinject.sh` keeps its note ledger in
+# `scribe-autoinject`, apart from the prior-art hooks'. The roster lives here
+# rather than as a path at each call site: one place to add to, and the tests
+# read THIS string rather than a copy of it.
SCRIBE_LEDGER_DIRS="scribe-priorart scribe-autoinject"
-# THE WRITTEN-SHAPES LEDGER (milestone 439). Every definition a write names,
+# THE WRITTEN-SHAPES LEDGER. Every definition a write names,
# appended as `pathkindname`, for the Stop hook to ask about at the
# end of the turn (scribe_shape_check.sh). Its own file — one file, one
-# question (lesson #4226) — under the prior-art directory, so the session
+# question — under the prior-art directory, so the session
# clear above already sweeps it. Read `kindname` lines on stdin (the
# scribe_defs format).
#
@@ -1226,7 +1180,7 @@ scribe_written_append() {
return 0
}
-# THE REPLY-SHAPE LEDGER (milestone 500). Which default reply shapes this
+# THE REPLY-SHAPE LEDGER. Which default reply shapes this
# session has been shown IN FULL — `core`, `completion`, `asks`, `plan` — one
# key per line in .shapes.ids under the prior-art directory. The server
# sends a shape in full when its key is absent and as a one-line reminder when
@@ -1274,10 +1228,10 @@ scribe_clear_session_ledgers() {
}
# ---------------------------------------------------------------------------
-# WHAT HAPPENED IN THE LAST TURN of a transcript (#4107): scribe_turn.awk's
+# WHAT HAPPENED IN THE LAST TURN of a transcript: scribe_turn.awk's
# facts — bounded, closed, task_ids, reply — one `KEYVALUE` per line.
# Shared by the two Stop hooks that read a turn: the report check and the
-# reply check (milestone 458). Empty output when the turn cannot be bounded.
+# reply check. Empty output when the turn cannot be bounded.
#
# The turn is parsed once. A window of recent lines, flattened record by
# record and then read by scribe_turn.awk, which carries the turn-bounding
@@ -1289,9 +1243,8 @@ scribe_clear_session_ledgers() {
# WHERE THE TURN STARTS, FOUND BEFORE PARSING RATHER THAN AFTER. The window is
# 3000 lines and routinely 7MB, of which a turn is the last few hundred lines
# and about a sixth of the bytes — the rest is tool results these checks never
-# look at. The predecessor parsed all of it and threw most away, which jq
-# could afford and a parser written in awk cannot: measured at 6.5s for a 7MB
-# window against 94ms, on a hook that runs at the end of every turn.
+# look at. Parsing all of it in awk takes seconds (6.5s for a 7MB window,
+# against 94ms narrowed first), on a hook that runs at the end of every turn.
#
# So grep — C, and reading a FIXED string — narrows first. A prompt record is
# `"type":"user"` whose `content` is a STRING; a tool result is the same type
@@ -1342,14 +1295,12 @@ scribe_turn_fact() {
}
# ---------------------------------------------------------------------------
-# WHICH PROJECT IS THIS DIRECTORY'S? (#4085)
+# WHICH PROJECT IS THIS DIRECTORY'S?
#
-# Every hook here scopes its request to a project, and until this existed all
-# six did it the same single way: `git remote get-url origin`, resolved
-# server-side through the repo bindings. That works well inside a bound repo
-# and not at all outside one — a session in a plain directory got no project
-# scope from ANY hook, silently, because the one key the whole chain turns on
-# only exists in a git repo.
+# Every hook here scopes its request to a project. The first key is `git
+# remote get-url origin`, resolved server-side through the repo bindings. That
+# works inside a bound repo and not at all outside one, where a session would
+# get no project scope from any hook.
#
# The second key is a `.scribe` file in the directory (or above it, the way
# git finds its root), naming the project the work belongs to. It is a
diff --git a/plugin/hooks/scribe_json.awk b/plugin/hooks/scribe_json.awk
index 6c6c8965..cad996ca 100644
--- a/plugin/hooks/scribe_json.awk
+++ b/plugin/hooks/scribe_json.awk
@@ -1,4 +1,4 @@
-# Scribe plugin — JSON, read without jq (#4107).
+# Scribe plugin — JSON, read without jq.
#
# WHY THIS FILE EXISTS. Every hook here parsed JSON with `jq`, and every hook
# opened with `command -v jq >/dev/null 2>&1 || exit 0`. jq is not installed by
@@ -28,8 +28,8 @@
#
# EVERY ARRAY ALSO EMITS `PATH[#]` with its LENGTH, including an empty one —
# which is what makes "the server answered with zero notes" distinguishable
-# from "the server did not answer", a distinction #2932 built a whole outage
-# marker around. An empty object emits `PATH{#}` 0 for the same reason.
+# from "the server did not answer", the distinction the outage line rests
+# on. An empty object emits `PATH{#}` 0 for the same reason.
#
# TWO MODES, because the two jobs have opposite failure behaviour:
#
diff --git a/plugin/hooks/scribe_moment.sh b/plugin/hooks/scribe_moment.sh
index da99d0d3..9ba1d7be 100644
--- a/plugin/hooks/scribe_moment.sh
+++ b/plugin/hooks/scribe_moment.sh
@@ -1,6 +1,6 @@
#!/usr/bin/env bash
# Scribe — PreToolUse moment arm: rules MOUNTED on a moment arrive when the
-# moment happens (milestone 458 step 4).
+# moment happens.
#
# The other PreToolUse arms search: they turn the act into a query and hope a
# rule's words resemble it. A mount needs no resemblance. The operator said
@@ -59,7 +59,7 @@ cache_dir="${TMPDIR:-/tmp}/scribe-moment"
mkdir -p "$cache_dir" 2>/dev/null || true
now=$(date +%s 2>/dev/null) || now=0
-# ── The acts ledger (milestone 458 step 7) ────────────────────────────────
+# ── The acts ledger ──────────────────────────────────────────────────────
#
# Every call, before any filter: `epochevent`, one line each. When the
# session then opens a rule, scribe_record_opened.sh sends the last few
@@ -122,7 +122,7 @@ ledger_q=""
rule_seen=$(scribe_rules_live "$rulefile")
[ -n "$rule_seen" ] && ledger_q="&exclude_rule_ids=${rule_seen}"
ledger_q="${ledger_q}$(scribe_held_query "$state_dir/${safe_sid}.opened.ids")"
-# The reply-shape ledger (milestone 500): a shape this session already holds in
+# The reply-shape ledger: a shape this session already holds in
# full comes back as its reminder.
shapefile=$(scribe_shapes_file "$safe_sid")
shapes_seen=$(scribe_shapes_seen "$shapefile")
diff --git a/plugin/hooks/scribe_precompact_preserve.sh b/plugin/hooks/scribe_precompact_preserve.sh
index 568d7940..5cab98b6 100644
--- a/plugin/hooks/scribe_precompact_preserve.sh
+++ b/plugin/hooks/scribe_precompact_preserve.sh
@@ -1,12 +1,7 @@
#!/usr/bin/env bash
-# Scribe plugin — PreCompact: tell the summarizer what must survive (#3680).
+# Scribe plugin — PreCompact: tell the summarizer what must survive.
#
-# WHAT THIS HOOK ACTUALLY DOES, and why it is not what the design first assumed.
-#
-# Spike #3680 asked whether a PreCompact hook can reach the model, and note
-# #3679 assumed the channel would be `hookSpecificOutput.additionalContext`
-# plus a block. Both halves of that are wrong, read out of the installed build
-# (Claude Code 2.1.273):
+# HOW A PRECOMPACT HOOK REACHES THE MODEL (as read out of Claude Code 2.1.273):
#
# * `additionalContext` is NEVER read on PreCompact. The hook-output schema
# has no PreCompact variant at all; the field is honoured for SessionStart,
@@ -18,8 +13,8 @@
# operator typed after `/compact` and passed into the prompt that writes
# the summary. Every path does this — manual, auto and partial compaction.
#
-# So there IS a model-reaching channel, and it is better than the one designed:
-# we do not interrupt the compaction, we steer the summary it produces. The
+# So the channel is the summary itself: we do not interrupt the compaction, we
+# steer the summary it produces. The
# summary is what the next turn reads, so text that survives into it survives
# the compaction.
#
@@ -27,8 +22,7 @@
# `{"decision":"block"}`) does not pause for the model and cannot tell it
# anything — the compaction is SKIPPED, a warning goes to the operator's
# screen, and the session continues uncompacted toward its context limit with
-# no summary at all. That failure is silent from the model's side, which is
-# exactly the outcome #3680 existed to avoid shipping. This hook exits 0 on
+# no summary at all, silently from the model's side. This hook exits 0 on
# every path.
#
# SCOPE: in a subagent the handler discards hook stdout and keeps only a block,
@@ -49,11 +43,9 @@ set -uo pipefail
# shellcheck source=plugin/hooks/scribe_defs.sh
. "$(dirname "${BASH_SOURCE[0]}")/scribe_defs.sh"
-# THE EVENT IS NOW READ, where it used to be drained. The old comment here said
-# nothing in it changes what we emit, and that was true while every line was
-# static. It stopped being true at #4216: the slippage readout below is
-# specific to THIS session, and `session_id` is the key to the ledgers that
-# hold it. The instruction is still the same whether the operator typed
+# THE EVENT IS READ for `session_id`: the slippage readout below is specific
+# to THIS session, and the id is the key to the ledgers that hold it. The
+# instruction is still the same whether the operator typed
# `/compact` or the session hit its limit — what varies is the measurement
# appended to it.
event=$(cat 2>/dev/null || true)
@@ -83,7 +75,7 @@ Everything else here can be recovered from the repository or from Scribe. These
cannot: they are this session's only copy.
EOF
-# ── The slippage readout (#4216, milestone 419) ───────────────────────────
+# ── The slippage readout ─────────────────────────────────────────────────
#
# WHY IT BELONGS HERE RATHER THAN IN A REPLY. A rule read and left unresolved
# is invisible by construction: it looks exactly like a rule that worked. The
diff --git a/plugin/hooks/scribe_prior_art.sh b/plugin/hooks/scribe_prior_art.sh
index 80516c07..1f0924bf 100755
--- a/plugin/hooks/scribe_prior_art.sh
+++ b/plugin/hooks/scribe_prior_art.sh
@@ -8,16 +8,16 @@
# that path or in its directory, plus snippets resembling the code about to be
# written. Titles + ids only, never bodies.
#
-# The answer comes in two framings (#2708). A snippet recorded AT the exact
+# The answer comes in two framings. A snippet recorded AT the exact
# file being edited is the SYNC class — "you are editing the recorded file;
# updating the record is part of the edit" — which is how records stay current
-# on an instance with no forge connection (decision #2707). Everything else is
+# on an instance with no forge connection. Everything else is
# the REUSE menu. The two dedup separately (see the state files below).
#
-# It is also the shape ledger's write-path feed (#2791): it names the
+# It is also the shape ledger's write-path feed: it names the
# definitions being written (`shapes=`), and the server — only when the
# session has PULLED a snippet this code references or resembles — records
-# it as a PROPOSAL on them ("looks like #N"), never a status (milestone 439).
+# it as a PROPOSAL on them ("looks like #N"), never a status.
# The agent's own end-of-turn judgment is the verdict; the same names go to
# the written-shapes ledger the Stop hook asks about.
#
@@ -27,8 +27,8 @@
# recall aid must not be able to stop the operator's work.
#
# Config (same as the other hooks), exported to the hook by Claude Code with the
-# userConfig key UPPERCASED (see #2198 — the lowercase spelling reads as empty
-# and this hook then exits 0 in silence, looking exactly like "no prior art"):
+# userConfig key UPPERCASED (the lowercase spelling reads as empty, and this
+# hook then exits 0 in silence, looking exactly like "no prior art"):
# CLAUDE_PLUGIN_OPTION_API_ENDPOINT base URL, no trailing slash
# CLAUDE_PLUGIN_OPTION_API_TOKEN fmcp_ API key (sensitive)
# SCRIBE_URL / SCRIBE_TOKEN override for the settings.json dogfooding path.
@@ -36,10 +36,10 @@ set -uo pipefail
command -v curl >/dev/null 2>&1 || exit 0
-# Shared with the after-write hook (#2901): the prose/data skip list, the
-# definition extractor and the local by-name duplicate arm live in
-# scribe_defs.sh so the two hooks cannot drift apart. Sourced FIRST because the
-# JSON reader is there too now (#4107).
+# Shared with the after-write hook: the prose/data skip list, the definition
+# extractor and the local by-name duplicate arm live in scribe_defs.sh so the
+# two hooks cannot drift apart. Sourced FIRST because the JSON reader is there
+# too.
# shellcheck source=plugin/hooks/scribe_defs.sh
. "$(dirname "${BASH_SOURCE[0]}")/scribe_defs.sh"
@@ -77,11 +77,11 @@ if [ -n "$repo_root" ]; then
esac
fi
-# ARM 1 — BY NAME, LOCALLY (#2280): does a definition of this already exist
+# ARM 1 — BY NAME, LOCALLY: does a definition of this already exist
# in the repo? (scribe_local_dups in scribe_defs.sh carries the why.)
names=""
if [ -n "$code" ]; then
- # `|| true` inside: an early-exiting `head` must not void its own output (#4042).
+ # `|| true` inside: an early-exiting `head` must not void its own output.
names=$(printf '%s' "$code" | scribe_defs | sort -u | head -12 || true)
fi
@@ -97,7 +97,7 @@ if [ -n "$local_lines" ]; then
fi
# ---------------------------------------------------------------------------
-# THE LEDGER FEED (#2791). The server keeps a shape ledger — every definition
+# THE LEDGER FEED. The server keeps a shape ledger — every definition
# in the bound repo, classified against recorded canon — and this hook is the
# one place that sees a shape AT THE MOMENT IT IS WRITTEN. So it names the
# shapes in play: every definition in the payload, or — for an Edit that
@@ -109,14 +109,9 @@ fi
# names, not bodies.
# ---------------------------------------------------------------------------
#
-# THE ENCLOSING DEFINITION IS THE LAST ONE AT OR ABOVE THE EDIT, and taking it
-# needs no `tac`. This read `head -n $ln | tac | scribe_defs | head -1` — reverse
-# the lines, extract, take the first. `tac` is GNU-only: it is absent on macOS
-# (whose equivalent is `tail -r`), so the guard below it meant this arm did
-# nothing at all on every Mac, silently, since the day it shipped. scribe_defs
-# judges each line independently, so reversing first and taking the head is the
-# same answer as extracting forward and taking the tail — and `tail` also drops
-# the `head -1` whose early exit is the SIGPIPE trap #4042 was filed for.
+# THE ENCLOSING DEFINITION IS THE LAST ONE AT OR ABOVE THE EDIT: extract
+# forward and take the tail. No `tac`, which is GNU-only and absent on macOS,
+# and no `head -1`, whose early exit is a SIGPIPE trap under pipefail.
shapes="$names"
if [ -z "$shapes" ] && [ -f "$file_path" ]; then
old_first=$(scribe_json_pick "$event_flat" '.tool_input.old_string')
@@ -156,12 +151,9 @@ fi
# built a URL from the whole payload. head -c caps the total, which is the point.
q=$(printf '%s' "$code" | head -c 1200)
-# Encoded whole, never line by line. The predecessor (`jq -rR`) read input LINE
-# BY LINE, so a multi-line payload came back as several separately-encoded lines
-# joined by raw newlines — an invalid URL that made curl fail, and this hook then
-# exited 0 in silence. Newlines are exactly what code contains, so this hook
-# could never have worked that way (issue #2198 / #2082). scribe_urlenc reads
-# bytes and has no notion of a line.
+# Encoded whole, never line by line: a payload encoded per line comes back as
+# separately-encoded lines joined by raw newlines, an invalid URL, and code is
+# full of newlines. scribe_urlenc reads bytes and has no notion of a line.
path_enc=$(printf '%s' "$rel_path" | scribe_urlenc)
[ -n "$path_enc" ] || exit 0
code_enc=$(printf '%s' "$q" | scribe_urlenc)
@@ -176,7 +168,7 @@ repo_q=""
# each other: a title that flew past in a prompt menu twenty turns ago is
# exactly what should reappear at the moment the duplicate is being written.
#
-# TWO channels, not one (#2708). The server answers in two classes — REUSE
+# TWO channels, not one. The server answers in two classes — REUSE
# ("something similar/nearby is recorded") and SYNC ("a snippet records the
# exact file being edited — updating the record is part of the edit"). They
# dedup separately: a reuse hint shown early in the session must not suppress
@@ -184,16 +176,16 @@ repo_q=""
state_dir="${TMPDIR:-/tmp}/scribe-priorart"
mkdir -p "$state_dir" 2>/dev/null || true
#
-# A THIRD channel (#2900): the ledger's derive arm names a duplicate family
+# A THIRD channel: the ledger's derive arm names a duplicate family
# (a derive group id) or a canon elsewhere (`canon:`) for the
# shapes being written. Keyed by that token, not a note id, so it dedups on
# its own file and a family is named once per session, not at every edit.
#
-# A FOURTH channel (milestone 307): standing RULES the write resembles. Its own
+# A FOURTH channel: standing RULES the write resembles. Its own
# file for the same reason as the others — a rule named once should not be
# re-offered on every subsequent write in the session.
#
-# A FIFTH (milestone 444): the Systems whose RULINGS were shown in full. SHARED
+# A FIFTH: the Systems whose RULINGS were shown in full. SHARED
# with scribe_tool_rules.sh, like the rule file: an area's rulings shown before
# a command must not be repeated in full before the next edit there.
idfile=""
@@ -208,7 +200,7 @@ rulingsfile=""
rulings_q=""
if [ -n "$session_id" ]; then
safe_sid=$(printf '%s' "$session_id" | tr -c 'A-Za-z0-9._-' '_')
- # What this write defined, for the end-of-turn question (milestone 439). A
+ # What this write defined, for the end-of-turn question. A
# Write onto a path that does not exist yet creates a file, which may be a
# unit in its own right (see scribe_written_append). Written before any
# server call: the ask at the end of the turn must not depend on this
@@ -234,17 +226,17 @@ if [ -n "$session_id" ]; then
derive_seen=$(tr '\n' ',' < "$derivefile" 2>/dev/null | sed 's/,$//' | scribe_urlenc) || derive_seen=""
[ -n "$derive_seen" ] && derive_exclude_q="&exclude_derive=${derive_seen}"
fi
- # Ageing, not a flat read (#3751), and the ONLY ledger here that ages: the
+ # Ageing, not a flat read, and the ONLY ledger here that ages: the
# note channels above are a different question with a different answer, and
# this arm's sibling hook reads the same rule file through the same helper.
rule_seen=$(scribe_rules_live "$rulefile")
[ -n "$rule_seen" ] && rule_exclude_q="&exclude_rule_ids=${rule_seen}"
- # What the session actually OPENED, as against what it was shown (#4100).
+ # What the session actually OPENED, as against what it was shown.
rule_exclude_q="${rule_exclude_q}$(scribe_held_query "$state_dir/${safe_sid}.opened.ids")"
fi
# Not `|| exit 0`: an unreachable instance must not discard a local finding
-# that needed no instance to produce. And not silence either (#2932): a call
+# that needed no instance to produce. And not silence either: a call
# that was owed and didn't come back is said, once per outage, so the session
# knows this write went unchecked.
reached=1
@@ -285,13 +277,13 @@ if [ -n "$body" ]; then
fi
fi
-# ARM 1½ — the RECORD nudge (#2664). The local arm just proved the thing being
+# ARM 1½ — the RECORD nudge. The local arm just proved the thing being
# written already exists elsewhere in this repo, and Scribe returned no record
# of anything for it. That is the one moment "record it" is earned rather than
# noise: the duplication is demonstrated, not guessed. Gated on BOTH sides so
# an ordinary new helper (no other copies) and an already-recorded one (the
# server spoke) stay nudge-free — a reflex that fires on everything is one
-# that gets skipped. A server that did not ANSWER earns no nudge (#2932): "none
+# that gets skipped. A server that did not ANSWER earns no nudge: "none
# of those copies is recorded" is a claim only an answer can back — the
# unreached line says what actually happened instead.
if [ -n "$local_lines" ] && [ "$reached" = 1 ]; then
@@ -302,11 +294,11 @@ if [ -n "$local_lines" ] && [ "$reached" = 1 ]; then
fi
# ---------------------------------------------------------------------------
-# ARM 0 — THE CONTRACT AROUND THE CHANGE (#4215, milestone 419).
+# ARM 0 — THE CONTRACT AROUND THE CHANGE.
#
# "You altered the arity, name or shape of something — here is everything that
-# reads it." Rule 33's interface-contract check one scope down: not between
-# layers but between a definition and its callers.
+# reads it." An interface-contract check between a definition and its
+# callers.
#
# LOCAL AND SERVERLESS, like ARM 1. It needs the working tree and nothing
# else — the server has no checkout, so this is the only place the question
diff --git a/plugin/hooks/scribe_record_opened.sh b/plugin/hooks/scribe_record_opened.sh
index 8dec556b..9a42c54a 100644
--- a/plugin/hooks/scribe_record_opened.sh
+++ b/plugin/hooks/scribe_record_opened.sh
@@ -1,25 +1,23 @@
#!/usr/bin/env bash
-# Scribe — record that this session OPENED a rule, not merely saw it named (#4100).
+# Scribe — record that this session OPENED a rule, not merely saw it named.
#
-# WHAT THIS CLOSES
+# WHAT THIS ANSWERS
#
-# The rule arms keep a ledger of every id they have NAMED, and the injected
-# line used to tell the reader "You saw it earlier this session". That claim
-# was never checked. The line those arms emit is a TEASER — title, trigger,
-# `get_rule(N)` — so a session can be named a rule twenty times and never read
-# one word of it, and after a compaction the teaser is summarised away leaving
-# nothing at all. The server was asserting something about the reader's
-# context that it had no way to know.
+# The rule arms keep a ledger of every id they have NAMED. The line those arms
+# emit is a TEASER — title, trigger, `get_rule(N)` — so a session can be named
+# a rule twenty times and never read one word of it, and after a compaction
+# the teaser is summarised away leaving nothing at all. Named is not read, so
+# the server needs to be told which rules were opened.
#
# This is the observable half. PostToolUse fires for MCP tools (the event's own
# output schema carries `updatedMCPToolOutput`, which would be meaningless
# otherwise), so the `get_rule` CALL can be watched directly.
#
-# WHY THIS IS NOT THE SELF-REPORT MILESTONE 386 REJECTED
+# WHY THIS IS NOT SELF-REPORT
#
-# 386 ruled out asking the session whether it holds a rule, because a model
-# asked "do you still hold rule 156?" will say yes and the answer is
-# unverifiable self-report. That objection is about ASKING. This asks nobody:
+# Asking the session whether it holds a rule is useless: a model asked "do you
+# still hold rule N?" will say yes, and the answer is unverifiable. That
+# objection is about ASKING. This asks nobody:
# a tool call happened or it did not, and the harness reports it either way.
# Recording what a session DID is a different kind of evidence from believing
# what it says about itself.
@@ -28,13 +26,12 @@
#
# It does not prove the rule is still in context — nothing can, and a
# compaction can drop it moments later. That is why `.opened.ids` ages exactly
-# like `.rules.ids` and is cleared on the same events (#3749): both ledgers
+# like `.rules.ids` and is cleared on the same events: both ledgers
# describe a context that no longer exists once the context is destroyed. The
# claim it supports is only ever "you opened this, pull it again if you no
# longer hold it", which stays true in every case and carries its own remedy.
#
-# SINCE MILESTONE 458 STEP 7 it also reports the open, with the calls that
-# came just before it, so the server can learn which moments a rule belongs
+# It also reports the open, with the calls that came just before it, so the server can learn which moments a rule belongs
# on (below). Silent on outage, like every arm that fires per call.
#
# EXIT 0, ALWAYS. This decorates a ledger; a bookkeeping failure must never
@@ -73,7 +70,7 @@ safe_sid=$(printf '%s' "$session_id" | tr -c 'A-Za-z0-9._-' '_')
# (`scribe_rules_live`) ages both, and the last entry for an id wins.
printf '%s\n' "$rule_id" | scribe_rules_append "$state_dir/${safe_sid}.opened.ids"
-# ── What came just before the open (milestone 458 step 7) ─────────────────
+# ── What came just before the open ───────────────────────────────────────
#
# scribe_moment.sh writes every call to `scribe-moment/.acts`. The ones
# from the last three minutes go to the server with this rule's id: a rule
diff --git a/plugin/hooks/scribe_record_outcome.sh b/plugin/hooks/scribe_record_outcome.sh
index b899549f..489d5ad1 100755
--- a/plugin/hooks/scribe_record_outcome.sh
+++ b/plugin/hooks/scribe_record_outcome.sh
@@ -1,16 +1,16 @@
#!/usr/bin/env bash
# Scribe — record that this session said what a rule DID, not merely that it
-# read one (#4216, milestone 419).
+# read one.
#
-# THE THIRD LEDGER, AND WHY THE TWO THAT EXIST ARE NOT ENOUGH.
+# THE THIRD LEDGER, AND WHY THE OTHER TWO ARE NOT ENOUGH.
#
-# `.rules.ids` says a rule was NAMED. `.opened.ids` says it was READ (#4100 —
-# a PostToolUse hook watches the `get_rule` call, so it is a recorded event
+# `.rules.ids` says a rule was NAMED. `.opened.ids` says it was READ (a
+# PostToolUse hook watches the `get_rule` call, so it is a recorded event
# rather than a model's claim about its own context). Neither can say what
-# happened next, and that is the whole of what milestone 419 is about: a rule
-# read and followed and a rule read and forgotten leave identical traces.
+# happened next: a rule read and followed and a rule read and forgotten leave
+# identical traces.
#
-# `rule_outcome` (#4212) is the call that closes that gap, and the server
+# `rule_outcome` is the call that closes that gap, and the server
# records it. But the server's row carries no session — `rule_usage_events` is
# per user over a window — so a SESSION-scoped readout cannot be asked of it.
# The question "which rules changed something in THIS session" has to be
@@ -18,13 +18,11 @@
#
# SAME EVIDENCE CLASS AS `.opened.ids`, deliberately. A tool call happened or
# it did not, and the harness reports it either way; nothing here asks the
-# model whether it followed anything. That is the distinction milestone 386
-# drew when it ruled out self-report, and this stays on the right side of it.
+# model whether it followed anything, so it is evidence, not self-report.
#
# WHAT IT CANNOT SAY: that the rule was followed WELL, or that `applied` was
# honest. It records that an outcome was declared. The value of that is not the
-# claim itself — it is that the absence of one becomes visible, which is the
-# state nothing could previously name.
+# claim itself — it is that the absence of one becomes visible.
#
# EXIT 0, ALWAYS. This decorates a ledger; a bookkeeping failure must never
# turn a successful tool call into a hook error.
diff --git a/plugin/hooks/scribe_reply_check.sh b/plugin/hooks/scribe_reply_check.sh
index 848d6a69..edc14dd4 100644
--- a/plugin/hooks/scribe_reply_check.sh
+++ b/plugin/hooks/scribe_reply_check.sh
@@ -1,6 +1,5 @@
#!/usr/bin/env bash
-# Scribe — Stop hook: the reply moment (milestone 458 step 4, folded in from
-# milestone 456 step 8).
+# Scribe — Stop hook: the reply moment.
#
# A reply is the one act no tool call marks, and it is where "please check
# this on your end" or "it's done" gets said — so every arm that fires on a
@@ -9,11 +8,10 @@
# text against every rule's trigger, as the backstop for whatever the earlier
# arms missed. An unopened rule from either half holds the reply for one read.
#
-# ONE END-OF-TURN REQUEST (milestone 500 step 4). When the turn closed a task,
-# the same request carries the task ids and the server also checks the reply
-# for the completion sections — once a Stop hook of its own
-# (scribe_report_check.sh), now the same call. The section check's outcome is
-# recorded (`report_check`, milestone 409's adherence number), and when it
+# ONE END-OF-TURN REQUEST. When the turn closed a task, the same request
+# carries the task ids and the server also checks the reply for the completion
+# sections. The section check's outcome is recorded (`report_check`, the
+# adherence number), and when it
# held the reply, the rewrite is sent back once with `rewrite: true` so the
# server can record how it came out. A rewrite is never held.
#
diff --git a/plugin/hooks/scribe_session_context.sh b/plugin/hooks/scribe_session_context.sh
index d672e31b..20b8bb26 100755
--- a/plugin/hooks/scribe_session_context.sh
+++ b/plugin/hooks/scribe_session_context.sh
@@ -4,9 +4,9 @@
# Tier 1 (STATIC, always fires, no auth, no network): injects the Claude Code
# adapter's own guidance (scribe_static_context.md) — where Scribe's reflexes
# are stated (the using-scribe skill), Claude Code's memory files, /compact,
-# /scribe:sync, and what to do when Scribe is unavailable. Since milestone 410
-# (decision #4027) it carries only what this client needs said; the reflexes
-# themselves are owned by the shared skills and the server's index.
+# /scribe:sync, and what to do when Scribe is unavailable. It carries only what
+# this client needs said; the reflexes themselves are owned by the shared
+# skills and the server's index.
#
# Tier 2 (DYNAMIC, best-effort enrichment): curls the operator's Scribe instance
# for active-project context and appends it. Config comes from
@@ -15,8 +15,8 @@
# CLAUDE_PLUGIN_OPTION_API_TOKEN fmcp_ API key (sensitive)
# NOTE THE CASE: Claude Code uppercases the userConfig key when exporting it, so
# the `api_token` option arrives as CLAUDE_PLUGIN_OPTION_API_TOKEN. Reading the
-# lowercase spelling silently yields nothing — that was issue #2198, and it
-# disabled the dynamic tier, auto-inject, and the write-path trigger at once.
+# lowercase spelling silently yields nothing, which disables the dynamic tier,
+# auto-inject and the write-path trigger at once.
# The active project is resolved server-side from the working repo's git remote
# (see services/repo_bindings); bind each repo once with the bind_repo MCP tool.
#
@@ -27,8 +27,8 @@
# in-flight tasks from Scribe. This stays the durable path: record-as-you-go
# plus a post-compaction reload from the instance.
#
-# The other half arrived with #3680. A host hook still cannot make the model
-# flush before a compaction — but scribe_precompact_preserve.sh steers the
+# The other half: a host hook cannot make the model flush before a compaction,
+# but scribe_precompact_preserve.sh steers the
# SUMMARY, because a PreCompact hook's stdout becomes the compaction's custom
# instructions. The two are complementary, and neither replaces the other: that
# hook decides what survives into the summary, this one reloads from the record
@@ -43,7 +43,7 @@
# FAIL-OPEN, BUT NOT SILENT: the dynamic tier never blocks a session, and every
# way it can come up empty produces a short status line — failed fetch, missing
# token, and missing-everything alike. Nothing about the credential path is
-# allowed to fail quietly; see the #2198 comment at the status block below.
+# allowed to fail quietly; see the comment at the status block below.
set -uo pipefail
# shellcheck source=plugin/hooks/scribe_defs.sh
@@ -61,7 +61,7 @@ event=$(cat 2>/dev/null || true)
event_flat=$(printf '%s' "$event" | scribe_json_flat)
source=$(scribe_json_pick "$event_flat" '.source')
-# --- The rule ledger outlives the context it describes (#3749) ---
+# --- The rule ledger outlives the context it describes ---
#
# scribe_prior_art.sh and scribe_tool_rules.sh record every rule id they have
# named in /.rules.ids and hand it back as exclude_rule_ids, so a
@@ -78,9 +78,7 @@ source=$(scribe_json_pick "$event_flat" '.source')
#
# The session id survives a compaction — the ledgers are keyed by it and are
# still found under the same name afterwards, so a stale ledger is genuinely
-# reached again rather than orphaned. (This used to point at an etag marker as
-# the evidence for that; milestone 394 removed the preload the etag described,
-# and `plugin_context.py` records its retirement.)
+# reached again rather than orphaned.
#
# CLEARED ON THE SOURCES THAT DESTROY CONTEXT, AND ONLY THOSE:
#
@@ -94,36 +92,28 @@ source=$(scribe_json_pick "$event_flat" '.source')
# fork carries the conversation, so if it inherits the id the ledger
# is accurate, and if it gets a new one the file is empty anyway.
#
-# EVERY LEDGER, AND THE NOTE ARMS NEEDED IT MOST (#4101).
-#
-# This used to clear the two rule ledgers by name and say, in a comment, that
-# leaving `.ids` / `.sync.ids` / `.derive.ids` alone was "a decision rather than
-# an oversight". Reading the note arms says otherwise, on two counts:
+# EVERY LEDGER, AND THE NOTE ARMS NEED IT MOST:
#
# - They are HARD exclusions. `exclude_ids` goes into `semantic_search_notes`
# itself, so a surfaced note is removed from the result set — it is not
-# rendered as a reference the way #3750 made a repeated rule. There is no
-# weaker form for it to fall back to.
-# - They never AGE. #3751 gave the rules ledger a TTL precisely because
-# salience decays without a context event; the note channels were left on a
-# flat read.
+# rendered as a reference the way a repeated rule is. There is no weaker
+# form for it to fall back to.
+# - They never AGE. The rules ledger has a TTL because salience decays
+# without a context event; the note channels are a flat read.
#
-# Hard plus permanent plus never cleared means a note surfaced in the first
-# minute of a session is unreachable for the rest of it, through any number of
-# compactions. That is milestone 386's original defect, alive on the arms that
-# fire most often, and nothing about it was decided.
+# Hard plus permanent plus never cleared would make a note surfaced in the
+# first minute of a session unreachable for the rest of it, through any number
+# of compactions.
#
-# THE LIST WAS THE BUG, so the fix is not a longer list. `scribe_clear_session_
-# ledgers` matches on the naming convention — a per-session ledger is
-# `[.].ids` — which covers all five and covers the sixth on the day
-# it is written. Best-effort, like every other filesystem touch in these hooks:
+# `scribe_clear_session_ledgers` matches on the naming convention — a
+# per-session ledger is `[.].ids` — so it covers every ledger,
+# including one added later. Best-effort, like every other filesystem touch in these hooks:
# a ledger that cannot be removed costs a repeated exclusion, never a session.
#
# NOT swept: `.unreached`, which records that the instance was unreachable
# rather than what the session holds, and survives on purpose. The directories
# swept are `scribe_defs.sh`'s `SCRIBE_LEDGER_DIRS` — plural, because the
-# auto-inject arm keeps its ledger somewhere else and a single-directory sweep
-# missed exactly the arm that fires most.
+# auto-inject arm keeps its ledger somewhere else.
case "$source" in
compact|clear)
sid=$(scribe_json_pick "$event_flat" '.session_id')
@@ -152,8 +142,7 @@ prepend() { if [ -n "$out" ]; then out="$1"$'\n\n---\n\n'"${out}"; else out="$1"
# manifest version changes
#
# So inspecting the clone shows a fix present while the broken copy keeps
-# running, and the obvious debugging move actively misleads (#2209). Twice, the
-# only detector was the operator saying "I don't think it updated".
+# running, and the obvious debugging move actively misleads.
#
# Naming the running version in every session makes that answerable from the
# transcript instead of by archaeology in the cache directory. Deliberately NOT
@@ -177,7 +166,7 @@ dyn=""
status=""
if [ -n "$url" ] && [ -n "$token" ] && command -v curl >/dev/null 2>&1; then
# Which project is this directory's? A `.scribe` marker first, then the git
- # remote (#4085). scribe_scope_query answers that, and is shared with the
+ # remote. scribe_scope_query answers that, and is shared with the
# other five hooks so a directory scopes the same way everywhere; the pieces
# are re-read here only to explain what happened when nothing resolved.
repo_dir=${CLAUDE_PROJECT_DIR:-$PWD}
@@ -187,19 +176,18 @@ 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")
- # The source and the session id decide what the claim section says
- # (milestone 381): a compaction gets back the work this session had claimed,
+ # The source and the session id decide what the claim section says: 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
- # could the operator afterwards. curl's write-out still prints on a failed
- # transfer (as `000`), so the status and the elapsed time come back either way.
+ # ONE FETCH, NAMED WHEN IT FAILS. A timeout, an HTTP error and a refused key
+ # need different fixes, so each gets its own sentence. curl's write-out still
+ # prints on a failed transfer (as `000`), so the status and the elapsed time
+ # come back either way.
fetch_context() {
local resp meta
resp=$(curl -sS --max-time "$1" -w '\n%{http_code} %{time_total}' \
@@ -212,7 +200,7 @@ if [ -n "$url" ] && [ -n "$token" ] && command -v curl >/dev/null 2>&1; then
ctx_code=${meta%% *}
ctx_took=${meta#* }
}
- # Deadlines: 8s is the long-standing first try, sized for a cold instance.
+ # Deadlines: 8s for the first try, sized for a cold instance.
# ONE retry at 6s, and only for failures a second try can change — a
# timeout, a dropped connection, a 5xx. A 4xx is the key or the scope and
# will say the same thing twice. Worst case is ~14s of startup, against a
@@ -227,10 +215,6 @@ if [ -n "$url" ] && [ -n "$token" ] && command -v curl >/dev/null 2>&1; then
body_flat=$(printf '%s' "$body" | scribe_json_flat)
dyn=$(scribe_json_pick "$body_flat" '.context')
fi
- # The rules marker is gone with the resident set it described
- # (milestone 394). Nothing is preloaded, so there is no set whose
- # drift a later write could be told about — a rule is retrieved at
- # the moment it applies, which cannot be stale.
if [ -z "$dyn" ]; then
if [ "$ctx_rc" = 28 ]; then
why="the instance did not answer in time (8s, then ${ctx_took}s on a retry)"
@@ -243,9 +227,9 @@ if [ -n "$url" ] && [ -n "$token" ] && command -v curl >/dev/null 2>&1; then
else
why="the instance answered but sent no context"
fi
- # The first move, stated as one. "As needed" read as optional, and a
- # session that skips it starts with no recent milestones or open tasks —
- # so it cannot know what prior work exists to look for (#4366).
+ # The first move, stated as one rather than as optional: a session that
+ # skips it starts with no recent milestones or open tasks, so it cannot
+ # know what prior work exists to look for.
if [ -n "$marker_id" ] && [ -z "$marker_why" ]; then
first="Start by calling \`enter_project(${marker_id})\`"
else
@@ -256,23 +240,20 @@ if [ -n "$url" ] && [ -n "$token" ] && command -v curl >/dev/null 2>&1; then
elif [ -n "$url" ] && [ -z "$token" ]; then
status="> ⚠️ Scribe: live context disabled this session — the API key is not configured (Scribe base URL is). Set it with \`/plugin\` → Scribe → configure, or export SCRIBE_TOKEN. Tools still work; ask for rules with \`search(content_type=\"rule\")\` and project context with \`enter_project()\`."
elif [ -z "$url" ] && [ -z "$token" ]; then
- # NEITHER value arrived. Previously this case stayed silent as "an unconfigured
- # install", which made issue #2198 invisible for weeks: a *casing* bug here
- # (reading CLAUDE_PLUGIN_OPTION_api_token when Claude Code exports the key
- # UPPERCASED) looks identical to never having configured the plugin, and
- # silently disabled auto-inject and the write-path trigger too. It is not a
- # benign state — the plugin prompts for both values at enable time, so if
- # neither reached the hook, something is wrong. Say so.
+ # NEITHER value arrived. Not a benign "unconfigured install": the plugin
+ # prompts for both values at enable time, so if neither reached the hook,
+ # something is wrong (an env-var casing mismatch looks exactly like this).
+ # Say so.
status="> ⚠️ Scribe: live context disabled this session — neither the Scribe base URL nor the API key reached this hook. Configure the plugin (\`/plugin\` → Scribe), or export SCRIBE_URL + SCRIBE_TOKEN. Note this also disables prompt auto-inject and the write-path prior-art trigger. Tools still work; ask for rules with \`search(content_type=\"rule\")\` and project context with \`enter_project()\`."
fi
[ -n "$dyn" ] && append "$dyn"
[ -n "$status" ] && append "$status"
-# --- Nothing resolved: say WHICH nothing, and what would fix it (#4085) ---
+# --- Nothing resolved: say WHICH nothing, and what would fix it ---
#
-# The server can say "no project is bound to this working directory", and
-# until now that was the whole answer. It is the same sentence for a directory
+# The server can say "no project is bound to this working directory". It is
+# the same sentence for a directory
# that is not a repo, a repo whose remote nobody bound, and a marker file
# naming a project this account cannot read — three different problems with
# three different fixes, and no way to tell them apart from inside the session.
diff --git a/plugin/hooks/scribe_session_end.sh b/plugin/hooks/scribe_session_end.sh
index 1fa8e0a7..44a090b7 100755
--- a/plugin/hooks/scribe_session_end.sh
+++ b/plugin/hooks/scribe_session_end.sh
@@ -1,5 +1,5 @@
#!/usr/bin/env bash
-# Scribe — release this session's task claims as it ends (milestone 381 step 4).
+# Scribe — release this session's task claims as it ends.
#
# 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
@@ -31,8 +31,8 @@ event_flat=$(printf '%s' "$event" | scribe_json_flat)
session_id=$(scribe_json_pick "$event_flat" '.session_id')
[ -n "$session_id" ] || exit 0
-# The moment arm's per-session files (its tool cache and the acts ledger,
-# milestone 458) describe a session that is over, on any exit — a clear
+# The moment arm's per-session files (its tool cache and the acts ledger)
+# describe a session that is over, on any exit — a clear
# included, since the next conversation is a new session id.
safe_sid=$(printf '%s' "$session_id" | tr -c 'A-Za-z0-9._-' '_')
rm -f "${TMPDIR:-/tmp}/scribe-moment/${safe_sid}".* 2>/dev/null || true
diff --git a/plugin/hooks/scribe_shape_check.sh b/plugin/hooks/scribe_shape_check.sh
index c745c6c8..f2d5f86c 100644
--- a/plugin/hooks/scribe_shape_check.sh
+++ b/plugin/hooks/scribe_shape_check.sh
@@ -1,6 +1,6 @@
#!/usr/bin/env bash
# Scribe plugin — Stop hook: the turn's new shapes are judged by the agent
-# that wrote them (milestone 439).
+# that wrote them.
#
# The write hooks (scribe_prior_art.sh, scribe_after_write.sh) append every
# definition a write names to a session ledger, `.written.ids`
diff --git a/plugin/hooks/scribe_sync_processes.sh b/plugin/hooks/scribe_sync_processes.sh
index bc7a5c60..9586d8ec 100755
--- a/plugin/hooks/scribe_sync_processes.sh
+++ b/plugin/hooks/scribe_sync_processes.sh
@@ -19,8 +19,8 @@
# safe as a second SessionStart hook). On any fetch failure it exits without
# touching existing stubs — a transient outage must not wipe the user's skills.
# Config mirrors the context hook: CLAUDE_PLUGIN_OPTION_API_ENDPOINT /
-# CLAUDE_PLUGIN_OPTION_API_TOKEN (userConfig key UPPERCASED by Claude Code — see
-# #2198), with SCRIBE_URL / SCRIBE_TOKEN as the override.
+# CLAUDE_PLUGIN_OPTION_API_TOKEN (userConfig key UPPERCASED by Claude Code),
+# with SCRIBE_URL / SCRIBE_TOKEN as the override.
set -uo pipefail
# shellcheck source=plugin/hooks/scribe_defs.sh
diff --git a/plugin/hooks/scribe_tool_rules.sh b/plugin/hooks/scribe_tool_rules.sh
index 22cd82f5..f39818fe 100644
--- a/plugin/hooks/scribe_tool_rules.sh
+++ b/plugin/hooks/scribe_tool_rules.sh
@@ -1,13 +1,12 @@
#!/usr/bin/env bash
-# Scribe — PreToolUse rule arm for ACTIONS (#3476).
+# Scribe — PreToolUse rule arm for ACTIONS.
#
# The sibling of scribe_prior_art.sh. That hook is registered on Write|Edit and
# asks "what is recorded about the file being written". This one asks "does a
-# standing rule speak to the command about to be run" — the question nothing
-# could ask before, and the reason every rule about which tool to reach for had
-# to live in the preload instead, back when there was one.
+# standing rule speak to the command about to be run" — which is how a rule
+# about which tool to reach for arrives at all.
#
-# WHY A HOOK AND NOT AN INSTRUCTION. A reflex generates no query (note #3089):
+# WHY A HOOK AND NOT AN INSTRUCTION. A reflex generates no query:
# you reach for `curl` confidently, with no moment of doubt, so a surface that
# waits to be asked never fires. Here nothing is asked — the tool call IS the
# query, and the reflex has to become a tool call before it can do anything.
@@ -22,7 +21,7 @@
command -v curl >/dev/null 2>&1 || exit 0
-# Sourced FIRST, because the JSON reader below lives there (#4107). It defines
+# Sourced FIRST, because the JSON reader below lives there. It defines
# functions and clears `url`/`token`; nothing here runs before it is needed.
# shellcheck source=plugin/hooks/scribe_defs.sh
. "$(dirname "${BASH_SOURCE[0]}")/scribe_defs.sh"
@@ -47,7 +46,7 @@ command_text=$(scribe_json_pick "$event_flat" '.tool_input.command')
# scribe_config, not a hand-rolled pair of parameter expansions: it also treats
# an UNEXPANDED `${...}` placeholder as unset, which would otherwise be sent as
-# a garbage Bearer token and 401 on every call (#2198's class).
+# a garbage Bearer token and 401 on every call.
scribe_config || exit 0
# Bounded before encoding: a heredoc or a pasted script can be enormous, and
@@ -56,8 +55,8 @@ scribe_config || exit 0
# place. `head -c`, never `cut -c`: cut truncates each LINE and caps nothing.
command_text=$(printf '%s' "$command_text" | head -c 2000)
-# Whole, never line by line: the predecessor (`jq -rR`) encoded a multi-line
-# command one line at a time and joined them with raw newlines — an invalid URL.
+# Whole, never line by line: per-line encoding joins a multi-line command with
+# raw newlines — an invalid URL.
# scribe_urlenc reads bytes and has no notion of a line.
cmd_enc=$(printf '%s' "$command_text" | scribe_urlenc)
tool_enc=$(printf '%s' "$tool_name" | scribe_urlenc)
@@ -68,7 +67,7 @@ lookup_dir=${event_cwd:-${CLAUDE_PROJECT_DIR:-$PWD}}
scope=$(scribe_scope_query "$lookup_dir")
[ -n "$scope" ] && repo_q="&${scope}"
-# Where the command runs, against the repo's root (milestone 444): the paths a
+# Where the command runs, against the repo's root: the paths a
# command names are relative to its cwd or absolute, and a System's patterns
# are relative to the root. The server does the arithmetic; this sends both.
where_q=""
@@ -96,11 +95,11 @@ rulings_q=""
if [ -n "$session_id" ]; then
safe_sid=$(printf '%s' "$session_id" | tr -c 'A-Za-z0-9._-' '_')
rulefile="$state_dir/${safe_sid}.rules.ids"
- # Ageing, not a flat read (#3751): an id named two hours ago is not one the
+ # Ageing, not a flat read: an id named two hours ago is not one the
# session is still holding. scribe_rules_live carries the reasoning.
rule_seen=$(scribe_rules_live "$rulefile")
[ -n "$rule_seen" ] && rule_exclude_q="&exclude_rule_ids=${rule_seen}"
- # What the session actually OPENED, as against what it was shown (#4100).
+ # What the session actually OPENED, as against what it was shown.
rule_exclude_q="${rule_exclude_q}$(scribe_held_query "$state_dir/${safe_sid}.opened.ids")"
# The Systems whose rulings were shown in full — shared with the write-path
# hook, for the reason the rule file is.
@@ -130,20 +129,20 @@ if [ -n "$rulingsfile" ]; then
scribe_json_list "$body_flat" '.ruling_system_ids' >> "$rulingsfile" || true
fi
-# ── The pre-act checkpoint (#4214, milestone 419) ────────────────────────
+# ── The pre-act checkpoint ───────────────────────────────────────────────
#
# WHY THIS ARM AND NOT THE WRITE PATH. scribe_prior_art.sh carries an explicit,
# tested property that it never returns a permissionDecision — a recall aid may
# not stand in the way of a write, which is the operator's decision and is
# guarded by test_hook_never_returns_a_permission_decision. No equivalent
-# decision covers this arm, and the misses that motivated the milestone on the
-# command side are the ones a stop actually reaches: a commit message asserting
-# what CI said, a verification script that checks nothing.
+# decision covers this arm, and the command-side misses are the ones a stop
+# actually reaches: a commit message asserting what CI said, a verification
+# script that checks nothing.
#
# WHAT THE STOP IS FOR. Everything else this plugin emits is additionalContext,
# which Claude Code delivers alongside the tool RESULT — so the rule is read
# after the call is written and reads as commentary on a decision already made.
-# That is milestone 419's central finding. A deny returns the reason to the
+# A deny returns the reason to the
# model with the call unrun, so the rule's own text can be read before the act
# exists. It costs the operator nothing: no prompt reaches them, and the model
# clears it by reading one record and re-submitting.
diff --git a/plugin/hooks/scribe_turn.awk b/plugin/hooks/scribe_turn.awk
index a43a901d..7ee2b57b 100644
--- a/plugin/hooks/scribe_turn.awk
+++ b/plugin/hooks/scribe_turn.awk
@@ -1,4 +1,4 @@
-# Scribe plugin — what happened in the LAST TURN of a transcript (#4107).
+# Scribe plugin — what happened in the LAST TURN of a transcript.
#
# Reads the flat `IDXPATHVALUE` stream that scribe_json.awk produces
# in `mode=lines` from a Claude Code transcript, and answers the four questions
@@ -24,10 +24,9 @@
# tool_use it refutes, so a single forward pass would have already counted it.
# TAB-SEPARATED, and stated rather than assumed. Under awk's default splitting
-# a value is cut at its first SPACE, so `$3` of a reply line was the reply's
-# first word — the kind of defect that hides completely behind a test whose
-# replies are all empty. Found by differential-testing this against the jq
-# program it replaces, over real transcript windows (#4107).
+# a value is cut at its first SPACE, so `$3` of a reply line would be the
+# reply's first word — a defect that hides completely behind a test whose
+# replies are all empty.
BEGIN { FS = "\t" }
{
diff --git a/plugin/skills/shape-accounting/SKILL.md b/plugin/skills/shape-accounting/SKILL.md
index 435150a4..f5d4d660 100644
--- a/plugin/skills/shape-accounting/SKILL.md
+++ b/plugin/skills/shape-accounting/SKILL.md
@@ -100,8 +100,7 @@ from the derivation, not from asking permission.
## Derive groups are drift, not audit material
The catalogue exists so the codebase is DRY **from inception**, not as DRY as
-the last sweep left it. Three surfaces say so without anyone running an audit
-(milestone 299):
+the last sweep left it. Three surfaces say so without anyone running an audit:
- **At the write** — the prior-art hint (delivered beside a write where your
client supports it; shell edits count as much as editor writes)
@@ -122,7 +121,7 @@ the last sweep left it. Three surfaces say so without anyone running an audit
reason_code="convention-plumbing", reason=…)` (or `classify_shapes_by_rule`
for a whole family) removes it from the queue. Dismissal is a judgment and
it is recorded; silence is not.
-- **CSS is watched by name, never by body** (note 2917). Classes serving
+- **CSS is watched by name, never by body.** Classes serving
different purposes share declarations because the style system makes them
alike — `.text-muted` and `.pin-badge-auto` carrying the same `color:` are
two meanings, not two copies — so a CSS family is the *same class defined
@@ -132,7 +131,7 @@ the last sweep left it. Three surfaces say so without anyone running an audit
genuinely different things is dismissed with `reason_code="scoped-css"`.
The datum that decides between the two is **what renders it**: every css
row carries `used_by` (the files whose markup names the class — the CSS
- consumer map, milestone 302), a derive group carries the family's
+ consumer map), a derive group carries the family's
`consumers`, and the write-path line says "used by N template(s)". Many
templates, one recipe → derive; one template each, different purposes →
dismiss. `list_shapes(flag="unused-css")` is the map's negative space —
@@ -148,7 +147,7 @@ afterwards is drift of the moment, and the hint already said so at the write.
## The divergence readout — button B where button A is canon
-Three questions the ledger answers mechanically (#2793):
+Three questions the ledger answers mechanically:
- **Divergence** — `list_shapes(project_id, flag="divergence")` (and the
coverage line's "N DIVERGENT"): a shape new since the previous refresh, in
@@ -166,7 +165,7 @@ Three questions the ledger answers mechanically (#2793):
re-confirm it (classify again with the same status) or re-judge.
- **Weak stamps** — the coverage line's "N weak stamps … to judge —
stamps_to_review": rows the write-path hook asserted, unattended, on a
- resemblance below today's floor. They no longer elect a directory's canon
+ resemblance below the current floor. They do not elect a directory's canon
or raise a divergence, but they still claim to be instances. Read each
one and record the verdict with `classify_shapes` — `instance` if it is,
otherwise what it actually is.
diff --git a/plugin/skills/writing-plans/SKILL.md b/plugin/skills/writing-plans/SKILL.md
index 91e942b4..81ff7872 100644
--- a/plugin/skills/writing-plans/SKILL.md
+++ b/plugin/skills/writing-plans/SKILL.md
@@ -85,8 +85,8 @@ step in the list, `{{ref:milestone}}` is the milestone. They are replaced with
the real id and title as the records are created.
**Do not** write plans or specs to local `.md` files — the milestone is the
-record, not a file on disk. (The old `kind=plan` task is retired; `start_planning`
-no longer creates one.)
+record, not a file on disk. A plan is never a task: `kind="plan"` is refused,
+and `start_planning` makes a milestone.
Before designing from scratch, **recall**: `search` Scribe for a related prior
decision or note. Often the thinking (or half of it) already exists.
diff --git a/src/scribe/mcp/server.py b/src/scribe/mcp/server.py
index 2f228c12..726a02a6 100644
--- a/src/scribe/mcp/server.py
+++ b/src/scribe/mcp/server.py
@@ -10,9 +10,8 @@ from quart import Quart
## What this block is — read before editing it
#
-# ONE OWNER PER PIECE OF GUIDANCE (decision #4027, milestone 410). Scribe's
-# guidance to agents lives where it can be delivered, and each topic is stated
-# in full exactly once:
+# ONE OWNER PER PIECE OF GUIDANCE. Scribe's guidance to agents lives where it
+# can be delivered, and each topic is stated in full exactly once:
# - Tool docstrings: each tool's contract, delivered with its schema.
# - In-band tool responses: behaviour prose cannot be trusted to trigger —
# the duplicate gate, the guessed-id refusal, `systems_hint`,
@@ -25,22 +24,19 @@ from quart import Quart
#
# THIS BLOCK IS THE SERVER'S ORIENTATION: the workflow across tools. It reaches
# every MCP client, so it names no client, and it points at where each practice
-# is stated rather than restating it. What belongs here, per the spec and the
-# field (spike #4389): how the tools fit together — which to call first, which
-# before which. What does not: a tool's own contract (its description carries
-# it), and stance — who judges, how to report, what a missed rule means. Those
-# fire mid-work, not at the start; the skills state them and in-band responses
-# (`placement`, `report_back`) cue them at the moment they apply. A new topic
-# gets a line here only if it is a session-start practice.
+# is stated rather than restating it. What belongs here, per the MCP spec: how
+# the tools fit together — which to call first, which before which. What does
+# not: a tool's own contract (its description carries it), and stance — who
+# judges, how to report, what a missed rule means. Those fire mid-work, not at
+# the start; the skills state them and in-band responses (`placement`,
+# `report_back`) cue them at the moment they apply. A new topic gets a line
+# here only if it is a session-start practice.
#
# BUDGET: at most 1,600 characters (test_instructions_fit_the_fold). Claude
# Code injects only the first ~2,048 characters of a server's instructions and
-# cuts the rest mid-word (#2562, observed live), and other clients differ, so
-# nothing load-bearing may sit past the fold; the rest of the field runs
-# ~600–1,600 (#4389), and a block pressed against the cap is being used as a
-# rulebook. The history of what was traded
-# for space before the ownership split (milestones 317, 333, 409) is in
-# decision #4027 and the notes it supersedes.
+# cuts the rest mid-word, and other clients differ, so nothing load-bearing may
+# sit past the fold. A block pressed against the cap is being used as a
+# rulebook.
_INSTRUCTIONS = """
Scribe is the operator's system of record, and yours: recall before acting,
record as you go, keep one copy here, not in local memory. Each
@@ -83,7 +79,7 @@ shared:true records are another user's suggestion, not settled practice.
# the point; staleness is the cost, and test_mcp_auth covers that: EVERY
# registered tool must appear in exactly one of _READ_ONLY_TOOLS, _WRITE_TOOLS or
# _DELIBERATELY_WRITE_SCOPED below, so adding one forces a decision instead of
-# silently denying it — whatever the tool is called (#3191).
+# silently denying it — whatever the tool is called.
#
# Membership means "reads the operator's data and mutates none of it". Several
# getters record a retrieval event via record_pulled; that is telemetry about
@@ -105,7 +101,7 @@ _READ_ONLY_TOOLS = frozenset({
"find_duplicate_snippets", "find_duplicate_records",
# Snippets and processes are notes with a kind. A key that may read a note
# but not a snippet inverts the sensitivity ordering: it exposes the
- # free-text records and withholds the structured ones (#2496).
+ # free-text records and withholds the structured ones.
"get_snippet", "list_snippets",
"get_process", "list_processes",
"get_lesson", "list_lessons",
@@ -119,58 +115,56 @@ _READ_ONLY_TOOLS = frozenset({
# Which repos map to which project. Read-only by nature; bind_repo /
# unbind_repo are the writes.
"list_repo_bindings",
- # The shape ledger's todo query (#2789). Reads only — classify_shapes is
+ # The shape ledger's todo query. Reads only — classify_shapes is
# the write, and it is deliberately NOT here.
"list_shapes", "shape_history", "stamps_to_review",
- # The retrieval telemetry readout (#2975). Aggregates two log tables and
+ # The retrieval telemetry readout. Aggregates two log tables and
# writes nothing. Listed explicitly because its name carries no read
# prefix, so the completeness test below cannot derive it — the same
# reason `enter_project` is spelled out above.
"retrieval_telemetry",
- # The note staleness sweep (milestone 317). A pure read — mark_note_verified
+ # The note staleness sweep. A pure read — mark_note_verified
# is the write, and it is deliberately NOT here. Spelled out for
# retrieval_telemetry's reason: `notes_due_for_verification` matches none of
# the prefixes the completeness test derives from, so nothing would have
# prompted this decision.
"notes_due_for_verification",
- # The review pass's sample (#4772). Re-runs logged queries and writes no
+ # The review pass's sample. Re-runs logged queries and writes no
# row of any kind — not even telemetry, since nothing is put in front of a
# working session. Spelled out for retrieval_telemetry's reason.
"menus_to_review",
- # Its rule twin and a rule's edit history (milestones 312 and 323). Both
- # pure reads, and both sat unlisted — so a read key was refused them — for
- # the same reason: no read prefix, back when the completeness test only
- # looked at names that had one (#3191). rule_history records a pull the way
- # the getters above do.
+ # Its rule twin and a rule's edit history. Both pure reads with no read
+ # prefix, so spelled out; rule_history records a pull the way the getters
+ # above do.
"rules_due_for_verification", "rule_history",
# What each retrieval surface's floor and budget currently are, and what
- # has been changed about them (#4102). Both pure reads; `tune_retrieval` is
+ # has been changed about them. Both pure reads; `tune_retrieval` is
# the write and is deliberately NOT here. Read access matters more than
# usual for these two: a session that cannot see the bar in force, or the
# reason it was last moved, is a session that will move it again blind.
"retrieval_surfaces", "retrieval_tuning_history",
- # The wide net (#4103) — ranked rule candidates with no bar, for the
+ # The wide net — ranked rule candidates with no bar, for the
# moment before a consequential act. A pure read, and one a read key needs
# most: it is the surface the "ask before acting" reflex calls, and a key
# that could not reach it would be denied exactly the check it should run.
"what_might_apply",
- # The moment catalog (milestone 458). A pure read of a constant; the
+ # The moment catalog. A pure read of a constant; the
# mount and mapping writes that use it are separate tools. Spelled out for
# retrieval_telemetry's reason, and needed by a read key so that a line
# naming a moment can be understood by whoever was shown it.
"list_moments",
- # The default reply shapes (milestone 500). A pure read of a constant, and
+ # The default reply shapes. A pure read of a constant, and
# one a read key needs: a session shown a shape may want the rest.
"list_reply_shapes",
- # The platform catalog and a project's answers (milestone 463). A pure
+ # The platform catalog and a project's answers. A pure
# read; set_project_platforms is the write.
"list_platforms",
- # Family canon (milestone 463): ideas, one idea with its precedents, the
+ # Family canon: ideas, one idea with its precedents, the
# decision log, and the adoption ledger. Reads of records the caller can
# read.
"list_family_ideas", "get_family_idea", "list_family_decisions",
"get_family_adoption", "list_family_adoptions",
- # The pass over the corpus and its queue (milestone 458 step 7): which
+ # The pass over the corpus and its queue: which
# rules are unjudged and which proposals wait. Reads of the caller's own
# rules, as list_rules is.
"rules_to_mount", "rule_moment_proposals",
@@ -180,8 +174,8 @@ _READ_ONLY_TOOLS = frozenset({
# absent from _READ_ONLY_TOOLS is already denied to a read key. It exists so the
# classification is total: test_mcp_auth requires every registered tool to sit
# in exactly one of the three sets, which is what makes forgetting impossible
-# rather than merely unlikely. Before #3191 the test only asked about tools whose
-# names looked like reads, and two reads with other names were denied for weeks.
+# rather than merely unlikely. A test that only asked about read-looking names
+# would miss a read named any other way.
_WRITE_TOOLS = frozenset({
# notes, tasks, planning
"create_note", "update_note", "delete_note",
@@ -223,22 +217,22 @@ _WRITE_TOOLS = frozenset({
# reads that happen to leave a trace, while this tool's entire effect is
# the row — and the row carries `detail`, free prose the agent authored.
# A read-scoped key that can put text into the operator's database is not
- # read-scoped, whatever table it lands in (#4212).
+ # read-scoped, whatever table it lands in.
"rule_outcome",
# retrieval tuning — a write in both senses: it moves the number the arm
- # reads, and it appends the reason to the audit trail (#4102).
+ # reads, and it appends the reason to the audit trail.
"tune_retrieval",
"migrate_retrieval_floor",
- # Which actions reach which moment on this install (milestone 458). Each
+ # Which actions reach which moment on this install. Each
# changes what fires for every later session, so a read key is refused.
"map_action", "unmap_action",
# Proposing moments for a rule writes a judgment row; judging one mounts
- # or unmounts the rule (step 7).
+ # or unmounts the rule.
"propose_rule_moments", "judge_rule_moments",
# A misfire report writes a counted row that can become an unmount
- # proposal (step 7b).
+ # proposal.
"rule_misfired",
- # A reviewer's verdicts on logged menu lines (#4772) — rows carrying free
+ # A reviewer's verdicts on logged menu lines — rows carrying free
# prose the agent authored, `rule_outcome`'s reason for being a write.
"judge_menu",
# trash
@@ -309,12 +303,11 @@ class StrictArgsMCPServer(MCPServer):
default. On the create/update tools the default is "", which turns a
plausible near-miss (`content=` for `body=`, primed by add_task_log's
`content`) into SILENT DATA LOSS: the call reports success and stores an
- empty body, leaving a record search cannot see (#2709). Two notes were
- persisted body-less that way before anyone noticed.
+ empty body, leaving a record search cannot see.
An error the caller sees once is strictly better than data half-written
- forever, so the policy is applied to every tool, not just the two that
- bit: nothing here knows tool semantics, only that an argument nobody
+ forever, so the policy is applied to every tool: nothing here knows tool
+ semantics, only that an argument nobody
declared cannot have been meant to be dropped.
"""
@@ -344,11 +337,11 @@ class StrictArgsMCPServer(MCPServer):
try:
return await super().call_tool(name, arguments, context)
except UnexpectedToolError as exc:
- # ValueError is how every Scribe tool refuses (#4794): what was
- # refused, why, and what to do instead, written for the agent.
- # SDK 2.x masks it like a crash, so the agent got a failure with
- # no reason and could only retry blind. Other exceptions ARE
- # crashes; their text can carry internals and stays masked.
+ # ValueError is how every Scribe tool refuses: what was refused,
+ # why, and what to do instead, written for the agent. SDK 2.x
+ # masks it like a crash, which would leave the agent retrying
+ # blind. Other exceptions ARE crashes; their text can carry
+ # internals and stays masked.
if isinstance(exc.__cause__, ValueError):
raise ToolError(f"{exc}: {exc.__cause__}") from exc.__cause__
raise
@@ -386,17 +379,17 @@ def mount_mcp(app: Quart) -> None:
# stateless_http=True: don't hand the client a persistent Mcp-Session-Id.
# The stateful default strands Claude Code after a container redeploy —
# it reconnects with the now-unknown session id, the server returns 404,
- # and the client won't re-initialize on a 404 (Claude Code issue #60949),
+ # and the client won't re-initialize on a 404,
# so the connection stays dead until a manual /mcp retry. Stateless makes
# every request self-contained (bearer-auth only), so a post-deploy
# reconnect just works. Trade-off: no server-pushed list_changed stream,
- # which we don't use — tools are re-fetched on reconnect anyway. (Clients
- # on the 2026-07-28 revision are stateless by protocol and take the SDK's
- # modern path regardless; this governs the 2025-era handshake path.)
+ # which we don't use — tools are re-fetched on reconnect anyway. Clients
+ # on the stateless protocol revision take the SDK's modern path
+ # regardless; this governs the older handshake.
#
# DNS-rebinding protection is disabled: the SDK's default allow-list is
# just localhost variants, which means any deployment behind a reverse
- # proxy (Traefik with a hostname like devassistant.traefik.internal,
+ # proxy (Traefik,
# Cloudflare, nginx, etc.) gets 421 Misdirected Request. The threat
# model that protection addresses — a malicious browser page rebinding
# DNS to hit a localhost MCP — doesn't apply here: this is HTTP transport
diff --git a/src/scribe/mcp/tools/design_systems.py b/src/scribe/mcp/tools/design_systems.py
index 6423ad60..36fb596d 100644
--- a/src/scribe/mcp/tools/design_systems.py
+++ b/src/scribe/mcp/tools/design_systems.py
@@ -113,7 +113,7 @@ async def get_design_system(design_system_id: int) -> dict:
system in the inheritance chain, outermost ancestor first, so the house
style comes before this system's departures from it. `design_system`'s
own `guidance` field is only the departures. Read `resolved_guidance`
- before writing UI; enter_project carries just the summary (#4045).
+ before writing UI; enter_project carries just the summary.
"""
uid = current_user_id()
system = await ds_svc.get_design_system(uid, design_system_id)
diff --git a/src/scribe/mcp/tools/family.py b/src/scribe/mcp/tools/family.py
index 23b3a35a..0c0a45a7 100644
--- a/src/scribe/mcp/tools/family.py
+++ b/src/scribe/mcp/tools/family.py
@@ -1,4 +1,4 @@
-"""Family canon MCP tools — the promotion engine's agent door (milestone 463).
+"""Family canon MCP tools — the promotion engine's agent door.
Thin wrappers over services/family.py. The agent is the decider here: no
person approves a promotion, so the criteria are stated in these docstrings
diff --git a/src/scribe/mcp/tools/lessons.py b/src/scribe/mcp/tools/lessons.py
index 09d98cf6..76ca2836 100644
--- a/src/scribe/mcp/tools/lessons.py
+++ b/src/scribe/mcp/tools/lessons.py
@@ -50,7 +50,7 @@ async def list_lessons(
offset: Skip this many before returning — page past the cap. `total`
is the unpaged count, so it says whether more remains.
project_id: Narrow to where a lesson was WRITTEN. 0 = every project.
- A lesson is retrievable from anywhere regardless (step 3); this
+ A lesson is retrievable from anywhere regardless; this
filters the listing, not the reach.
unjudged: List only the lessons nobody has answered "which rule is
this an instance of?" for — no rule named, and no "no rule fits"
@@ -80,7 +80,7 @@ async def list_lessons(
)
labelled = await access_svc.label_shared_items(uid, items)
# One aggregate for the page, like the snippet listing — surfaced-vs-opened
- # per lesson (#4196). An agent listing lessons can see which of its own
+ # per lesson. An agent listing lessons can see which of its own
# triggers are firing and which are not, which is the reading that leads to
# `update_lesson` rather than to a second lesson about the same failure.
await attach_usage(labelled)
@@ -188,7 +188,7 @@ async def create_lesson(
the binding choice its situation falls under. A lesson never
becomes a rule; it points at the one that governs it, and the rule
is then reachable through the situation the lesson describes
- (milestone 440). Naming a rule here confirms the link.
+ Naming a rule here confirms the link.
no_rule: The reason no rule governs this situation, in a line — the
other answer to "which rule?". Give one or the other, not both.
When other lessons in the same situation also answered "no rule
@@ -251,7 +251,7 @@ async def create_lesson(
async def _name_convergence(uid: int, data: dict, lesson_id: int) -> None:
"""A "no rule fits" answer is the moment to notice it is not the first
- for this situation (#4634) — `convergence` names the group and the rule
+ for this situation — `convergence` names the group and the rule
it may be missing. Absent when there is no group."""
group = await lesson_rules_svc.convergence_for(uid, lesson_id)
if group:
@@ -301,7 +301,7 @@ async def get_lesson(lesson_id: int, project_id: int = 0) -> dict:
# Every explicit open records the pull. A lesson is surfaced by the same
# retrieval as any other note, so a getter that records nothing would leave
# the kind permanently at zero pulls — reading as dead weight beside kinds
- # that merely had a counter (#2476, the repeat of #2245).
+ # that have a counter.
# Read BEFORE the pull is recorded, so the number an agent is shown is the
# one that was true when it asked — otherwise every first read of a lesson
# reports a pull that is its own.
@@ -348,10 +348,9 @@ async def update_lesson(
system_ids: Replace the Systems this lesson is filed under. None
leaves unchanged; pass the FULL list, and `[]` to clear.
- Here because `create_lesson` took it and this did not, so a lesson
- written without one could never be filed afterwards — and the end
- of a piece of work, when a lesson is usually written, is exactly
- when that argument gets dropped (#4249). A System tag is how
+ A lesson written without one can be filed afterwards here — the
+ end of a piece of work, when a lesson is usually written, is
+ exactly when that argument gets dropped. A System tag is how
`list_system_records` gathers an area's pile, so an untagged
lesson is reachable by search and by nothing else.
rule_ids: Replace the rule(s) this lesson is an instance of. None
@@ -430,10 +429,9 @@ async def delete_lesson(lesson_id: int) -> dict:
deletion: its trigger is keyed to a situation nobody is in, and rewording
that with update_lesson keeps what was learned.
- Deletion was always possible through `delete_note` — a lesson is a note and
- the trash is kind-agnostic — but nothing said so, and a kind whose own
- tools offer create/read/update reads as one you cannot retire (#2250, which
- recorded exactly this for processes).
+ The same as `delete_note` — a lesson is a note and the trash is
+ kind-agnostic — offered here because a kind whose own tools offer only
+ create/read/update reads as one you cannot retire.
"""
uid = current_user_id()
note = await lessons_svc.get_lesson(uid, lesson_id)
diff --git a/src/scribe/mcp/tools/milestones.py b/src/scribe/mcp/tools/milestones.py
index 66f17aa9..77987888 100644
--- a/src/scribe/mcp/tools/milestones.py
+++ b/src/scribe/mcp/tools/milestones.py
@@ -60,7 +60,7 @@ async def get_milestone(milestone_id: int) -> dict:
Each step also carries `log_count`. A step's STATUS is set by hand and a
plan is exactly where that goes stale; the count says which steps have a
work log that would say otherwise, so `get_task(id)` goes to the step
- with a record rather than to each one in turn (#4241).
+ with a record rather than to each one in turn.
"""
uid = current_user_id()
milestone = await milestones_svc.get_milestone(uid, milestone_id)
@@ -180,7 +180,7 @@ async def update_milestone(
raise ValueError(f"milestone {milestone_id} not found")
data = milestone.to_dict()
if closing:
- # Family canon's milestone trigger (milestone 463): a plan closing on
+ # Family canon's milestone trigger: a plan closing on
# a platform is the moment to ask whether what it built is shared.
hint = await family_svc.milestone_trigger(uid, milestone)
if hint:
diff --git a/src/scribe/mcp/tools/moments.py b/src/scribe/mcp/tools/moments.py
index 4d50a995..2c642273 100644
--- a/src/scribe/mcp/tools/moments.py
+++ b/src/scribe/mcp/tools/moments.py
@@ -1,4 +1,4 @@
-"""Moments as MCP tools: the catalog, and the in-session corrections to it (milestone 458).
+"""Moments as MCP tools: the catalog, and the in-session corrections to it.
A session needs the vocabulary in hand to mount a rule, to read a line that
names a moment, and to fix a misfire — and the operator's ruling is that the
diff --git a/src/scribe/mcp/tools/notes.py b/src/scribe/mcp/tools/notes.py
index 45e57d15..f5cbdc3d 100644
--- a/src/scribe/mcp/tools/notes.py
+++ b/src/scribe/mcp/tools/notes.py
@@ -38,7 +38,7 @@ async def list_notes(
Optionally filter by a single tag (plain string, no # prefix) or by
`search_text`, which matches on MEANING — the same semantic match every
- search surface uses (#2462; keyword ILIKE is only the fallback when the
+ search surface uses (keyword ILIKE is only the fallback when the
embedder is down). With `search_text`, results order by relevance; without
it, by last-updated descending. Reaches your records plus shared-project
records, like every other list.
@@ -97,7 +97,7 @@ async def get_note(note_id: int, project_id: int = 0) -> dict:
# Records the pull for ANY note kind, not just snippets: the auto-inject
# menu surfaces notes, tasks and processes too, so restricting this to
# snippets would leave those permanently at zero pulls and make them look
- # like dead weight next to snippets that merely had a counter (#2085).
+ # like dead weight next to snippets.
record_pulled(
user_id=uid, note_id=int(note.id),
source="mcp_get_note", project_id=project_id,
@@ -241,7 +241,7 @@ async def create_note(
await systems_tools.attach_systems(uid, uid, data, note.id, project_id or None)
await supersession_svc.attach_relations(uid, note.id, data, hint=True)
data.update(dedup_svc.note_overlap_response(overlaps, "note"))
- # Family canon's write-time triggers (milestone 463): a cited source of a
+ # Family canon's write-time triggers: a cited source of a
# pattern, or the same idea in another project on a shared platform.
await family_svc.attach_family_hint(uid, data, note, created=True)
return await moment_delivery.attach_moment_rules(uid, "create_note", {"project_id": project_id}, data)
diff --git a/src/scribe/mcp/tools/platforms.py b/src/scribe/mcp/tools/platforms.py
index ddc23fae..3968b0a6 100644
--- a/src/scribe/mcp/tools/platforms.py
+++ b/src/scribe/mcp/tools/platforms.py
@@ -1,4 +1,4 @@
-"""Platform MCP tools — what a project is built on or ships as (milestone 463).
+"""Platform MCP tools — what a project is built on or ships as.
Thin wrappers over services/platforms.py. The catalog is global; a project's
platforms decide which family ideas reach it.
diff --git a/src/scribe/mcp/tools/processes.py b/src/scribe/mcp/tools/processes.py
index 03c9e42f..60c4aefc 100644
--- a/src/scribe/mcp/tools/processes.py
+++ b/src/scribe/mcp/tools/processes.py
@@ -80,8 +80,7 @@ async def create_process(
body: The full prompt to run later (markdown). Required.
tags: Plain-string tags, no # prefix.
system_ids: Systems (subsystems/areas) to file this process under, so
- an area-scoped read finds it. A process is a note, so it has always
- been taggable in the data model; neither door offered it (#4249).
+ an area-scoped read finds it.
moments: The moments from list_moments this procedure is FOR — a
release procedure is a deliver, a review procedure a verify.
Loading the process then reaches each one, as well as its own
@@ -180,9 +179,8 @@ async def get_process(name_or_id: str, project_id: int = 0) -> dict:
out.update(await access_svc.describe_provenance(uid, note))
# A process is embedded like any other note, so auto-inject can surface one —
# and its menu header names THIS tool as the way to open that kind. Without
- # this, the getter the product points at is the one getter that records
- # nothing, and every process sits permanently at zero pulls looking like dead
- # weight beside kinds that merely had a counter (#2476, the repeat of #2245).
+ # this, every process would sit permanently at zero pulls, looking like dead
+ # weight beside kinds that have a counter.
record_pulled(
user_id=uid, note_id=int(note.id),
source="mcp_get_process", project_id=project_id,
@@ -250,9 +248,9 @@ async def delete_process(process_id: int) -> dict:
machine and auto-surfaces there, so a bad one is followed rather than merely
ignored — it costs more than a missing one.
- Deletion was always possible through `delete_note` (a process is a note, and
- the trash is kind-agnostic), but nothing said so, and a kind whose own tools
- offer create/read/update reads as one you cannot retire (#2250).
+ The same as `delete_note` (a process is a note, and the trash is
+ kind-agnostic), offered here because a kind whose own tools offer only
+ create/read/update reads as one you cannot retire.
"""
uid = current_user_id()
loaded = await notes_svc.get_note_for_user(uid, process_id)
diff --git a/src/scribe/mcp/tools/projects.py b/src/scribe/mcp/tools/projects.py
index fe369c35..dd4bf1c6 100644
--- a/src/scribe/mcp/tools/projects.py
+++ b/src/scribe/mcp/tools/projects.py
@@ -51,8 +51,8 @@ async def list_projects() -> dict:
# How many of each kind the handshake lists. Enough to say what was worked on
# lately and what's open; the rest is a list_milestones / list_tasks call away.
-# The handshake once carried every milestone's plan and came to ~222k
-# characters, past what a client accepts as a tool result (#4045).
+# Every milestone's plan would push the handshake past what a client accepts
+# as a tool result.
_HANDSHAKE_MILESTONES = 5
_HANDSHAKE_OPEN_TASKS = 10
_HANDSHAKE_UNPLANNED = 10
@@ -121,9 +121,9 @@ async def enter_project(project_id: int) -> dict:
`pattern_coverage` (usually null) is the shape-accounting line — how many
of the bound repo's extracted shapes carry a classification against canon
- (note 2786) — e.g. "shape accounting: 3100/4573 shapes accounted for —
- 12 canonical · 2900 instance (estimate, computed 2026-08-19); 1473
- unclassified, largest: internal/api". Unclassified IS the todo: as you
+ — e.g. "shape accounting: 3100/4573 shapes accounted for — 12 canonical ·
+ 2900 instance (estimate, computed ); 1473 unclassified, largest:
+ internal/api". Unclassified IS the todo: as you
touch code in those areas, classify the shapes you can (instances of
recorded canon, deliberate variants, one-off exemptions) and record the
canon that's missing with create_snippet. A null line on a forge-served
@@ -137,13 +137,13 @@ async def enter_project(project_id: int) -> dict:
create it with create_system rather than leaving the area unmodelled. Read
a subsystem's accumulated records with list_system_records. Each is id and name; get_system has the charter.
- `platforms` (milestone 463) is what the project is built on or ships as
+ `platforms` is what the project is built on or ships as
— Android app, container image, Go, … — each with how it is known
(`declared` by a person, `detected` from a bound repo). They decide which
family ideas reach this project. An empty list on a project that plainly
ships something is worth correcting with set_project_platforms.
- `family` (milestone 463) appears ONLY when this project has family canon
+ `family` appears ONLY when this project has family canon
to answer: how many canon ideas on its platforms are `unassessed`, how
many it `owed`s, and how many answers were given against an older canon
version (`needs_recheck`) — each with the list_family_adoptions call that
@@ -151,7 +151,7 @@ async def enter_project(project_id: int) -> dict:
(get_family_adoption, then assess_family_adoption, or classify the code
against it); the family-canon skill has the order.
- `inception` (milestone 297) appears ONLY when the project is yours and
+ `inception` appears ONLY when the project is yours and
nobody has decided what it inherits: it carries the current defaults
(design system, Systems, platforms), what to ask the
operator — once — and the decide_project_inception call that answers it;
@@ -210,15 +210,14 @@ async def enter_project(project_id: int) -> dict:
)
# The tagging vocabulary. Surfaced HERE because an instruction to "tag
# records to Systems" is only executable if the list is in front of the
- # agent when it writes — which it never was, and tagging stopped within
- # three days of the feature landing (#2546's audit). Untagged writes now
- # also ask with the vocabulary listed; this copy lets the first write tag.
+ # agent when it writes. Untagged writes also ask with the vocabulary
+ # listed; this copy lets the first write tag.
systems = await systems_svc.list_systems(uid, project_id)
platforms = platforms_svc.members(
await platforms_svc.project_platforms(uid, project_id) or []
)
- # The arrival-moment half of the bootstrap ask (#2683): session start is
+ # The arrival-moment half of the bootstrap ask: session start is
# when the agent has just read the project map and is not yet deep in a
# task — the one moment minting a starter vocabulary is cheap. The
# write-moment half rides untagged-record responses (attach_systems);
@@ -229,14 +228,15 @@ async def enter_project(project_id: int) -> dict:
uid, project_id
)
- # The inception ask (milestone 297): a project nobody has decided on
- # inherits nothing, silently — no design system, no Systems. Owner-only (deciding is the owner's), and only until a
- # decision is recorded; the key is ABSENT otherwise (#2483).
+ # The inception ask: a project nobody has decided on inherits nothing,
+ # silently — no design system, no Systems. Owner-only (deciding is the
+ # owner's), and only until a decision is recorded; the key is ABSENT
+ # otherwise.
inception_ask = None
if project.user_id == uid and not inception_svc.is_decided(project):
inception_ask = await inception_svc.inception_ask(uid, project_id)
- # An AMBIENT source (#2477): top-N-by-recency, not a ranked choice, and
+ # An AMBIENT source: top-N-by-recency, not a ranked choice, and
# the readout counts it apart so dead-weight detection isn't poisoned by
# "recently updated in a project you opened".
record_surfaced(
@@ -247,7 +247,7 @@ async def enter_project(project_id: int) -> dict:
)
# A project need not have one, and most installs won't — null is ordinary
# here, not a missing prerequisite. Summary only: the guidance is ~9k of
- # prose most sessions never use, so it's one call away (#4045).
+ # prose most sessions never use, so it's one call away.
design_system = None
if project.design_system_id:
design = await design_systems_svc.design_context(
@@ -269,7 +269,7 @@ async def enter_project(project_id: int) -> dict:
coverage = await coverage_svc.cached_coverage(
project.user_id or uid, project_id
)
- # Arrival self-seed (#2802): a ledger that is absent or stale refreshes in
+ # Arrival self-seed: a ledger that is absent or stale refreshes in
# the BACKGROUND — entering is the moment the number is wanted, and the
# UI button must not be the only path. This enter stays fast; the next
# one carries the line. Forge-less projects exit the seed quietly.
@@ -303,7 +303,7 @@ async def enter_project(project_id: int) -> dict:
],
}
# Attached only when it applies — a key that usually says null trains
- # readers to skip it (#2483), and this one exists to be acted on.
+ # readers to skip it, and this one exists to be acted on.
if omitted:
out["milestone_summary_omitted"] = (
f"{omitted} other milestone(s) not listed. "
@@ -318,7 +318,7 @@ async def enter_project(project_id: int) -> dict:
)
if systems_bootstrap:
out["systems_bootstrap"] = systems_bootstrap
- # Family canon (milestone 463 step 6): counts only, attached when one is
+ # Family canon: counts only, attached when one is
# non-zero. A readout must never fail the handshake it rides on.
try:
family = await family_adoption_svc.family_readout(uid, project_id)
@@ -329,7 +329,7 @@ async def enter_project(project_id: int) -> dict:
out["family"] = family
if inception_ask:
out["inception"] = inception_ask
- # The core reply shape (milestone 500): for a client with no prompt hook,
+ # The core reply shape: for a client with no prompt hook,
# entering the project is the one point every session passes before it
# writes a reply. A plugin session gets it with its first turn as well;
# one repeat per session is the price of reaching every client.
@@ -393,7 +393,7 @@ async def create_project(
) -> dict:
"""Create a new project in Scribe — and decide what it inherits.
- A project's inheritance is a decision, not a default (milestone 297):
+ A project's inheritance is a decision, not a default:
before calling, ask the operator the three inception questions and pass
the answers; a project created without either is UNDECIDED and
enter_project will ask until decide_project_inception records it.
@@ -446,7 +446,7 @@ async def decide_project_inception(
platforms: list[str] | None = None,
) -> dict:
"""Record what a project inherits — answer enter_project's `inception` ask,
- or re-decide later (milestone 297).
+ or re-decide later.
Owner-only. Applies the effects through the ordinary tools' paths —
set_project_design_system and the standard Systems seed — and writes the
diff --git a/src/scribe/mcp/tools/retrieval_review.py b/src/scribe/mcp/tools/retrieval_review.py
index f07d7617..4759b672 100644
--- a/src/scribe/mcp/tools/retrieval_review.py
+++ b/src/scribe/mcp/tools/retrieval_review.py
@@ -1,4 +1,4 @@
-"""Judging what a retrieval arm offered (#4772).
+"""Judging what a retrieval arm offered.
`retrieval_telemetry` says what the reader did with each line — opened it or
not. These two say whether the line RELATED, which is a reading only someone
diff --git a/src/scribe/mcp/tools/retrieval_tuning.py b/src/scribe/mcp/tools/retrieval_tuning.py
index 062ce364..c9686501 100644
--- a/src/scribe/mcp/tools/retrieval_tuning.py
+++ b/src/scribe/mcp/tools/retrieval_tuning.py
@@ -1,4 +1,4 @@
-"""Reading and moving a retrieval surface's floor and budget (#4102).
+"""Reading and moving a retrieval surface's floor and budget.
The write half of the loop `retrieval_telemetry` opens. That tool says what a
bar did; these say what the bar IS, and let it be changed with the argument
diff --git a/src/scribe/mcp/tools/rulebooks.py b/src/scribe/mcp/tools/rulebooks.py
index 64f1e050..83ae5045 100644
--- a/src/scribe/mcp/tools/rulebooks.py
+++ b/src/scribe/mcp/tools/rulebooks.py
@@ -5,12 +5,8 @@ edges. Thin wrappers over services/rulebooks.py — ownership is enforced in the
service, and the record shape comes from rule_brief / rule_detail there rather
than being rebuilt here.
-(The header used to say "Sixteen tools" and had been wrong for two milestones;
-the count lives in the registration test, which fails when it drifts.)
-
-Destructive ops (delete_*) require confirmed=True; otherwise return a
-preview-style warning. Mirrors the pattern in delete_event and the design
-spec.
+Destructive ops (delete_*) require confirmed=True; otherwise they return a
+preview-style warning.
"""
from __future__ import annotations
@@ -53,9 +49,9 @@ async def create_rulebook(title: str, description: str = "") -> dict:
"""Create a new rulebook (a themed grouping of GLOBAL rules).
A rule in a rulebook is global: it applies in every project its owner works
- on, and reaches a session by retrieval when the work makes it relevant
- (milestone 414). There is no subscribing a project to a rulebook, and no
- muting one per project — that machinery is gone. So a rulebook's rules must
+ on, and reaches a session by retrieval when the work makes it relevant.
+ There is no subscribing a project to a rulebook, and no muting one per
+ project. So a rulebook's rules must
stay general, agnostic to any single project. Project-specific rules go in
create_project_rule.
@@ -198,8 +194,8 @@ def _rule_summary(r, moments: list[str] | None = None) -> dict:
"""The list-row shape for a rule: what an agent needs to APPLY it. The
full record (why, how_to_apply, timestamps) is get_rule's job.
- One line, because the shape itself lives in the service — this was one of
- three hand-written copies that had already drifted apart (note 3026).
+ One line, because the shape itself lives in the service, shared with
+ every other door that lists rules so the copies cannot drift.
`moments` rides along only when the rule is mounted (rule_brief drops a
None), so an unmounted rule's row says nothing rather than "[]".
"""
@@ -465,10 +461,9 @@ async def create_rule(
This field is also the rule's RETRIEVAL SURFACE — it and the
statement are what a search is matched against, so it should
carry the SYMPTOM, not just the situation: the words someone
- would actually type while stuck. Measured (note 3078): a rule
- whose trigger named only its situation did not surface at all
- for the problem it solves; adding the symptom to the same field
- brought it back as the top hit. Where a rule prevents a specific
+ would actually type while stuck. A rule whose trigger names only
+ its situation can fail to surface for the problem it solves;
+ adding the symptom to the same field makes it the top hit. Where a rule prevents a specific
failure, put that failure's vocabulary here — the error text,
the wrong behaviour, the dead end.
The two spellings, side by side:
@@ -524,7 +519,7 @@ async def create_rule(
dup = await dedup_svc.find_duplicate_rule(title, topic_id=topic_id)
if dup is not None:
return dedup_svc.duplicate_response(dup, "rule")
- # Before the create, so the new rule cannot find itself (#4134).
+ # Before the create, so the new rule cannot find itself.
overlaps = await dedup_svc.find_overlapping_rules(
uid, title, statement, when_to_apply,
)
@@ -666,8 +661,8 @@ async def update_rule(
correct. Ordinary edits to an existing preference belong in
update_preference, which asks for what taught the change.
- `when_to_apply` IS HOW A RULE ARRIVES AT ALL. Nothing is preloaded since
- milestone 394, so a rule with no trigger is not a quiet rule — it is one
+ `when_to_apply` IS HOW A RULE ARRIVES AT ALL. Nothing is preloaded, so a
+ rule with no trigger is not a quiet rule — it is one
no session will ever be shown. `system_ids` REPLACES the rule's areas
(pass [] to clear), and they decide which PROJECTS a rule binds by area.
`moments` REPLACES the moments it arrives at the same way — see
@@ -684,8 +679,7 @@ async def update_rule(
ever types. `rule_document()` puts this field in twice, as the title's
other half and again above the body, so it dominates the vector: a trigger
describing the abstraction collapses the record toward its title and the
- rule never arrives. Measured in #3835 across 113 rules, and again in #3855
- on six preferences written before this was understood.
+ rule never arrives.
So when you touch a rule with an old trigger, re-read it against the query
that would have to match it — the command about to run, the code being
@@ -835,8 +829,7 @@ async def create_preference(
arose_from_id: The task or note that taught this. Required; see above.
system_ids: Ids from list_canonical_systems — the global AREAS this
preference is about, which is what lets it reach a session working
- in that area. `update_preference` took this and create did not, so
- a preference could only be filed after the fact (#4249).
+ in that area.
moments: Moments from list_moments this rule arrives at — whenever
one happens, the rule is delivered, whatever the words of the work
look like. Mount a rule here when it is about WHEN something is
@@ -978,9 +971,8 @@ async def rule_history(rule_id: int, version_id: int = 0) -> dict:
Read this before you argue with a rule, and before you rewrite one. A
rule that has been reworded may have been reworded for a reason you are
about to rediscover the hard way — and the wording it replaced is often
- the fastest way to see what the current one is guarding against. The
- rescoping of rule 79 is the case this exists for: the superseded
- statement had to be hand-copied into a task log to survive the edit.
+ the fastest way to see what the current one is guarding against, and it
+ survives the edit here without anyone copying it elsewhere.
EACH ENTRY HOLDS THE TEXT THE EDIT REPLACED, not the text it introduced.
So "what did this say before the most recent change?" is the first entry,
@@ -988,8 +980,7 @@ async def rule_history(rule_id: int, version_id: int = 0) -> dict:
with get_rule. Pair the two and you have the diff.
An empty history is ordinary and means the rule has never been reworded,
- not that its history was lost. Nothing is written before milestone 323,
- so a rule edited before then starts empty too.
+ not that its history was lost.
Args:
rule_id: The rule whose history to read.
@@ -1135,7 +1126,7 @@ async def unrelate_rules(relation_id: int) -> dict:
raise ValueError(f"relation {relation_id} not found")
return {"deleted": relation_id}
-# ── The staleness sweep (milestone 312) ────────────────────────────────
+# ── The staleness sweep ────────────────────────────────
async def rules_due_for_verification(
older_than_days: int = 0, never_only: bool = False,
diff --git a/src/scribe/mcp/tools/search.py b/src/scribe/mcp/tools/search.py
index 0683a72b..72487b53 100644
--- a/src/scribe/mcp/tools/search.py
+++ b/src/scribe/mcp/tools/search.py
@@ -22,17 +22,16 @@ from scribe.services.retrieval_telemetry import record_retrieval, retrieval_summ
# A matched chunk is at most _CHUNK_CHAR_BUDGET (1400) characters, and it is
-# the evidence the ranking was built on — so it is worth more room than the 240
-# characters of document opening this used to send. Elision inside a chunk is
-# far less lossy than a head cut of a whole record: the region is already the
-# right one.
+# the evidence the ranking was built on, so it gets most of that room. Elision
+# inside a chunk is far less lossy than a head cut of a whole record: the
+# region is already the right one.
_EXCERPT_CHARS = 1000
# The kinds `content_type` accepts are DERIVED from the facet table, not listed
-# again here — that table is where a kind is declared (#3161), and a second
-# hand-kept copy in this module is precisely how the agent's door came to offer
-# two kinds while browse offered nine (#4250). `content_type_filters` carries
+# again here — that table is where a kind is declared, and a second hand-kept
+# copy here would let the agent's door and browse offer different kinds.
+# `content_type_filters` carries
# the mapping, the 'all'/'note' special cases and the refusal.
#
# These two do not reach `semantic_search_notes` at all: they have their own
@@ -91,7 +90,7 @@ async def _search_rules(uid: int, q: str, limit: int, project_id: int) -> dict:
async def _search_milestones(uid: int, q: str, limit: int, project_id: int) -> dict:
- """Milestones by meaning — "is there already a plan for this?" (milestone 415).
+ """Milestones by meaning — "is there already a plan for this?".
Its own result shape, like rules: a milestone is a plan with progress, not
a note with a body. The plan itself is left out — get_milestone reads it —
@@ -100,7 +99,7 @@ async def _search_milestones(uid: int, q: str, limit: int, project_id: int) -> d
actually hit, with `matched_is` saying whether it is that passage or
merely the body's opening. Recognising a plan means recognising the part
of it you were asking about, and a description written at the start need
- not mention that part (#4243).
+ not mention that part.
Not part of content_type="all", whose results are note-shaped.
"""
report: dict = {}
@@ -124,7 +123,7 @@ async def _search_milestones(uid: int, q: str, limit: int, project_id: int) -> d
# The plan body stays out — get_milestone reads it — but the
# passage that MATCHED comes along, because a plan is
# recognised by the part of it the query was about and a
- # description need not mention that part at all (#4243).
+ # description need not mention that part at all.
**excerpt_fields(
m.body or "", chunks.get(int(m.id)), _EXCERPT_CHARS,
key="matched",
@@ -142,13 +141,13 @@ async def _search_milestones(uid: int, q: str, limit: int, project_id: int) -> d
async def _search_systems(uid: int, q: str, limit: int, project_id: int) -> dict:
- """Systems by meaning — "where does this belong?" (#4251).
+ """Systems by meaning — "where does this belong?".
A System's `description` is a charter: several hundred words saying what
belongs in that area and what does not. `list_systems` enumerates them and
- `system_id` filters by one, so before this a System could NARROW a search
- and could never be the answer to one — an agent asking where a record
- belonged had to read every charter or guess.
+ `system_id` filters by one; this is the search where a System is the
+ answer, so an agent asking where a record belongs need not read every
+ charter.
Its own result shape and its own search, not a `content_type` over notes,
because the question is different: "where does this belong?" is not "what
@@ -158,7 +157,7 @@ async def _search_systems(uid: int, q: str, limit: int, project_id: int) -> dict
The charter's `matched` passage comes along rather than the whole thing.
A charter is long and the paragraph that decides where a record belongs is
- the one worth reading; the rest is get_system (#4243).
+ the one worth reading; the rest is get_system.
The id that comes back is the one `search(system_id=…)`,
`list_system_records` and every `system_ids` argument take — so the answer
@@ -191,16 +190,11 @@ async def _search_systems(uid: int, q: str, limit: int, project_id: int) -> dict
def result_excerpt(note, chunk: dict | None) -> dict:
"""The part of a record a caller judges "should I open this?" on.
- This used to be `(note.body or "")[:240]` — the document's opening, with
- no marker that anything had been cut, so a 240-character preview of a
- 4000-character record was indistinguishable from a complete short one.
-
- The opening is the wrong span. The match was semantic and per-chunk, and
- `semantic_search_notes` collapses to best-chunk-per-note — so the system
- already knows which passage earned the hit and used to discard it. A
- record could rank first on its sixth paragraph and be previewed by its
- first, which the search had already judged less relevant, and the caller
- would decide from that and never know (#4243).
+ The document's opening is the wrong span. The match is semantic and
+ per-chunk, and `semantic_search_notes` collapses to best-chunk-per-note, so
+ the passage that earned the hit is known. A record can rank first on its
+ sixth paragraph; previewing its first would show the caller the part the
+ search judged less relevant. When the span is cut, the result says so.
The choice of span lives in services/text.py, shared with the web's
knowledge search, so the two doors cannot drift on which text a reader is
@@ -253,8 +247,7 @@ async def search(
THREE KINDS WITH THEIR OWN SEARCH AND THEIR OWN RESULT SHAPE,
because each answers a question no note search can:
- 'rule' (the operator's standing instructions — searchable by
- meaning since milestone 307), 'milestone' ("is there already a
+ 'rule' (the operator's standing instructions), 'milestone' ("is there already a
plan for this?"), and 'system' ("where does this belong?" — a
System's charter says what belongs in an area and what does not,
and the id it returns is the one `system_id` and `system_ids`
@@ -321,7 +314,7 @@ async def search(
uid, q, limit=limit, **filters,
project_id=project_id or None,
system_id=system_id or None,
- # A LESSON is reachable from any project (milestone 385). The kind
+ # A LESSON is reachable from any project. The kind
# exists to carry an insight to the next project, so a project filter
# that hid it would hide it precisely where it is worth having. Only
# the project filter widens — everything else about the scoping holds,
@@ -372,12 +365,11 @@ async def retrieval_telemetry(
) -> dict:
"""What the retrieval telemetry says about YOUR surfaces, over a window.
- The read half of the tuning loop, whose write half is `tune_retrieval`
- (#2975, #4102). Reach for it before moving any floor or budget, and read
- the records it names rather than its percentiles alone: this readout has
- been measured pointing the WRONG WAY — 69 consecutive declines where every
- percentile said "lower the bar" and the refused record was a false positive
- — so `near_miss_samples=5` and opening the ids it returns is the step that
+ The read half of the tuning loop, whose write half is `tune_retrieval`.
+ Reach for it before moving any floor or budget, and read the records it
+ names rather than its percentiles alone: every percentile can say "lower
+ the bar" while each refused record is a false positive, so
+ `near_miss_samples=5` and opening the ids it returns is the step that
separates a real miss from a bar doing its job.
Five readouts, from the five tables built for them:
@@ -418,29 +410,21 @@ async def retrieval_telemetry(
moment you are making a decision, not for every readout.
`near_misses` is `null` when no declining call in the window measured it —
- rows written before #3670 shipped cannot know. That is "not measured", not
+ older rows may not carry the measurement. That is "not measured", not
"nothing came close"; a 0.0 there would be a claim about the corpus
invented out of a caller's silence.
- A NULL HERE NOW MEANS ONE THING, which it did not at first. A semantic
- search returns nothing three ways WITHOUT having run — an empty query, an
- unavailable embedder, and a failed database query — and each used to write
- a row indistinguishable from a ranker that declined (#3765). Those calls no
- longer write a row at all, on the same reasoning that already keeps a blank
- command out of the log: a row there reports a call that never happened and
- drags the clear rate down with phantom declines. So a null is "searched,
- and nothing came close", and a broken search shows up as a WARNING in the
- application log rather than as a quiet zero in here.
+ A search that returns nothing WITHOUT having run — an empty query, an
+ unavailable embedder, a failed database query — writes no row: a row would
+ report a call that never happened and drag the clear rate down with
+ phantom declines. So a null is "searched, and nothing came close", and a
+ broken search shows up as a WARNING in the application log rather than as
+ a quiet zero in here.
- THERE IS NO `cleared_threshold` ANY MORE, and if you remember one, that
- memory is of a tautology (#3670). The search applies the bar before
- returning, so every returned result cleared it by construction and a call
- with no results has no score to compare: the field was true exactly when
- `result_count > 0`, i.e. it was `calls - zero_result_calls` under a name
- that promised a second opinion. `zero_result_calls + cleared_threshold ==
- calls` held on all nineteen readings ever taken. The reading procedure
- built on it — "clears its bar on nearly every call" — asked you to compare
- a number with itself.
+ There is no "cleared the threshold" count, because it would be a
+ tautology: the search applies the bar before returning, so every returned
+ result cleared it, and that count would always equal
+ `calls - zero_result_calls`.
CHECK `suppression` BEFORE CONCLUDING ANYTHING FROM `zero_result_calls`. A
zero-result call is two different events wearing one number: the ranker
@@ -452,8 +436,8 @@ async def retrieval_telemetry(
`suppression` is `null` when NO row in the window reported it, and that is
"not measured here", NOT "none suppressed". Surfaces that pass their
exclusions into the search never see what was dropped, so they cannot say.
- Do not read a null as a zero: reading an artifact as a measurement is how
- this surface got mis-scoped once already (#3311, #3497).
+ Read a null as "unknown", never as a zero: an artifact read as a
+ measurement mis-scopes the surface.
`usage` — NOTES ONLY, from `note_usage_events`, at the per-note grain
`retrieval_logs` cannot be indexed at: `surfaced` (ranked surfacings — a
@@ -477,10 +461,10 @@ async def retrieval_telemetry(
about a record nothing chose). Read it as: of the distinct notes THIS
surface put in front of the agent, how many did the agent then open?
-It is an UPPER BOUND per surface: a pull records the door it came
+ It is an UPPER BOUND per surface: a pull records the door it came
through, not the surface that led there, so a note surfaced by two surfaces
- and opened once counts for both — attribution would need the session
- identity #2085 declined to invent. `by_source_failed: true` means that one
+ and opened once counts for both — attribution would need a session
+ identity the logs do not carry. `by_source_failed: true` means that one
query failed while the rest of the readout stood.
`rule_usage` — the same question for RULES, from `rule_usage_events`:
@@ -489,7 +473,7 @@ It is an UPPER BOUND per surface: a pull records the door it came
definition (agent pulls over RANKED surfacings).
`applied`, `departed` AND `distinct_rules_acted` ARE WHAT HAPPENED AFTER
- THE RULE WAS OPENED (#4213). A pull says the rule was read; these say it
+ THE RULE WAS OPENED. A pull says the rule was read; these say it
changed something. `applied` counts rules followed, `departed` rules
deliberately not followed — kept apart rather than summed, because a
departure carries the reason the agent gave and is evidence about the
@@ -511,8 +495,7 @@ It is an UPPER BOUND per surface: a pull records the door it came
a pull can settle. `ambient` counts BULK DELIVERIES: the `rules_payload`
surfaces (`enter_project`, `get_project`, `get_milestone`,
`start_planning`, `get_task`), which hand over the whole applicable set at
- once with nobody choosing anything — plus, in rows older than milestone
- 394, the SessionStart preload it removed. A large `ambient` says a bulk set
+ once with nobody choosing anything. A large `ambient` says a bulk set
is big and arrives often — never that it is useful, and never that it is
read.
@@ -523,13 +506,10 @@ It is an UPPER BOUND per surface: a pull records the door it came
those same rules over time: a set surfaced thousands of times and opened
never is the dead-weight signal, one level up.
- Read it against `sources["write_path_rule"]`. That arm was once believed
- never to decline — the reading that scoped #3311 — but it was the arm's
- `retrieval_logs` row being written only on calls that FOUND something, so
- the zeros were missing rather than absent (#3497). Measured since, it
- declines the large majority of its calls like any other surface.
+ Read it against `sources["write_path_rule"]`, which declines the large
+ majority of its calls like any other surface.
- `system_usage` — the RULINGS arm (#4769), from `system_usage_events`: how
+ `system_usage` — the RULINGS arm, from `system_usage_events`: how
often an area's rulings were shown because a command (`rulings_pre_tool`)
or an edit (`rulings_write_path`) touched its files, how often a System
was then opened (`mcp_get_system`), and `by_system` naming the areas, most
@@ -539,7 +519,7 @@ It is an UPPER BOUND per surface: a pull records the door it came
a ratio of opens would read near zero on an arm that is working.
`system_usage_failed: true` means its read broke and the zeros mean nothing.
- `judged` — whether the lines RELATED (#4772), from `retrieval_judgments`:
+ `judged` — whether the lines RELATED, from `retrieval_judgments`:
per source, a reviewer's verdicts (`on_point` / `adjacent` / `unrelated`)
on a sample of logged menus, `within_budget` against `beyond_budget` and
`by_rank`. Every usage block above counts what the reader DID; a line
@@ -564,8 +544,7 @@ It is an UPPER BOUND per surface: a pull records the door it came
across a deploy. A counter added last week, read over a 30-day window,
reports a real count against an imagined denominator — and the result is
a plausible fraction rather than an obvious zero, which is what makes it
- dangerous. That reading cost milestone #379 five steps aimed at a defect
- that did not exist.
+ dangerous.
`covers_window` is null, never false, when nothing was ever recorded:
"no measurement" is not "partial measurement", the same distinction
@@ -583,10 +562,9 @@ It is an UPPER BOUND per surface: a pull records the door it came
they are zeros meaning "could not find out", not "nothing happened" — do
not report a pull-through from a block carrying that flag.
- `warnings` IS THE PART TO READ FIRST (#3431). Everything above is a
- distribution; this is a verdict, and it exists because the same four
- checks were being redone by hand on every reading and were easy to
- forget. An EMPTY LIST means checked and clean — it is always present, so
+ `warnings` IS THE PART TO READ FIRST. Everything above is a
+ distribution; this is a verdict, the checks a careful reader would
+ otherwise redo by hand on every reading. An EMPTY LIST means checked and clean — it is always present, so
its emptiness is an answer rather than a gap. Each entry carries the
numbers that triggered it, so you can disagree with the rule instead of
having to redo the arithmetic:
@@ -596,7 +574,7 @@ It is an UPPER BOUND per surface: a pull records the door it came
Only ever raised for unbidden arms known to log unconditionally: a
search returning a list every time is doing its job, and an arm whose
zeros were never written would flag a LOGGING bug while pointing you at
- a threshold, which is #3497 exactly.
+ a threshold.
- `band_hugs_floor` — the weakest tenth of what an arm returns sits on
its floor. The bar is doing the selecting and the score is not, so
moving that floor changes how MUCH you get, not how good it is.
@@ -615,8 +593,7 @@ It is an UPPER BOUND per surface: a pull records the door it came
For RULES there is no judged sample: a rule set aside again and again
is a trigger to fix (`update_rule(when_to_apply=...)`), not a floor.
- `read_and_unacted` — distinct rules OPENED in the window that recorded
- no outcome, against the ones that did. The failure milestone 419 was
- opened on, and the worse sibling of `surfaced_never_pulled` above: a
+ no outcome, against the ones that did. The worse sibling of `surfaced_never_pulled` above: a
rule nobody opens is cheap, while a rule read and silently unchanged is
indistinguishable from one that worked. It does not say which of the two
causes it is — a rule mis-triggering, arriving where it does not apply,
@@ -658,8 +635,8 @@ It is an UPPER BOUND per surface: a pull records the door it came
asked for, query text included, and is not a shared record kind.
`read_failed: true` means the query itself failed — deliberately distinct
- from an empty window, because those two looked identical for weeks once
- (#2663) and every counter silently read zero.
+ from an empty window, which would otherwise read as every counter at
+ zero.
Args:
days: window size, default 30. Clamped to at least 1.
diff --git a/src/scribe/mcp/tools/shapes.py b/src/scribe/mcp/tools/shapes.py
index 4c8ea4f2..1a3c7e4c 100644
--- a/src/scribe/mcp/tools/shapes.py
+++ b/src/scribe/mcp/tools/shapes.py
@@ -1,11 +1,11 @@
-"""Shape-ledger MCP tools — the classification write/read surface (#2789).
+"""Shape-ledger MCP tools — the classification write/read surface.
-The accounting model (note 2786): the snippet library records CANON (small);
+The accounting model: the snippet library records CANON (small);
the ledger accounts for EVERY extracted shape (total). These tools are how
agents move shapes out of `unclassified` — the todo state — and how they read
what still needs judgment. The ledger rows themselves are fed by the coverage
refresh; a judgment given with `repo` may also land on a shape written this
-turn that the sync has not seen yet (milestone 439).
+turn that the sync has not seen yet.
"""
from __future__ import annotations
@@ -19,7 +19,7 @@ async def classify_shapes(
) -> dict:
"""Record judgments for a project's code shapes — in batch, as rows.
- EVERY shape in a bound repo should end up classified (note 2786):
+ EVERY shape in a bound repo should end up classified:
- `instance` of snippet N — it conforms to recorded canon (family-level
canon in another project counts; that fully accounts for the shape).
- `variant` of snippet N — a deliberate, named departure. `reason`
@@ -50,7 +50,7 @@ async def classify_shapes(
one-off-handler, test-helper, convention-plumbing, pure-helper,
generated, script, typed-record) so the ledger can be filtered
and aggregated by kind of one-off — the prose stays the record.
- uses is an OPTIONAL list of snippet ids this shape CALLS (#2870):
+ uses is an OPTIONAL list of snippet ids this shape CALLS:
conformance (status + snippet_id) says what shape it is, uses
says which canonical helpers it consumes — a service function
can be an instance of the service-function convention AND use
@@ -106,7 +106,7 @@ async def list_shapes(
Args:
status: canonical | instance | variant | exempt | scoped | unclassified.
- `scoped` (#2869) is the sync's mechanical stamp on one-offs by
+ `scoped` is the sync's mechanical stamp on one-offs by
construction (a Vue component's scoped