docs(6204): shipped instruction text describes the product, not this instance's records
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 15s
CI & Build / TypeScript typecheck (push) Successful in 53s
CI & Build / integration (push) Successful in 1m10s
CI & Build / Python tests (push) Successful in 1m55s
CI & Build / Build & push image (push) Successful in 39s

MCP tool docstrings and comments, the server's orientation notes, the plugin
skills, every hook script and the plugin README/PACKAGING drop record ids,
milestone numbers, dates and incident stories, and state each non-obvious why
as a present-tense fact. History stays in the task logs.

Corrections found on the way:
- plugin README/PACKAGING said every hook is a GET; moment, rule-opened and
  reply-rules POST, so the plugin's key needs write scope. Both now say so,
  name Settings -> MCP Access, and list every wired hook and endpoint.
- the contract-change hint printed "(lesson #4207)", this instance's record,
  to every install. It no longer cites a record.
- tasks.py claimed there is no delete_task; there is.
- rulebooks.py cited delete_event and a design spec that no longer exist.
- scribe_after_write.sh carried an always-empty etag_q; removed.
- test_write_path_trigger pinned "| tac | scribe_defs | head -1", text that
  existed only in a comment about the old code; it now pins the real tail -1
  pipeline.
- docs/development.md: CI runs on Gitea Actions.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-21 19:45:09 -04:00
co-authored by Claude Opus 5.5
parent fc8f554cee
commit 17777e9bb1
43 changed files with 641 additions and 777 deletions
+1 -1
View File
@@ -49,7 +49,7 @@ The hooks must run on a bare machine: POSIX shell, awk, sed and the usual coreut
## CI and releases ## 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 | | Push | Image tags |
|------|-----------| |------|-----------|
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "scribe", "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).", "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": { "author": {
"name": "Bryan Van Deusen" "name": "Bryan Van Deusen"
}, },
+11 -6
View File
@@ -2,7 +2,7 @@
This is the maintainer contract for shipping Scribe into an agent client: 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 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 the skills hold the depth, and a client adapter only times delivery and says
what that one client needs said.** 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 | | 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 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** | `<base URL>/mcp` | HTTP, `Authorization: Bearer <fmcp_ key>`. 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 MCP server** | `<base URL>/mcp` | HTTP, `Authorization: Bearer <fmcp_ key>`. 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** | `<base URL>/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 adapter API** | `<base URL>/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 → API Keys | One `fmcp_` key per install. Read scope for hooks; write scope for the MCP tools. | | **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 ## 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_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_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' `<sid>.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_shape_check.sh` | Stop: sends the definitions the turn wrote (the write hooks' `<sid>.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_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_defs.sh` | Shared shell helpers: config, dedup ledgers, outage line. |
| `hooks/scribe_static_context.md` | The adapter static text. | | `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 ## 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.<client>/`) that other clients ignore. - **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.<client>/`) that other clients ignore.
- **Gemini CLI**: `gemini-extension.json` configuring the MCP server, a context file, and bundled skills. - **Gemini CLI**: `gemini-extension.json` configuring the MCP server, a context file, and bundled skills.
+34 -11
View File
@@ -17,7 +17,8 @@ instance into a first-class Claude Code extension:
any client's package would ship: using-scribe, writing-plans, reporting-back 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), (reply to the operator in a shape that says where the work stands),
systematic-debugging, verification, brainstorming, reusing-code (record and 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 - **Your Scribe Processes as skills** — saved Processes are synced into local
`~/.claude/skills/scribe-proc-*` stubs that auto-surface by relevance; the `~/.claude/skills/scribe-proc-*` stubs that auto-surface by relevance; the
stub fetches the live procedure via `get_process`. Refreshed each session and 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 of auto-memory — leave auto-memory at its default; Scribe replaces its job by
holding the one copy, not by switching it off. 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 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 — 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 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 | | Setting | What |
|---|---| |---|---|
| **Scribe base URL** | e.g. `https://scribe.example.com` (no trailing slash) | | **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 | | **Active project id** | optional — numeric project id to scope the session-start context |
## What gets wired ## 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 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 UNCHECKED"), so a session can tell "checked, nothing there" from "never
checked"; an answer clears the marker. The local by-name arm needs no 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/hooks.json` → PostToolUse hook on `Bash`
(`hooks/scribe_after_write.sh`): code written through sed/heredocs/scripts (`hooks/scribe_after_write.sh`): code written through sed/heredocs/scripts
never reaches the PreToolUse hook, so this one diffs the working tree after 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 command → generate `~/.claude/skills/scribe-proc-*` stubs from your Scribe
Processes (via `GET /api/plugin/processes`); also **fail-open**, and pruned to Processes (via `GET /api/plugin/processes`); also **fail-open**, and pruned to
match what exists in Scribe. 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 ## Notes
- **Do not hand-edit `version` in `.claude-plugin/plugin.json`.** It is minted - **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 from the clock — run `python3 scripts/mint_plugin_version.py` (or `make
mint-plugin`, where `make` is installed) after changing anything under mint-plugin`, where `make` is installed) after changing anything under
`plugin/`, and commit the result. The installer decides whether to refresh the cache it `plugin/`, and commit the result. The installer decides whether to refresh
executes from by comparing that string, so content that ships without a new the cache it executes from by comparing that string, so content that ships
version reaches the repo and stops there (#2209). CI fails the lane if you without a new version reaches the repo and stops there. CI fails the lane if
forget. you forget.
- The session-start, auto-inject and prior-art hooks need only a **read**-scoped - The plugin uses one key for its MCP tools and its hooks, so give it **write**
key; the MCP tools need **write** scope to create/update. Every hook is a GET scope: the MCP tools create and update records, and the moment, rule-opened
for that reason — a read key cannot POST. and reply hooks POST. A read-scoped key runs the GET hooks only; the others
fail open and add nothing.
+15 -22
View File
@@ -1,14 +1,13 @@
#!/usr/bin/env bash #!/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 # 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 # other way — sed, heredocs, python edit scripts, `cat > file` — never reaches
# it, so a whole class of edits (the ones a long session makes most) got no # it, and those are the edits a long session makes most. So after EVERY Bash
# prior-art hint, no ledger feed and no duplicate-family warning. This hook # call it asks git what changed in the working
# closes that: 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 # 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 # 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. # /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 # 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" fresh="new"
fi fi
[ -n "$code" ] || continue [ -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) 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. # 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" 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) → # 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="" derive_seen=$(tr '\n' ',' < "$derivefile" 2>/dev/null | sed 's/,$//' | scribe_urlenc) || derive_seen=""
[ -n "$derive_seen" ] && derive_exclude_q="&exclude_derive=${derive_seen}" [ -n "$derive_seen" ] && derive_exclude_q="&exclude_derive=${derive_seen}"
fi 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 if [ -n "$path_enc" ]; then
# 8s, not the pre-write hook's 5: this hook runs AFTER the tool, so it # 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 # 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) # after a redeploy is a cold start (embedding warm-up takes several
# that a 4s cap turned into a silent fail-open — the one write a # seconds) that a tighter cap would turn into a silent fail-open.
# session most wants the ledger's word on lost it.
reached=1 reached=1
body=$(curl -fsS --max-time 8 \ body=$(curl -fsS --max-time 8 \
-H "Authorization: Bearer ${token}" \ -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; } "${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 # 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 # shared marker with the pre-write hook, so one outage is one
# line however the code was written. # line however the code was written.
if [ "$reached" = 1 ]; then if [ "$reached" = 1 ]; then
scribe_reached "$state_dir" "$safe_sid" scribe_reached "$state_dir" "$safe_sid"
@@ -219,9 +212,9 @@ while IFS= read -r rel_path; do
fi fi
fi fi
# The record nudge (#2664), same gate as the pre-write hook: duplication # The record nudge, same gate as the pre-write hook: duplication
# demonstrated locally AND nothing recorded for it — and (#2932) never on a # demonstrated locally AND nothing recorded for it — and never on a call
# call that did not answer; "nothing recorded" is a claim only an answer # that did not answer; "nothing recorded" is a claim only an answer
# can back. # can back.
if [ -n "$local_lines" ] && [ "$reached" != 0 ]; then if [ -n "$local_lines" ] && [ "$reached" != 0 ]; then
n_recorded=$(scribe_json_len "$body_flat" '.note_ids') n_recorded=$(scribe_json_len "$body_flat" '.note_ids')
+20 -23
View File
@@ -7,12 +7,12 @@
# only — never bodies; the agent calls get_note(id) to pull anything it judges # only — never bodies; the agent calls get_note(id) to pull anything it judges
# relevant. Most turns inject nothing. # 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 # 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 # 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 # 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 # phrasing, raise a conflict before acting, end a finding with an offer —
# no moment to fire at. The operator's message is the only query that exists # would have no moment to fire at. The operator's message is the only query that exists
# before a response is composed. # before a response is composed.
# #
# The two arms are gated separately server-side: turning the notes menu off # 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. # 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 # 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 # with the userConfig key UPPERCASED (reading the lowercase spelling silently
# silently disables this hook, and silence is indistinguishable from "nothing # disables this hook, and silence is indistinguishable from "nothing
# cleared the threshold"): # cleared the threshold"):
# CLAUDE_PLUGIN_OPTION_API_ENDPOINT base URL, no trailing slash # CLAUDE_PLUGIN_OPTION_API_ENDPOINT base URL, no trailing slash
# CLAUDE_PLUGIN_OPTION_API_TOKEN fmcp_ API key (sensitive) # 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 command -v curl >/dev/null 2>&1 || exit 0
# UserPromptSubmit delivers a JSON event on stdin: { prompt, session_id, cwd, ... } # 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 # the largest payload any hook reads, and re-parsing it per field is three
# passes over the same text. # passes over the same text.
event=$(cat 2>/dev/null || true) event=$(cat 2>/dev/null || true)
@@ -54,12 +54,12 @@ transcript=$(scribe_json_pick "$event_flat" '.transcript_path')
# Nothing to retrieve against. # Nothing to retrieve against.
[ -n "$prompt" ] || exit 0 [ -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 # 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 # request matters more for the LOG than for the noise: a retrieval call
# recorded against a notification inflates this surface's denominator and # recorded against a notification inflates this surface's denominator and
# seeds `near_misses` with demand nobody expressed. scribe_skip_prompt carries # seeds `near_misses` with demand nobody expressed. scribe_skip_prompt carries
# the measurement. # the reasoning.
scribe_skip_prompt "$prompt" && exit 0 scribe_skip_prompt "$prompt" && exit 0
# Unconfigured install → silent (auto-inject is pure enrichment). # 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. # 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 # `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 # long multi-line prompt would sail past the budget. scripts/check_plugin.py
# prior-art hook's code cap; this copy was missed when that one was fixed, and # checks every hook for this.
# scripts/check_plugin.py caught it.
q=$(printf '%s' "$prompt" | head -c 2000) q=$(printf '%s' "$prompt" | head -c 2000)
# Encoded whole, never line by line. The predecessor here was `jq -rR`, which # Encoded whole, never line by line: per-line encoding joins the pieces with
# reads a line at a time: a multi-line prompt came back as several separately # raw newlines, an invalid URL, and the long multi-line prompts most worth
# encoded lines joined by raw newlines and the request died. Single-line prompts # retrieving against would be the ones dropped. scribe_urlenc reads bytes and
# worked, which is why this looked healthy — the long, substantial prompts most # has no notion of a line.
# worth retrieving against were exactly the ones silently dropped. scribe_urlenc
# reads bytes and has no notion of a line.
q_enc=$(printf '%s' "$q" | scribe_urlenc) || exit 0 q_enc=$(printf '%s' "$q" | scribe_urlenc) || exit 0
[ -n "$q_enc" ] || 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 # 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 # 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. # 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 # 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. # 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 # 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 # different. The directory is named for the prior-art hook, which is not a
# directory name is the prior-art hook's history, not a scope claim. # scope claim.
rule_state_dir="${TMPDIR:-/tmp}/scribe-priorart" rule_state_dir="${TMPDIR:-/tmp}/scribe-priorart"
mkdir -p "$rule_state_dir" 2>/dev/null || true mkdir -p "$rule_state_dir" 2>/dev/null || true
idfile="" idfile=""
@@ -126,9 +123,9 @@ if [ -n "$session_id" ]; then
# stops holding what it was told. scribe_rules_live carries the reasoning. # stops holding what it was told. scribe_rules_live carries the reasoning.
rule_seen=$(scribe_rules_live "$rulefile") rule_seen=$(scribe_rules_live "$rulefile")
[ -n "$rule_seen" ] && exclude_q="${exclude_q}&exclude_rule_ids=${rule_seen}" [ -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")" 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. # sent whole once, and as its one-line reminder on every later turn.
shapefile=$(scribe_shapes_file "$safe_sid") shapefile=$(scribe_shapes_file "$safe_sid")
shapes_seen=$(scribe_shapes_seen "$shapefile") shapes_seen=$(scribe_shapes_seen "$shapefile")
@@ -149,7 +146,7 @@ if [ -n "$idfile" ]; then
scribe_json_list "$body_flat" '.note_ids' >> "$idfile" || true scribe_json_list "$body_flat" '.note_ids' >> "$idfile" || true
fi fi
# Rules onto the SHARED ledger, stamped so they can age out. Only FRESH ids # 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 # the ledger, and re-appending it would keep pushing its stamp forward so it
# never aged at all. # never aged at all.
if [ -n "$rulefile" ]; then if [ -n "$rulefile" ]; then
+1 -1
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env bash #!/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 # 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 # `in_progress`, a work log on an open task — so every MCP client gets a claim
+143 -192
View File
@@ -1,6 +1,6 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# shellcheck shell=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 # 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 # 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_path PATH formats that hold prose or data, not shapes
# scribe_skip_prompt TEXT the turn was written by the CLIENT, not the # 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 → "kind<TAB>name" per definition # scribe_defs stdin code → "kind<TAB>name" per definition
# scribe_local_dups ROOT REL "kind<TAB>name" lines on stdin → the by-name # scribe_local_dups ROOT REL "kind<TAB>name" 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 # 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_reached STATE SID the server answered: the next outage speaks again
# scribe_config sets `url` + `token` from the env, returns 0 # 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, # 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_rules_append FILE stdin ids -> the ledger, timestamped
# scribe_json_flat stdin JSON -> IDX<TAB>PATH<TAB>VALUE lines, # scribe_json_flat stdin JSON -> IDX<TAB>PATH<TAB>VALUE lines,
# queried with scribe_json_pick / _list / _len. # queried with scribe_json_pick / _list / _len.
# scribe_json_out writes the hook envelope, # 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=<enc>` for DIR — the # scribe_scope_query DIR `project_id=N` or `repo=<enc>` for DIR — the
# project-scope key EVERY hook sends (#4085). # project-scope key EVERY hook sends.
# Helpers: scribe_marker_file, scribe_url_host, # Helpers: scribe_marker_file, scribe_url_host,
# scribe_marker_read (id<TAB>why-not), # scribe_marker_read (id<TAB>why-not),
# scribe_marker_project (the id alone) # scribe_marker_project (the id alone)
@@ -45,23 +45,21 @@ scribe_skip_path() {
return 1 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 # submits several kinds of machine-written text through UserPromptSubmit — a
# task notification, the echo of a slash command, the caveat banner a local # task notification, the echo of a slash command, the caveat banner a local
# command prints — and they reach `.prompt` indistinguishable from typed words # command prints — and they reach `.prompt` indistinguishable from typed words
# unless something looks. # unless something looks.
# #
# THIS IS A TELEMETRY FIX FIRST AND A NOISE FIX SECOND, which is the part # THIS PROTECTS TELEMETRY FIRST AND CUTS NOISE SECOND. Retrieving against a
# worth keeping straight: retrieving against a notification usually returns # notification usually returns nothing, so the visible cost looks like one
# nothing, so the visible cost looks like one wasted embedding. The real cost # wasted embedding. The real cost is that the call is LOGGED. It inflates the
# is that the call is LOGGED. It inflates the denominator of every # denominator of every prompt-boundary surface, so delivery rate reads low for
# prompt-boundary surface, so delivery rate reads low for a reason that has # a reason that has nothing to do with retrieval; and the refusal lands in
# nothing to do with retrieval; and the refusal lands in `near_misses`, where # `near_misses`, where a tuning decision reads it as unmet demand. Notification
# a later tuning decision reads it as unmet demand. Measured on #3898: 15 of # blocks resemble each other, so they pile up just under a floor, and a floor
# the top 20 preference near-misses were `<task-notification>` blocks, ALL # lowered to serve that apparent demand would inject the same record into
# matching one record, ALL within thousandths of the floor. A floor lowered to # every notification.
# serve that apparent demand would inject that record into every notification
# — the instrument arguing for the wrong fix, which is #379's lesson again.
# #
# A PREFIX TEST, NOT A SUBSTRING ONE, and that distinction is the whole safety # 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 # 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 # 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 # and in most CI containers, and a hook that exits quietly without it looks
# is already required by code above this line. # 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 # scribe_json.awk does the parsing and carries the format. These are the four
# jobs the hooks actually had jq for: # 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 # $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. # 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, # 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 # 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 # parsed — a transcript is mostly tool results, and parsing those in awk costs
# multi-second cost the completion-report check measured (#4107). Fixed strings # seconds. Fixed strings
# with UNESCAPED quotes can only match at a record's own top level. # 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. # A sidechain is a subagent talking, not this session, so it is skipped.
scribe_recent_context() { scribe_recent_context() {
@@ -160,9 +154,8 @@ scribe_recent_context() {
# $1 flat text, $2 exact path → the decoded scalar, or "" if absent. # $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. # `null` READS AS ABSENT. A field the server left null and one it never sent
# A field the server left null and one it never sent mean the same thing to # mean the same thing to every caller here, and the shells downstream test `[ -n "$x" ]`.
# every caller here, and the shells downstream test `[ -n "$x" ]`.
scribe_json_pick() { scribe_json_pick() {
printf '%s\n' "${1:-}" \ printf '%s\n' "${1:-}" \
| awk -F'\t' -v p="$2" '$2 == p { if ($3 != "null") print $3; exit }' 2>/dev/null \ | 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 # $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 # 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 # 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 # reuse file are precisely `note_ids` minus `sync_note_ids`.
# `(.note_ids // []) - (.sync_note_ids // [])`.
scribe_json_list_minus() { scribe_json_list_minus() {
local keep local keep
keep=$(scribe_json_list "$1" "$3") 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), # 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 # 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 # 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 # and mawk write the single byte n % 256, so "·" (U+00B7) would arrive as a
# 0xB7 and "—" (U+2014) as 0x14 wherever the hook ran without a UTF-8 locale — # 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
# 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
# below is the only one that happens. # below is the only one that happens.
scribe_json_unescape() { scribe_json_unescape() {
LC_ALL=C awk ' LC_ALL=C awk '
@@ -348,12 +338,12 @@ scribe_urlenc() {
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# kind<TAB>name for each thing a piece of code DEFINES, in source order. One # kind<TAB>name for each thing a piece of code DEFINES, in source order. One
# program, two consumers: the local duplicate arm (every definition in the # 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 # 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 # services/coverage.py extract_shapes — ledger rows are keyed by what THAT
# sees, so the two must agree on what counts as a definition. # 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: # 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 # a wrapped docstring beginning "class AND the …" announces a shape called
# `AND`, which reaches the session mid-edit as a divergence prompt about a # `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) sub(/[^A-Za-z0-9_$].*$/, "", t)
# `type` defines only when something follows the name (= or {); an # `type` defines only when something follows the name (= or {); an
# import specifier `type Foo,` is the same two words and defines # 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:]]/) { if (line ~ /^type[[:space:]]/) {
rest = line; sub(/^type[[:space:]]+[A-Za-z_$][A-Za-z0-9_$]*/, "", rest) rest = line; sub(/^type[[:space:]]+[A-Za-z_$][A-Za-z0-9_$]*/, "", rest)
if (rest !~ /[={]/) return 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 # The recorded arms ask Scribe what was RECORDED; the ledger arm asks what a
# what a BOUND repo's ledger knows. A helper nobody recorded, in a repo nobody # BOUND repo's ledger knows. A helper nobody recorded, in a repo nobody bound,
# bound, is invisible to both — which is how `.btn-primary` came to be defined # is invisible to both, so it gets defined again in the next file. This arm asks the one question only the
# four times, already diverged. This arm asks the one question only the
# developer's machine can answer, inside the repo, holding the code about to # 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 # be written: no index, no storage, no server — it runs even on an install
# that has never configured Scribe. # that has never configured Scribe.
@@ -521,23 +510,22 @@ scribe_defs() {
# Definition-shaped patterns only. Grepping for bare occurrences would match # 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 # 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 # 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 # $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. # 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. # 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 # 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 # is everything that reads it." An interface-contract check between a
# down: not between layers but between a definition and its callers. # definition and its callers.
# #
# WHY IT IS A CHECK AND NOT A LESSON. #4207 was written — "widening a tuple is # WHY IT IS A CHECK AND NOT ADVICE. "Widening a tuple is an interface change to
# an interface change to every unpack site, and the compiler will not tell # every unpack site, and the compiler will not tell you" is easy to know and
# you" — hours before a structurally identical mistake was made by its author, # easy to forget mid-edit. Text delivered at the moment of acting is too weak a
# and it was surfaced twice in the turns before. Text delivered at the moment # carrier for a reflex that has to change what the act IS, so this looks the
# of acting is too weak a carrier for a reflex that has to change what the act # callers up instead.
# IS. This looks it up instead.
# #
# `scribe_exposed` — the names a CALLER can depend on, from a blob of code. # `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 # 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 # arg its parameter names arity and order
# key quoted keys of dict literals the shape of what it RETURNS # 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 # The third matters because a returned dict can change shape with no
# function gained one dict key; three arms read that dict inside a fail-open # signature changing: callers that read it inside a fail-open `except` go
# `except`, so every one of them silently became a no-op and ten tests went # silently inert, with nothing pointing at the cause.
# red at once with nothing pointing at the cause. No signature changed. A
# check that only watched signatures would have watched the wrong thing.
scribe_exposed() { scribe_exposed() {
# The `sym` half DELEGATES to scribe_defs rather than repeating its patterns. # The `sym` half DELEGATES to scribe_defs rather than repeating its patterns.
# Those patterns cover nine languages and have been corrected several times # Those patterns cover nine languages and carry their own edge cases (the Go
# (the Go receiver form, the `type` import-specifier false positive, the # receiver form, the `type` import specifier, the dunder skip); a second copy
# dunder skip); a second copy here would inherit today's version and then # here would drift from them. One reader, called twice.
# quietly stop agreeing with it, which is #3497's history for the two rule
# arms. One reader, called twice.
local blob local blob
blob=$(cat) blob=$(cat)
{ {
@@ -612,7 +596,7 @@ scribe_exposed() {
# not match `cfg_path`, and the defining file is excluded — a definition is # not match `cfg_path`, and the defining file is excluded — a definition is
# always its own first mention and listing it says nothing. # 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 # 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 # still-writing git grep, and an outer fallback then wipes the hits head had
# already printed. # already printed.
@@ -679,7 +663,7 @@ scribe_contract_block() {
[ -n "$gained" ] && printf '> gained: %s\n' "$gained" [ -n "$gained" ] && printf '> gained: %s\n' "$gained"
[ -n "$lost" ] && printf '> lost: %s\n' "$lost" [ -n "$lost" ] && printf '> lost: %s\n' "$lost"
printf '> read by: %s\n' "$(printf '%s' "$readers" | tr '\n' ' ' | sed 's/ $//')" 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 [ -n "$ledger" ] && printf '%s\n' "$subject" >> "$ledger" 2>/dev/null
return 0 return 0
} }
@@ -705,8 +689,7 @@ scribe_slippage_lines() {
# is the moment the exclusion ledgers are about to be cleared and the moment # 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 # 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 # 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 # and read as though it had reported all of it.
# one this milestone exists to stop producing.
named=$(scribe_ledger_kept "$dir/${sid}.rules.keep.ids") named=$(scribe_ledger_kept "$dir/${sid}.rules.keep.ids")
opened=$(scribe_ledger_kept "$dir/${sid}.opened.keep.ids") opened=$(scribe_ledger_kept "$dir/${sid}.opened.keep.ids")
acted=$(scribe_ledger_kept "$dir/${sid}.acted.keep.ids") acted=$(scribe_ledger_kept "$dir/${sid}.acted.keep.ids")
@@ -751,38 +734,33 @@ scribe_ids_minus() {
' </dev/null 2>/dev/null ' </dev/null 2>/dev/null
} }
# ARM 1, BY NAME (#2280) — and the confirmation pass that makes it mean # ARM 1, BY NAME — and the confirmation pass that makes it mean something.
# something (#4227).
# #
# WHY A GREP IS NOT ENOUGH. The pattern below looks for a definition keyword # 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. # 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`.
# 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.
# #
# SO EVERY HIT IS CONFIRMED by running the real extractor over the candidate # 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 # 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. # 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 # `{` or `:` after the name rejects `class with only…` — and also rejects
# `class Foo extends Bar {`, `class Foo : Base()` and `type Foo struct {`. This # `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 # index and no binding, which makes a miss here invisible. Trading a visible
# false positive for an invisible false negative is a bad trade. # 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 # 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 # usually a candidate for several names at once, and the extractor reads the
# whole file either way. Measured on this repo, a deliberately pathological # whole file either way. `scribe_defs` runs at roughly 33ms per 250KB, so
# payload — nine names that are ordinary English words — produced 28 distinct # even a pathological payload of ordinary-word names costs a few hundred ms to
# candidate files totalling 947KB, and `scribe_defs` runs at roughly 33ms per # confirm; the per-(name, file) shape would pay that several times over for
# 250KB, so the confirmation costs about 200ms in the worst case anyone has # the same answer.
# been able to construct here. The per-(name, file) shape would have paid that
# several times over for the same answer.
_SCRIBE_DUP_CANDIDATES=12 _SCRIBE_DUP_CANDIDATES=12
_SCRIBE_DUP_SHOWN=4 _SCRIBE_DUP_SHOWN=4
_SCRIBE_DUP_BUDGET=48 _SCRIBE_DUP_BUDGET=48
@@ -794,12 +772,10 @@ scribe_local_dups() {
# PASS 1 — candidates. One grep per name, unchanged except for the cap. # 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 # THE CANDIDATE CAP IS WIDER THAN THE DISPLAY CAP. Confirmation REMOVES
# hits, so capping before it runs lets three phantom matches crowd out a real # hits, so capping before it runs would let phantom matches crowd out a real
# definition in the fourth file — hits dropped before anyone looked at them, # definition. The display cap (_SCRIBE_DUP_SHOWN) applies to CONFIRMED hits,
# which is #4042's bug wearing a different hat. The display cap stays at four # which is where a cap belongs.
# (_SCRIBE_DUP_SHOWN); it now applies to CONFIRMED hits, which is where a cap
# belongs.
while IFS=$'\t' read -r kind name; do while IFS=$'\t' read -r kind name; do
[ -n "${name:-}" ] || continue [ -n "${name:-}" ] || continue
case "$kind" in 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:]]*=" ;; *) 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 esac
# -I skips binaries; :(exclude) drops the file being written. # -I skips binaries; :(exclude) drops the file being written.
# `|| true` INSIDE the substitution, not `|| hits=""` outside it (#4042): # `|| true` INSIDE the substitution, not `|| hits=""` outside it: under
# under the hooks' `pipefail`, `head` exiting early kills a git grep that # the hooks' `pipefail`, `head` exiting early kills a git grep that is
# is still writing, the pipeline reports SIGPIPE, and an outer fallback # still writing, the pipeline reports SIGPIPE, and an outer fallback would
# then wipes the hits head had already printed. The name most duplicated — # wipe the hits head had already printed — dropping exactly the most
# the one this arm exists for — was the one it dropped. # duplicated name.
hits=$(git -C "$root" grep -I -l -E -e "$pat" -- . ":(exclude)${rel}" 2>/dev/null \ hits=$(git -C "$root" grep -I -l -E -e "$pat" -- . ":(exclude)${rel}" 2>/dev/null \
| head -"$_SCRIBE_DUP_CANDIDATES" || true) | head -"$_SCRIBE_DUP_CANDIDATES" || true)
[ -n "$hits" ] || continue [ -n "$hits" ] || continue
@@ -868,19 +844,9 @@ scribe_local_dups() {
} }
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# The blind spot made visible (#2932). Both write-path hooks fail OPEN when the # Where every hook gets its endpoint and credential. One copy, because the
# instance is slow or down — right for noise, wrong for silence: a session # `${...}` guard below is a correctness detail a hook with its own copy would
# cannot tell "the ledger checked and found nothing" from "the ledger never # forget, and nothing would fail loudly.
# 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.
# #
# Sets `url` and `token` as globals rather than echoing them: a token must not # 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` # 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" ] [ -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_QUIET=600
scribe_unreached() { 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 # 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 # 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 # 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. # 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 # 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 # 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 # fallen out of attention. Present in context and salient at the moment are
# context and salient at the moment are different properties) applied to time # different properties.
# instead of to tier.
# #
# PER-ENTRY TIMESTAMPS, NOT A FILE MTIME. Clearing the whole ledger when the # 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 # 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. # which produces one extra full line per rule and no other harm.
_SCRIBE_RULE_TTL=2700 _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 # It is a judgement made to be revised: how often an aged-out rule gets PULLED
# telemetry that would settle it is the one #3807 just built, and a reading of # after it returns is the reading that should move it.
# how often an aged-out rule gets PULLED after it returns is what should move
# this.
# #
# Too short and the exclusion stops existing and the repetition it prevents # 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 # 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 # it governs, short enough that a multi-hour session gets a genuine refresh
# rather than one 9am mention. # rather than one 9am mention.
# #
# Being wrong on the short side is now the cheaper error, which is why this # Being wrong on the short side is the cheaper error, which is why this leans
# leans short. Since #3750 an excluded rule is REFERENCED rather than withheld, # short. An excluded rule is REFERENCED rather than withheld, so an expired
# so the ledger is no longer the only thing standing between a session and a # entry costs one full line instead of one short one, and the exclusion
# rule it has forgotten — an expired entry costs one full line instead of one # re-arms the moment it is spent.
# 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 # 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 # 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. # 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. # 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 BARE ID — no tab, no timestamp — IS LIVE. Unknown means "not measured" and
# a session in flight when this ships has a ledger full of them. Treating # never "old": treating it as expired would re-announce every such rule at
# unknown as expired would make every one of those sessions re-announce every # once, which is precisely the noise this exists to prevent. Those entries
# rule it had already been told, all at once, which is precisely the noise this # simply never age, which is bounded: the session ends.
# 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.
scribe_rules_live() { scribe_rules_live() {
local f="$1" now local f="$1" now
[ -n "$f" ] && [ -f "$f" ] || return 0 [ -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 # 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_json_list "$flat" '.rule_ids'` already produces at both call sites.
scribe_rules_append() { 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 # `$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 # 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 # The twin is an EVIDENCE ledger: it answers "did this happen?", which no
# compaction can make untrue. It is never aged and never swept. # compaction can make untrue. It is never aged and never swept.
# #
# These are opposite lifetimes and `.opened.ids` was serving both, which is # These are opposite lifetimes, so one file cannot serve both: a compaction
# how a session with 45 `get_rule` calls came to report none: the compaction # would clear the ledger that is also the record.
# cleared the ledger that was also the record. Measured across six sessions
# on this instance — 208 opens, 3 surviving — before the split.
# #
# DERIVED HERE rather than listed at the call sites, because a list is what # DERIVED HERE rather than listed at the call sites, because a list goes
# broke this before (see `scribe_clear_session_ledgers`). Every ledger # stale (see `scribe_clear_session_ledgers`). Every ledger
# written through this function gets its twin, including the next one # written through this function gets its twin, including the next one
# somebody adds. The cost is a second small file per ledger per session. # somebody adds. The cost is a second small file per ledger per session.
local f="$1" now local f="$1" now
@@ -1055,19 +1020,7 @@ scribe_ledger_kept() {
} END { if (out != "") print out }' "$f" 2>/dev/null || true } END { if (out != "") print out }' "$f" 2>/dev/null || true
} }
# The OPENED ledger's contribution to a rule arm's query string (#4100). # ── The pre-act checkpoint's session ledger ──────────────────────────────
#
# 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) ─────────
# #
# A checkpoint STOPS an act rather than annotating it, so unlike every other # 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 # 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') 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 # `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 # 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=$(printf '%s' "$n" | tr -cd '0-9')
[ -n "$n" ] || n=0 [ -n "$n" ] || n=0
[ "$n" -ge "$_SCRIBE_CHECKPOINT_CAP" ] && return 1 [ "$n" -ge "$_SCRIBE_CHECKPOINT_CAP" ] && return 1
@@ -1135,6 +1088,18 @@ scribe_json_deny() {
"$1" "$esc" "$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() { scribe_held_query() {
local ids local ids
ids=$(scribe_rules_live "$1") ids=$(scribe_rules_live "$1")
@@ -1142,8 +1107,8 @@ scribe_held_query() {
return 0 return 0
} }
# The Systems whose rulings this session was already shown in full (milestone # The Systems whose rulings this session was already shown in full, as the
# 444), as the `seen_ruling_systems` query both PreToolUse hooks send. A flat # `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 # 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 # 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 # follows the ledger name convention, so a compaction clears it and the
@@ -1156,20 +1121,11 @@ scribe_rulings_query() {
return 0 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 compaction destroys the context, so a ledger that survives it makes the
# a time, and it named two of the five: `.rules.ids` and `.opened.ids` were # records it lists unreachable for the rest of the session. A hand-kept list
# cleared while `.ids` (notes), `.sync.ids` (shape signals) and `.derive.ids` # of files to clear misses whichever ledger was added after it was written.
# 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.
# #
# So the rule is the NAME: a per-session ledger is `<sid>[.<kind>].ids`, and # So the rule is the NAME: a per-session ledger is `<sid>[.<kind>].ids`, and
# everything matching that goes. A new ledger following the convention is # 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 # compact rather than discarded can still live in these directories without
# being swept away by a glob that was never told about it. `<sid>.unreached` # being swept away by a glob that was never told about it. `<sid>.unreached`
# is the live example: it records that the instance could not be reached, not # 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 # TWO DIRECTORIES: `scribe_autoinject.sh` keeps its note ledger in
# `scribe-priorart` — every ledger named in the hooks was there, so the list # `scribe-autoinject`, apart from the prior-art hooks'. The roster lives here
# looked complete. `scribe_autoinject.sh` keeps its note ledger in # rather than as a path at each call site: one place to add to, and the tests
# `scribe-autoinject`, so the arm that fires most (598 calls in five days) was # read THIS string rather than a copy of it.
# 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.
SCRIBE_LEDGER_DIRS="scribe-priorart scribe-autoinject" 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 `path<TAB>kind<TAB>name`, for the Stop hook to ask about at the # appended as `path<TAB>kind<TAB>name`, for the Stop hook to ask about at the
# end of the turn (scribe_shape_check.sh). Its own file — one file, one # 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 `kind<TAB>name` lines on stdin (the # clear above already sweeps it. Read `kind<TAB>name` lines on stdin (the
# scribe_defs format). # scribe_defs format).
# #
@@ -1226,7 +1180,7 @@ scribe_written_append() {
return 0 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 # session has been shown IN FULL — `core`, `completion`, `asks`, `plan` — one
# key per line in <sid>.shapes.ids under the prior-art directory. The server # key per line in <sid>.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 # 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 `KEY<TAB>VALUE` per line. # facts — bounded, closed, task_ids, reply — one `KEY<TAB>VALUE` per line.
# Shared by the two Stop hooks that read a turn: the report check and the # 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 # 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 # 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 # 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 # 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 # 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 # look at. Parsing all of it in awk takes seconds (6.5s for a 7MB window,
# could afford and a parser written in awk cannot: measured at 6.5s for a 7MB # against 94ms narrowed first), on a hook that runs at the end of every turn.
# window against 94ms, 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 # 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 # `"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 # Every hook here scopes its request to a project. The first key is `git
# six did it the same single way: `git remote get-url origin`, resolved # remote get-url origin`, resolved server-side through the repo bindings. That
# server-side through the repo bindings. That works well inside a bound repo # works inside a bound repo and not at all outside one, where a session would
# and not at all outside one — a session in a plain directory got no project # get no project scope from any hook.
# scope from ANY hook, silently, because the one key the whole chain turns on
# only exists in a git repo.
# #
# The second key is a `.scribe` file in the directory (or above it, the way # 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 # git finds its root), naming the project the work belongs to. It is a
+3 -3
View File
@@ -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 # 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 # 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 — # EVERY ARRAY ALSO EMITS `PATH[#]` with its LENGTH, including an empty one —
# which is what makes "the server answered with zero notes" distinguishable # which is what makes "the server answered with zero notes" distinguishable
# from "the server did not answer", a distinction #2932 built a whole outage # from "the server did not answer", the distinction the outage line rests
# marker around. An empty object emits `PATH{#}` 0 for the same reason. # on. An empty object emits `PATH{#}` 0 for the same reason.
# #
# TWO MODES, because the two jobs have opposite failure behaviour: # TWO MODES, because the two jobs have opposite failure behaviour:
# #
+3 -3
View File
@@ -1,6 +1,6 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# Scribe — PreToolUse moment arm: rules MOUNTED on a moment arrive when the # 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 # 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 # 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 mkdir -p "$cache_dir" 2>/dev/null || true
now=$(date +%s 2>/dev/null) || now=0 now=$(date +%s 2>/dev/null) || now=0
# ── The acts ledger (milestone 458 step 7) ──────────────────────────────── # ── The acts ledger ──────────────────────────────────────────────────────
# #
# Every call, before any filter: `epoch<TAB>event`, one line each. When the # Every call, before any filter: `epoch<TAB>event`, one line each. When the
# session then opens a rule, scribe_record_opened.sh sends the last few # 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") rule_seen=$(scribe_rules_live "$rulefile")
[ -n "$rule_seen" ] && ledger_q="&exclude_rule_ids=${rule_seen}" [ -n "$rule_seen" ] && ledger_q="&exclude_rule_ids=${rule_seen}"
ledger_q="${ledger_q}$(scribe_held_query "$state_dir/${safe_sid}.opened.ids")" 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. # full comes back as its reminder.
shapefile=$(scribe_shapes_file "$safe_sid") shapefile=$(scribe_shapes_file "$safe_sid")
shapes_seen=$(scribe_shapes_seen "$shapefile") shapes_seen=$(scribe_shapes_seen "$shapefile")
+9 -17
View File
@@ -1,12 +1,7 @@
#!/usr/bin/env bash #!/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. # HOW A PRECOMPACT HOOK REACHES THE MODEL (as read out of Claude Code 2.1.273):
#
# 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):
# #
# * `additionalContext` is NEVER read on PreCompact. The hook-output schema # * `additionalContext` is NEVER read on PreCompact. The hook-output schema
# has no PreCompact variant at all; the field is honoured for SessionStart, # 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 # operator typed after `/compact` and passed into the prompt that writes
# the summary. Every path does this — manual, auto and partial compaction. # 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: # So the channel is the summary itself: we do not interrupt the compaction, we
# we do not interrupt the compaction, we steer the summary it produces. The # steer the summary it produces. The
# summary is what the next turn reads, so text that survives into it survives # summary is what the next turn reads, so text that survives into it survives
# the compaction. # the compaction.
# #
@@ -27,8 +22,7 @@
# `{"decision":"block"}`) does not pause for the model and cannot tell it # `{"decision":"block"}`) does not pause for the model and cannot tell it
# anything — the compaction is SKIPPED, a warning goes to the operator's # anything — the compaction is SKIPPED, a warning goes to the operator's
# screen, and the session continues uncompacted toward its context limit with # 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 # no summary at all, silently from the model's side. This hook exits 0 on
# exactly the outcome #3680 existed to avoid shipping. This hook exits 0 on
# every path. # every path.
# #
# SCOPE: in a subagent the handler discards hook stdout and keeps only a block, # 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 # shellcheck source=plugin/hooks/scribe_defs.sh
. "$(dirname "${BASH_SOURCE[0]}")/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 # THE EVENT IS READ for `session_id`: the slippage readout below is specific
# nothing in it changes what we emit, and that was true while every line was # to THIS session, and the id is the key to the ledgers that hold it. The
# static. It stopped being true at #4216: the slippage readout below is # instruction is still the same whether the operator typed
# 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
# `/compact` or the session hit its limit — what varies is the measurement # `/compact` or the session hit its limit — what varies is the measurement
# appended to it. # appended to it.
event=$(cat 2>/dev/null || true) 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. cannot: they are this session's only copy.
EOF 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 # 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 # is invisible by construction: it looks exactly like a rule that worked. The
+32 -40
View File
@@ -8,16 +8,16 @@
# that path or in its directory, plus snippets resembling the code about to be # that path or in its directory, plus snippets resembling the code about to be
# written. Titles + ids only, never bodies. # 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; # 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 # 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). # 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 # definitions being written (`shapes=`), and the server — only when the
# session has PULLED a snippet this code references or resembles — records # 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 agent's own end-of-turn judgment is the verdict; the same names go to
# the written-shapes ledger the Stop hook asks about. # 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. # 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 # 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 # userConfig key UPPERCASED (the lowercase spelling reads as empty, and this
# and this hook then exits 0 in silence, looking exactly like "no prior art"): # 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_ENDPOINT base URL, no trailing slash
# CLAUDE_PLUGIN_OPTION_API_TOKEN fmcp_ API key (sensitive) # CLAUDE_PLUGIN_OPTION_API_TOKEN fmcp_ API key (sensitive)
# SCRIBE_URL / SCRIBE_TOKEN override for the settings.json dogfooding path. # 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 command -v curl >/dev/null 2>&1 || exit 0
# Shared with the after-write hook (#2901): the prose/data skip list, the # Shared with the after-write hook: the prose/data skip list, the definition
# definition extractor and the local by-name duplicate arm live in # extractor and the local by-name duplicate arm live in scribe_defs.sh so the
# scribe_defs.sh so the two hooks cannot drift apart. Sourced FIRST because the # two hooks cannot drift apart. Sourced FIRST because the JSON reader is there
# JSON reader is there too now (#4107). # too.
# shellcheck source=plugin/hooks/scribe_defs.sh # shellcheck source=plugin/hooks/scribe_defs.sh
. "$(dirname "${BASH_SOURCE[0]}")/scribe_defs.sh" . "$(dirname "${BASH_SOURCE[0]}")/scribe_defs.sh"
@@ -77,11 +77,11 @@ if [ -n "$repo_root" ]; then
esac esac
fi 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.) # in the repo? (scribe_local_dups in scribe_defs.sh carries the why.)
names="" names=""
if [ -n "$code" ]; then 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) names=$(printf '%s' "$code" | scribe_defs | sort -u | head -12 || true)
fi fi
@@ -97,7 +97,7 @@ if [ -n "$local_lines" ]; then
fi 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 # 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 # 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 # shapes in play: every definition in the payload, or — for an Edit that
@@ -109,14 +109,9 @@ fi
# names, not bodies. # names, not bodies.
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# #
# THE ENCLOSING DEFINITION IS THE LAST ONE AT OR ABOVE THE EDIT, and taking it # THE ENCLOSING DEFINITION IS THE LAST ONE AT OR ABOVE THE EDIT: extract
# needs no `tac`. This read `head -n $ln | tac | scribe_defs | head -1` — reverse # forward and take the tail. No `tac`, which is GNU-only and absent on macOS,
# the lines, extract, take the first. `tac` is GNU-only: it is absent on macOS # and no `head -1`, whose early exit is a SIGPIPE trap under pipefail.
# (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.
shapes="$names" shapes="$names"
if [ -z "$shapes" ] && [ -f "$file_path" ]; then if [ -z "$shapes" ] && [ -f "$file_path" ]; then
old_first=$(scribe_json_pick "$event_flat" '.tool_input.old_string') 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. # built a URL from the whole payload. head -c caps the total, which is the point.
q=$(printf '%s' "$code" | head -c 1200) q=$(printf '%s' "$code" | head -c 1200)
# Encoded whole, never line by line. The predecessor (`jq -rR`) read input LINE # Encoded whole, never line by line: a payload encoded per line comes back as
# BY LINE, so a multi-line payload came back as several separately-encoded lines # separately-encoded lines joined by raw newlines, an invalid URL, and code is
# joined by raw newlines — an invalid URL that made curl fail, and this hook then # full of newlines. scribe_urlenc reads bytes and has no notion of a line.
# 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.
path_enc=$(printf '%s' "$rel_path" | scribe_urlenc) path_enc=$(printf '%s' "$rel_path" | scribe_urlenc)
[ -n "$path_enc" ] || exit 0 [ -n "$path_enc" ] || exit 0
code_enc=$(printf '%s' "$q" | scribe_urlenc) 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 # 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. # 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 # ("something similar/nearby is recorded") and SYNC ("a snippet records the
# exact file being edited — updating the record is part of the edit"). They # 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 # 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" state_dir="${TMPDIR:-/tmp}/scribe-priorart"
mkdir -p "$state_dir" 2>/dev/null || true 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:<snippet_id>`) for the # (a derive group id) or a canon elsewhere (`canon:<snippet_id>`) for the
# shapes being written. Keyed by that token, not a note id, so it dedups on # 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. # 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 # file for the same reason as the others — a rule named once should not be
# re-offered on every subsequent write in the session. # 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 # 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. # a command must not be repeated in full before the next edit there.
idfile="" idfile=""
@@ -208,7 +200,7 @@ rulingsfile=""
rulings_q="" rulings_q=""
if [ -n "$session_id" ]; then if [ -n "$session_id" ]; then
safe_sid=$(printf '%s' "$session_id" | tr -c 'A-Za-z0-9._-' '_') 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 # 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 # 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 # 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="" derive_seen=$(tr '\n' ',' < "$derivefile" 2>/dev/null | sed 's/,$//' | scribe_urlenc) || derive_seen=""
[ -n "$derive_seen" ] && derive_exclude_q="&exclude_derive=${derive_seen}" [ -n "$derive_seen" ] && derive_exclude_q="&exclude_derive=${derive_seen}"
fi 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 # 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. # this arm's sibling hook reads the same rule file through the same helper.
rule_seen=$(scribe_rules_live "$rulefile") rule_seen=$(scribe_rules_live "$rulefile")
[ -n "$rule_seen" ] && rule_exclude_q="&exclude_rule_ids=${rule_seen}" [ -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")" rule_exclude_q="${rule_exclude_q}$(scribe_held_query "$state_dir/${safe_sid}.opened.ids")"
fi fi
# Not `|| exit 0`: an unreachable instance must not discard a local finding # 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 # that was owed and didn't come back is said, once per outage, so the session
# knows this write went unchecked. # knows this write went unchecked.
reached=1 reached=1
@@ -285,13 +277,13 @@ if [ -n "$body" ]; then
fi fi
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 # 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 # 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 # 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 # 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 # 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 # of those copies is recorded" is a claim only an answer can back — the
# unreached line says what actually happened instead. # unreached line says what actually happened instead.
if [ -n "$local_lines" ] && [ "$reached" = 1 ]; then if [ -n "$local_lines" ] && [ "$reached" = 1 ]; then
@@ -302,11 +294,11 @@ if [ -n "$local_lines" ] && [ "$reached" = 1 ]; then
fi 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 # "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 # reads it." An interface-contract check between a definition and its
# layers but between a definition and its callers. # callers.
# #
# LOCAL AND SERVERLESS, like ARM 1. It needs the working tree and nothing # 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 # else — the server has no checkout, so this is the only place the question
+14 -17
View File
@@ -1,25 +1,23 @@
#!/usr/bin/env bash #!/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 # The rule arms keep a ledger of every id they have NAMED. The line those arms
# line used to tell the reader "You saw it earlier this session". That claim # emit is a TEASER — title, trigger, `get_rule(N)` — so a session can be named
# was never checked. The line those arms emit is a TEASER — title, trigger, # a rule twenty times and never read one word of it, and after a compaction
# `get_rule(N)` — so a session can be named a rule twenty times and never read # the teaser is summarised away leaving nothing at all. Named is not read, so
# one word of it, and after a compaction the teaser is summarised away leaving # the server needs to be told which rules were opened.
# nothing at all. The server was asserting something about the reader's
# context that it had no way to know.
# #
# This is the observable half. PostToolUse fires for MCP tools (the event's own # This is the observable half. PostToolUse fires for MCP tools (the event's own
# output schema carries `updatedMCPToolOutput`, which would be meaningless # output schema carries `updatedMCPToolOutput`, which would be meaningless
# otherwise), so the `get_rule` CALL can be watched directly. # 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 # Asking the session whether it holds a rule is useless: a model asked "do you
# asked "do you still hold rule 156?" will say yes and the answer is # still hold rule N?" will say yes, and the answer is unverifiable. That
# unverifiable self-report. That objection is about ASKING. This asks nobody: # objection is about ASKING. This asks nobody:
# a tool call happened or it did not, and the harness reports it either way. # 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 # Recording what a session DID is a different kind of evidence from believing
# what it says about itself. # what it says about itself.
@@ -28,13 +26,12 @@
# #
# It does not prove the rule is still in context — nothing can, and a # 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 # 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 # 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 # 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. # 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 # It also reports the open, with the calls that came just before it, so the server can learn which moments a rule belongs
# 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. # on (below). Silent on outage, like every arm that fires per call.
# #
# EXIT 0, ALWAYS. This decorates a ledger; a bookkeeping failure must never # 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. # (`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" 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/<sid>.acts`. The ones # scribe_moment.sh writes every call to `scribe-moment/<sid>.acts`. The ones
# from the last three minutes go to the server with this rule's id: a rule # from the last three minutes go to the server with this rule's id: a rule
+9 -11
View File
@@ -1,16 +1,16 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# Scribe — record that this session said what a rule DID, not merely that it # 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 — # `.rules.ids` says a rule was NAMED. `.opened.ids` says it was READ (a
# a PostToolUse hook watches the `get_rule` call, so it is a recorded event # 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 # 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 # happened next: a rule read and followed and a rule read and forgotten leave
# read and followed and a rule read and forgotten leave identical traces. # 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 # 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. # 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 # 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 # 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 # 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 # model whether it followed anything, so it is evidence, not self-report.
# drew when it ruled out self-report, and this stays on the right side of it.
# #
# WHAT IT CANNOT SAY: that the rule was followed WELL, or that `applied` was # 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 # 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 # claim itself — it is that the absence of one becomes visible.
# state nothing could previously name.
# #
# EXIT 0, ALWAYS. This decorates a ledger; a bookkeeping failure must never # EXIT 0, ALWAYS. This decorates a ledger; a bookkeeping failure must never
# turn a successful tool call into a hook error. # turn a successful tool call into a hook error.
+5 -7
View File
@@ -1,6 +1,5 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# Scribe — Stop hook: the reply moment (milestone 458 step 4, folded in from # Scribe — Stop hook: the reply moment.
# milestone 456 step 8).
# #
# A reply is the one act no tool call marks, and it is where "please check # 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 # 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 # 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. # 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, # ONE END-OF-TURN REQUEST. When the turn closed a task, the same request
# the same request carries the task ids and the server also checks the reply # carries the task ids and the server also checks the reply for the completion
# for the completion sections — once a Stop hook of its own # sections. The section check's outcome is recorded (`report_check`, the
# (scribe_report_check.sh), now the same call. The section check's outcome is # adherence number), and when it
# recorded (`report_check`, milestone 409's adherence number), and when it
# held the reply, the rewrite is sent back once with `rewrite: true` so the # 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. # server can record how it came out. A rewrite is never held.
# #
+41 -60
View File
@@ -4,9 +4,9 @@
# Tier 1 (STATIC, always fires, no auth, no network): injects the Claude Code # 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 # adapter's own guidance (scribe_static_context.md) — where Scribe's reflexes
# are stated (the using-scribe skill), Claude Code's memory files, /compact, # 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 # /scribe:sync, and what to do when Scribe is unavailable. It carries only what
# (decision #4027) it carries only what this client needs said; the reflexes # this client needs said; the reflexes themselves are owned by the shared
# themselves are owned by the shared skills and the server's index. # skills and the server's index.
# #
# Tier 2 (DYNAMIC, best-effort enrichment): curls the operator's Scribe instance # Tier 2 (DYNAMIC, best-effort enrichment): curls the operator's Scribe instance
# for active-project context and appends it. Config comes from # for active-project context and appends it. Config comes from
@@ -15,8 +15,8 @@
# CLAUDE_PLUGIN_OPTION_API_TOKEN fmcp_ API key (sensitive) # CLAUDE_PLUGIN_OPTION_API_TOKEN fmcp_ API key (sensitive)
# NOTE THE CASE: Claude Code uppercases the userConfig key when exporting it, so # 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 # the `api_token` option arrives as CLAUDE_PLUGIN_OPTION_API_TOKEN. Reading the
# lowercase spelling silently yields nothing — that was issue #2198, and it # lowercase spelling silently yields nothing, which disables the dynamic tier,
# disabled the dynamic tier, auto-inject, and the write-path trigger at once. # auto-inject and the write-path trigger at once.
# The active project is resolved server-side from the working repo's git remote # 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. # (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 # in-flight tasks from Scribe. This stays the durable path: record-as-you-go
# plus a post-compaction reload from the instance. # plus a post-compaction reload from the instance.
# #
# The other half arrived with #3680. A host hook still cannot make the model # The other half: a host hook cannot make the model flush before a compaction,
# flush before a compaction — but scribe_precompact_preserve.sh steers the # but scribe_precompact_preserve.sh steers the
# SUMMARY, because a PreCompact hook's stdout becomes the compaction's custom # SUMMARY, because a PreCompact hook's stdout becomes the compaction's custom
# instructions. The two are complementary, and neither replaces the other: that # instructions. The two are complementary, and neither replaces the other: that
# hook decides what survives into the summary, this one reloads from the record # 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 # 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 # 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 # 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 set -uo pipefail
# shellcheck source=plugin/hooks/scribe_defs.sh # 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) event_flat=$(printf '%s' "$event" | scribe_json_flat)
source=$(scribe_json_pick "$event_flat" '.source') 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 # scribe_prior_art.sh and scribe_tool_rules.sh record every rule id they have
# named in <state>/<sid>.rules.ids and hand it back as exclude_rule_ids, so a # named in <state>/<sid>.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 # 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 # 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 # reached again rather than orphaned.
# the evidence for that; milestone 394 removed the preload the etag described,
# and `plugin_context.py` records its retirement.)
# #
# CLEARED ON THE SOURCES THAT DESTROY CONTEXT, AND ONLY THOSE: # 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 # 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. # is accurate, and if it gets a new one the file is empty anyway.
# #
# EVERY LEDGER, AND THE NOTE ARMS NEEDED IT MOST (#4101). # EVERY LEDGER, AND THE NOTE ARMS NEED IT MOST:
#
# 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:
# #
# - They are HARD exclusions. `exclude_ids` goes into `semantic_search_notes` # - 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 # 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 # rendered as a reference the way a repeated rule is. There is no weaker
# weaker form for it to fall back to. # form for it to fall back to.
# - They never AGE. #3751 gave the rules ledger a TTL precisely because # - They never AGE. The rules ledger has a TTL because salience decays
# salience decays without a context event; the note channels were left on a # without a context event; the note channels are a flat read.
# flat read.
# #
# Hard plus permanent plus never cleared means a note surfaced in the first # Hard plus permanent plus never cleared would make a note surfaced in the
# minute of a session is unreachable for the rest of it, through any number of # first minute of a session unreachable for the rest of it, through any number
# compactions. That is milestone 386's original defect, alive on the arms that # of compactions.
# fire most often, and nothing about it was decided.
# #
# THE LIST WAS THE BUG, so the fix is not a longer list. `scribe_clear_session_ # `scribe_clear_session_ledgers` matches on the naming convention — a
# ledgers` matches on the naming convention — a per-session ledger is # per-session ledger is `<sid>[.<kind>].ids` — so it covers every ledger,
# `<sid>[.<kind>].ids` — which covers all five and covers the sixth on the day # including one added later. Best-effort, like every other filesystem touch in these hooks:
# it is written. Best-effort, like every other filesystem touch in these hooks:
# a ledger that cannot be removed costs a repeated exclusion, never a session. # a ledger that cannot be removed costs a repeated exclusion, never a session.
# #
# NOT swept: `<sid>.unreached`, which records that the instance was unreachable # NOT swept: `<sid>.unreached`, which records that the instance was unreachable
# rather than what the session holds, and survives on purpose. The directories # rather than what the session holds, and survives on purpose. The directories
# swept are `scribe_defs.sh`'s `SCRIBE_LEDGER_DIRS` — plural, because the # 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 # auto-inject arm keeps its ledger somewhere else.
# missed exactly the arm that fires most.
case "$source" in case "$source" in
compact|clear) compact|clear)
sid=$(scribe_json_pick "$event_flat" '.session_id') 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 # manifest version changes
# #
# So inspecting the clone shows a fix present while the broken copy keeps # So inspecting the clone shows a fix present while the broken copy keeps
# running, and the obvious debugging move actively misleads (#2209). Twice, the # running, and the obvious debugging move actively misleads.
# only detector was the operator saying "I don't think it updated".
# #
# Naming the running version in every session makes that answerable from the # Naming the running version in every session makes that answerable from the
# transcript instead of by archaeology in the cache directory. Deliberately NOT # transcript instead of by archaeology in the cache directory. Deliberately NOT
@@ -177,7 +166,7 @@ dyn=""
status="" status=""
if [ -n "$url" ] && [ -n "$token" ] && command -v curl >/dev/null 2>&1; then 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 # 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 # 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. # are re-read here only to explain what happened when nothing resolved.
repo_dir=${CLAUDE_PROJECT_DIR:-$PWD} 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'} marker_why=${marker_read#*$'\t'}
repo=$(git -C "$repo_dir" remote get-url origin 2>/dev/null || true) repo=$(git -C "$repo_dir" remote get-url origin 2>/dev/null || true)
scope=$(scribe_scope_query "$repo_dir") scope=$(scribe_scope_query "$repo_dir")
# The source and the session id decide what the claim section says # The source and the session id decide what the claim section says: a
# (milestone 381): a compaction gets back the work this session had claimed, # compaction gets back the work this session had claimed,
# with its latest logs; a new session hears about other sessions' claims. # with its latest logs; a new session hears about other sessions' claims.
sid_now=$(scribe_json_pick "$event_flat" '.session_id') sid_now=$(scribe_json_pick "$event_flat" '.session_id')
q="source=$(printf '%s' "$source" | scribe_urlenc)" q="source=$(printf '%s' "$source" | scribe_urlenc)"
[ -n "$sid_now" ] && q="${q}&session_id=$(printf '%s' "$sid_now" | scribe_urlenc)" [ -n "$sid_now" ] && q="${q}&session_id=$(printf '%s' "$sid_now" | scribe_urlenc)"
[ -n "$scope" ] && q="${q}&${scope}" [ -n "$scope" ] && q="${q}&${scope}"
q="?${q}" q="?${q}"
# ONE FETCH, NAMED WHEN IT FAILS (#4366). This used to be `curl -f … || # ONE FETCH, NAMED WHEN IT FAILS. A timeout, an HTTP error and a refused key
# body=""`, which folded a timeout, an HTTP error and a refused key into one # need different fixes, so each gets its own sentence. curl's write-out still
# sentence — so a session that started blind could not say why, and neither # prints on a failed transfer (as `000`), so the status and the elapsed time
# could the operator afterwards. curl's write-out still prints on a failed # come back either way.
# transfer (as `000`), so the status and the elapsed time come back either way.
fetch_context() { fetch_context() {
local resp meta local resp meta
resp=$(curl -sS --max-time "$1" -w '\n%{http_code} %{time_total}' \ 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_code=${meta%% *}
ctx_took=${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 # 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 # 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 # 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) body_flat=$(printf '%s' "$body" | scribe_json_flat)
dyn=$(scribe_json_pick "$body_flat" '.context') dyn=$(scribe_json_pick "$body_flat" '.context')
fi 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 [ -z "$dyn" ]; then
if [ "$ctx_rc" = 28 ]; then if [ "$ctx_rc" = 28 ]; then
why="the instance did not answer in time (8s, then ${ctx_took}s on a retry)" 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 else
why="the instance answered but sent no context" why="the instance answered but sent no context"
fi fi
# The first move, stated as one. "As needed" read as optional, and a # The first move, stated as one rather than as optional: a session that
# session that skips it starts with no recent milestones or open tasks — # skips it starts with no recent milestones or open tasks, so it cannot
# so it cannot know what prior work exists to look for (#4366). # know what prior work exists to look for.
if [ -n "$marker_id" ] && [ -z "$marker_why" ]; then if [ -n "$marker_id" ] && [ -z "$marker_why" ]; then
first="Start by calling \`enter_project(${marker_id})\`" first="Start by calling \`enter_project(${marker_id})\`"
else else
@@ -256,23 +240,20 @@ if [ -n "$url" ] && [ -n "$token" ] && command -v curl >/dev/null 2>&1; then
elif [ -n "$url" ] && [ -z "$token" ]; 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()\`." 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 elif [ -z "$url" ] && [ -z "$token" ]; then
# NEITHER value arrived. Previously this case stayed silent as "an unconfigured # NEITHER value arrived. Not a benign "unconfigured install": the plugin
# install", which made issue #2198 invisible for weeks: a *casing* bug here # prompts for both values at enable time, so if neither reached the hook,
# (reading CLAUDE_PLUGIN_OPTION_api_token when Claude Code exports the key # something is wrong (an env-var casing mismatch looks exactly like this).
# UPPERCASED) looks identical to never having configured the plugin, and # Say so.
# 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.
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()\`." 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 fi
[ -n "$dyn" ] && append "$dyn" [ -n "$dyn" ] && append "$dyn"
[ -n "$status" ] && append "$status" [ -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 # The server can say "no project is bound to this working directory". It is
# until now that was the whole answer. It is the same sentence for a directory # the same sentence for a directory
# that is not a repo, a repo whose remote nobody bound, and a marker file # 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 # 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. # three different fixes, and no way to tell them apart from inside the session.
+3 -3
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env bash #!/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 # 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 # 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') session_id=$(scribe_json_pick "$event_flat" '.session_id')
[ -n "$session_id" ] || exit 0 [ -n "$session_id" ] || exit 0
# The moment arm's per-session files (its tool cache and the acts ledger, # 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 # describe a session that is over, on any exit — a clear
# included, since the next conversation is a new session id. # included, since the next conversation is a new session id.
safe_sid=$(printf '%s' "$session_id" | tr -c 'A-Za-z0-9._-' '_') safe_sid=$(printf '%s' "$session_id" | tr -c 'A-Za-z0-9._-' '_')
rm -f "${TMPDIR:-/tmp}/scribe-moment/${safe_sid}".* 2>/dev/null || true rm -f "${TMPDIR:-/tmp}/scribe-moment/${safe_sid}".* 2>/dev/null || true
+1 -1
View File
@@ -1,6 +1,6 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# Scribe plugin — Stop hook: the turn's new shapes are judged by the agent # 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 # The write hooks (scribe_prior_art.sh, scribe_after_write.sh) append every
# definition a write names to a session ledger, `<sid>.written.ids` # definition a write names to a session ledger, `<sid>.written.ids`
+2 -2
View File
@@ -19,8 +19,8 @@
# safe as a second SessionStart hook). On any fetch failure it exits without # 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. # touching existing stubs — a transient outage must not wipe the user's skills.
# Config mirrors the context hook: CLAUDE_PLUGIN_OPTION_API_ENDPOINT / # Config mirrors the context hook: CLAUDE_PLUGIN_OPTION_API_ENDPOINT /
# CLAUDE_PLUGIN_OPTION_API_TOKEN (userConfig key UPPERCASED by Claude Code — see # CLAUDE_PLUGIN_OPTION_API_TOKEN (userConfig key UPPERCASED by Claude Code),
# #2198), with SCRIBE_URL / SCRIBE_TOKEN as the override. # with SCRIBE_URL / SCRIBE_TOKEN as the override.
set -uo pipefail set -uo pipefail
# shellcheck source=plugin/hooks/scribe_defs.sh # shellcheck source=plugin/hooks/scribe_defs.sh
+16 -17
View File
@@ -1,13 +1,12 @@
#!/usr/bin/env bash #!/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 # 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 # 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 # standing rule speak to the command about to be run" — which is how a rule
# could ask before, and the reason every rule about which tool to reach for had # about which tool to reach for arrives at all.
# to live in the preload instead, back when there was one.
# #
# 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 # 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 # 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. # 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 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. # functions and clears `url`/`token`; nothing here runs before it is needed.
# shellcheck source=plugin/hooks/scribe_defs.sh # shellcheck source=plugin/hooks/scribe_defs.sh
. "$(dirname "${BASH_SOURCE[0]}")/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 # scribe_config, not a hand-rolled pair of parameter expansions: it also treats
# an UNEXPANDED `${...}` placeholder as unset, which would otherwise be sent as # 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 scribe_config || exit 0
# Bounded before encoding: a heredoc or a pasted script can be enormous, and # 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. # place. `head -c`, never `cut -c`: cut truncates each LINE and caps nothing.
command_text=$(printf '%s' "$command_text" | head -c 2000) command_text=$(printf '%s' "$command_text" | head -c 2000)
# Whole, never line by line: the predecessor (`jq -rR`) encoded a multi-line # Whole, never line by line: per-line encoding joins a multi-line command with
# command one line at a time and joined them with raw newlines — an invalid URL. # raw newlines — an invalid URL.
# scribe_urlenc reads bytes and has no notion of a line. # scribe_urlenc reads bytes and has no notion of a line.
cmd_enc=$(printf '%s' "$command_text" | scribe_urlenc) cmd_enc=$(printf '%s' "$command_text" | scribe_urlenc)
tool_enc=$(printf '%s' "$tool_name" | 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") scope=$(scribe_scope_query "$lookup_dir")
[ -n "$scope" ] && repo_q="&${scope}" [ -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 # 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. # are relative to the root. The server does the arithmetic; this sends both.
where_q="" where_q=""
@@ -96,11 +95,11 @@ rulings_q=""
if [ -n "$session_id" ]; then if [ -n "$session_id" ]; then
safe_sid=$(printf '%s' "$session_id" | tr -c 'A-Za-z0-9._-' '_') safe_sid=$(printf '%s' "$session_id" | tr -c 'A-Za-z0-9._-' '_')
rulefile="$state_dir/${safe_sid}.rules.ids" 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. # session is still holding. scribe_rules_live carries the reasoning.
rule_seen=$(scribe_rules_live "$rulefile") rule_seen=$(scribe_rules_live "$rulefile")
[ -n "$rule_seen" ] && rule_exclude_q="&exclude_rule_ids=${rule_seen}" [ -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")" 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 # The Systems whose rulings were shown in full — shared with the write-path
# hook, for the reason the rule file is. # 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 scribe_json_list "$body_flat" '.ruling_system_ids' >> "$rulingsfile" || true
fi 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, # 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 # 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 # 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 # guarded by test_hook_never_returns_a_permission_decision. No equivalent
# decision covers this arm, and the misses that motivated the milestone on the # decision covers this arm, and the command-side misses are the ones a stop
# command side are the ones a stop actually reaches: a commit message asserting # actually reaches: a commit message asserting what CI said, a verification
# what CI said, a verification script that checks nothing. # script that checks nothing.
# #
# WHAT THE STOP IS FOR. Everything else this plugin emits is additionalContext, # 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 # 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. # 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 # 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 # exists. It costs the operator nothing: no prompt reaches them, and the model
# clears it by reading one record and re-submitting. # clears it by reading one record and re-submitting.
+4 -5
View File
@@ -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 `IDX<TAB>PATH<TAB>VALUE` stream that scribe_json.awk produces # Reads the flat `IDX<TAB>PATH<TAB>VALUE` stream that scribe_json.awk produces
# in `mode=lines` from a Claude Code transcript, and answers the four questions # 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. # 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 # 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 # a value is cut at its first SPACE, so `$3` of a reply line would be the
# first word — the kind of defect that hides completely behind a test whose # reply's first word — a defect that hides completely behind a test whose
# replies are all empty. Found by differential-testing this against the jq # replies are all empty.
# program it replaces, over real transcript windows (#4107).
BEGIN { FS = "\t" } BEGIN { FS = "\t" }
{ {
+5 -6
View File
@@ -100,8 +100,7 @@ from the derivation, not from asking permission.
## Derive groups are drift, not audit material ## Derive groups are drift, not audit material
The catalogue exists so the codebase is DRY **from inception**, not as DRY as 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 the last sweep left it. Three surfaces say so without anyone running an audit:
(milestone 299):
- **At the write** — the prior-art hint (delivered beside a write where your - **At the write** — the prior-art hint (delivered beside a write where your
client supports it; shell edits count as much as editor writes) 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` reason_code="convention-plumbing", reason=…)` (or `classify_shapes_by_rule`
for a whole family) removes it from the queue. Dismissal is a judgment and for a whole family) removes it from the queue. Dismissal is a judgment and
it is recorded; silence is not. 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 different purposes share declarations because the style system makes them
alike — `.text-muted` and `.pin-badge-auto` carrying the same `color:` are 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 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"`. genuinely different things is dismissed with `reason_code="scoped-css"`.
The datum that decides between the two is **what renders it**: every 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 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 `consumers`, and the write-path line says "used by N template(s)". Many
templates, one recipe → derive; one template each, different purposes → templates, one recipe → derive; one template each, different purposes →
dismiss. `list_shapes(flag="unused-css")` is the map's negative space — 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 ## 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 - **Divergence** — `list_shapes(project_id, flag="divergence")` (and the
coverage line's "N DIVERGENT"): a shape new since the previous refresh, in 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. re-confirm it (classify again with the same status) or re-judge.
- **Weak stamps** — the coverage line's "N weak stamps … to judge — - **Weak stamps** — the coverage line's "N weak stamps … to judge —
stamps_to_review": rows the write-path hook asserted, unattended, on a 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 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, one and record the verdict with `classify_shapes` — `instance` if it is,
otherwise what it actually is. otherwise what it actually is.
+2 -2
View File
@@ -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. the real id and title as the records are created.
**Do not** write plans or specs to local `.md` files — the milestone is the **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` record, not a file on disk. A plan is never a task: `kind="plan"` is refused,
no longer creates one.) and `start_planning` makes a milestone.
Before designing from scratch, **recall**: `search` Scribe for a related prior Before designing from scratch, **recall**: `search` Scribe for a related prior
decision or note. Often the thinking (or half of it) already exists. decision or note. Often the thinking (or half of it) already exists.
+49 -56
View File
@@ -10,9 +10,8 @@ from quart import Quart
## What this block is — read before editing it ## What this block is — read before editing it
# #
# ONE OWNER PER PIECE OF GUIDANCE (decision #4027, milestone 410). Scribe's # ONE OWNER PER PIECE OF GUIDANCE. Scribe's guidance to agents lives where it
# guidance to agents lives where it can be delivered, and each topic is stated # can be delivered, and each topic is stated in full exactly once:
# in full exactly once:
# - Tool docstrings: each tool's contract, delivered with its schema. # - Tool docstrings: each tool's contract, delivered with its schema.
# - In-band tool responses: behaviour prose cannot be trusted to trigger — # - In-band tool responses: behaviour prose cannot be trusted to trigger —
# the duplicate gate, the guessed-id refusal, `systems_hint`, # 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 # 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 # 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 # is stated rather than restating it. What belongs here, per the MCP spec: how
# field (spike #4389): how the tools fit together — which to call first, which # the tools fit together — which to call first, which before which. What does
# before which. What does not: a tool's own contract (its description carries # not: a tool's own contract (its description carries it), and stance — who
# it), and stance — who judges, how to report, what a missed rule means. Those # judges, how to report, what a missed rule means. Those fire mid-work, not at
# fire mid-work, not at the start; the skills state them and in-band responses # the start; the skills state them and in-band responses (`placement`,
# (`placement`, `report_back`) cue them at the moment they apply. A new topic # `report_back`) cue them at the moment they apply. A new topic gets a line
# gets a line here only if it is a session-start practice. # here only if it is a session-start practice.
# #
# BUDGET: at most 1,600 characters (test_instructions_fit_the_fold). Claude # 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 # 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 # cuts the rest mid-word, and other clients differ, so nothing load-bearing may
# nothing load-bearing may sit past the fold; the rest of the field runs # sit past the fold. A block pressed against the cap is being used as a
# ~600–1,600 (#4389), and a block pressed against the cap is being used as a # rulebook.
# 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.
_INSTRUCTIONS = """ _INSTRUCTIONS = """
Scribe is the operator's system of record, and yours: recall before acting, 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 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 # 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 # 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 # _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 # 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 # 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", "find_duplicate_snippets", "find_duplicate_records",
# Snippets and processes are notes with a kind. A key that may read a note # 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 # 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_snippet", "list_snippets",
"get_process", "list_processes", "get_process", "list_processes",
"get_lesson", "list_lessons", "get_lesson", "list_lessons",
@@ -119,58 +115,56 @@ _READ_ONLY_TOOLS = frozenset({
# Which repos map to which project. Read-only by nature; bind_repo / # Which repos map to which project. Read-only by nature; bind_repo /
# unbind_repo are the writes. # unbind_repo are the writes.
"list_repo_bindings", "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. # the write, and it is deliberately NOT here.
"list_shapes", "shape_history", "stamps_to_review", "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 # writes nothing. Listed explicitly because its name carries no read
# prefix, so the completeness test below cannot derive it — the same # prefix, so the completeness test below cannot derive it — the same
# reason `enter_project` is spelled out above. # reason `enter_project` is spelled out above.
"retrieval_telemetry", "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 # is the write, and it is deliberately NOT here. Spelled out for
# retrieval_telemetry's reason: `notes_due_for_verification` matches none of # retrieval_telemetry's reason: `notes_due_for_verification` matches none of
# the prefixes the completeness test derives from, so nothing would have # the prefixes the completeness test derives from, so nothing would have
# prompted this decision. # prompted this decision.
"notes_due_for_verification", "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 # row of any kind — not even telemetry, since nothing is put in front of a
# working session. Spelled out for retrieval_telemetry's reason. # working session. Spelled out for retrieval_telemetry's reason.
"menus_to_review", "menus_to_review",
# Its rule twin and a rule's edit history (milestones 312 and 323). Both # Its rule twin and a rule's edit history. Both pure reads with no read
# pure reads, and both sat unlisted — so a read key was refused them — for # prefix, so spelled out; rule_history records a pull the way the getters
# the same reason: no read prefix, back when the completeness test only # above do.
# looked at names that had one (#3191). rule_history records a pull the way
# the getters above do.
"rules_due_for_verification", "rule_history", "rules_due_for_verification", "rule_history",
# What each retrieval surface's floor and budget currently are, and what # 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 # 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 # 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. # reason it was last moved, is a session that will move it again blind.
"retrieval_surfaces", "retrieval_tuning_history", "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 # 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 # 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. # that could not reach it would be denied exactly the check it should run.
"what_might_apply", "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 # 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 # 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. # naming a moment can be understood by whoever was shown it.
"list_moments", "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. # one a read key needs: a session shown a shape may want the rest.
"list_reply_shapes", "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. # read; set_project_platforms is the write.
"list_platforms", "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 # decision log, and the adoption ledger. Reads of records the caller can
# read. # read.
"list_family_ideas", "get_family_idea", "list_family_decisions", "list_family_ideas", "get_family_idea", "list_family_decisions",
"get_family_adoption", "list_family_adoptions", "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 are unjudged and which proposals wait. Reads of the caller's own
# rules, as list_rules is. # rules, as list_rules is.
"rules_to_mount", "rule_moment_proposals", "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 # 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 # 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 # 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 # rather than merely unlikely. A test that only asked about read-looking names
# names looked like reads, and two reads with other names were denied for weeks. # would miss a read named any other way.
_WRITE_TOOLS = frozenset({ _WRITE_TOOLS = frozenset({
# notes, tasks, planning # notes, tasks, planning
"create_note", "update_note", "delete_note", "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 # 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. # 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 # 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", "rule_outcome",
# retrieval tuning — a write in both senses: it moves the number the arm # 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", "tune_retrieval",
"migrate_retrieval_floor", "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. # changes what fires for every later session, so a read key is refused.
"map_action", "unmap_action", "map_action", "unmap_action",
# Proposing moments for a rule writes a judgment row; judging one mounts # 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", "propose_rule_moments", "judge_rule_moments",
# A misfire report writes a counted row that can become an unmount # A misfire report writes a counted row that can become an unmount
# proposal (step 7b). # proposal.
"rule_misfired", "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. # prose the agent authored, `rule_outcome`'s reason for being a write.
"judge_menu", "judge_menu",
# trash # trash
@@ -309,12 +303,11 @@ class StrictArgsMCPServer(MCPServer):
default. On the create/update tools the default is "", which turns a default. On the create/update tools the default is "", which turns a
plausible near-miss (`content=` for `body=`, primed by add_task_log's plausible near-miss (`content=` for `body=`, primed by add_task_log's
`content`) into SILENT DATA LOSS: the call reports success and stores an `content`) into SILENT DATA LOSS: the call reports success and stores an
empty body, leaving a record search cannot see (#2709). Two notes were empty body, leaving a record search cannot see.
persisted body-less that way before anyone noticed.
An error the caller sees once is strictly better than data half-written 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 forever, so the policy is applied to every tool: nothing here knows tool
bit: nothing here knows tool semantics, only that an argument nobody semantics, only that an argument nobody
declared cannot have been meant to be dropped. declared cannot have been meant to be dropped.
""" """
@@ -344,11 +337,11 @@ class StrictArgsMCPServer(MCPServer):
try: try:
return await super().call_tool(name, arguments, context) return await super().call_tool(name, arguments, context)
except UnexpectedToolError as exc: except UnexpectedToolError as exc:
# ValueError is how every Scribe tool refuses (#4794): what was # ValueError is how every Scribe tool refuses: what was refused,
# refused, why, and what to do instead, written for the agent. # why, and what to do instead, written for the agent. SDK 2.x
# SDK 2.x masks it like a crash, so the agent got a failure with # masks it like a crash, which would leave the agent retrying
# no reason and could only retry blind. Other exceptions ARE # blind. Other exceptions ARE crashes; their text can carry
# crashes; their text can carry internals and stays masked. # internals and stays masked.
if isinstance(exc.__cause__, ValueError): if isinstance(exc.__cause__, ValueError):
raise ToolError(f"{exc}: {exc.__cause__}") from exc.__cause__ raise ToolError(f"{exc}: {exc.__cause__}") from exc.__cause__
raise raise
@@ -386,17 +379,17 @@ def mount_mcp(app: Quart) -> None:
# stateless_http=True: don't hand the client a persistent Mcp-Session-Id. # stateless_http=True: don't hand the client a persistent Mcp-Session-Id.
# The stateful default strands Claude Code after a container redeploy — # The stateful default strands Claude Code after a container redeploy —
# it reconnects with the now-unknown session id, the server returns 404, # 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 # so the connection stays dead until a manual /mcp retry. Stateless makes
# every request self-contained (bearer-auth only), so a post-deploy # every request self-contained (bearer-auth only), so a post-deploy
# reconnect just works. Trade-off: no server-pushed list_changed stream, # reconnect just works. Trade-off: no server-pushed list_changed stream,
# which we don't use — tools are re-fetched on reconnect anyway. (Clients # 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 # on the stateless protocol revision take the SDK's modern path
# modern path regardless; this governs the 2025-era handshake path.) # regardless; this governs the older handshake.
# #
# DNS-rebinding protection is disabled: the SDK's default allow-list is # DNS-rebinding protection is disabled: the SDK's default allow-list is
# just localhost variants, which means any deployment behind a reverse # 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 # Cloudflare, nginx, etc.) gets 421 Misdirected Request. The threat
# model that protection addresses — a malicious browser page rebinding # model that protection addresses — a malicious browser page rebinding
# DNS to hit a localhost MCP — doesn't apply here: this is HTTP transport # DNS to hit a localhost MCP — doesn't apply here: this is HTTP transport
+1 -1
View File
@@ -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 system in the inheritance chain, outermost ancestor first, so the house
style comes before this system's departures from it. `design_system`'s style comes before this system's departures from it. `design_system`'s
own `guidance` field is only the departures. Read `resolved_guidance` 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() uid = current_user_id()
system = await ds_svc.get_design_system(uid, design_system_id) system = await ds_svc.get_design_system(uid, design_system_id)
+1 -1
View File
@@ -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 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 person approves a promotion, so the criteria are stated in these docstrings
+11 -13
View File
@@ -50,7 +50,7 @@ async def list_lessons(
offset: Skip this many before returning — page past the cap. `total` offset: Skip this many before returning — page past the cap. `total`
is the unpaged count, so it says whether more remains. is the unpaged count, so it says whether more remains.
project_id: Narrow to where a lesson was WRITTEN. 0 = every project. 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. filters the listing, not the reach.
unjudged: List only the lessons nobody has answered "which rule is unjudged: List only the lessons nobody has answered "which rule is
this an instance of?" for — no rule named, and no "no rule fits" 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) labelled = await access_svc.label_shared_items(uid, items)
# One aggregate for the page, like the snippet listing — surfaced-vs-opened # 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 # 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. # `update_lesson` rather than to a second lesson about the same failure.
await attach_usage(labelled) await attach_usage(labelled)
@@ -188,7 +188,7 @@ async def create_lesson(
the binding choice its situation falls under. A lesson never the binding choice its situation falls under. A lesson never
becomes a rule; it points at the one that governs it, and the rule becomes a rule; it points at the one that governs it, and the rule
is then reachable through the situation the lesson describes 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 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. other answer to "which rule?". Give one or the other, not both.
When other lessons in the same situation also answered "no rule 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: 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 """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.""" it may be missing. Absent when there is no group."""
group = await lesson_rules_svc.convergence_for(uid, lesson_id) group = await lesson_rules_svc.convergence_for(uid, lesson_id)
if group: 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 # 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 # 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 # 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 # 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 # one that was true when it asked — otherwise every first read of a lesson
# reports a pull that is its own. # 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 system_ids: Replace the Systems this lesson is filed under. None
leaves unchanged; pass the FULL list, and `[]` to clear. leaves unchanged; pass the FULL list, and `[]` to clear.
Here because `create_lesson` took it and this did not, so a lesson A lesson written without one can be filed afterwards here — the
written without one could never be filed afterwards — and the end end of a piece of work, when a lesson is usually written, is
of a piece of work, when a lesson is usually written, is exactly exactly when that argument gets dropped. A System tag is how
when that argument gets dropped (#4249). A System tag is how
`list_system_records` gathers an area's pile, so an untagged `list_system_records` gathers an area's pile, so an untagged
lesson is reachable by search and by nothing else. lesson is reachable by search and by nothing else.
rule_ids: Replace the rule(s) this lesson is an instance of. None 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 deletion: its trigger is keyed to a situation nobody is in, and rewording
that with update_lesson keeps what was learned. that with update_lesson keeps what was learned.
Deletion was always possible through `delete_note` — a lesson is a note and The same as `delete_note` — a lesson is a note and the trash is
the trash is kind-agnostic — but nothing said so, and a kind whose own kind-agnostic — offered here because a kind whose own tools offer only
tools offer create/read/update reads as one you cannot retire (#2250, which create/read/update reads as one you cannot retire.
recorded exactly this for processes).
""" """
uid = current_user_id() uid = current_user_id()
note = await lessons_svc.get_lesson(uid, lesson_id) note = await lessons_svc.get_lesson(uid, lesson_id)
+2 -2
View File
@@ -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 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 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 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() uid = current_user_id()
milestone = await milestones_svc.get_milestone(uid, milestone_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") raise ValueError(f"milestone {milestone_id} not found")
data = milestone.to_dict() data = milestone.to_dict()
if closing: 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. # a platform is the moment to ask whether what it built is shared.
hint = await family_svc.milestone_trigger(uid, milestone) hint = await family_svc.milestone_trigger(uid, milestone)
if hint: if hint:
+1 -1
View File
@@ -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 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 names a moment, and to fix a misfire — and the operator's ruling is that the
+3 -3
View File
@@ -38,7 +38,7 @@ async def list_notes(
Optionally filter by a single tag (plain string, no # prefix) or by Optionally filter by a single tag (plain string, no # prefix) or by
`search_text`, which matches on MEANING — the same semantic match every `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 embedder is down). With `search_text`, results order by relevance; without
it, by last-updated descending. Reaches your records plus shared-project it, by last-updated descending. Reaches your records plus shared-project
records, like every other list. 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 # Records the pull for ANY note kind, not just snippets: the auto-inject
# menu surfaces notes, tasks and processes too, so restricting this to # menu surfaces notes, tasks and processes too, so restricting this to
# snippets would leave those permanently at zero pulls and make them look # 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( record_pulled(
user_id=uid, note_id=int(note.id), user_id=uid, note_id=int(note.id),
source="mcp_get_note", project_id=project_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 systems_tools.attach_systems(uid, uid, data, note.id, project_id or None)
await supersession_svc.attach_relations(uid, note.id, data, hint=True) await supersession_svc.attach_relations(uid, note.id, data, hint=True)
data.update(dedup_svc.note_overlap_response(overlaps, "note")) 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. # pattern, or the same idea in another project on a shared platform.
await family_svc.attach_family_hint(uid, data, note, created=True) 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) return await moment_delivery.attach_moment_rules(uid, "create_note", {"project_id": project_id}, data)
+1 -1
View File
@@ -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 Thin wrappers over services/platforms.py. The catalog is global; a project's
platforms decide which family ideas reach it. platforms decide which family ideas reach it.
+6 -8
View File
@@ -80,8 +80,7 @@ async def create_process(
body: The full prompt to run later (markdown). Required. body: The full prompt to run later (markdown). Required.
tags: Plain-string tags, no # prefix. tags: Plain-string tags, no # prefix.
system_ids: Systems (subsystems/areas) to file this process under, so 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 an area-scoped read finds it.
been taggable in the data model; neither door offered it (#4249).
moments: The moments from list_moments this procedure is FOR — a moments: The moments from list_moments this procedure is FOR — a
release procedure is a deliver, a review procedure a verify. release procedure is a deliver, a review procedure a verify.
Loading the process then reaches each one, as well as its own 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)) out.update(await access_svc.describe_provenance(uid, note))
# A process is embedded like any other note, so auto-inject can surface one — # 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 # 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 # this, every process would sit permanently at zero pulls, looking like dead
# nothing, and every process sits permanently at zero pulls looking like dead # weight beside kinds that have a counter.
# weight beside kinds that merely had a counter (#2476, the repeat of #2245).
record_pulled( record_pulled(
user_id=uid, note_id=int(note.id), user_id=uid, note_id=int(note.id),
source="mcp_get_process", project_id=project_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 machine and auto-surfaces there, so a bad one is followed rather than merely
ignored — it costs more than a missing one. ignored — it costs more than a missing one.
Deletion was always possible through `delete_note` (a process is a note, and The same as `delete_note` (a process is a note, and the trash is
the trash is kind-agnostic), but nothing said so, and a kind whose own tools kind-agnostic), offered here because a kind whose own tools offer only
offer create/read/update reads as one you cannot retire (#2250). create/read/update reads as one you cannot retire.
""" """
uid = current_user_id() uid = current_user_id()
loaded = await notes_svc.get_note_for_user(uid, process_id) loaded = await notes_svc.get_note_for_user(uid, process_id)
+23 -23
View File
@@ -51,8 +51,8 @@ async def list_projects() -> dict:
# How many of each kind the handshake lists. Enough to say what was worked on # 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. # 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 # Every milestone's plan would push the handshake past what a client accepts
# characters, past what a client accepts as a tool result (#4045). # as a tool result.
_HANDSHAKE_MILESTONES = 5 _HANDSHAKE_MILESTONES = 5
_HANDSHAKE_OPEN_TASKS = 10 _HANDSHAKE_OPEN_TASKS = 10
_HANDSHAKE_UNPLANNED = 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 `pattern_coverage` (usually null) is the shape-accounting line — how many
of the bound repo's extracted shapes carry a classification against canon of the bound repo's extracted shapes carry a classification against canon
(note 2786) — e.g. "shape accounting: 3100/4573 shapes accounted for — — e.g. "shape accounting: 3100/4573 shapes accounted for — 12 canonical ·
12 canonical · 2900 instance (estimate, computed 2026-08-19); 1473 2900 instance (estimate, computed <date>); 1473 unclassified, largest:
unclassified, largest: internal/api". Unclassified IS the todo: as you internal/api". Unclassified IS the todo: as you
touch code in those areas, classify the shapes you can (instances of touch code in those areas, classify the shapes you can (instances of
recorded canon, deliberate variants, one-off exemptions) and record the recorded canon, deliberate variants, one-off exemptions) and record the
canon that's missing with create_snippet. A null line on a forge-served 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 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. 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 — Android app, container image, Go, … — each with how it is known
(`declared` by a person, `detected` from a bound repo). They decide which (`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 family ideas reach this project. An empty list on a project that plainly
ships something is worth correcting with set_project_platforms. 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 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 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 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 (get_family_adoption, then assess_family_adoption, or classify the code
against it); the family-canon skill has the order. 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 nobody has decided what it inherits: it carries the current defaults
(design system, Systems, platforms), what to ask the (design system, Systems, platforms), what to ask the
operator — once — and the decide_project_inception call that answers it; 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 # The tagging vocabulary. Surfaced HERE because an instruction to "tag
# records to Systems" is only executable if the list is in front of the # 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 # agent when it writes. Untagged writes also ask with the vocabulary
# three days of the feature landing (#2546's audit). Untagged writes now # listed; this copy lets the first write tag.
# also ask with the vocabulary listed; this copy lets the first write tag.
systems = await systems_svc.list_systems(uid, project_id) systems = await systems_svc.list_systems(uid, project_id)
platforms = platforms_svc.members( platforms = platforms_svc.members(
await platforms_svc.project_platforms(uid, project_id) or [] 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 # 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 # task — the one moment minting a starter vocabulary is cheap. The
# write-moment half rides untagged-record responses (attach_systems); # write-moment half rides untagged-record responses (attach_systems);
@@ -229,14 +228,15 @@ async def enter_project(project_id: int) -> dict:
uid, project_id uid, project_id
) )
# The inception ask (milestone 297): a project nobody has decided on # The inception ask: a project nobody has decided on inherits nothing,
# inherits nothing, silently — no design system, no Systems. Owner-only (deciding is the owner's), and only until a # silently — no design system, no Systems. Owner-only (deciding is the
# decision is recorded; the key is ABSENT otherwise (#2483). # owner's), and only until a decision is recorded; the key is ABSENT
# otherwise.
inception_ask = None inception_ask = None
if project.user_id == uid and not inception_svc.is_decided(project): if project.user_id == uid and not inception_svc.is_decided(project):
inception_ask = await inception_svc.inception_ask(uid, project_id) 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 # the readout counts it apart so dead-weight detection isn't poisoned by
# "recently updated in a project you opened". # "recently updated in a project you opened".
record_surfaced( 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 # 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 # 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 design_system = None
if project.design_system_id: if project.design_system_id:
design = await design_systems_svc.design_context( 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( coverage = await coverage_svc.cached_coverage(
project.user_id or uid, project_id 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 # 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 # 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. # 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 # 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: if omitted:
out["milestone_summary_omitted"] = ( out["milestone_summary_omitted"] = (
f"{omitted} other milestone(s) not listed. " f"{omitted} other milestone(s) not listed. "
@@ -318,7 +318,7 @@ async def enter_project(project_id: int) -> dict:
) )
if systems_bootstrap: if systems_bootstrap:
out["systems_bootstrap"] = 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. # non-zero. A readout must never fail the handshake it rides on.
try: try:
family = await family_adoption_svc.family_readout(uid, project_id) 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 out["family"] = family
if inception_ask: if inception_ask:
out["inception"] = 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 # 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; # 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. # one repeat per session is the price of reaching every client.
@@ -393,7 +393,7 @@ async def create_project(
) -> dict: ) -> dict:
"""Create a new project in Scribe — and decide what it inherits. """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 before calling, ask the operator the three inception questions and pass
the answers; a project created without either is UNDECIDED and the answers; a project created without either is UNDECIDED and
enter_project will ask until decide_project_inception records it. enter_project will ask until decide_project_inception records it.
@@ -446,7 +446,7 @@ async def decide_project_inception(
platforms: list[str] | None = None, platforms: list[str] | None = None,
) -> dict: ) -> dict:
"""Record what a project inherits — answer enter_project's `inception` ask, """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 — Owner-only. Applies the effects through the ordinary tools' paths —
set_project_design_system and the standard Systems seed — and writes the set_project_design_system and the standard Systems seed — and writes the
+1 -1
View File
@@ -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 `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 not. These two say whether the line RELATED, which is a reading only someone
+1 -1
View File
@@ -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 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 bar did; these say what the bar IS, and let it be changed with the argument
+19 -28
View File
@@ -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 service, and the record shape comes from rule_brief / rule_detail there rather
than being rebuilt here. than being rebuilt here.
(The header used to say "Sixteen tools" and had been wrong for two milestones; Destructive ops (delete_*) require confirmed=True; otherwise they return a
the count lives in the registration test, which fails when it drifts.) preview-style warning.
Destructive ops (delete_*) require confirmed=True; otherwise return a
preview-style warning. Mirrors the pattern in delete_event and the design
spec.
""" """
from __future__ import annotations 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). """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 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 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 There is no subscribing a project to a rulebook, and no muting one per
muting one per project — that machinery is gone. So a rulebook's rules must project. So a rulebook's rules must
stay general, agnostic to any single project. Project-specific rules go in stay general, agnostic to any single project. Project-specific rules go in
create_project_rule. 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 """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. 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 One line, because the shape itself lives in the service, shared with
three hand-written copies that had already drifted apart (note 3026). every other door that lists rules so the copies cannot drift.
`moments` rides along only when the rule is mounted (rule_brief drops a `moments` rides along only when the rule is mounted (rule_brief drops a
None), so an unmounted rule's row says nothing rather than "[]". 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 This field is also the rule's RETRIEVAL SURFACE — it and the
statement are what a search is matched against, so it should statement are what a search is matched against, so it should
carry the SYMPTOM, not just the situation: the words someone carry the SYMPTOM, not just the situation: the words someone
would actually type while stuck. Measured (note 3078): a rule would actually type while stuck. A rule whose trigger names only
whose trigger named only its situation did not surface at all its situation can fail to surface for the problem it solves;
for the problem it solves; adding the symptom to the same field adding the symptom to the same field makes it the top hit. Where a rule prevents a specific
brought it back as the top hit. Where a rule prevents a specific
failure, put that failure's vocabulary here — the error text, failure, put that failure's vocabulary here — the error text,
the wrong behaviour, the dead end. the wrong behaviour, the dead end.
The two spellings, side by side: 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) dup = await dedup_svc.find_duplicate_rule(title, topic_id=topic_id)
if dup is not None: if dup is not None:
return dedup_svc.duplicate_response(dup, "rule") 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( overlaps = await dedup_svc.find_overlapping_rules(
uid, title, statement, when_to_apply, uid, title, statement, when_to_apply,
) )
@@ -666,8 +661,8 @@ async def update_rule(
correct. Ordinary edits to an existing preference belong in correct. Ordinary edits to an existing preference belong in
update_preference, which asks for what taught the change. update_preference, which asks for what taught the change.
`when_to_apply` IS HOW A RULE ARRIVES AT ALL. Nothing is preloaded since `when_to_apply` IS HOW A RULE ARRIVES AT ALL. Nothing is preloaded, so a
milestone 394, so a rule with no trigger is not a quiet rule — it is one 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 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. (pass [] to clear), and they decide which PROJECTS a rule binds by area.
`moments` REPLACES the moments it arrives at the same way — see `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 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 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 describing the abstraction collapses the record toward its title and the
rule never arrives. Measured in #3835 across 113 rules, and again in #3855 rule never arrives.
on six preferences written before this was understood.
So when you touch a rule with an old trigger, re-read it against the query 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 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. arose_from_id: The task or note that taught this. Required; see above.
system_ids: Ids from list_canonical_systems — the global AREAS this system_ids: Ids from list_canonical_systems — the global AREAS this
preference is about, which is what lets it reach a session working preference is about, which is what lets it reach a session working
in that area. `update_preference` took this and create did not, so in that area.
a preference could only be filed after the fact (#4249).
moments: Moments from list_moments this rule arrives at — whenever moments: Moments from list_moments this rule arrives at — whenever
one happens, the rule is delivered, whatever the words of the work 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 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 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 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 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 the fastest way to see what the current one is guarding against, and it
rescoping of rule 79 is the case this exists for: the superseded survives the edit here without anyone copying it elsewhere.
statement had to be hand-copied into a task log to survive the edit.
EACH ENTRY HOLDS THE TEXT THE EDIT REPLACED, not the text it introduced. 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, 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. with get_rule. Pair the two and you have the diff.
An empty history is ordinary and means the rule has never been reworded, 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, not that its history was lost.
so a rule edited before then starts empty too.
Args: Args:
rule_id: The rule whose history to read. 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") raise ValueError(f"relation {relation_id} not found")
return {"deleted": relation_id} return {"deleted": relation_id}
# ── The staleness sweep (milestone 312) ──────────────────────────────── # ── The staleness sweep ────────────────────────────────
async def rules_due_for_verification( async def rules_due_for_verification(
older_than_days: int = 0, never_only: bool = False, older_than_days: int = 0, never_only: bool = False,
+56 -79
View File
@@ -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 # 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 # the evidence the ranking was built on, so it gets most of that room. Elision
# characters of document opening this used to send. Elision inside a chunk is # inside a chunk is far less lossy than a head cut of a whole record: the
# far less lossy than a head cut of a whole record: the region is already the # region is already the right one.
# right one.
_EXCERPT_CHARS = 1000 _EXCERPT_CHARS = 1000
# The kinds `content_type` accepts are DERIVED from the facet table, not listed # 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 # again here — that table is where a kind is declared, and a second hand-kept
# hand-kept copy in this module is precisely how the agent's door came to offer # copy here would let the agent's door and browse offer different kinds.
# two kinds while browse offered nine (#4250). `content_type_filters` carries # `content_type_filters` carries
# the mapping, the 'all'/'note' special cases and the refusal. # the mapping, the 'all'/'note' special cases and the refusal.
# #
# These two do not reach `semantic_search_notes` at all: they have their own # 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: 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 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 — 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 actually hit, with `matched_is` saying whether it is that passage or
merely the body's opening. Recognising a plan means recognising the part 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 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. Not part of content_type="all", whose results are note-shaped.
""" """
report: dict = {} 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 # The plan body stays out — get_milestone reads it — but the
# passage that MATCHED comes along, because a plan is # passage that MATCHED comes along, because a plan is
# recognised by the part of it the query was about and a # 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( **excerpt_fields(
m.body or "", chunks.get(int(m.id)), _EXCERPT_CHARS, m.body or "", chunks.get(int(m.id)), _EXCERPT_CHARS,
key="matched", 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: 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 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 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 `system_id` filters by one; this is the search where a System is the
and could never be the answer to one — an agent asking where a record answer, so an agent asking where a record belongs need not read every
belonged had to read every charter or guess. charter.
Its own result shape and its own search, not a `content_type` over notes, 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 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. 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 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=…)`, The id that comes back is the one `search(system_id=…)`,
`list_system_records` and every `system_ids` argument take — so the answer `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: def result_excerpt(note, chunk: dict | None) -> dict:
"""The part of a record a caller judges "should I open this?" on. """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 The document's opening is the wrong span. The match is semantic and
no marker that anything had been cut, so a 240-character preview of a per-chunk, and `semantic_search_notes` collapses to best-chunk-per-note, so
4000-character record was indistinguishable from a complete short one. 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
The opening is the wrong span. The match was semantic and per-chunk, and search judged less relevant. When the span is cut, the result says so.
`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 choice of span lives in services/text.py, shared with the web's 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 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, THREE KINDS WITH THEIR OWN SEARCH AND THEIR OWN RESULT SHAPE,
because each answers a question no note search can: because each answers a question no note search can:
'rule' (the operator's standing instructions — searchable by 'rule' (the operator's standing instructions), 'milestone' ("is there already a
meaning since milestone 307), 'milestone' ("is there already a
plan for this?"), and 'system' ("where does this belong?" — a plan for this?"), and 'system' ("where does this belong?" — a
System's charter says what belongs in an area and what does not, 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` 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, uid, q, limit=limit, **filters,
project_id=project_id or None, project_id=project_id or None,
system_id=system_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 # 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 # that hid it would hide it precisely where it is worth having. Only
# the project filter widens — everything else about the scoping holds, # the project filter widens — everything else about the scoping holds,
@@ -372,12 +365,11 @@ async def retrieval_telemetry(
) -> dict: ) -> dict:
"""What the retrieval telemetry says about YOUR surfaces, over a window. """What the retrieval telemetry says about YOUR surfaces, over a window.
The read half of the tuning loop, whose write half is `tune_retrieval` 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 Reach for it before moving any floor or budget, and read the records it
the records it names rather than its percentiles alone: this readout has names rather than its percentiles alone: every percentile can say "lower
been measured pointing the WRONG WAY — 69 consecutive declines where every the bar" while each refused record is a false positive, so
percentile said "lower the bar" and the refused record was a false positive `near_miss_samples=5` and opening the ids it returns is the step that
— 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. separates a real miss from a bar doing its job.
Five readouts, from the five tables built for them: 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. moment you are making a decision, not for every readout.
`near_misses` is `null` when no declining call in the window measured it — `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 "nothing came close"; a 0.0 there would be a claim about the corpus
invented out of a caller's silence. invented out of a caller's silence.
A NULL HERE NOW MEANS ONE THING, which it did not at first. A semantic A search that returns nothing WITHOUT having run — an empty query, an
search returns nothing three ways WITHOUT having run — an empty query, an unavailable embedder, a failed database query — writes no row: a row would
unavailable embedder, and a failed database query — and each used to write report a call that never happened and drag the clear rate down with
a row indistinguishable from a ranker that declined (#3765). Those calls no phantom declines. So a null is "searched, and nothing came close", and a
longer write a row at all, on the same reasoning that already keeps a blank broken search shows up as a WARNING in the application log rather than as
command out of the log: a row there reports a call that never happened and a quiet zero in here.
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.
THERE IS NO `cleared_threshold` ANY MORE, and if you remember one, that There is no "cleared the threshold" count, because it would be a
memory is of a tautology (#3670). The search applies the bar before tautology: the search applies the bar before returning, so every returned
returning, so every returned result cleared it by construction and a call result cleared it, and that count would always equal
with no results has no score to compare: the field was true exactly when `calls - zero_result_calls`.
`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.
CHECK `suppression` BEFORE CONCLUDING ANYTHING FROM `zero_result_calls`. A CHECK `suppression` BEFORE CONCLUDING ANYTHING FROM `zero_result_calls`. A
zero-result call is two different events wearing one number: the ranker 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 `suppression` is `null` when NO row in the window reported it, and that is
"not measured here", NOT "none suppressed". Surfaces that pass their "not measured here", NOT "none suppressed". Surfaces that pass their
exclusions into the search never see what was dropped, so they cannot say. 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 Read a null as "unknown", never as a zero: an artifact read as a
this surface got mis-scoped once already (#3311, #3497). measurement mis-scopes the surface.
`usage` — NOTES ONLY, from `note_usage_events`, at the per-note grain `usage` — NOTES ONLY, from `note_usage_events`, at the per-note grain
`retrieval_logs` cannot be indexed at: `surfaced` (ranked surfacings — a `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 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? 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 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 and opened once counts for both — attribution would need a session
identity #2085 declined to invent. `by_source_failed: true` means that one identity the logs do not carry. `by_source_failed: true` means that one
query failed while the rest of the readout stood. query failed while the rest of the readout stood.
`rule_usage` — the same question for RULES, from `rule_usage_events`: `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). definition (agent pulls over RANKED surfacings).
`applied`, `departed` AND `distinct_rules_acted` ARE WHAT HAPPENED AFTER `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 changed something. `applied` counts rules followed, `departed` rules
deliberately not followed — kept apart rather than summed, because a deliberately not followed — kept apart rather than summed, because a
departure carries the reason the agent gave and is evidence about the 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` a pull can settle. `ambient` counts BULK DELIVERIES: the `rules_payload`
surfaces (`enter_project`, `get_project`, `get_milestone`, surfaces (`enter_project`, `get_project`, `get_milestone`,
`start_planning`, `get_task`), which hand over the whole applicable set at `start_planning`, `get_task`), which hand over the whole applicable set at
once with nobody choosing anything — plus, in rows older than milestone once with nobody choosing anything. A large `ambient` says a bulk set
394, the SessionStart preload it removed. A large `ambient` says a bulk set
is big and arrives often — never that it is useful, and never that it is is big and arrives often — never that it is useful, and never that it is
read. 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 those same rules over time: a set surfaced thousands of times and opened
never is the dead-weight signal, one level up. never is the dead-weight signal, one level up.
Read it against `sources["write_path_rule"]`. That arm was once believed Read it against `sources["write_path_rule"]`, which declines the large
never to decline — the reading that scoped #3311 — but it was the arm's majority of its calls like any other surface.
`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.
`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`) 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 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 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. 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. `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`) per source, a reviewer's verdicts (`on_point` / `adjacent` / `unrelated`)
on a sample of logged menus, `within_budget` against `beyond_budget` and 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 `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, 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 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 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 dangerous.
that did not exist.
`covers_window` is null, never false, when nothing was ever recorded: `covers_window` is null, never false, when nothing was ever recorded:
"no measurement" is not "partial measurement", the same distinction "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 they are zeros meaning "could not find out", not "nothing happened" — do
not report a pull-through from a block carrying that flag. not report a pull-through from a block carrying that flag.
`warnings` IS THE PART TO READ FIRST (#3431). Everything above is a `warnings` IS THE PART TO READ FIRST. Everything above is a
distribution; this is a verdict, and it exists because the same four distribution; this is a verdict, the checks a careful reader would
checks were being redone by hand on every reading and were easy to otherwise redo by hand on every reading. An EMPTY LIST means checked and clean — it is always present, so
forget. 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 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 numbers that triggered it, so you can disagree with the rule instead of
having to redo the arithmetic: 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 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 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 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 - `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 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. 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 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. is a trigger to fix (`update_rule(when_to_apply=...)`), not a floor.
- `read_and_unacted` — distinct rules OPENED in the window that recorded - `read_and_unacted` — distinct rules OPENED in the window that recorded
no outcome, against the ones that did. The failure milestone 419 was no outcome, against the ones that did. The worse sibling of `surfaced_never_pulled` above: a
opened on, and the worse sibling of `surfaced_never_pulled` above: a
rule nobody opens is cheap, while a rule read and silently unchanged is 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 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, 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. asked for, query text included, and is not a shared record kind.
`read_failed: true` means the query itself failed — deliberately distinct `read_failed: true` means the query itself failed — deliberately distinct
from an empty window, because those two looked identical for weeks once from an empty window, which would otherwise read as every counter at
(#2663) and every counter silently read zero. zero.
Args: Args:
days: window size, default 30. Clamped to at least 1. days: window size, default 30. Clamped to at least 1.
+22 -22
View File
@@ -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 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 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 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 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 from __future__ import annotations
@@ -19,7 +19,7 @@ async def classify_shapes(
) -> dict: ) -> dict:
"""Record judgments for a project's code shapes — in batch, as rows. """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 - `instance` of snippet N — it conforms to recorded canon (family-level
canon in another project counts; that fully accounts for the shape). canon in another project counts; that fully accounts for the shape).
- `variant` of snippet N — a deliberate, named departure. `reason` - `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, one-off-handler, test-helper, convention-plumbing, pure-helper,
generated, script, typed-record) so the ledger can be filtered generated, script, typed-record) so the ledger can be filtered
and aggregated by kind of one-off — the prose stays the record. 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 conformance (status + snippet_id) says what shape it is, uses
says which canonical helpers it consumes — a service function says which canonical helpers it consumes — a service function
can be an instance of the service-function convention AND use can be an instance of the service-function convention AND use
@@ -106,7 +106,7 @@ async def list_shapes(
Args: Args:
status: canonical | instance | variant | exempt | scoped | unclassified. 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 <style> rules and its construction (a Vue component's scoped <style> rules and its
<script setup> functions): accounted for, not judged, still <script setup> functions): accounted for, not judged, still
proposed against / grouped / flagged, and overridable by any proposed against / grouped / flagged, and overridable by any
@@ -115,7 +115,7 @@ async def list_shapes(
(the coverage line's "largest" dirs go straight in here). (the coverage line's "largest" dirs go straight in here).
snippet_id: rows classified against this snippet (instance/variant snippet_id: rows classified against this snippet (instance/variant
of it — conformance). of it — conformance).
uses: rows that CALL this snippet (#2870) — the consumer map proper, uses: rows that CALL this snippet — the consumer map proper,
whatever shape each row is itself; edges come from judgments whatever shape each row is itself; edges come from judgments
(classify_shapes uses=), the write-path hook, and the proposer's (classify_shapes uses=), the write-path hook, and the proposer's
by-name reference hits. by-name reference hits.
@@ -126,7 +126,7 @@ async def list_shapes(
set — no commits, shas or timestamps. THE form for an audit: set — no commits, shas or timestamps. THE form for an audit:
a full 500-row page fits the tool budget. The default rows carry a full 500-row page fits the tool budget. The default rows carry
everything (shape_history-grade bookkeeping). everything (shape_history-grade bookkeeping).
proposal: the proposer's queue (#2792) — "any", "canon" (rows the proposal: the proposer's queue — "any", "canon" (rows the
machine thinks are an instance of a snippet: `proposal` carries machine thinks are an instance of a snippet: `proposal` carries
snippet_id, basis, score), "derive" (rows that repeat with NO snippet_id, basis, score), "derive" (rows that repeat with NO
canon: `proposal.group` names the family), or one basis canon: `proposal.group` names the family), or one basis
@@ -134,27 +134,27 @@ async def list_shapes(
a canon family idea's reference — another project's, often a canon family idea's reference — another project's, often
another language's — that the body means: the top match over another language's — that the body means: the top match over
every snippet you can read, on a platform the project shares. every snippet you can read, on a platform the project shares.
flag: the divergence readout (#2793) — "divergence": shapes new flag: the divergence readout — "divergence": shapes new
since the previous refresh in a directory where one canon since the previous refresh in a directory where one canon
dominates the judged siblings and NOT proposed as that canon dominates the judged siblings and NOT proposed as that canon
(`diverges_from` names it: button B where button A is canon — (`diverges_from` names it: button B where button A is canon —
classify it: instance if it should use the canon, variant with classify it: instance if it should use the canon, variant with
the why if deliberate); "recheck": judged instances/variants the why if deliberate); "recheck": judged instances/variants
whose body changed since judged (the judgment stands; confirm whose body changed since judged (the judgment stands; confirm
it again with classify_shapes, or re-judge); "unused-css" it again with classify_shapes, or re-judge); "unused-css":
(milestone 302): live css rules no file's markup names — a live css rules no file's markup names — a
deletion candidate to look at, never auto-deleted. Transition deletion candidate to look at, never auto-deleted. Transition
classes and concatenated names are read (#2970), so the list is classes and concatenated names are read, so the list is
worth acting on; a name assembled in a script still is not. worth acting on; a name assembled in a script still is not.
Returns {"shapes": [...], "total": N} — total counts every match, not Returns {"shapes": [...], "total": N} — total counts every match, not
just this page. Every css row carries `used_by` {count, paths} — the just this page. Every css row carries `used_by` {count, paths} — the
files whose markup names its class (milestone 302, the CSS consumer files whose markup names its class (the CSS consumer
map: a scoped rule is used by its own template; a shared recipe by map: a scoped rule is used by its own template; a shared recipe by
many; a count of 0 is "no template names it"). Each row's many; a count of 0 is "no template names it"). Each row's
`classified_by` says who judged: agent / `classified_by` says who judged: agent /
audit / import are judgments; `mechanical` is the canonical stamp the audit / import are judgments; `mechanical` is the canonical stamp the
sync applies; `hook` is write-path EVIDENCE (#2791) — the session pulled sync applies; `hook` is write-path EVIDENCE — the session pulled
a snippet and then wrote code referencing/resembling it, so the shape a snippet and then wrote code referencing/resembling it, so the shape
was stamped an instance with the evidence in `reason`. A hook row is was stamped an instance with the evidence in `reason`. A hook row is
overridable by any classify_shapes call; it never overrides yours. overridable by any classify_shapes call; it never overrides yours.
@@ -223,7 +223,7 @@ async def classify_shapes_by_rule(
via: "agent" (default) | "audit" | "import". via: "agent" (default) | "audit" | "import".
reason_code: Optional catalogue code beside the reason (see reason_code: Optional catalogue code beside the reason (see
classify_shapes) — a sweep is exactly where one applies. classify_shapes) — a sweep is exactly where one applies.
uses: Optional snippet ids every matched shape CALLS (#2870) — e.g. uses: Optional snippet ids every matched shape CALLS — e.g.
"every *_scheduler.py symbol uses ScheduledJob". "every *_scheduler.py symbol uses ScheduledJob".
include_judged: By default only unjudged rows are touched — include_judged: By default only unjudged rows are touched —
`unclassified` and the sync's mechanical `scoped` stamp — a `unclassified` and the sync's mechanical `scoped` stamp — a
@@ -254,7 +254,7 @@ async def shape_history(
project_id: int, path: str, symbol: str = "", limit: int = 200 project_id: int, path: str, symbol: str = "", limit: int = 200
) -> dict: ) -> dict:
"""What was used here, when, and why — a shape's (or a directory's) """What was used here, when, and why — a shape's (or a directory's)
history from the ledger (#2793). history from the ledger.
`shapes` are the current rows at `path` (a file, or a directory and `shapes` are the current rows at `path` (a file, or a directory and
everything beneath it; `symbol` narrows to one definition) with everything beneath it; `symbol` narrows to one definition) with
@@ -304,7 +304,7 @@ async def stamps_to_review(project_id: int, top: int = 10) -> dict:
Disagreement is judged by FAMILY (a sync helper and an async one are one Disagreement is judged by FAMILY (a sync helper and an async one are one
family), and a method defined beside a member class counts as part of the family), and a method defined beside a member class counts as part of the
shape rather than against it — so a model convention that covers both the shape rather than against it — so a model convention that covers both the
class and its `to_dict` reads as coherent, which it is (#4220). Each class and its `to_dict` reads as coherent, which it is. Each
entry carries `families` and `forms`, the majority `family`, `attached` entry carries `families` and `forms`, the majority `family`, `attached`
(minority rows excused as methods of a member), `strangers` — THE ROWS (minority rows excused as methods of a member), `strangers` — THE ROWS
THAT DO NOT FIT, which is what you judge — and `unattended` / `weak_rows`, THAT DO NOT FIT, which is what you judge — and `unattended` / `weak_rows`,
@@ -330,7 +330,7 @@ async def confirm_shape_proposals(
) -> dict: ) -> dict:
"""Confirm the proposer's canon proposals you have reviewed, in batch. """Confirm the proposer's canon proposals you have reviewed, in batch.
The machine proposes, judgment classifies (#2792): each matching row — The machine proposes, judgment classifies: each matching row —
live, unclassified, carrying a `proposal` with a snippet_id — becomes live, unclassified, carrying a `proposal` with a snippet_id — becomes
`instance` of that snippet, classified_by="agent", reason naming the `instance` of that snippet, classified_by="agent", reason naming the
basis and score. Narrow to what you actually looked at: at least one of basis and score. Narrow to what you actually looked at: at least one of
@@ -359,7 +359,7 @@ async def confirm_shape_proposals(
async def refresh_pattern_coverage(project_id: int) -> dict: async def refresh_pattern_coverage(project_id: int) -> dict:
"""Seed or refresh the project's shape ledger NOW, and return the readout. """Seed or refresh the project's shape ledger NOW, and return the readout.
The synchronous form of the arrival-moment background seed (#2802): The synchronous form of the arrival-moment background seed:
downloads the bound repos through the OWNER's forge connections, upserts downloads the bound repos through the OWNER's forge connections, upserts
every extracted shape into the ledger (new shapes arrive `unclassified`), every extracted shape into the ledger (new shapes arrive `unclassified`),
re-stamps snippet reference locations as canonical, and recomputes the re-stamps snippet reference locations as canonical, and recomputes the
@@ -372,7 +372,7 @@ async def refresh_pattern_coverage(project_id: int) -> dict:
owner adds one (Settings → Integrations → Git Forges); no served repo → owner adds one (Settings → Integrations → Git Forges); no served repo →
bind_repo on a host a connection serves. bind_repo on a host a connection serves.
The refresh is also when the mechanical proposer runs (#2792): with the The refresh is also when the mechanical proposer runs: with the
repo bodies in hand it matches every changed unclassified shape against repo bodies in hand it matches every changed unclassified shape against
canon and records proposals (see list_shapes proposal=), then regroups canon and records proposals (see list_shapes proposal=), then regroups
the derive-first candidates. Semantic matching is capped per refresh, so the derive-first candidates. Semantic matching is capped per refresh, so
@@ -382,9 +382,9 @@ async def refresh_pattern_coverage(project_id: int) -> dict:
unclassified, repos, largest_gaps, `proposed` (canon proposals awaiting unclassified, repos, largest_gaps, `proposed` (canon proposals awaiting
confirmation), `derive_groups` (the biggest repeats-with-no-canon confirmation), `derive_groups` (the biggest repeats-with-no-canon
families, each css one with `consumers` — the files whose markup families, each css one with `consumers` — the files whose markup
render it, milestone 302), `unused_css` (css rules no template names — render it), `unused_css` (css rules no template names —
counting a `<Transition name=>`'s generated classes and concatenated counting a `<Transition name=>`'s generated classes and concatenated
names as named, #2970; None where the map has no evidence of templates), `derive_new` (copies names as named; None where the map has no evidence of templates), `derive_new` (copies
that joined a family since the previous that joined a family since the previous
refresh — the drift to act on now: derive the canon, don't queue an refresh — the drift to act on now: derive the canon, don't queue an
audit), `proposer` (what this refresh examined) — plus audit), `proposer` (what this refresh examined) — plus
+12 -12
View File
@@ -187,13 +187,13 @@ async def create_snippet(
and force is almost never the right answer to them. Only then a semantic and force is almost never the right answer to them. Only then a semantic
check, held to a high bar so that VARIANTS of one component are not refused: check, held to a high bar so that VARIANTS of one component are not refused:
`.btn-primary` and `.btn-secondary` read alike and are two different things, `.btn-primary` and `.btn-secondary` read alike and are two different things,
so record both (#2518). so record both.
""" """
if not (name or "").strip() or not (code or "").strip(): if not (name or "").strip() or not (code or "").strip():
raise ValueError("create_snippet requires a non-empty name and code") raise ValueError("create_snippet requires a non-empty name and code")
uid = current_user_id() uid = current_user_id()
# The NAME is the title (milestone 427); the trigger rides in `data` and # The NAME is the title; the trigger rides in `data` and
# joins the title only in the embedded document. # joins the title only in the embedded document.
title = name.strip() title = name.strip()
body = snippets_svc.compose_body( body = snippets_svc.compose_body(
@@ -206,7 +206,7 @@ async def create_snippet(
uid, title, body, project_id=project_id or None, uid, title, body, project_id=project_id or None,
is_task=False, note_type=snippets_svc.SNIPPET_NOTE_TYPE, is_task=False, note_type=snippets_svc.SNIPPET_NOTE_TYPE,
# The artefact itself, not just its description — the gate compares # The artefact itself, not just its description — the gate compares
# location and code before it compares prose (#2518). # location and code before it compares prose.
code=code, code=code,
locations=snippets_svc.resolve_locations(repo, path, symbol, locations), locations=snippets_svc.resolve_locations(repo, path, symbol, locations),
data=snippets_svc.compose_data(name=name, when_to_use=when_to_use), data=snippets_svc.compose_data(name=name, when_to_use=when_to_use),
@@ -227,7 +227,7 @@ async def create_snippet(
advice = snippets_svc.trigger_advice(when_to_use) advice = snippets_svc.trigger_advice(when_to_use)
if advice: if advice:
data["trigger_advice"] = advice data["trigger_advice"] = advice
# Family canon (milestone 463): the same shape recorded in another project # Family canon: the same shape recorded in another project
# on a shared platform opens an evaluation of the earlier one. # on a shared platform opens an evaluation of the earlier one.
await family_svc.attach_family_hint(uid, data, note, created=True) await family_svc.attach_family_hint(uid, data, note, created=True)
return await moment_delivery.attach_moment_rules(uid, "create_snippet", {"project_id": project_id}, data) return await moment_delivery.attach_moment_rules(uid, "create_snippet", {"project_id": project_id}, data)
@@ -246,7 +246,7 @@ async def get_snippet(snippet_id: int, project_id: int = 0) -> dict:
A record kept VERBATIM is confirmed by containment. A deliberately A record kept VERBATIM is confirmed by containment. A deliberately
ANNOTATED one — commentary the source does not carry — cannot be, so it ANNOTATED one — commentary the source does not carry — cannot be, so it
reads "current" on the authority of a standing `ok` verdict stamped at reads "current" on the authority of a standing `ok` verdict stamped at
the very commit just fetched (#2782); `verification` in the same payload the very commit just fetched; `verification` in the same payload
shows that basis. Edit the record, or let the file move past that commit, shows that basis. Edit the record, or let the file move past that commit,
and it reads "diverged" again until someone re-runs verify_snippet. and it reads "diverged" again until someone re-runs verify_snippet.
@@ -270,14 +270,14 @@ async def get_snippet(snippet_id: int, project_id: int = 0) -> dict:
if note is None: if note is None:
raise ValueError(f"snippet {snippet_id} not found") raise ValueError(f"snippet {snippet_id} not found")
data = snippets_svc.snippet_to_dict(note) data = snippets_svc.snippet_to_dict(note)
# Forge-checked freshness (#2690): attaches body_source/body_freshness # Forge-checked freshness: attaches body_source/body_freshness
# when the instance has a forge; a no-forge instance sees no new fields. # when the instance has a forge; a no-forge instance sees no new fields.
await snippets_svc.attach_live_body(note, data) await snippets_svc.attach_live_body(note, data)
data.update(await access_svc.describe_provenance(uid, note)) data.update(await access_svc.describe_provenance(uid, note))
# A "pull" is an explicit open, so it's recorded HERE rather than in # A "pull" is an explicit open, so it's recorded HERE rather than in
# snippets_svc.get_snippet — the service is also reached by update/merge # snippets_svc.get_snippet — the service is also reached by update/merge
# paths, and counting those would inflate exactly the number that is # paths, and counting those would inflate exactly the number that is
# supposed to mean "someone chose to look at this" (#2085). # supposed to mean "someone chose to look at this".
record_pulled( record_pulled(
user_id=uid, note_id=int(note.id), user_id=uid, note_id=int(note.id),
source="mcp_get_snippet", project_id=project_id, source="mcp_get_snippet", project_id=project_id,
@@ -285,8 +285,8 @@ async def get_snippet(snippet_id: int, project_id: int = 0) -> dict:
await systems_tools.attach_systems( await systems_tools.attach_systems(
uid, note.user_id, data, note.id, note.project_id uid, note.user_id, data, note.id, note.project_id
) )
# The structured consumer map (#2789): ledger rows judged against this # The structured consumer map: ledger rows judged against this
# snippet. Attached only when non-empty (#2483) — and never for projects # snippet. Attached only when non-empty — and never for projects
# the caller can't read. # the caller can't read.
from scribe.services import shape_ledger as shape_ledger_svc from scribe.services import shape_ledger as shape_ledger_svc
@@ -296,7 +296,7 @@ async def get_snippet(snippet_id: int, project_id: int = 0) -> dict:
if consumers["variants"]: if consumers["variants"]:
data["variants"] = consumers["variants"] data["variants"] = consumers["variants"]
if consumers.get("uses"): if consumers.get("uses"):
# The call sites (#2870): shapes that use this snippet, whatever # The call sites: shapes that use this snippet, whatever
# shape they are themselves. # shape they are themselves.
data["uses"] = consumers["uses"] data["uses"] = consumers["uses"]
return data return data
@@ -409,7 +409,7 @@ async def verify_snippet(
CONSUMERS you enumerate while checking ("all N call sites still route CONSUMERS you enumerate while checking ("all N call sites still route
through it") are ledger rows, not detail prose: classify each consuming through it") are ledger rows, not detail prose: classify each consuming
definition as an `instance` of this snippet with classify_shapes — prose definition as an `instance` of this snippet with classify_shapes — prose
cannot be sorted, queried, or diffed (note 2786's lesson), and the rows cannot be sorted, queried, or diffed, and the rows
are what make "what uses this?" answerable forever. `detail` keeps the are what make "what uses this?" answerable forever. `detail` keeps the
WHY and what changed, nothing that belongs in a row. WHY and what changed, nothing that belongs in a row.
@@ -514,7 +514,7 @@ async def update_snippet(
if note is None: if note is None:
raise ValueError(f"snippet {snippet_id} not found") raise ValueError(f"snippet {snippet_id} not found")
if system_ids is not None: if system_ids is not None:
# The CALLER, not the owner (#4249). `update_snippet` above already # The CALLER, not the owner. `update_snippet` above already
# raised PermissionError if this user may not write, so the tagging # raised PermissionError if this user may not write, so the tagging
# runs with the actor's own System visibility rather than the owner's. # runs with the actor's own System visibility rather than the owner's.
await systems_svc.set_record_systems(uid, snippet_id, system_ids) await systems_svc.set_record_systems(uid, snippet_id, system_ids)
+22 -23
View File
@@ -23,21 +23,20 @@ from scribe.services.system_usage import record_system_pulled
# Below this, a project is young enough that the mild "which area is this # Below this, a project is young enough that the mild "which area is this
# about?" question stays proportionate; at or above it, a zero-Systems project # about?" question stays proportionate; at or above it, a zero-Systems project
# has demonstrated that the question never converts (#2683 — Minstrel reached # has shown that the mild question does not get answered, and the ask
# 282 records without a single System) and the ask escalates. # escalates.
_BOOTSTRAP_MIN_RECORDS = 20 _BOOTSTRAP_MIN_RECORDS = 20
_BOOTSTRAP_TITLES = 6 _BOOTSTRAP_TITLES = 6
# The standard vocabulary (#2798): area names that recur across software # The standard vocabulary: area names that recur across software
# projects, offered so "CI & Release" means the same thing in every project # projects, offered so "CI & Release" means the same thing in every project
# on the instance. Consistency comes from the shared names — NOT from asking # on the instance. Consistency comes from the shared names — NOT from asking
# the operator to approve each System; agents mint directly. Generic by # the operator to approve each System; agents mint directly. Generic by
# design (rule #115): archetypes any codebase could have, never one # design: archetypes any codebase could have, never one install's
# install's subsystems. Mint freely beyond the list; the duplicate gate # subsystems. Mint freely beyond the list; the duplicate gate guards sprawl.
# guards sprawl. # The vocabulary lives in the GLOBAL canonical catalog — the inception seed
# The standard vocabulary lives in the GLOBAL canonical catalog since # mints it and this ask names it, one list for both, in a table so a rule can
# milestone 307 — the inception seed mints it and this ask names it, one list # reference an area by id.
# for both, now a table so a rule can reference an area by id (note 3026).
async def bootstrap_systems_ask(user_id: int, project_id: int) -> str | None: async def bootstrap_systems_ask(user_id: int, project_id: int) -> str | None:
@@ -45,13 +44,13 @@ async def bootstrap_systems_ask(user_id: int, project_id: int) -> str | None:
The generic zero-state question habituates: identical on every record, The generic zero-state question habituates: identical on every record,
maximal in scope ("invent the taxonomy"), asked at wrap-up time — so maximal in scope ("invent the taxonomy"), asked at wrap-up time — so
organic sessions skip it forever and only audit-shaped sessions ever mint organic sessions skip it forever and only audit-shaped sessions ever mint.
(#2683). What separates the nudges that convert from the prose that What separates the nudges that convert from the prose that
doesn't (the duplicate gate, the prior-art "already defined in 2 files") doesn't (the duplicate gate, the prior-art "already defined in 2 files")
is the project's OWN evidence in the ask — so this one carries the record is the project's OWN evidence in the ask — so this one carries the record
count and the recent titles, and asks for a concrete deliverable: create count and the recent titles, and asks for a concrete deliverable: create
a starter set directly, preferring the standard cross-project names. a starter set directly, preferring the standard cross-project names.
Deliberately NOT an approval flow (#2798): the operator is not a Deliberately NOT an approval flow: the operator is not a
permission gate for vocabulary; the standard names carry consistency. permission gate for vocabulary; the standard names carry consistency.
Self-retiring by construction: callers only reach for it while the Self-retiring by construction: callers only reach for it while the
@@ -102,12 +101,12 @@ async def untagged_systems_hint(user_id: int, project_id: int) -> str | None:
arrives in-band at the exact moment a record is touched. It is ONE arrives in-band at the exact moment a record is touched. It is ONE
question regardless of vocabulary state ("which area is this about, and is question regardless of vocabulary state ("which area is this about, and is
it modelled?"); only the vocabulary listing varies, because an empty it modelled?"); only the vocabulary listing varies, because an empty
vocabulary is not an exemption — it is the question at its most urgent vocabulary is not an exemption — it is the question at its most urgent.
(#2562, #2569). Instruction prose alone demonstrably doesn't fire at write Instruction prose alone does not fire at write time; in-band behavior
time; in-band behavior (the duplicate gate) does. (the duplicate gate) does.
In a MATURE zero-Systems project the question escalates to the bootstrap In a MATURE zero-Systems project the question escalates to the bootstrap
ask instead (#2683): the mild form demonstrably never converts there, and ask instead: the mild form does not get answered there, and
an evidence-carrying, deliverable-shaped ask is the form that does. an evidence-carrying, deliverable-shaped ask is the form that does.
""" """
# Fail-open like the dedup gate: a hint must never break the call. # Fail-open like the dedup gate: a hint must never break the call.
@@ -147,7 +146,7 @@ async def attach_systems(
tagged record shows its areas (the touching-a-System reflex needs the tagged record shows its areas (the touching-a-System reflex needs the
affiliation visible on read, not just settable on write), an untagged affiliation visible on read, not just settable on write), an untagged
project record carries the question instead. Neither field is ever project record carries the question instead. Neither field is ever
attached empty (same reasoning as supersession_svc.attach_relations / #2483 — a attached empty (same reasoning as supersession_svc.attach_relations — a
field that always says nothing trains readers to skip fields). The hint field that always says nothing trains readers to skip fields). The hint
goes only to the record's owner: tagging someone else's record in someone goes only to the record's owner: tagging someone else's record in someone
else's project is not the caller's call to make. Fail-open — decoration else's project is not the caller's call to make. Fail-open — decoration
@@ -181,7 +180,7 @@ async def create_system(
yet — the same two-or-more test snippets use. Don't wait to be asked to yet — the same two-or-more test snippets use. Don't wait to be asked to
name an area that plainly exists in the code, and don't route the name an area that plainly exists in the code, and don't route the
creation through operator approval — minting vocabulary is the agent's creation through operator approval — minting vocabulary is the agent's
call (#2798); an unmodelled area means every record about it stays call; an unmodelled area means every record about it stays
untaggable. An audit or sweep that walks the codebase is a DISCOVERY untaggable. An audit or sweep that walks the codebase is a DISCOVERY
moment: mint the Systems it names as it names them — the duplicate gate moment: mint the Systems it names as it names them — the duplicate gate
below, plus reviewing the existing list, is what guards against sprawl, below, plus reviewing the existing list, is what guards against sprawl,
@@ -220,7 +219,7 @@ async def create_system(
tag records to that one, or update_system it if its charter needs work. tag records to that one, or update_system it if its charter needs work.
Also mapped against the GLOBAL area catalog, so the same word means the Also mapped against the GLOBAL area catalog, so the same word means the
same thing in every project (milestone 307). A name that IS a catalog area same thing in every project. A name that IS a catalog area
up to spelling ("CI and Release" vs "CI & Release") is mapped for you and up to spelling ("CI and Release" vs "CI & Release") is mapped for you and
the response says so. A name that merely RESEMBLES one comes back with the response says so. A name that merely RESEMBLES one comes back with
`canonical_suggestion` — an offer, not a decision: apply it with `canonical_suggestion` — an offer, not a decision: apply it with
@@ -297,7 +296,7 @@ async def get_system(system_id: int) -> dict:
system = await systems_svc.get_system(uid, system_id) system = await systems_svc.get_system(uid, system_id)
if system is None: if system is None:
raise ValueError(f"system {system_id} not found") raise ValueError(f"system {system_id} not found")
# The pull half of the rulings arm's measurement (milestone 444): a # The pull half of the rulings arm's measurement: a
# System whose rulings were shown, then opened. # System whose rulings were shown, then opened.
record_system_pulled(user_id=uid, system_id=system_id, source="mcp_get_system") record_system_pulled(user_id=uid, system_id=system_id, source="mcp_get_system")
records = await systems_svc.list_records_for_system(uid, system_id) records = await systems_svc.list_records_for_system(uid, system_id)
@@ -316,7 +315,7 @@ async def get_system(system_id: int) -> dict:
data["tasks"] = tasks data["tasks"] = tasks
data["notes"] = notes data["notes"] = notes
# How often this area's rulings were shown to a session and the System # How often this area's rulings were shown to a session and the System
# opened (#4769). Here and not on list_systems: one lookup on a read that # opened. Here and not on list_systems: one lookup on a read that
# asked for the System, rather than a column on every project handshake. # asked for the System, rather than a column on every project handshake.
data["usage"] = (await system_usage_svc.usage_for_systems([system.id]))[system.id] data["usage"] = (await system_usage_svc.usage_for_systems([system.id]))[system.id]
return data return data
@@ -412,8 +411,8 @@ async def list_canonical_systems() -> dict:
"""The GLOBAL vocabulary of area names, shared by every project. """The GLOBAL vocabulary of area names, shared by every project.
These are the standard names to prefer when creating a System, so the same These are the standard names to prefer when creating a System, so the same
word means the same thing in every project on the instance — and, from word means the same thing in every project on the instance — and the ids
milestone 307, the ids a cross-project record can point at. A project's own a cross-project record can point at. A project's own
System keeps whatever name the project calls the area; mapping it here is System keeps whatever name the project calls the area; mapping it here is
an association, never a rename. an association, never a rename.
+16 -23
View File
@@ -4,15 +4,9 @@ Tasks are notes with a non-null `status` — same model, different filter.
Wrappers call services/notes.py for CRUD with is_task=True and add the Wrappers call services/notes.py for CRUD with is_task=True and add the
task-specific fields (status, priority, due_date, parent_id), plus task-specific fields (status, priority, due_date, parent_id), plus
services/task_logs.py for add_task_log AND for reading those logs back — services/task_logs.py for add_task_log AND for reading those logs back —
`get_task` returns them and `list_tasks` counts them. For most of this `get_task` returns them and `list_tasks` counts them. The log is the record
module's life `add_task_log` wrote to a surface no agent could read: the written during the work; a body is a claim written before it, so a session
entries reached the web UI and nothing else, so a session opening a task opening a task needs both.
saw only the body — a claim written before the work — with the record
written during it invisible beside it. A stale body then had nothing to
contradict it, and shipped work got rebuilt (#4241).
There is no delete_task — matches the existing fable-mcp surface.
Cancel by updating status to "cancelled".
Sentinels (preserved from existing fable-mcp): Sentinels (preserved from existing fable-mcp):
- status="" / priority="" / title="" / body="" → "leave unchanged" on update - status="" / priority="" / title="" / body="" → "leave unchanged" on update
@@ -181,8 +175,8 @@ async def get_task(
written once, at the start, when the least is known; the log is written written once, at the start, when the least is known; the log is written
during the work and after it. A task whose body says "not started" and during the work and after it. A task whose body says "not started" and
whose log records a partial ship is not a contradiction to resolve — the whose log records a partial ship is not a contradiction to resolve — the
log is simply later. This block exists because its absence cost a session log is simply later; trusting the body alone is how finished work gets
a day of rebuilding work that had already shipped (#4241). rebuilt.
The NEWEST entry arrives whole (to 4000 characters); older ones are The NEWEST entry arrives whole (to 4000 characters); older ones are
shortened to a headline. Anything shortened is cut from the MIDDLE, so shortened to a headline. Anything shortened is cut from the MIDDLE, so
@@ -228,10 +222,9 @@ async def get_task(
# Same reasoning as get_note's record_pulled, and this is the tool where it # Same reasoning as get_note's record_pulled, and this is the tool where it
# matters MOST: auto-inject ranks kind-blind over a corpus that is # matters MOST: auto-inject ranks kind-blind over a corpus that is
# overwhelmingly tasks and issues, so tasks dominate what it surfaces. Without # overwhelmingly tasks and issues, so tasks dominate what it surfaces. Without
# this the surfaced→pulled loop was open exactly where the volume is — every # this every surfaced task would count as never-pulled, driving
# surfaced task counted as never-pulled because the tool that opens one didn't # auto-inject's measured pull-through toward zero for its own dominant
# say so, driving auto-inject's measured pull-through toward zero for its own # kind.
# dominant kind. #1038 and #2085 are explicitly gated on that number (#2245).
await systems_tools.attach_systems( await systems_tools.attach_systems(
uid, getattr(note, "user_id", uid) or uid, data, note.id, note.project_id uid, getattr(note, "user_id", uid) or uid, data, note.id, note.project_id
) )
@@ -365,7 +358,7 @@ async def create_task(
await systems_tools.attach_systems(uid, uid, data, note.id, project_id or None) await systems_tools.attach_systems(uid, uid, data, note.id, project_id or None)
data.update(dedup_svc.note_overlap_response(overlaps, "task")) data.update(dedup_svc.note_overlap_response(overlaps, "task"))
# A task that says it is "matching" another project's work names the # A task that says it is "matching" another project's work names the
# source of a pattern — family canon's citation trigger (milestone 463). # source of a pattern — family canon's citation trigger.
await family_svc.attach_family_hint(uid, data, note, created=True) await family_svc.attach_family_hint(uid, data, note, created=True)
return await placement_svc.attach_placement(uid, data, note) return await placement_svc.attach_placement(uid, data, note)
@@ -467,8 +460,8 @@ async def update_task(
await family_svc.attach_family_hint(uid, data, note, created=False) await family_svc.attach_family_hint(uid, data, note, created=False)
if status in _CLOSING_STATUSES: if status in _CLOSING_STATUSES:
data["report_back"] = REPORT_BACK_CUE data["report_back"] = REPORT_BACK_CUE
# Owed family adoptions filed while the task was open (milestone 463 # Owed family adoptions filed while the task was open — work now
# step 6) — work now waiting in some project, which the report names. # waiting in some project, which the report names.
await family_adoption_svc.attach_owed_adoptions(uid, data, note) await family_adoption_svc.attach_owed_adoptions(uid, data, note)
return await moment_delivery.attach_moment_rules( return await moment_delivery.attach_moment_rules(
uid, "update_task", {"status": status, "project_id": project_id}, data, uid, "update_task", {"status": status, "project_id": project_id}, data,
@@ -490,7 +483,7 @@ async def add_task_log(task_id: int, content: str) -> dict:
out, and where it actually stands. out, and where it actually stands.
A log on an open task also marks it as being worked by this session — its A log on an open task also marks it as being worked by this session — its
`claim` (milestone 381). That is what brings the task and its newest `claim`. That is what brings the task and its newest
entries back to you after a compaction; it lapses by itself once the entries back to you after a compaction; it lapses by itself once the
session stops writing, and a status of done, cancelled or todo clears it. session stops writing, and a status of done, cancelled or todo clears it.
@@ -520,8 +513,8 @@ async def add_task_log(task_id: int, content: str) -> dict:
return data return data
# The in-band half of milestone 409 step 3. The completion shape itself rides # The in-band completion cue. The completion shape itself rides the closing
# the closing response as `reply_shape` (milestone 500); this is the one line # response as `reply_shape`; this is the one line
# that says what the reply covers, for every MCP client, at the moment a piece # that says what the reply covers, for every MCP client, at the moment a piece
# of work closes. One line on purpose: a template here would be read as the # of work closes. One line on purpose: a template here would be read as the
# reply itself. # reply itself.
@@ -537,7 +530,7 @@ _ITEM_KEYS = {"title", "body", "type", "status", "priority", "kind", "tags", "sy
def _batch_items(records: list[dict], *, what: str = "record") -> list[batch_svc.BatchItem]: def _batch_items(records: list[dict], *, what: str = "record") -> list[batch_svc.BatchItem]:
"""Parse the door's plain dicts into BatchItems, refusing unknown keys. """Parse the door's plain dicts into BatchItems, refusing unknown keys.
Strict for the same reason StrictArgsMCPServer is (#2709): a misspelt key Strict for the same reason StrictArgsMCPServer is: a misspelt key
silently dropped — `milestone` for a per-record milestone, `desc` for body — silently dropped — `milestone` for a per-record milestone, `desc` for body —
creates a record that looks right and is missing what the caller sent. creates a record that looks right and is missing what the caller sent.
""" """
@@ -574,7 +567,7 @@ async def _first_duplicate(
"""The duplicate gate over a whole batch — the first hit blocks all of it. """The duplicate gate over a whole batch — the first hit blocks all of it.
`overlaps`, when given, collects each record's near matches below the copy `overlaps`, when given, collects each record's near matches below the copy
band by its 1-based position (#4306), for the reply once the batch is band by its 1-based position, for the reply once the batch is
created.""" created."""
for i, item in enumerate(items, start=1): for i, item in enumerate(items, start=1):
found: list = [] found: list = []
+18 -27
View File
@@ -1,17 +1,11 @@
"""The wide net — every candidate that might govern what you are about to do (#4103). """The wide net — every candidate that might govern what you are about to do.
WHY THIS EXISTS WHY IT IS A PULL
Milestone 416 step 5, from the operator's compromise: Up to fifty candidates pushed unasked would be a wall the reader skims, with
the governing record indistinguishable from forty-nine others. Fifty in a PULL
"if you're worried about excluding potentially important data let limit it is a different object: it arrives only when a session asks, it crowds nothing
to 50 entries or something like that" out, and it is the honest home for "do not exclude potentially important data".
Fifty in the PUSH would be milestone 394 with extra steps — a wall the reader
skims, with the governing record indistinguishable from forty-nine others.
Fifty in a PULL is a different object: it arrives only when a session asks, it
crowds nothing out, and it is the honest home for "do not exclude potentially
important data".
WHY IT IS NOT `search(content_type="rule")` WHY IT IS NOT `search(content_type="rule")`
@@ -26,15 +20,13 @@ to open it. Opposite trade-off, so it is a second tool rather than a bigger
WHY THE TAIL IS TRUNCATED RATHER THAN FULL WHY THE TAIL IS TRUNCATED RATHER THAN FULL
Because the step's premise needed correcting. The task said fifty "costs Fifty is not free. A line runs to a hundred-odd tokens once its trigger is
nothing", and `_rule_hint_line` had already measured otherwise: a line runs rendered, so fifty full lines is several thousand — cheap next to an arm that
~143 tokens once its trigger is rendered, and #3855 tripled trigger lengths fires before every Bash call, but a tool that promises a free wide net gets
across the corpus. Fifty of those is ~7,000 tokens — cheap next to an arm that reached for casually and then regretted.
fires before every Bash call, but not free, and a tool that promises a free
wide net gets reached for casually and then regretted.
So this reuses the graduated shape #3851 measured for the push: the top few So this uses the push arms' graduated shape: the top few carry their trigger
carry their trigger in full, the rest carry a cut of it. TRUNCATED, never in full, the rest carry a cut of it. TRUNCATED, never
dropped — the trigger is what lets a reader judge relevance without opening dropped — the trigger is what lets a reader judge relevance without opening
the record, and a teaser without one is just an id. the record, and a teaser without one is just an id.
""" """
@@ -49,9 +41,8 @@ from scribe.services.retrieval_telemetry import record_retrieval
# The telemetry `source`, and a PULL — never added to AMBIENT_SOURCES (those # The telemetry `source`, and a PULL — never added to AMBIENT_SOURCES (those
# are deliveries nobody chose) and never confused with a push arm. Keeping it # are deliveries nobody chose) and never confused with a push arm. Keeping it
# separate is load-bearing right now: the push arms' near-miss distributions # separate keeps the push arms' near-miss distributions about the push arms: a
# are the evidence #4121 rests on, and a pull mixed into them would move the # pull mixed into them would move the numbers their floors are tuned from.
# very numbers that step is arguing from.
SOURCE = "wide_net" SOURCE = "wide_net"
# No bar. That is the point of the tool rather than an oversight — the caller # No bar. That is the point of the tool rather than an oversight — the caller
@@ -78,7 +69,7 @@ def _teaser(text: str) -> tuple[str, bool]:
The technique is `plugin_context._goal_line`'s, and the fallback is the The technique is `plugin_context._goal_line`'s, and the fallback is the
part worth copying: `textwrap.shorten` returns a bare "…" when the string part worth copying: `textwrap.shorten` returns a bare "…" when the string
is one unbroken word longer than the cap, and a raw slice ends mid-word is one unbroken word longer than the cap, and a raw slice ends mid-word
claiming to be the whole thing (#4036). Kept local rather than shared claiming to be the whole thing. Kept local rather than shared
because the two callers wrap it in different sentences; if a third appears, because the two callers wrap it in different sentences; if a third appears,
that is the moment to extract it rather than now. that is the moment to extract it rather than now.
""" """
@@ -177,7 +168,7 @@ async def what_might_apply(
# Force, not topic. See the docstring — this is never inferred. # Force, not topic. See the docstring — this is never inferred.
"kind": rule.kind, "kind": rule.kind,
# A rule in a rulebook topic is global; one on a project binds # A rule in a rulebook topic is global; one on a project binds
# there alone (milestone 414). Which it is changes how far a # there alone. Which it is changes how far a
# reader should generalise from it. # reader should generalise from it.
"scope": "project" if rule.project_id else "global", "scope": "project" if rule.project_id else "global",
"when_to_apply": trigger, "when_to_apply": trigger,
@@ -186,7 +177,7 @@ async def what_might_apply(
}) })
# Logged as a pull, with `searched` honoured: a search that never ran must # Logged as a pull, with `searched` honoured: a search that never ran must
# not be recorded as a ranker declining (#3765). # not be recorded as a ranker declining.
record_retrieval( record_retrieval(
user_id=uid, user_id=uid,
source=SOURCE, source=SOURCE,
@@ -208,7 +199,7 @@ async def what_might_apply(
"detailed": min(detail, len(candidates)), "detailed": min(detail, len(candidates)),
# Present so a caller can tell "the corpus offered nothing" from "the # Present so a caller can tell "the corpus offered nothing" from "the
# search never ran" — an empty query, an unavailable embedder and a # search never ran" — an empty query, an unavailable embedder and a
# failed query all return zero rows and mean different things (#3670). # failed query all return zero rows and mean different things.
"searched": bool(report.get("searched", True)), "searched": bool(report.get("searched", True)),
"open_with": "get_rule(id)", "open_with": "get_rule(id)",
} }
+1 -1
View File
@@ -1463,7 +1463,7 @@ def test_hook_names_the_shapes_being_written():
assert "scribe_defs()" in lib # one extractor, shared (#2901) assert "scribe_defs()" in lib # one extractor, shared (#2901)
assert "scribe_defs()" not in src # ...not a second copy here assert "scribe_defs()" not in src # ...not a second copy here
assert ".tool_input.old_string" in src # the Edit's anchor assert ".tool_input.old_string" in src # the Edit's anchor
assert "| tac | scribe_defs | head -1" in src # nearest definition above assert '"$file_path" | scribe_defs | tail -1' in src # nearest definition above
# The ledger feed sends NAMES, never bodies, and stays on the one GET. # The ledger feed sends NAMES, never bodies, and stays on the one GET.
assert src.count("/api/plugin/prior-art?") == 1 assert src.count("/api/plugin/prior-art?") == 1