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