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
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:
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "scribe",
|
||||
"description": "Scribe for Claude Code: connects the scribe MCP server, adds the hooks that deliver live project state and relevant records at the right moment, ships the shared client-neutral Scribe skills (using-scribe, writing-plans, reporting-back, systematic-debugging, verification, brainstorming, reusing-code, shape-accounting, family-canon), and syncs your saved Scribe Processes as skills (/scribe:sync).",
|
||||
"version": "2026.10.09.1955",
|
||||
"version": "2026.10.21.2344",
|
||||
"author": {
|
||||
"name": "Bryan Van Deusen"
|
||||
},
|
||||
|
||||
+11
-6
@@ -2,7 +2,7 @@
|
||||
|
||||
This is the maintainer contract for shipping Scribe into an agent client:
|
||||
what every client package shares, what each one adds, and where a new client's
|
||||
files go. It follows from decision #4027 (milestone 410): **the server orients,
|
||||
files go. One principle governs it: **the server orients,
|
||||
the skills hold the depth, and a client adapter only times delivery and says
|
||||
what that one client needs said.**
|
||||
|
||||
@@ -14,9 +14,9 @@ another one means adding files, not moving or rewriting any.
|
||||
| Piece | Where | What it is |
|
||||
|---|---|---|
|
||||
| **The skills** | `plugin/skills/*/SKILL.md` | Agent Skills (the open SKILL.md format). They state every Scribe reflex in full and name no client. `tests/test_guidance_ownership.py` fails if a skill names a particular client, or references anything outside its own folder. Every client package ships this folder verbatim. |
|
||||
| **The MCP server** | `<base URL>/mcp` | HTTP, `Authorization: Bearer <fmcp_ key>`. Its `_INSTRUCTIONS` is a client-neutral orientation — the workflow across tools (≤1,600 chars, #4389); each tool's description carries its contract; in-band responses (`placement`, `report_back`, `systems_hint`, the duplicate gate, the guessed-id refusal) fire in every client. |
|
||||
| **The adapter API** | `<base URL>/api/plugin/*` | Plain `GET` endpoints any client's hooks can call with the same key (read scope is enough): `context` (live session state), `retrieve` (rules, preferences and notes for a message), `prior-art` (records and shape-ledger hints for code being written), `tool-rules` (rules for a command about to run), `reply-rules` (a `POST`: the finished reply, held for an unopened rule or — when the turn closed a task — for missing completion sections), `processes` (stored Processes to expose as skills). |
|
||||
| **The API key** | Scribe → Settings → API Keys | One `fmcp_` key per install. Read scope for hooks; write scope for the MCP tools. |
|
||||
| **The MCP server** | `<base URL>/mcp` | HTTP, `Authorization: Bearer <fmcp_ key>`. Its `_INSTRUCTIONS` is a client-neutral orientation — the workflow across tools (≤1,600 chars); each tool's description carries its contract; in-band responses (`placement`, `report_back`, `systems_hint`, the duplicate gate, the guessed-id refusal) fire in every client. |
|
||||
| **The adapter API** | `<base URL>/api/plugin/*` | Endpoints any client's hooks can call with the same key. `GET`, so read scope is enough: `context` (live session state), `retrieve` (rules, preferences and notes for a message), `prior-art` (records and shape-ledger hints for code being written), `tool-rules` (rules for a command about to run), `moment-tools` (which tools reach a mounted moment), `shape-check` (definitions written this turn that nobody has judged), `claim-session` / `release-session` (task claims), `processes` (stored Processes to expose as skills). `POST`, so write scope: `moment` (rules mounted on the moments a tool call reaches), `rule-opened` (a rule was read), `reply-rules` (the finished reply, held for an unopened rule or — when the turn closed a task — for missing completion sections). |
|
||||
| **The API key** | Scribe → Settings → MCP Access | One `fmcp_` key per install. Write scope covers the MCP tools and every hook; a read key runs only the `GET` hooks. |
|
||||
|
||||
## Added by each client
|
||||
|
||||
@@ -42,15 +42,20 @@ another one means adding files, not moving or rewriting any.
|
||||
| `hooks/scribe_moment.sh` | PreToolUse on every tool: the rules mounted on the moments the call reaches, `POST /api/plugin/moment`; skips tools `GET /api/plugin/moment-tools` says reach nothing mounted, and Scribe's own (their responses carry `moment_rules`). |
|
||||
| `hooks/scribe_reply_check.sh` | Stop: sends the finished reply to `POST /api/plugin/reply-rules` — the rules mounted on the reply moments, and the reply against every rule's trigger, and — when the turn closed a task — the completion-section check; holds once per rule, with the reason the server returns, and never holds the rewrite. |
|
||||
| `hooks/scribe_shape_check.sh` | Stop: sends the definitions the turn wrote (the write hooks' `<sid>.written.ids` ledger) to `GET /api/plugin/shape-check`; blocks once, with the reason the server returns, so the agent judges what it built. |
|
||||
| `hooks/scribe_record_opened.sh` | PostToolUse on `get_rule`: notes the rule as read in the session ledger and `POST /api/plugin/rule-opened`. |
|
||||
| `hooks/scribe_record_outcome.sh` | PostToolUse on `rule_outcome`: notes the rule as acted on, locally, for the session-scoped readout. |
|
||||
| `hooks/scribe_claim_session.sh` | PostToolUse on `update_task` / `add_task_log`: `GET /api/plugin/claim-session`, so the task comes back after a compaction. |
|
||||
| `hooks/scribe_precompact_preserve.sh` | PreCompact: tells the summarizer what to keep literally. Local, and never blocks. |
|
||||
| `hooks/scribe_session_end.sh` | SessionEnd: `GET /api/plugin/release-session` releases the session's claims. |
|
||||
| `hooks/scribe_sync_processes.sh` + `commands/sync.md` | `GET /api/plugin/processes` → `~/.claude/skills/scribe-proc-*` stubs; `/scribe:sync` on demand. |
|
||||
| `hooks/scribe_defs.sh` | Shared shell helpers: config, dedup ledgers, outage line. |
|
||||
| `hooks/scribe_static_context.md` | The adapter static text. |
|
||||
|
||||
Hook config arrives as `CLAUDE_PLUGIN_OPTION_API_ENDPOINT` / `CLAUDE_PLUGIN_OPTION_API_TOKEN` (uppercased by Claude Code, #2198), with `SCRIBE_URL` / `SCRIBE_TOKEN` as an override.
|
||||
Hook config arrives as `CLAUDE_PLUGIN_OPTION_API_ENDPOINT` / `CLAUDE_PLUGIN_OPTION_API_TOKEN` (uppercased by Claude Code), with `SCRIBE_URL` / `SCRIBE_TOKEN` as an override.
|
||||
|
||||
## How other clients would map
|
||||
|
||||
**Researched, not tested** (spike #4023, September 2026). Re-check each client's current docs before building.
|
||||
**Researched, not tested.** Re-check each client's current docs before building.
|
||||
|
||||
- **Agent Plugins 1.0** (Codex, Cursor, GitHub Copilot / VS Code, Kiro, ChatGPT): a root `plugin.json`, a `skills/` folder, and an `mcp.json`. That is the same `plugin/skills/` plus two small files. Hooks, commands and rules are not part of v1; each client adds its own under a reverse-domain directory (e.g. `com.<client>/`) that other clients ignore.
|
||||
- **Gemini CLI**: `gemini-extension.json` configuring the MCP server, a context file, and bundled skills.
|
||||
|
||||
+34
-11
@@ -17,7 +17,8 @@ instance into a first-class Claude Code extension:
|
||||
any client's package would ship: using-scribe, writing-plans, reporting-back
|
||||
(reply to the operator in a shape that says where the work stands),
|
||||
systematic-debugging, verification, brainstorming, reusing-code (record and
|
||||
recall reusable code as snippets). Replaces superpowers.
|
||||
recall reusable code as snippets), shape-accounting and family-canon.
|
||||
Replaces superpowers.
|
||||
- **Your Scribe Processes as skills** — saved Processes are synced into local
|
||||
`~/.claude/skills/scribe-proc-*` stubs that auto-surface by relevance; the
|
||||
stub fetches the live procedure via `get_process`. Refreshed each session and
|
||||
@@ -27,7 +28,7 @@ It is designed so you can uninstall `superpowers` and depend on Scribe instead
|
||||
of auto-memory — leave auto-memory at its default; Scribe replaces its job by
|
||||
holding the one copy, not by switching it off.
|
||||
|
||||
**How the pieces divide the work** (decision #4027): the Scribe server orients
|
||||
**How the pieces divide the work**: the Scribe server orients
|
||||
every MCP client and serves live state; the skills in `skills/` state every
|
||||
reflex in full and name no client; this plugin is the Claude Code adapter —
|
||||
hooks that deliver at the right moment, `/scribe:sync`, and the few things only
|
||||
@@ -49,7 +50,7 @@ On install you'll be asked for:
|
||||
| Setting | What |
|
||||
|---|---|
|
||||
| **Scribe base URL** | e.g. `https://scribe.example.com` (no trailing slash) |
|
||||
| **Scribe API key** | an `fmcp_` key from **Settings → API Keys** (stored in your OS keychain) |
|
||||
| **Scribe API key** | a write-scoped `fmcp_` key from **Settings → MCP Access** (stored in your OS keychain) |
|
||||
| **Active project id** | optional — numeric project id to scope the session-start context |
|
||||
|
||||
## What gets wired
|
||||
@@ -71,7 +72,23 @@ On install you'll be asked for:
|
||||
in time is said, once per outage ("Scribe did not answer … this write went
|
||||
UNCHECKED"), so a session can tell "checked, nothing there" from "never
|
||||
checked"; an answer clears the marker. The local by-name arm needs no
|
||||
server and always runs. Toggle in **Settings → Knowledge auto-inject**.
|
||||
server and always runs. Toggle in **Settings → General → Knowledge
|
||||
auto-inject**.
|
||||
- `hooks/hooks.json` → UserPromptSubmit hook (`hooks/scribe_autoinject.sh`) →
|
||||
`GET /api/plugin/retrieve`: the rules, preferences and records that match the
|
||||
message, as a short menu of titles and matched passages.
|
||||
- `hooks/hooks.json` → PreToolUse hook on `Bash` (`hooks/scribe_tool_rules.sh`)
|
||||
→ `GET /api/plugin/tool-rules`: the rules that match the command about to run.
|
||||
- `hooks/hooks.json` → PreToolUse hook on every tool (`hooks/scribe_moment.sh`)
|
||||
→ `POST /api/plugin/moment`: the rules mounted on the moments the call
|
||||
reaches. It skips tools that `GET /api/plugin/moment-tools` says reach
|
||||
nothing mounted, and Scribe's own tools, whose responses carry
|
||||
`moment_rules`.
|
||||
- `hooks/hooks.json` → PostToolUse hooks on Scribe's `get_rule`,
|
||||
`rule_outcome`, `update_task` and `add_task_log`: they keep the session's
|
||||
ledgers of rules opened and acted on (`get_rule` also reports to
|
||||
`POST /api/plugin/rule-opened`), and claim the task being worked
|
||||
(`GET /api/plugin/claim-session`) so it comes back after a compaction.
|
||||
- `hooks/hooks.json` → PostToolUse hook on `Bash`
|
||||
(`hooks/scribe_after_write.sh`): code written through sed/heredocs/scripts
|
||||
never reaches the PreToolUse hook, so this one diffs the working tree after
|
||||
@@ -106,16 +123,22 @@ On install you'll be asked for:
|
||||
command → generate `~/.claude/skills/scribe-proc-*` stubs from your Scribe
|
||||
Processes (via `GET /api/plugin/processes`); also **fail-open**, and pruned to
|
||||
match what exists in Scribe.
|
||||
- `hooks/hooks.json` → PreCompact hook (`hooks/scribe_precompact_preserve.sh`):
|
||||
tells the summarizer which parts of the conversation to keep literally. Local
|
||||
only, and it never blocks, because a blocked compaction is skipped outright.
|
||||
- `hooks/hooks.json` → SessionEnd hook (`hooks/scribe_session_end.sh`) →
|
||||
`GET /api/plugin/release-session`: releases the session's task claims.
|
||||
|
||||
## Notes
|
||||
|
||||
- **Do not hand-edit `version` in `.claude-plugin/plugin.json`.** It is minted
|
||||
from the clock — run `python3 scripts/mint_plugin_version.py` (or `make
|
||||
mint-plugin`, where `make` is installed) after changing anything under
|
||||
`plugin/`, and commit the result. The installer decides whether to refresh the cache it
|
||||
executes from by comparing that string, so content that ships without a new
|
||||
version reaches the repo and stops there (#2209). CI fails the lane if you
|
||||
forget.
|
||||
- The session-start, auto-inject and prior-art hooks need only a **read**-scoped
|
||||
key; the MCP tools need **write** scope to create/update. Every hook is a GET
|
||||
for that reason — a read key cannot POST.
|
||||
`plugin/`, and commit the result. The installer decides whether to refresh
|
||||
the cache it executes from by comparing that string, so content that ships
|
||||
without a new version reaches the repo and stops there. CI fails the lane if
|
||||
you forget.
|
||||
- The plugin uses one key for its MCP tools and its hooks, so give it **write**
|
||||
scope: the MCP tools create and update records, and the moment, rule-opened
|
||||
and reply hooks POST. A read-scoped key runs the GET hooks only; the others
|
||||
fail open and add nothing.
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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,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
@@ -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
|
||||
|
||||
@@ -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:
|
||||
#
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
#
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,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`
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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" }
|
||||
|
||||
{
|
||||
|
||||
@@ -100,8 +100,7 @@ from the derivation, not from asking permission.
|
||||
## Derive groups are drift, not audit material
|
||||
|
||||
The catalogue exists so the codebase is DRY **from inception**, not as DRY as
|
||||
the last sweep left it. Three surfaces say so without anyone running an audit
|
||||
(milestone 299):
|
||||
the last sweep left it. Three surfaces say so without anyone running an audit:
|
||||
|
||||
- **At the write** — the prior-art hint (delivered beside a write where your
|
||||
client supports it; shell edits count as much as editor writes)
|
||||
@@ -122,7 +121,7 @@ the last sweep left it. Three surfaces say so without anyone running an audit
|
||||
reason_code="convention-plumbing", reason=…)` (or `classify_shapes_by_rule`
|
||||
for a whole family) removes it from the queue. Dismissal is a judgment and
|
||||
it is recorded; silence is not.
|
||||
- **CSS is watched by name, never by body** (note 2917). Classes serving
|
||||
- **CSS is watched by name, never by body.** Classes serving
|
||||
different purposes share declarations because the style system makes them
|
||||
alike — `.text-muted` and `.pin-badge-auto` carrying the same `color:` are
|
||||
two meanings, not two copies — so a CSS family is the *same class defined
|
||||
@@ -132,7 +131,7 @@ the last sweep left it. Three surfaces say so without anyone running an audit
|
||||
genuinely different things is dismissed with `reason_code="scoped-css"`.
|
||||
The datum that decides between the two is **what renders it**: every css
|
||||
row carries `used_by` (the files whose markup names the class — the CSS
|
||||
consumer map, milestone 302), a derive group carries the family's
|
||||
consumer map), a derive group carries the family's
|
||||
`consumers`, and the write-path line says "used by N template(s)". Many
|
||||
templates, one recipe → derive; one template each, different purposes →
|
||||
dismiss. `list_shapes(flag="unused-css")` is the map's negative space —
|
||||
@@ -148,7 +147,7 @@ afterwards is drift of the moment, and the hint already said so at the write.
|
||||
|
||||
## The divergence readout — button B where button A is canon
|
||||
|
||||
Three questions the ledger answers mechanically (#2793):
|
||||
Three questions the ledger answers mechanically:
|
||||
|
||||
- **Divergence** — `list_shapes(project_id, flag="divergence")` (and the
|
||||
coverage line's "N DIVERGENT"): a shape new since the previous refresh, in
|
||||
@@ -166,7 +165,7 @@ Three questions the ledger answers mechanically (#2793):
|
||||
re-confirm it (classify again with the same status) or re-judge.
|
||||
- **Weak stamps** — the coverage line's "N weak stamps … to judge —
|
||||
stamps_to_review": rows the write-path hook asserted, unattended, on a
|
||||
resemblance below today's floor. They no longer elect a directory's canon
|
||||
resemblance below the current floor. They do not elect a directory's canon
|
||||
or raise a divergence, but they still claim to be instances. Read each
|
||||
one and record the verdict with `classify_shapes` — `instance` if it is,
|
||||
otherwise what it actually is.
|
||||
|
||||
@@ -85,8 +85,8 @@ step in the list, `{{ref:milestone}}` is the milestone. They are replaced with
|
||||
the real id and title as the records are created.
|
||||
|
||||
**Do not** write plans or specs to local `.md` files — the milestone is the
|
||||
record, not a file on disk. (The old `kind=plan` task is retired; `start_planning`
|
||||
no longer creates one.)
|
||||
record, not a file on disk. A plan is never a task: `kind="plan"` is refused,
|
||||
and `start_planning` makes a milestone.
|
||||
|
||||
Before designing from scratch, **recall**: `search` Scribe for a related prior
|
||||
decision or note. Often the thinking (or half of it) already exists.
|
||||
|
||||
Reference in New Issue
Block a user