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
+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