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
+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" }
{