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
-1
@@ -49,7 +49,7 @@ The hooks must run on a bare machine: POSIX shell, awk, sed and the usual coreut
|
||||
|
||||
## CI and releases
|
||||
|
||||
CI runs on Forgejo Actions in the shared `ci-python:3.14` image ([`ci-requirements.md`](../ci-requirements.md) lists what it relies on). Six jobs: TypeScript typecheck, plugin hooks, Python lint (plus design-token and dangling-style checks), unit tests, integration tests, and the image build, which waits on all of them except the plugin job.
|
||||
CI runs on Gitea Actions (workflow in `.forgejo/workflows/ci.yml`) in the shared `ci-python:3.14` image ([`ci-requirements.md`](../ci-requirements.md) lists what it relies on). Six jobs: TypeScript typecheck, plugin hooks, Python lint (plus design-token and dangling-style checks), unit tests, integration tests, and the image build, which waits on all of them except the plugin job.
|
||||
|
||||
| Push | Image tags |
|
||||
|------|-----------|
|
||||
|
||||
@@ -1,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.
|
||||
|
||||
+49
-56
@@ -10,9 +10,8 @@ from quart import Quart
|
||||
|
||||
## What this block is — read before editing it
|
||||
#
|
||||
# ONE OWNER PER PIECE OF GUIDANCE (decision #4027, milestone 410). Scribe's
|
||||
# guidance to agents lives where it can be delivered, and each topic is stated
|
||||
# in full exactly once:
|
||||
# ONE OWNER PER PIECE OF GUIDANCE. Scribe's guidance to agents lives where it
|
||||
# can be delivered, and each topic is stated in full exactly once:
|
||||
# - Tool docstrings: each tool's contract, delivered with its schema.
|
||||
# - In-band tool responses: behaviour prose cannot be trusted to trigger —
|
||||
# the duplicate gate, the guessed-id refusal, `systems_hint`,
|
||||
@@ -25,22 +24,19 @@ from quart import Quart
|
||||
#
|
||||
# THIS BLOCK IS THE SERVER'S ORIENTATION: the workflow across tools. It reaches
|
||||
# every MCP client, so it names no client, and it points at where each practice
|
||||
# is stated rather than restating it. What belongs here, per the spec and the
|
||||
# field (spike #4389): how the tools fit together — which to call first, which
|
||||
# before which. What does not: a tool's own contract (its description carries
|
||||
# it), and stance — who judges, how to report, what a missed rule means. Those
|
||||
# fire mid-work, not at the start; the skills state them and in-band responses
|
||||
# (`placement`, `report_back`) cue them at the moment they apply. A new topic
|
||||
# gets a line here only if it is a session-start practice.
|
||||
# is stated rather than restating it. What belongs here, per the MCP spec: how
|
||||
# the tools fit together — which to call first, which before which. What does
|
||||
# not: a tool's own contract (its description carries it), and stance — who
|
||||
# judges, how to report, what a missed rule means. Those fire mid-work, not at
|
||||
# the start; the skills state them and in-band responses (`placement`,
|
||||
# `report_back`) cue them at the moment they apply. A new topic gets a line
|
||||
# here only if it is a session-start practice.
|
||||
#
|
||||
# BUDGET: at most 1,600 characters (test_instructions_fit_the_fold). Claude
|
||||
# Code injects only the first ~2,048 characters of a server's instructions and
|
||||
# cuts the rest mid-word (#2562, observed live), and other clients differ, so
|
||||
# nothing load-bearing may sit past the fold; the rest of the field runs
|
||||
# ~600–1,600 (#4389), and a block pressed against the cap is being used as a
|
||||
# rulebook. The history of what was traded
|
||||
# for space before the ownership split (milestones 317, 333, 409) is in
|
||||
# decision #4027 and the notes it supersedes.
|
||||
# cuts the rest mid-word, and other clients differ, so nothing load-bearing may
|
||||
# sit past the fold. A block pressed against the cap is being used as a
|
||||
# rulebook.
|
||||
_INSTRUCTIONS = """
|
||||
Scribe is the operator's system of record, and yours: recall before acting,
|
||||
record as you go, keep one copy here, not in local memory. Each
|
||||
@@ -83,7 +79,7 @@ shared:true records are another user's suggestion, not settled practice.
|
||||
# the point; staleness is the cost, and test_mcp_auth covers that: EVERY
|
||||
# registered tool must appear in exactly one of _READ_ONLY_TOOLS, _WRITE_TOOLS or
|
||||
# _DELIBERATELY_WRITE_SCOPED below, so adding one forces a decision instead of
|
||||
# silently denying it — whatever the tool is called (#3191).
|
||||
# silently denying it — whatever the tool is called.
|
||||
#
|
||||
# Membership means "reads the operator's data and mutates none of it". Several
|
||||
# getters record a retrieval event via record_pulled; that is telemetry about
|
||||
@@ -105,7 +101,7 @@ _READ_ONLY_TOOLS = frozenset({
|
||||
"find_duplicate_snippets", "find_duplicate_records",
|
||||
# Snippets and processes are notes with a kind. A key that may read a note
|
||||
# but not a snippet inverts the sensitivity ordering: it exposes the
|
||||
# free-text records and withholds the structured ones (#2496).
|
||||
# free-text records and withholds the structured ones.
|
||||
"get_snippet", "list_snippets",
|
||||
"get_process", "list_processes",
|
||||
"get_lesson", "list_lessons",
|
||||
@@ -119,58 +115,56 @@ _READ_ONLY_TOOLS = frozenset({
|
||||
# Which repos map to which project. Read-only by nature; bind_repo /
|
||||
# unbind_repo are the writes.
|
||||
"list_repo_bindings",
|
||||
# The shape ledger's todo query (#2789). Reads only — classify_shapes is
|
||||
# The shape ledger's todo query. Reads only — classify_shapes is
|
||||
# the write, and it is deliberately NOT here.
|
||||
"list_shapes", "shape_history", "stamps_to_review",
|
||||
# The retrieval telemetry readout (#2975). Aggregates two log tables and
|
||||
# The retrieval telemetry readout. Aggregates two log tables and
|
||||
# writes nothing. Listed explicitly because its name carries no read
|
||||
# prefix, so the completeness test below cannot derive it — the same
|
||||
# reason `enter_project` is spelled out above.
|
||||
"retrieval_telemetry",
|
||||
# The note staleness sweep (milestone 317). A pure read — mark_note_verified
|
||||
# The note staleness sweep. A pure read — mark_note_verified
|
||||
# is the write, and it is deliberately NOT here. Spelled out for
|
||||
# retrieval_telemetry's reason: `notes_due_for_verification` matches none of
|
||||
# the prefixes the completeness test derives from, so nothing would have
|
||||
# prompted this decision.
|
||||
"notes_due_for_verification",
|
||||
# The review pass's sample (#4772). Re-runs logged queries and writes no
|
||||
# The review pass's sample. Re-runs logged queries and writes no
|
||||
# row of any kind — not even telemetry, since nothing is put in front of a
|
||||
# working session. Spelled out for retrieval_telemetry's reason.
|
||||
"menus_to_review",
|
||||
# Its rule twin and a rule's edit history (milestones 312 and 323). Both
|
||||
# pure reads, and both sat unlisted — so a read key was refused them — for
|
||||
# the same reason: no read prefix, back when the completeness test only
|
||||
# looked at names that had one (#3191). rule_history records a pull the way
|
||||
# the getters above do.
|
||||
# Its rule twin and a rule's edit history. Both pure reads with no read
|
||||
# prefix, so spelled out; rule_history records a pull the way the getters
|
||||
# above do.
|
||||
"rules_due_for_verification", "rule_history",
|
||||
# What each retrieval surface's floor and budget currently are, and what
|
||||
# has been changed about them (#4102). Both pure reads; `tune_retrieval` is
|
||||
# has been changed about them. Both pure reads; `tune_retrieval` is
|
||||
# the write and is deliberately NOT here. Read access matters more than
|
||||
# usual for these two: a session that cannot see the bar in force, or the
|
||||
# reason it was last moved, is a session that will move it again blind.
|
||||
"retrieval_surfaces", "retrieval_tuning_history",
|
||||
# The wide net (#4103) — ranked rule candidates with no bar, for the
|
||||
# The wide net — ranked rule candidates with no bar, for the
|
||||
# moment before a consequential act. A pure read, and one a read key needs
|
||||
# most: it is the surface the "ask before acting" reflex calls, and a key
|
||||
# that could not reach it would be denied exactly the check it should run.
|
||||
"what_might_apply",
|
||||
# The moment catalog (milestone 458). A pure read of a constant; the
|
||||
# The moment catalog. A pure read of a constant; the
|
||||
# mount and mapping writes that use it are separate tools. Spelled out for
|
||||
# retrieval_telemetry's reason, and needed by a read key so that a line
|
||||
# naming a moment can be understood by whoever was shown it.
|
||||
"list_moments",
|
||||
# The default reply shapes (milestone 500). A pure read of a constant, and
|
||||
# The default reply shapes. A pure read of a constant, and
|
||||
# one a read key needs: a session shown a shape may want the rest.
|
||||
"list_reply_shapes",
|
||||
# The platform catalog and a project's answers (milestone 463). A pure
|
||||
# The platform catalog and a project's answers. A pure
|
||||
# read; set_project_platforms is the write.
|
||||
"list_platforms",
|
||||
# Family canon (milestone 463): ideas, one idea with its precedents, the
|
||||
# Family canon: ideas, one idea with its precedents, the
|
||||
# decision log, and the adoption ledger. Reads of records the caller can
|
||||
# read.
|
||||
"list_family_ideas", "get_family_idea", "list_family_decisions",
|
||||
"get_family_adoption", "list_family_adoptions",
|
||||
# The pass over the corpus and its queue (milestone 458 step 7): which
|
||||
# The pass over the corpus and its queue: which
|
||||
# rules are unjudged and which proposals wait. Reads of the caller's own
|
||||
# rules, as list_rules is.
|
||||
"rules_to_mount", "rule_moment_proposals",
|
||||
@@ -180,8 +174,8 @@ _READ_ONLY_TOOLS = frozenset({
|
||||
# absent from _READ_ONLY_TOOLS is already denied to a read key. It exists so the
|
||||
# classification is total: test_mcp_auth requires every registered tool to sit
|
||||
# in exactly one of the three sets, which is what makes forgetting impossible
|
||||
# rather than merely unlikely. Before #3191 the test only asked about tools whose
|
||||
# names looked like reads, and two reads with other names were denied for weeks.
|
||||
# rather than merely unlikely. A test that only asked about read-looking names
|
||||
# would miss a read named any other way.
|
||||
_WRITE_TOOLS = frozenset({
|
||||
# notes, tasks, planning
|
||||
"create_note", "update_note", "delete_note",
|
||||
@@ -223,22 +217,22 @@ _WRITE_TOOLS = frozenset({
|
||||
# reads that happen to leave a trace, while this tool's entire effect is
|
||||
# the row — and the row carries `detail`, free prose the agent authored.
|
||||
# A read-scoped key that can put text into the operator's database is not
|
||||
# read-scoped, whatever table it lands in (#4212).
|
||||
# read-scoped, whatever table it lands in.
|
||||
"rule_outcome",
|
||||
# retrieval tuning — a write in both senses: it moves the number the arm
|
||||
# reads, and it appends the reason to the audit trail (#4102).
|
||||
# reads, and it appends the reason to the audit trail.
|
||||
"tune_retrieval",
|
||||
"migrate_retrieval_floor",
|
||||
# Which actions reach which moment on this install (milestone 458). Each
|
||||
# Which actions reach which moment on this install. Each
|
||||
# changes what fires for every later session, so a read key is refused.
|
||||
"map_action", "unmap_action",
|
||||
# Proposing moments for a rule writes a judgment row; judging one mounts
|
||||
# or unmounts the rule (step 7).
|
||||
# or unmounts the rule.
|
||||
"propose_rule_moments", "judge_rule_moments",
|
||||
# A misfire report writes a counted row that can become an unmount
|
||||
# proposal (step 7b).
|
||||
# proposal.
|
||||
"rule_misfired",
|
||||
# A reviewer's verdicts on logged menu lines (#4772) — rows carrying free
|
||||
# A reviewer's verdicts on logged menu lines — rows carrying free
|
||||
# prose the agent authored, `rule_outcome`'s reason for being a write.
|
||||
"judge_menu",
|
||||
# trash
|
||||
@@ -309,12 +303,11 @@ class StrictArgsMCPServer(MCPServer):
|
||||
default. On the create/update tools the default is "", which turns a
|
||||
plausible near-miss (`content=` for `body=`, primed by add_task_log's
|
||||
`content`) into SILENT DATA LOSS: the call reports success and stores an
|
||||
empty body, leaving a record search cannot see (#2709). Two notes were
|
||||
persisted body-less that way before anyone noticed.
|
||||
empty body, leaving a record search cannot see.
|
||||
|
||||
An error the caller sees once is strictly better than data half-written
|
||||
forever, so the policy is applied to every tool, not just the two that
|
||||
bit: nothing here knows tool semantics, only that an argument nobody
|
||||
forever, so the policy is applied to every tool: nothing here knows tool
|
||||
semantics, only that an argument nobody
|
||||
declared cannot have been meant to be dropped.
|
||||
"""
|
||||
|
||||
@@ -344,11 +337,11 @@ class StrictArgsMCPServer(MCPServer):
|
||||
try:
|
||||
return await super().call_tool(name, arguments, context)
|
||||
except UnexpectedToolError as exc:
|
||||
# ValueError is how every Scribe tool refuses (#4794): what was
|
||||
# refused, why, and what to do instead, written for the agent.
|
||||
# SDK 2.x masks it like a crash, so the agent got a failure with
|
||||
# no reason and could only retry blind. Other exceptions ARE
|
||||
# crashes; their text can carry internals and stays masked.
|
||||
# ValueError is how every Scribe tool refuses: what was refused,
|
||||
# why, and what to do instead, written for the agent. SDK 2.x
|
||||
# masks it like a crash, which would leave the agent retrying
|
||||
# blind. Other exceptions ARE crashes; their text can carry
|
||||
# internals and stays masked.
|
||||
if isinstance(exc.__cause__, ValueError):
|
||||
raise ToolError(f"{exc}: {exc.__cause__}") from exc.__cause__
|
||||
raise
|
||||
@@ -386,17 +379,17 @@ def mount_mcp(app: Quart) -> None:
|
||||
# stateless_http=True: don't hand the client a persistent Mcp-Session-Id.
|
||||
# The stateful default strands Claude Code after a container redeploy —
|
||||
# it reconnects with the now-unknown session id, the server returns 404,
|
||||
# and the client won't re-initialize on a 404 (Claude Code issue #60949),
|
||||
# and the client won't re-initialize on a 404,
|
||||
# so the connection stays dead until a manual /mcp retry. Stateless makes
|
||||
# every request self-contained (bearer-auth only), so a post-deploy
|
||||
# reconnect just works. Trade-off: no server-pushed list_changed stream,
|
||||
# which we don't use — tools are re-fetched on reconnect anyway. (Clients
|
||||
# on the 2026-07-28 revision are stateless by protocol and take the SDK's
|
||||
# modern path regardless; this governs the 2025-era handshake path.)
|
||||
# which we don't use — tools are re-fetched on reconnect anyway. Clients
|
||||
# on the stateless protocol revision take the SDK's modern path
|
||||
# regardless; this governs the older handshake.
|
||||
#
|
||||
# DNS-rebinding protection is disabled: the SDK's default allow-list is
|
||||
# just localhost variants, which means any deployment behind a reverse
|
||||
# proxy (Traefik with a hostname like devassistant.traefik.internal,
|
||||
# proxy (Traefik,
|
||||
# Cloudflare, nginx, etc.) gets 421 Misdirected Request. The threat
|
||||
# model that protection addresses — a malicious browser page rebinding
|
||||
# DNS to hit a localhost MCP — doesn't apply here: this is HTTP transport
|
||||
|
||||
@@ -113,7 +113,7 @@ async def get_design_system(design_system_id: int) -> dict:
|
||||
system in the inheritance chain, outermost ancestor first, so the house
|
||||
style comes before this system's departures from it. `design_system`'s
|
||||
own `guidance` field is only the departures. Read `resolved_guidance`
|
||||
before writing UI; enter_project carries just the summary (#4045).
|
||||
before writing UI; enter_project carries just the summary.
|
||||
"""
|
||||
uid = current_user_id()
|
||||
system = await ds_svc.get_design_system(uid, design_system_id)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Family canon MCP tools — the promotion engine's agent door (milestone 463).
|
||||
"""Family canon MCP tools — the promotion engine's agent door.
|
||||
|
||||
Thin wrappers over services/family.py. The agent is the decider here: no
|
||||
person approves a promotion, so the criteria are stated in these docstrings
|
||||
|
||||
@@ -50,7 +50,7 @@ async def list_lessons(
|
||||
offset: Skip this many before returning — page past the cap. `total`
|
||||
is the unpaged count, so it says whether more remains.
|
||||
project_id: Narrow to where a lesson was WRITTEN. 0 = every project.
|
||||
A lesson is retrievable from anywhere regardless (step 3); this
|
||||
A lesson is retrievable from anywhere regardless; this
|
||||
filters the listing, not the reach.
|
||||
unjudged: List only the lessons nobody has answered "which rule is
|
||||
this an instance of?" for — no rule named, and no "no rule fits"
|
||||
@@ -80,7 +80,7 @@ async def list_lessons(
|
||||
)
|
||||
labelled = await access_svc.label_shared_items(uid, items)
|
||||
# One aggregate for the page, like the snippet listing — surfaced-vs-opened
|
||||
# per lesson (#4196). An agent listing lessons can see which of its own
|
||||
# per lesson. An agent listing lessons can see which of its own
|
||||
# triggers are firing and which are not, which is the reading that leads to
|
||||
# `update_lesson` rather than to a second lesson about the same failure.
|
||||
await attach_usage(labelled)
|
||||
@@ -188,7 +188,7 @@ async def create_lesson(
|
||||
the binding choice its situation falls under. A lesson never
|
||||
becomes a rule; it points at the one that governs it, and the rule
|
||||
is then reachable through the situation the lesson describes
|
||||
(milestone 440). Naming a rule here confirms the link.
|
||||
Naming a rule here confirms the link.
|
||||
no_rule: The reason no rule governs this situation, in a line — the
|
||||
other answer to "which rule?". Give one or the other, not both.
|
||||
When other lessons in the same situation also answered "no rule
|
||||
@@ -251,7 +251,7 @@ async def create_lesson(
|
||||
|
||||
async def _name_convergence(uid: int, data: dict, lesson_id: int) -> None:
|
||||
"""A "no rule fits" answer is the moment to notice it is not the first
|
||||
for this situation (#4634) — `convergence` names the group and the rule
|
||||
for this situation — `convergence` names the group and the rule
|
||||
it may be missing. Absent when there is no group."""
|
||||
group = await lesson_rules_svc.convergence_for(uid, lesson_id)
|
||||
if group:
|
||||
@@ -301,7 +301,7 @@ async def get_lesson(lesson_id: int, project_id: int = 0) -> dict:
|
||||
# Every explicit open records the pull. A lesson is surfaced by the same
|
||||
# retrieval as any other note, so a getter that records nothing would leave
|
||||
# the kind permanently at zero pulls — reading as dead weight beside kinds
|
||||
# that merely had a counter (#2476, the repeat of #2245).
|
||||
# that have a counter.
|
||||
# Read BEFORE the pull is recorded, so the number an agent is shown is the
|
||||
# one that was true when it asked — otherwise every first read of a lesson
|
||||
# reports a pull that is its own.
|
||||
@@ -348,10 +348,9 @@ async def update_lesson(
|
||||
system_ids: Replace the Systems this lesson is filed under. None
|
||||
leaves unchanged; pass the FULL list, and `[]` to clear.
|
||||
|
||||
Here because `create_lesson` took it and this did not, so a lesson
|
||||
written without one could never be filed afterwards — and the end
|
||||
of a piece of work, when a lesson is usually written, is exactly
|
||||
when that argument gets dropped (#4249). A System tag is how
|
||||
A lesson written without one can be filed afterwards here — the
|
||||
end of a piece of work, when a lesson is usually written, is
|
||||
exactly when that argument gets dropped. A System tag is how
|
||||
`list_system_records` gathers an area's pile, so an untagged
|
||||
lesson is reachable by search and by nothing else.
|
||||
rule_ids: Replace the rule(s) this lesson is an instance of. None
|
||||
@@ -430,10 +429,9 @@ async def delete_lesson(lesson_id: int) -> dict:
|
||||
deletion: its trigger is keyed to a situation nobody is in, and rewording
|
||||
that with update_lesson keeps what was learned.
|
||||
|
||||
Deletion was always possible through `delete_note` — a lesson is a note and
|
||||
the trash is kind-agnostic — but nothing said so, and a kind whose own
|
||||
tools offer create/read/update reads as one you cannot retire (#2250, which
|
||||
recorded exactly this for processes).
|
||||
The same as `delete_note` — a lesson is a note and the trash is
|
||||
kind-agnostic — offered here because a kind whose own tools offer only
|
||||
create/read/update reads as one you cannot retire.
|
||||
"""
|
||||
uid = current_user_id()
|
||||
note = await lessons_svc.get_lesson(uid, lesson_id)
|
||||
|
||||
@@ -60,7 +60,7 @@ async def get_milestone(milestone_id: int) -> dict:
|
||||
Each step also carries `log_count`. A step's STATUS is set by hand and a
|
||||
plan is exactly where that goes stale; the count says which steps have a
|
||||
work log that would say otherwise, so `get_task(id)` goes to the step
|
||||
with a record rather than to each one in turn (#4241).
|
||||
with a record rather than to each one in turn.
|
||||
"""
|
||||
uid = current_user_id()
|
||||
milestone = await milestones_svc.get_milestone(uid, milestone_id)
|
||||
@@ -180,7 +180,7 @@ async def update_milestone(
|
||||
raise ValueError(f"milestone {milestone_id} not found")
|
||||
data = milestone.to_dict()
|
||||
if closing:
|
||||
# Family canon's milestone trigger (milestone 463): a plan closing on
|
||||
# Family canon's milestone trigger: a plan closing on
|
||||
# a platform is the moment to ask whether what it built is shared.
|
||||
hint = await family_svc.milestone_trigger(uid, milestone)
|
||||
if hint:
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Moments as MCP tools: the catalog, and the in-session corrections to it (milestone 458).
|
||||
"""Moments as MCP tools: the catalog, and the in-session corrections to it.
|
||||
|
||||
A session needs the vocabulary in hand to mount a rule, to read a line that
|
||||
names a moment, and to fix a misfire — and the operator's ruling is that the
|
||||
|
||||
@@ -38,7 +38,7 @@ async def list_notes(
|
||||
|
||||
Optionally filter by a single tag (plain string, no # prefix) or by
|
||||
`search_text`, which matches on MEANING — the same semantic match every
|
||||
search surface uses (#2462; keyword ILIKE is only the fallback when the
|
||||
search surface uses (keyword ILIKE is only the fallback when the
|
||||
embedder is down). With `search_text`, results order by relevance; without
|
||||
it, by last-updated descending. Reaches your records plus shared-project
|
||||
records, like every other list.
|
||||
@@ -97,7 +97,7 @@ async def get_note(note_id: int, project_id: int = 0) -> dict:
|
||||
# Records the pull for ANY note kind, not just snippets: the auto-inject
|
||||
# menu surfaces notes, tasks and processes too, so restricting this to
|
||||
# snippets would leave those permanently at zero pulls and make them look
|
||||
# like dead weight next to snippets that merely had a counter (#2085).
|
||||
# like dead weight next to snippets.
|
||||
record_pulled(
|
||||
user_id=uid, note_id=int(note.id),
|
||||
source="mcp_get_note", project_id=project_id,
|
||||
@@ -241,7 +241,7 @@ async def create_note(
|
||||
await systems_tools.attach_systems(uid, uid, data, note.id, project_id or None)
|
||||
await supersession_svc.attach_relations(uid, note.id, data, hint=True)
|
||||
data.update(dedup_svc.note_overlap_response(overlaps, "note"))
|
||||
# Family canon's write-time triggers (milestone 463): a cited source of a
|
||||
# Family canon's write-time triggers: a cited source of a
|
||||
# pattern, or the same idea in another project on a shared platform.
|
||||
await family_svc.attach_family_hint(uid, data, note, created=True)
|
||||
return await moment_delivery.attach_moment_rules(uid, "create_note", {"project_id": project_id}, data)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Platform MCP tools — what a project is built on or ships as (milestone 463).
|
||||
"""Platform MCP tools — what a project is built on or ships as.
|
||||
|
||||
Thin wrappers over services/platforms.py. The catalog is global; a project's
|
||||
platforms decide which family ideas reach it.
|
||||
|
||||
@@ -80,8 +80,7 @@ async def create_process(
|
||||
body: The full prompt to run later (markdown). Required.
|
||||
tags: Plain-string tags, no # prefix.
|
||||
system_ids: Systems (subsystems/areas) to file this process under, so
|
||||
an area-scoped read finds it. A process is a note, so it has always
|
||||
been taggable in the data model; neither door offered it (#4249).
|
||||
an area-scoped read finds it.
|
||||
moments: The moments from list_moments this procedure is FOR — a
|
||||
release procedure is a deliver, a review procedure a verify.
|
||||
Loading the process then reaches each one, as well as its own
|
||||
@@ -180,9 +179,8 @@ async def get_process(name_or_id: str, project_id: int = 0) -> dict:
|
||||
out.update(await access_svc.describe_provenance(uid, note))
|
||||
# A process is embedded like any other note, so auto-inject can surface one —
|
||||
# and its menu header names THIS tool as the way to open that kind. Without
|
||||
# this, the getter the product points at is the one getter that records
|
||||
# nothing, and every process sits permanently at zero pulls looking like dead
|
||||
# weight beside kinds that merely had a counter (#2476, the repeat of #2245).
|
||||
# this, every process would sit permanently at zero pulls, looking like dead
|
||||
# weight beside kinds that have a counter.
|
||||
record_pulled(
|
||||
user_id=uid, note_id=int(note.id),
|
||||
source="mcp_get_process", project_id=project_id,
|
||||
@@ -250,9 +248,9 @@ async def delete_process(process_id: int) -> dict:
|
||||
machine and auto-surfaces there, so a bad one is followed rather than merely
|
||||
ignored — it costs more than a missing one.
|
||||
|
||||
Deletion was always possible through `delete_note` (a process is a note, and
|
||||
the trash is kind-agnostic), but nothing said so, and a kind whose own tools
|
||||
offer create/read/update reads as one you cannot retire (#2250).
|
||||
The same as `delete_note` (a process is a note, and the trash is
|
||||
kind-agnostic), offered here because a kind whose own tools offer only
|
||||
create/read/update reads as one you cannot retire.
|
||||
"""
|
||||
uid = current_user_id()
|
||||
loaded = await notes_svc.get_note_for_user(uid, process_id)
|
||||
|
||||
@@ -51,8 +51,8 @@ async def list_projects() -> dict:
|
||||
|
||||
# How many of each kind the handshake lists. Enough to say what was worked on
|
||||
# lately and what's open; the rest is a list_milestones / list_tasks call away.
|
||||
# The handshake once carried every milestone's plan and came to ~222k
|
||||
# characters, past what a client accepts as a tool result (#4045).
|
||||
# Every milestone's plan would push the handshake past what a client accepts
|
||||
# as a tool result.
|
||||
_HANDSHAKE_MILESTONES = 5
|
||||
_HANDSHAKE_OPEN_TASKS = 10
|
||||
_HANDSHAKE_UNPLANNED = 10
|
||||
@@ -121,9 +121,9 @@ async def enter_project(project_id: int) -> dict:
|
||||
|
||||
`pattern_coverage` (usually null) is the shape-accounting line — how many
|
||||
of the bound repo's extracted shapes carry a classification against canon
|
||||
(note 2786) — e.g. "shape accounting: 3100/4573 shapes accounted for —
|
||||
12 canonical · 2900 instance (estimate, computed 2026-08-19); 1473
|
||||
unclassified, largest: internal/api". Unclassified IS the todo: as you
|
||||
— e.g. "shape accounting: 3100/4573 shapes accounted for — 12 canonical ·
|
||||
2900 instance (estimate, computed <date>); 1473 unclassified, largest:
|
||||
internal/api". Unclassified IS the todo: as you
|
||||
touch code in those areas, classify the shapes you can (instances of
|
||||
recorded canon, deliberate variants, one-off exemptions) and record the
|
||||
canon that's missing with create_snippet. A null line on a forge-served
|
||||
@@ -137,13 +137,13 @@ async def enter_project(project_id: int) -> dict:
|
||||
create it with create_system rather than leaving the area unmodelled. Read
|
||||
a subsystem's accumulated records with list_system_records. Each is id and name; get_system has the charter.
|
||||
|
||||
`platforms` (milestone 463) is what the project is built on or ships as
|
||||
`platforms` is what the project is built on or ships as
|
||||
— Android app, container image, Go, … — each with how it is known
|
||||
(`declared` by a person, `detected` from a bound repo). They decide which
|
||||
family ideas reach this project. An empty list on a project that plainly
|
||||
ships something is worth correcting with set_project_platforms.
|
||||
|
||||
`family` (milestone 463) appears ONLY when this project has family canon
|
||||
`family` appears ONLY when this project has family canon
|
||||
to answer: how many canon ideas on its platforms are `unassessed`, how
|
||||
many it `owed`s, and how many answers were given against an older canon
|
||||
version (`needs_recheck`) — each with the list_family_adoptions call that
|
||||
@@ -151,7 +151,7 @@ async def enter_project(project_id: int) -> dict:
|
||||
(get_family_adoption, then assess_family_adoption, or classify the code
|
||||
against it); the family-canon skill has the order.
|
||||
|
||||
`inception` (milestone 297) appears ONLY when the project is yours and
|
||||
`inception` appears ONLY when the project is yours and
|
||||
nobody has decided what it inherits: it carries the current defaults
|
||||
(design system, Systems, platforms), what to ask the
|
||||
operator — once — and the decide_project_inception call that answers it;
|
||||
@@ -210,15 +210,14 @@ async def enter_project(project_id: int) -> dict:
|
||||
)
|
||||
# The tagging vocabulary. Surfaced HERE because an instruction to "tag
|
||||
# records to Systems" is only executable if the list is in front of the
|
||||
# agent when it writes — which it never was, and tagging stopped within
|
||||
# three days of the feature landing (#2546's audit). Untagged writes now
|
||||
# also ask with the vocabulary listed; this copy lets the first write tag.
|
||||
# agent when it writes. Untagged writes also ask with the vocabulary
|
||||
# listed; this copy lets the first write tag.
|
||||
systems = await systems_svc.list_systems(uid, project_id)
|
||||
platforms = platforms_svc.members(
|
||||
await platforms_svc.project_platforms(uid, project_id) or []
|
||||
)
|
||||
|
||||
# The arrival-moment half of the bootstrap ask (#2683): session start is
|
||||
# The arrival-moment half of the bootstrap ask: session start is
|
||||
# when the agent has just read the project map and is not yet deep in a
|
||||
# task — the one moment minting a starter vocabulary is cheap. The
|
||||
# write-moment half rides untagged-record responses (attach_systems);
|
||||
@@ -229,14 +228,15 @@ async def enter_project(project_id: int) -> dict:
|
||||
uid, project_id
|
||||
)
|
||||
|
||||
# The inception ask (milestone 297): a project nobody has decided on
|
||||
# inherits nothing, silently — no design system, no Systems. Owner-only (deciding is the owner's), and only until a
|
||||
# decision is recorded; the key is ABSENT otherwise (#2483).
|
||||
# The inception ask: a project nobody has decided on inherits nothing,
|
||||
# silently — no design system, no Systems. Owner-only (deciding is the
|
||||
# owner's), and only until a decision is recorded; the key is ABSENT
|
||||
# otherwise.
|
||||
inception_ask = None
|
||||
if project.user_id == uid and not inception_svc.is_decided(project):
|
||||
inception_ask = await inception_svc.inception_ask(uid, project_id)
|
||||
|
||||
# An AMBIENT source (#2477): top-N-by-recency, not a ranked choice, and
|
||||
# An AMBIENT source: top-N-by-recency, not a ranked choice, and
|
||||
# the readout counts it apart so dead-weight detection isn't poisoned by
|
||||
# "recently updated in a project you opened".
|
||||
record_surfaced(
|
||||
@@ -247,7 +247,7 @@ async def enter_project(project_id: int) -> dict:
|
||||
)
|
||||
# A project need not have one, and most installs won't — null is ordinary
|
||||
# here, not a missing prerequisite. Summary only: the guidance is ~9k of
|
||||
# prose most sessions never use, so it's one call away (#4045).
|
||||
# prose most sessions never use, so it's one call away.
|
||||
design_system = None
|
||||
if project.design_system_id:
|
||||
design = await design_systems_svc.design_context(
|
||||
@@ -269,7 +269,7 @@ async def enter_project(project_id: int) -> dict:
|
||||
coverage = await coverage_svc.cached_coverage(
|
||||
project.user_id or uid, project_id
|
||||
)
|
||||
# Arrival self-seed (#2802): a ledger that is absent or stale refreshes in
|
||||
# Arrival self-seed: a ledger that is absent or stale refreshes in
|
||||
# the BACKGROUND — entering is the moment the number is wanted, and the
|
||||
# UI button must not be the only path. This enter stays fast; the next
|
||||
# one carries the line. Forge-less projects exit the seed quietly.
|
||||
@@ -303,7 +303,7 @@ async def enter_project(project_id: int) -> dict:
|
||||
],
|
||||
}
|
||||
# Attached only when it applies — a key that usually says null trains
|
||||
# readers to skip it (#2483), and this one exists to be acted on.
|
||||
# readers to skip it, and this one exists to be acted on.
|
||||
if omitted:
|
||||
out["milestone_summary_omitted"] = (
|
||||
f"{omitted} other milestone(s) not listed. "
|
||||
@@ -318,7 +318,7 @@ async def enter_project(project_id: int) -> dict:
|
||||
)
|
||||
if systems_bootstrap:
|
||||
out["systems_bootstrap"] = systems_bootstrap
|
||||
# Family canon (milestone 463 step 6): counts only, attached when one is
|
||||
# Family canon: counts only, attached when one is
|
||||
# non-zero. A readout must never fail the handshake it rides on.
|
||||
try:
|
||||
family = await family_adoption_svc.family_readout(uid, project_id)
|
||||
@@ -329,7 +329,7 @@ async def enter_project(project_id: int) -> dict:
|
||||
out["family"] = family
|
||||
if inception_ask:
|
||||
out["inception"] = inception_ask
|
||||
# The core reply shape (milestone 500): for a client with no prompt hook,
|
||||
# The core reply shape: for a client with no prompt hook,
|
||||
# entering the project is the one point every session passes before it
|
||||
# writes a reply. A plugin session gets it with its first turn as well;
|
||||
# one repeat per session is the price of reaching every client.
|
||||
@@ -393,7 +393,7 @@ async def create_project(
|
||||
) -> dict:
|
||||
"""Create a new project in Scribe — and decide what it inherits.
|
||||
|
||||
A project's inheritance is a decision, not a default (milestone 297):
|
||||
A project's inheritance is a decision, not a default:
|
||||
before calling, ask the operator the three inception questions and pass
|
||||
the answers; a project created without either is UNDECIDED and
|
||||
enter_project will ask until decide_project_inception records it.
|
||||
@@ -446,7 +446,7 @@ async def decide_project_inception(
|
||||
platforms: list[str] | None = None,
|
||||
) -> dict:
|
||||
"""Record what a project inherits — answer enter_project's `inception` ask,
|
||||
or re-decide later (milestone 297).
|
||||
or re-decide later.
|
||||
|
||||
Owner-only. Applies the effects through the ordinary tools' paths —
|
||||
set_project_design_system and the standard Systems seed — and writes the
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Judging what a retrieval arm offered (#4772).
|
||||
"""Judging what a retrieval arm offered.
|
||||
|
||||
`retrieval_telemetry` says what the reader did with each line — opened it or
|
||||
not. These two say whether the line RELATED, which is a reading only someone
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Reading and moving a retrieval surface's floor and budget (#4102).
|
||||
"""Reading and moving a retrieval surface's floor and budget.
|
||||
|
||||
The write half of the loop `retrieval_telemetry` opens. That tool says what a
|
||||
bar did; these say what the bar IS, and let it be changed with the argument
|
||||
|
||||
@@ -5,12 +5,8 @@ edges. Thin wrappers over services/rulebooks.py — ownership is enforced in the
|
||||
service, and the record shape comes from rule_brief / rule_detail there rather
|
||||
than being rebuilt here.
|
||||
|
||||
(The header used to say "Sixteen tools" and had been wrong for two milestones;
|
||||
the count lives in the registration test, which fails when it drifts.)
|
||||
|
||||
Destructive ops (delete_*) require confirmed=True; otherwise return a
|
||||
preview-style warning. Mirrors the pattern in delete_event and the design
|
||||
spec.
|
||||
Destructive ops (delete_*) require confirmed=True; otherwise they return a
|
||||
preview-style warning.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -53,9 +49,9 @@ async def create_rulebook(title: str, description: str = "") -> dict:
|
||||
"""Create a new rulebook (a themed grouping of GLOBAL rules).
|
||||
|
||||
A rule in a rulebook is global: it applies in every project its owner works
|
||||
on, and reaches a session by retrieval when the work makes it relevant
|
||||
(milestone 414). There is no subscribing a project to a rulebook, and no
|
||||
muting one per project — that machinery is gone. So a rulebook's rules must
|
||||
on, and reaches a session by retrieval when the work makes it relevant.
|
||||
There is no subscribing a project to a rulebook, and no muting one per
|
||||
project. So a rulebook's rules must
|
||||
stay general, agnostic to any single project. Project-specific rules go in
|
||||
create_project_rule.
|
||||
|
||||
@@ -198,8 +194,8 @@ def _rule_summary(r, moments: list[str] | None = None) -> dict:
|
||||
"""The list-row shape for a rule: what an agent needs to APPLY it. The
|
||||
full record (why, how_to_apply, timestamps) is get_rule's job.
|
||||
|
||||
One line, because the shape itself lives in the service — this was one of
|
||||
three hand-written copies that had already drifted apart (note 3026).
|
||||
One line, because the shape itself lives in the service, shared with
|
||||
every other door that lists rules so the copies cannot drift.
|
||||
`moments` rides along only when the rule is mounted (rule_brief drops a
|
||||
None), so an unmounted rule's row says nothing rather than "[]".
|
||||
"""
|
||||
@@ -465,10 +461,9 @@ async def create_rule(
|
||||
This field is also the rule's RETRIEVAL SURFACE — it and the
|
||||
statement are what a search is matched against, so it should
|
||||
carry the SYMPTOM, not just the situation: the words someone
|
||||
would actually type while stuck. Measured (note 3078): a rule
|
||||
whose trigger named only its situation did not surface at all
|
||||
for the problem it solves; adding the symptom to the same field
|
||||
brought it back as the top hit. Where a rule prevents a specific
|
||||
would actually type while stuck. A rule whose trigger names only
|
||||
its situation can fail to surface for the problem it solves;
|
||||
adding the symptom to the same field makes it the top hit. Where a rule prevents a specific
|
||||
failure, put that failure's vocabulary here — the error text,
|
||||
the wrong behaviour, the dead end.
|
||||
The two spellings, side by side:
|
||||
@@ -524,7 +519,7 @@ async def create_rule(
|
||||
dup = await dedup_svc.find_duplicate_rule(title, topic_id=topic_id)
|
||||
if dup is not None:
|
||||
return dedup_svc.duplicate_response(dup, "rule")
|
||||
# Before the create, so the new rule cannot find itself (#4134).
|
||||
# Before the create, so the new rule cannot find itself.
|
||||
overlaps = await dedup_svc.find_overlapping_rules(
|
||||
uid, title, statement, when_to_apply,
|
||||
)
|
||||
@@ -666,8 +661,8 @@ async def update_rule(
|
||||
correct. Ordinary edits to an existing preference belong in
|
||||
update_preference, which asks for what taught the change.
|
||||
|
||||
`when_to_apply` IS HOW A RULE ARRIVES AT ALL. Nothing is preloaded since
|
||||
milestone 394, so a rule with no trigger is not a quiet rule — it is one
|
||||
`when_to_apply` IS HOW A RULE ARRIVES AT ALL. Nothing is preloaded, so a
|
||||
rule with no trigger is not a quiet rule — it is one
|
||||
no session will ever be shown. `system_ids` REPLACES the rule's areas
|
||||
(pass [] to clear), and they decide which PROJECTS a rule binds by area.
|
||||
`moments` REPLACES the moments it arrives at the same way — see
|
||||
@@ -684,8 +679,7 @@ async def update_rule(
|
||||
ever types. `rule_document()` puts this field in twice, as the title's
|
||||
other half and again above the body, so it dominates the vector: a trigger
|
||||
describing the abstraction collapses the record toward its title and the
|
||||
rule never arrives. Measured in #3835 across 113 rules, and again in #3855
|
||||
on six preferences written before this was understood.
|
||||
rule never arrives.
|
||||
|
||||
So when you touch a rule with an old trigger, re-read it against the query
|
||||
that would have to match it — the command about to run, the code being
|
||||
@@ -835,8 +829,7 @@ async def create_preference(
|
||||
arose_from_id: The task or note that taught this. Required; see above.
|
||||
system_ids: Ids from list_canonical_systems — the global AREAS this
|
||||
preference is about, which is what lets it reach a session working
|
||||
in that area. `update_preference` took this and create did not, so
|
||||
a preference could only be filed after the fact (#4249).
|
||||
in that area.
|
||||
moments: Moments from list_moments this rule arrives at — whenever
|
||||
one happens, the rule is delivered, whatever the words of the work
|
||||
look like. Mount a rule here when it is about WHEN something is
|
||||
@@ -978,9 +971,8 @@ async def rule_history(rule_id: int, version_id: int = 0) -> dict:
|
||||
Read this before you argue with a rule, and before you rewrite one. A
|
||||
rule that has been reworded may have been reworded for a reason you are
|
||||
about to rediscover the hard way — and the wording it replaced is often
|
||||
the fastest way to see what the current one is guarding against. The
|
||||
rescoping of rule 79 is the case this exists for: the superseded
|
||||
statement had to be hand-copied into a task log to survive the edit.
|
||||
the fastest way to see what the current one is guarding against, and it
|
||||
survives the edit here without anyone copying it elsewhere.
|
||||
|
||||
EACH ENTRY HOLDS THE TEXT THE EDIT REPLACED, not the text it introduced.
|
||||
So "what did this say before the most recent change?" is the first entry,
|
||||
@@ -988,8 +980,7 @@ async def rule_history(rule_id: int, version_id: int = 0) -> dict:
|
||||
with get_rule. Pair the two and you have the diff.
|
||||
|
||||
An empty history is ordinary and means the rule has never been reworded,
|
||||
not that its history was lost. Nothing is written before milestone 323,
|
||||
so a rule edited before then starts empty too.
|
||||
not that its history was lost.
|
||||
|
||||
Args:
|
||||
rule_id: The rule whose history to read.
|
||||
@@ -1135,7 +1126,7 @@ async def unrelate_rules(relation_id: int) -> dict:
|
||||
raise ValueError(f"relation {relation_id} not found")
|
||||
return {"deleted": relation_id}
|
||||
|
||||
# ── The staleness sweep (milestone 312) ────────────────────────────────
|
||||
# ── The staleness sweep ────────────────────────────────
|
||||
|
||||
async def rules_due_for_verification(
|
||||
older_than_days: int = 0, never_only: bool = False,
|
||||
|
||||
@@ -22,17 +22,16 @@ from scribe.services.retrieval_telemetry import record_retrieval, retrieval_summ
|
||||
|
||||
|
||||
# A matched chunk is at most _CHUNK_CHAR_BUDGET (1400) characters, and it is
|
||||
# the evidence the ranking was built on — so it is worth more room than the 240
|
||||
# characters of document opening this used to send. Elision inside a chunk is
|
||||
# far less lossy than a head cut of a whole record: the region is already the
|
||||
# right one.
|
||||
# the evidence the ranking was built on, so it gets most of that room. Elision
|
||||
# inside a chunk is far less lossy than a head cut of a whole record: the
|
||||
# region is already the right one.
|
||||
_EXCERPT_CHARS = 1000
|
||||
|
||||
|
||||
# The kinds `content_type` accepts are DERIVED from the facet table, not listed
|
||||
# again here — that table is where a kind is declared (#3161), and a second
|
||||
# hand-kept copy in this module is precisely how the agent's door came to offer
|
||||
# two kinds while browse offered nine (#4250). `content_type_filters` carries
|
||||
# again here — that table is where a kind is declared, and a second hand-kept
|
||||
# copy here would let the agent's door and browse offer different kinds.
|
||||
# `content_type_filters` carries
|
||||
# the mapping, the 'all'/'note' special cases and the refusal.
|
||||
#
|
||||
# These two do not reach `semantic_search_notes` at all: they have their own
|
||||
@@ -91,7 +90,7 @@ async def _search_rules(uid: int, q: str, limit: int, project_id: int) -> dict:
|
||||
|
||||
|
||||
async def _search_milestones(uid: int, q: str, limit: int, project_id: int) -> dict:
|
||||
"""Milestones by meaning — "is there already a plan for this?" (milestone 415).
|
||||
"""Milestones by meaning — "is there already a plan for this?".
|
||||
|
||||
Its own result shape, like rules: a milestone is a plan with progress, not
|
||||
a note with a body. The plan itself is left out — get_milestone reads it —
|
||||
@@ -100,7 +99,7 @@ async def _search_milestones(uid: int, q: str, limit: int, project_id: int) -> d
|
||||
actually hit, with `matched_is` saying whether it is that passage or
|
||||
merely the body's opening. Recognising a plan means recognising the part
|
||||
of it you were asking about, and a description written at the start need
|
||||
not mention that part (#4243).
|
||||
not mention that part.
|
||||
Not part of content_type="all", whose results are note-shaped.
|
||||
"""
|
||||
report: dict = {}
|
||||
@@ -124,7 +123,7 @@ async def _search_milestones(uid: int, q: str, limit: int, project_id: int) -> d
|
||||
# The plan body stays out — get_milestone reads it — but the
|
||||
# passage that MATCHED comes along, because a plan is
|
||||
# recognised by the part of it the query was about and a
|
||||
# description need not mention that part at all (#4243).
|
||||
# description need not mention that part at all.
|
||||
**excerpt_fields(
|
||||
m.body or "", chunks.get(int(m.id)), _EXCERPT_CHARS,
|
||||
key="matched",
|
||||
@@ -142,13 +141,13 @@ async def _search_milestones(uid: int, q: str, limit: int, project_id: int) -> d
|
||||
|
||||
|
||||
async def _search_systems(uid: int, q: str, limit: int, project_id: int) -> dict:
|
||||
"""Systems by meaning — "where does this belong?" (#4251).
|
||||
"""Systems by meaning — "where does this belong?".
|
||||
|
||||
A System's `description` is a charter: several hundred words saying what
|
||||
belongs in that area and what does not. `list_systems` enumerates them and
|
||||
`system_id` filters by one, so before this a System could NARROW a search
|
||||
and could never be the answer to one — an agent asking where a record
|
||||
belonged had to read every charter or guess.
|
||||
`system_id` filters by one; this is the search where a System is the
|
||||
answer, so an agent asking where a record belongs need not read every
|
||||
charter.
|
||||
|
||||
Its own result shape and its own search, not a `content_type` over notes,
|
||||
because the question is different: "where does this belong?" is not "what
|
||||
@@ -158,7 +157,7 @@ async def _search_systems(uid: int, q: str, limit: int, project_id: int) -> dict
|
||||
|
||||
The charter's `matched` passage comes along rather than the whole thing.
|
||||
A charter is long and the paragraph that decides where a record belongs is
|
||||
the one worth reading; the rest is get_system (#4243).
|
||||
the one worth reading; the rest is get_system.
|
||||
|
||||
The id that comes back is the one `search(system_id=…)`,
|
||||
`list_system_records` and every `system_ids` argument take — so the answer
|
||||
@@ -191,16 +190,11 @@ async def _search_systems(uid: int, q: str, limit: int, project_id: int) -> dict
|
||||
def result_excerpt(note, chunk: dict | None) -> dict:
|
||||
"""The part of a record a caller judges "should I open this?" on.
|
||||
|
||||
This used to be `(note.body or "")[:240]` — the document's opening, with
|
||||
no marker that anything had been cut, so a 240-character preview of a
|
||||
4000-character record was indistinguishable from a complete short one.
|
||||
|
||||
The opening is the wrong span. The match was semantic and per-chunk, and
|
||||
`semantic_search_notes` collapses to best-chunk-per-note — so the system
|
||||
already knows which passage earned the hit and used to discard it. A
|
||||
record could rank first on its sixth paragraph and be previewed by its
|
||||
first, which the search had already judged less relevant, and the caller
|
||||
would decide from that and never know (#4243).
|
||||
The document's opening is the wrong span. The match is semantic and
|
||||
per-chunk, and `semantic_search_notes` collapses to best-chunk-per-note, so
|
||||
the passage that earned the hit is known. A record can rank first on its
|
||||
sixth paragraph; previewing its first would show the caller the part the
|
||||
search judged less relevant. When the span is cut, the result says so.
|
||||
|
||||
The choice of span lives in services/text.py, shared with the web's
|
||||
knowledge search, so the two doors cannot drift on which text a reader is
|
||||
@@ -253,8 +247,7 @@ async def search(
|
||||
|
||||
THREE KINDS WITH THEIR OWN SEARCH AND THEIR OWN RESULT SHAPE,
|
||||
because each answers a question no note search can:
|
||||
'rule' (the operator's standing instructions — searchable by
|
||||
meaning since milestone 307), 'milestone' ("is there already a
|
||||
'rule' (the operator's standing instructions), 'milestone' ("is there already a
|
||||
plan for this?"), and 'system' ("where does this belong?" — a
|
||||
System's charter says what belongs in an area and what does not,
|
||||
and the id it returns is the one `system_id` and `system_ids`
|
||||
@@ -321,7 +314,7 @@ async def search(
|
||||
uid, q, limit=limit, **filters,
|
||||
project_id=project_id or None,
|
||||
system_id=system_id or None,
|
||||
# A LESSON is reachable from any project (milestone 385). The kind
|
||||
# A LESSON is reachable from any project. The kind
|
||||
# exists to carry an insight to the next project, so a project filter
|
||||
# that hid it would hide it precisely where it is worth having. Only
|
||||
# the project filter widens — everything else about the scoping holds,
|
||||
@@ -372,12 +365,11 @@ async def retrieval_telemetry(
|
||||
) -> dict:
|
||||
"""What the retrieval telemetry says about YOUR surfaces, over a window.
|
||||
|
||||
The read half of the tuning loop, whose write half is `tune_retrieval`
|
||||
(#2975, #4102). Reach for it before moving any floor or budget, and read
|
||||
the records it names rather than its percentiles alone: this readout has
|
||||
been measured pointing the WRONG WAY — 69 consecutive declines where every
|
||||
percentile said "lower the bar" and the refused record was a false positive
|
||||
— so `near_miss_samples=5` and opening the ids it returns is the step that
|
||||
The read half of the tuning loop, whose write half is `tune_retrieval`.
|
||||
Reach for it before moving any floor or budget, and read the records it
|
||||
names rather than its percentiles alone: every percentile can say "lower
|
||||
the bar" while each refused record is a false positive, so
|
||||
`near_miss_samples=5` and opening the ids it returns is the step that
|
||||
separates a real miss from a bar doing its job.
|
||||
|
||||
Five readouts, from the five tables built for them:
|
||||
@@ -418,29 +410,21 @@ async def retrieval_telemetry(
|
||||
moment you are making a decision, not for every readout.
|
||||
|
||||
`near_misses` is `null` when no declining call in the window measured it —
|
||||
rows written before #3670 shipped cannot know. That is "not measured", not
|
||||
older rows may not carry the measurement. That is "not measured", not
|
||||
"nothing came close"; a 0.0 there would be a claim about the corpus
|
||||
invented out of a caller's silence.
|
||||
|
||||
A NULL HERE NOW MEANS ONE THING, which it did not at first. A semantic
|
||||
search returns nothing three ways WITHOUT having run — an empty query, an
|
||||
unavailable embedder, and a failed database query — and each used to write
|
||||
a row indistinguishable from a ranker that declined (#3765). Those calls no
|
||||
longer write a row at all, on the same reasoning that already keeps a blank
|
||||
command out of the log: a row there reports a call that never happened and
|
||||
drags the clear rate down with phantom declines. So a null is "searched,
|
||||
and nothing came close", and a broken search shows up as a WARNING in the
|
||||
application log rather than as a quiet zero in here.
|
||||
A search that returns nothing WITHOUT having run — an empty query, an
|
||||
unavailable embedder, a failed database query — writes no row: a row would
|
||||
report a call that never happened and drag the clear rate down with
|
||||
phantom declines. So a null is "searched, and nothing came close", and a
|
||||
broken search shows up as a WARNING in the application log rather than as
|
||||
a quiet zero in here.
|
||||
|
||||
THERE IS NO `cleared_threshold` ANY MORE, and if you remember one, that
|
||||
memory is of a tautology (#3670). The search applies the bar before
|
||||
returning, so every returned result cleared it by construction and a call
|
||||
with no results has no score to compare: the field was true exactly when
|
||||
`result_count > 0`, i.e. it was `calls - zero_result_calls` under a name
|
||||
that promised a second opinion. `zero_result_calls + cleared_threshold ==
|
||||
calls` held on all nineteen readings ever taken. The reading procedure
|
||||
built on it — "clears its bar on nearly every call" — asked you to compare
|
||||
a number with itself.
|
||||
There is no "cleared the threshold" count, because it would be a
|
||||
tautology: the search applies the bar before returning, so every returned
|
||||
result cleared it, and that count would always equal
|
||||
`calls - zero_result_calls`.
|
||||
|
||||
CHECK `suppression` BEFORE CONCLUDING ANYTHING FROM `zero_result_calls`. A
|
||||
zero-result call is two different events wearing one number: the ranker
|
||||
@@ -452,8 +436,8 @@ async def retrieval_telemetry(
|
||||
`suppression` is `null` when NO row in the window reported it, and that is
|
||||
"not measured here", NOT "none suppressed". Surfaces that pass their
|
||||
exclusions into the search never see what was dropped, so they cannot say.
|
||||
Do not read a null as a zero: reading an artifact as a measurement is how
|
||||
this surface got mis-scoped once already (#3311, #3497).
|
||||
Read a null as "unknown", never as a zero: an artifact read as a
|
||||
measurement mis-scopes the surface.
|
||||
|
||||
`usage` — NOTES ONLY, from `note_usage_events`, at the per-note grain
|
||||
`retrieval_logs` cannot be indexed at: `surfaced` (ranked surfacings — a
|
||||
@@ -477,10 +461,10 @@ async def retrieval_telemetry(
|
||||
about a record nothing chose). Read it as: of the distinct notes THIS
|
||||
surface put in front of the agent, how many did the agent then open?
|
||||
|
||||
It is an UPPER BOUND per surface: a pull records the door it came
|
||||
It is an UPPER BOUND per surface: a pull records the door it came
|
||||
through, not the surface that led there, so a note surfaced by two surfaces
|
||||
and opened once counts for both — attribution would need the session
|
||||
identity #2085 declined to invent. `by_source_failed: true` means that one
|
||||
and opened once counts for both — attribution would need a session
|
||||
identity the logs do not carry. `by_source_failed: true` means that one
|
||||
query failed while the rest of the readout stood.
|
||||
|
||||
`rule_usage` — the same question for RULES, from `rule_usage_events`:
|
||||
@@ -489,7 +473,7 @@ It is an UPPER BOUND per surface: a pull records the door it came
|
||||
definition (agent pulls over RANKED surfacings).
|
||||
|
||||
`applied`, `departed` AND `distinct_rules_acted` ARE WHAT HAPPENED AFTER
|
||||
THE RULE WAS OPENED (#4213). A pull says the rule was read; these say it
|
||||
THE RULE WAS OPENED. A pull says the rule was read; these say it
|
||||
changed something. `applied` counts rules followed, `departed` rules
|
||||
deliberately not followed — kept apart rather than summed, because a
|
||||
departure carries the reason the agent gave and is evidence about the
|
||||
@@ -511,8 +495,7 @@ It is an UPPER BOUND per surface: a pull records the door it came
|
||||
a pull can settle. `ambient` counts BULK DELIVERIES: the `rules_payload`
|
||||
surfaces (`enter_project`, `get_project`, `get_milestone`,
|
||||
`start_planning`, `get_task`), which hand over the whole applicable set at
|
||||
once with nobody choosing anything — plus, in rows older than milestone
|
||||
394, the SessionStart preload it removed. A large `ambient` says a bulk set
|
||||
once with nobody choosing anything. A large `ambient` says a bulk set
|
||||
is big and arrives often — never that it is useful, and never that it is
|
||||
read.
|
||||
|
||||
@@ -523,13 +506,10 @@ It is an UPPER BOUND per surface: a pull records the door it came
|
||||
those same rules over time: a set surfaced thousands of times and opened
|
||||
never is the dead-weight signal, one level up.
|
||||
|
||||
Read it against `sources["write_path_rule"]`. That arm was once believed
|
||||
never to decline — the reading that scoped #3311 — but it was the arm's
|
||||
`retrieval_logs` row being written only on calls that FOUND something, so
|
||||
the zeros were missing rather than absent (#3497). Measured since, it
|
||||
declines the large majority of its calls like any other surface.
|
||||
Read it against `sources["write_path_rule"]`, which declines the large
|
||||
majority of its calls like any other surface.
|
||||
|
||||
`system_usage` — the RULINGS arm (#4769), from `system_usage_events`: how
|
||||
`system_usage` — the RULINGS arm, from `system_usage_events`: how
|
||||
often an area's rulings were shown because a command (`rulings_pre_tool`)
|
||||
or an edit (`rulings_write_path`) touched its files, how often a System
|
||||
was then opened (`mcp_get_system`), and `by_system` naming the areas, most
|
||||
@@ -539,7 +519,7 @@ It is an UPPER BOUND per surface: a pull records the door it came
|
||||
a ratio of opens would read near zero on an arm that is working.
|
||||
`system_usage_failed: true` means its read broke and the zeros mean nothing.
|
||||
|
||||
`judged` — whether the lines RELATED (#4772), from `retrieval_judgments`:
|
||||
`judged` — whether the lines RELATED, from `retrieval_judgments`:
|
||||
per source, a reviewer's verdicts (`on_point` / `adjacent` / `unrelated`)
|
||||
on a sample of logged menus, `within_budget` against `beyond_budget` and
|
||||
`by_rank`. Every usage block above counts what the reader DID; a line
|
||||
@@ -564,8 +544,7 @@ It is an UPPER BOUND per surface: a pull records the door it came
|
||||
across a deploy. A counter added last week, read over a 30-day window,
|
||||
reports a real count against an imagined denominator — and the result is
|
||||
a plausible fraction rather than an obvious zero, which is what makes it
|
||||
dangerous. That reading cost milestone #379 five steps aimed at a defect
|
||||
that did not exist.
|
||||
dangerous.
|
||||
|
||||
`covers_window` is null, never false, when nothing was ever recorded:
|
||||
"no measurement" is not "partial measurement", the same distinction
|
||||
@@ -583,10 +562,9 @@ It is an UPPER BOUND per surface: a pull records the door it came
|
||||
they are zeros meaning "could not find out", not "nothing happened" — do
|
||||
not report a pull-through from a block carrying that flag.
|
||||
|
||||
`warnings` IS THE PART TO READ FIRST (#3431). Everything above is a
|
||||
distribution; this is a verdict, and it exists because the same four
|
||||
checks were being redone by hand on every reading and were easy to
|
||||
forget. An EMPTY LIST means checked and clean — it is always present, so
|
||||
`warnings` IS THE PART TO READ FIRST. Everything above is a
|
||||
distribution; this is a verdict, the checks a careful reader would
|
||||
otherwise redo by hand on every reading. An EMPTY LIST means checked and clean — it is always present, so
|
||||
its emptiness is an answer rather than a gap. Each entry carries the
|
||||
numbers that triggered it, so you can disagree with the rule instead of
|
||||
having to redo the arithmetic:
|
||||
@@ -596,7 +574,7 @@ It is an UPPER BOUND per surface: a pull records the door it came
|
||||
Only ever raised for unbidden arms known to log unconditionally: a
|
||||
search returning a list every time is doing its job, and an arm whose
|
||||
zeros were never written would flag a LOGGING bug while pointing you at
|
||||
a threshold, which is #3497 exactly.
|
||||
a threshold.
|
||||
- `band_hugs_floor` — the weakest tenth of what an arm returns sits on
|
||||
its floor. The bar is doing the selecting and the score is not, so
|
||||
moving that floor changes how MUCH you get, not how good it is.
|
||||
@@ -615,8 +593,7 @@ It is an UPPER BOUND per surface: a pull records the door it came
|
||||
For RULES there is no judged sample: a rule set aside again and again
|
||||
is a trigger to fix (`update_rule(when_to_apply=...)`), not a floor.
|
||||
- `read_and_unacted` — distinct rules OPENED in the window that recorded
|
||||
no outcome, against the ones that did. The failure milestone 419 was
|
||||
opened on, and the worse sibling of `surfaced_never_pulled` above: a
|
||||
no outcome, against the ones that did. The worse sibling of `surfaced_never_pulled` above: a
|
||||
rule nobody opens is cheap, while a rule read and silently unchanged is
|
||||
indistinguishable from one that worked. It does not say which of the two
|
||||
causes it is — a rule mis-triggering, arriving where it does not apply,
|
||||
@@ -658,8 +635,8 @@ It is an UPPER BOUND per surface: a pull records the door it came
|
||||
asked for, query text included, and is not a shared record kind.
|
||||
|
||||
`read_failed: true` means the query itself failed — deliberately distinct
|
||||
from an empty window, because those two looked identical for weeks once
|
||||
(#2663) and every counter silently read zero.
|
||||
from an empty window, which would otherwise read as every counter at
|
||||
zero.
|
||||
|
||||
Args:
|
||||
days: window size, default 30. Clamped to at least 1.
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
"""Shape-ledger MCP tools — the classification write/read surface (#2789).
|
||||
"""Shape-ledger MCP tools — the classification write/read surface.
|
||||
|
||||
The accounting model (note 2786): the snippet library records CANON (small);
|
||||
The accounting model: the snippet library records CANON (small);
|
||||
the ledger accounts for EVERY extracted shape (total). These tools are how
|
||||
agents move shapes out of `unclassified` — the todo state — and how they read
|
||||
what still needs judgment. The ledger rows themselves are fed by the coverage
|
||||
refresh; a judgment given with `repo` may also land on a shape written this
|
||||
turn that the sync has not seen yet (milestone 439).
|
||||
turn that the sync has not seen yet.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -19,7 +19,7 @@ async def classify_shapes(
|
||||
) -> dict:
|
||||
"""Record judgments for a project's code shapes — in batch, as rows.
|
||||
|
||||
EVERY shape in a bound repo should end up classified (note 2786):
|
||||
EVERY shape in a bound repo should end up classified:
|
||||
- `instance` of snippet N — it conforms to recorded canon (family-level
|
||||
canon in another project counts; that fully accounts for the shape).
|
||||
- `variant` of snippet N — a deliberate, named departure. `reason`
|
||||
@@ -50,7 +50,7 @@ async def classify_shapes(
|
||||
one-off-handler, test-helper, convention-plumbing, pure-helper,
|
||||
generated, script, typed-record) so the ledger can be filtered
|
||||
and aggregated by kind of one-off — the prose stays the record.
|
||||
uses is an OPTIONAL list of snippet ids this shape CALLS (#2870):
|
||||
uses is an OPTIONAL list of snippet ids this shape CALLS:
|
||||
conformance (status + snippet_id) says what shape it is, uses
|
||||
says which canonical helpers it consumes — a service function
|
||||
can be an instance of the service-function convention AND use
|
||||
@@ -106,7 +106,7 @@ async def list_shapes(
|
||||
|
||||
Args:
|
||||
status: canonical | instance | variant | exempt | scoped | unclassified.
|
||||
`scoped` (#2869) is the sync's mechanical stamp on one-offs by
|
||||
`scoped` is the sync's mechanical stamp on one-offs by
|
||||
construction (a Vue component's scoped <style> rules and its
|
||||
<script setup> functions): accounted for, not judged, still
|
||||
proposed against / grouped / flagged, and overridable by any
|
||||
@@ -115,7 +115,7 @@ async def list_shapes(
|
||||
(the coverage line's "largest" dirs go straight in here).
|
||||
snippet_id: rows classified against this snippet (instance/variant
|
||||
of it — conformance).
|
||||
uses: rows that CALL this snippet (#2870) — the consumer map proper,
|
||||
uses: rows that CALL this snippet — the consumer map proper,
|
||||
whatever shape each row is itself; edges come from judgments
|
||||
(classify_shapes uses=), the write-path hook, and the proposer's
|
||||
by-name reference hits.
|
||||
@@ -126,7 +126,7 @@ async def list_shapes(
|
||||
set — no commits, shas or timestamps. THE form for an audit:
|
||||
a full 500-row page fits the tool budget. The default rows carry
|
||||
everything (shape_history-grade bookkeeping).
|
||||
proposal: the proposer's queue (#2792) — "any", "canon" (rows the
|
||||
proposal: the proposer's queue — "any", "canon" (rows the
|
||||
machine thinks are an instance of a snippet: `proposal` carries
|
||||
snippet_id, basis, score), "derive" (rows that repeat with NO
|
||||
canon: `proposal.group` names the family), or one basis
|
||||
@@ -134,27 +134,27 @@ async def list_shapes(
|
||||
a canon family idea's reference — another project's, often
|
||||
another language's — that the body means: the top match over
|
||||
every snippet you can read, on a platform the project shares.
|
||||
flag: the divergence readout (#2793) — "divergence": shapes new
|
||||
flag: the divergence readout — "divergence": shapes new
|
||||
since the previous refresh in a directory where one canon
|
||||
dominates the judged siblings and NOT proposed as that canon
|
||||
(`diverges_from` names it: button B where button A is canon —
|
||||
classify it: instance if it should use the canon, variant with
|
||||
the why if deliberate); "recheck": judged instances/variants
|
||||
whose body changed since judged (the judgment stands; confirm
|
||||
it again with classify_shapes, or re-judge); "unused-css"
|
||||
(milestone 302): live css rules no file's markup names — a
|
||||
it again with classify_shapes, or re-judge); "unused-css":
|
||||
live css rules no file's markup names — a
|
||||
deletion candidate to look at, never auto-deleted. Transition
|
||||
classes and concatenated names are read (#2970), so the list is
|
||||
classes and concatenated names are read, so the list is
|
||||
worth acting on; a name assembled in a script still is not.
|
||||
|
||||
Returns {"shapes": [...], "total": N} — total counts every match, not
|
||||
just this page. Every css row carries `used_by` {count, paths} — the
|
||||
files whose markup names its class (milestone 302, the CSS consumer
|
||||
files whose markup names its class (the CSS consumer
|
||||
map: a scoped rule is used by its own template; a shared recipe by
|
||||
many; a count of 0 is "no template names it"). Each row's
|
||||
`classified_by` says who judged: agent /
|
||||
audit / import are judgments; `mechanical` is the canonical stamp the
|
||||
sync applies; `hook` is write-path EVIDENCE (#2791) — the session pulled
|
||||
sync applies; `hook` is write-path EVIDENCE — the session pulled
|
||||
a snippet and then wrote code referencing/resembling it, so the shape
|
||||
was stamped an instance with the evidence in `reason`. A hook row is
|
||||
overridable by any classify_shapes call; it never overrides yours.
|
||||
@@ -223,7 +223,7 @@ async def classify_shapes_by_rule(
|
||||
via: "agent" (default) | "audit" | "import".
|
||||
reason_code: Optional catalogue code beside the reason (see
|
||||
classify_shapes) — a sweep is exactly where one applies.
|
||||
uses: Optional snippet ids every matched shape CALLS (#2870) — e.g.
|
||||
uses: Optional snippet ids every matched shape CALLS — e.g.
|
||||
"every *_scheduler.py symbol uses ScheduledJob".
|
||||
include_judged: By default only unjudged rows are touched —
|
||||
`unclassified` and the sync's mechanical `scoped` stamp — a
|
||||
@@ -254,7 +254,7 @@ async def shape_history(
|
||||
project_id: int, path: str, symbol: str = "", limit: int = 200
|
||||
) -> dict:
|
||||
"""What was used here, when, and why — a shape's (or a directory's)
|
||||
history from the ledger (#2793).
|
||||
history from the ledger.
|
||||
|
||||
`shapes` are the current rows at `path` (a file, or a directory and
|
||||
everything beneath it; `symbol` narrows to one definition) with
|
||||
@@ -304,7 +304,7 @@ async def stamps_to_review(project_id: int, top: int = 10) -> dict:
|
||||
Disagreement is judged by FAMILY (a sync helper and an async one are one
|
||||
family), and a method defined beside a member class counts as part of the
|
||||
shape rather than against it — so a model convention that covers both the
|
||||
class and its `to_dict` reads as coherent, which it is (#4220). Each
|
||||
class and its `to_dict` reads as coherent, which it is. Each
|
||||
entry carries `families` and `forms`, the majority `family`, `attached`
|
||||
(minority rows excused as methods of a member), `strangers` — THE ROWS
|
||||
THAT DO NOT FIT, which is what you judge — and `unattended` / `weak_rows`,
|
||||
@@ -330,7 +330,7 @@ async def confirm_shape_proposals(
|
||||
) -> dict:
|
||||
"""Confirm the proposer's canon proposals you have reviewed, in batch.
|
||||
|
||||
The machine proposes, judgment classifies (#2792): each matching row —
|
||||
The machine proposes, judgment classifies: each matching row —
|
||||
live, unclassified, carrying a `proposal` with a snippet_id — becomes
|
||||
`instance` of that snippet, classified_by="agent", reason naming the
|
||||
basis and score. Narrow to what you actually looked at: at least one of
|
||||
@@ -359,7 +359,7 @@ async def confirm_shape_proposals(
|
||||
async def refresh_pattern_coverage(project_id: int) -> dict:
|
||||
"""Seed or refresh the project's shape ledger NOW, and return the readout.
|
||||
|
||||
The synchronous form of the arrival-moment background seed (#2802):
|
||||
The synchronous form of the arrival-moment background seed:
|
||||
downloads the bound repos through the OWNER's forge connections, upserts
|
||||
every extracted shape into the ledger (new shapes arrive `unclassified`),
|
||||
re-stamps snippet reference locations as canonical, and recomputes the
|
||||
@@ -372,7 +372,7 @@ async def refresh_pattern_coverage(project_id: int) -> dict:
|
||||
owner adds one (Settings → Integrations → Git Forges); no served repo →
|
||||
bind_repo on a host a connection serves.
|
||||
|
||||
The refresh is also when the mechanical proposer runs (#2792): with the
|
||||
The refresh is also when the mechanical proposer runs: with the
|
||||
repo bodies in hand it matches every changed unclassified shape against
|
||||
canon and records proposals (see list_shapes proposal=), then regroups
|
||||
the derive-first candidates. Semantic matching is capped per refresh, so
|
||||
@@ -382,9 +382,9 @@ async def refresh_pattern_coverage(project_id: int) -> dict:
|
||||
unclassified, repos, largest_gaps, `proposed` (canon proposals awaiting
|
||||
confirmation), `derive_groups` (the biggest repeats-with-no-canon
|
||||
families, each css one with `consumers` — the files whose markup
|
||||
render it, milestone 302), `unused_css` (css rules no template names —
|
||||
render it), `unused_css` (css rules no template names —
|
||||
counting a `<Transition name=>`'s generated classes and concatenated
|
||||
names as named, #2970; None where the map has no evidence of templates), `derive_new` (copies
|
||||
names as named; None where the map has no evidence of templates), `derive_new` (copies
|
||||
that joined a family since the previous
|
||||
refresh — the drift to act on now: derive the canon, don't queue an
|
||||
audit), `proposer` (what this refresh examined) — plus
|
||||
|
||||
@@ -187,13 +187,13 @@ async def create_snippet(
|
||||
and force is almost never the right answer to them. Only then a semantic
|
||||
check, held to a high bar so that VARIANTS of one component are not refused:
|
||||
`.btn-primary` and `.btn-secondary` read alike and are two different things,
|
||||
so record both (#2518).
|
||||
so record both.
|
||||
"""
|
||||
if not (name or "").strip() or not (code or "").strip():
|
||||
raise ValueError("create_snippet requires a non-empty name and code")
|
||||
uid = current_user_id()
|
||||
|
||||
# The NAME is the title (milestone 427); the trigger rides in `data` and
|
||||
# The NAME is the title; the trigger rides in `data` and
|
||||
# joins the title only in the embedded document.
|
||||
title = name.strip()
|
||||
body = snippets_svc.compose_body(
|
||||
@@ -206,7 +206,7 @@ async def create_snippet(
|
||||
uid, title, body, project_id=project_id or None,
|
||||
is_task=False, note_type=snippets_svc.SNIPPET_NOTE_TYPE,
|
||||
# The artefact itself, not just its description — the gate compares
|
||||
# location and code before it compares prose (#2518).
|
||||
# location and code before it compares prose.
|
||||
code=code,
|
||||
locations=snippets_svc.resolve_locations(repo, path, symbol, locations),
|
||||
data=snippets_svc.compose_data(name=name, when_to_use=when_to_use),
|
||||
@@ -227,7 +227,7 @@ async def create_snippet(
|
||||
advice = snippets_svc.trigger_advice(when_to_use)
|
||||
if advice:
|
||||
data["trigger_advice"] = advice
|
||||
# Family canon (milestone 463): the same shape recorded in another project
|
||||
# Family canon: the same shape recorded in another project
|
||||
# on a shared platform opens an evaluation of the earlier one.
|
||||
await family_svc.attach_family_hint(uid, data, note, created=True)
|
||||
return await moment_delivery.attach_moment_rules(uid, "create_snippet", {"project_id": project_id}, data)
|
||||
@@ -246,7 +246,7 @@ async def get_snippet(snippet_id: int, project_id: int = 0) -> dict:
|
||||
A record kept VERBATIM is confirmed by containment. A deliberately
|
||||
ANNOTATED one — commentary the source does not carry — cannot be, so it
|
||||
reads "current" on the authority of a standing `ok` verdict stamped at
|
||||
the very commit just fetched (#2782); `verification` in the same payload
|
||||
the very commit just fetched; `verification` in the same payload
|
||||
shows that basis. Edit the record, or let the file move past that commit,
|
||||
and it reads "diverged" again until someone re-runs verify_snippet.
|
||||
|
||||
@@ -270,14 +270,14 @@ async def get_snippet(snippet_id: int, project_id: int = 0) -> dict:
|
||||
if note is None:
|
||||
raise ValueError(f"snippet {snippet_id} not found")
|
||||
data = snippets_svc.snippet_to_dict(note)
|
||||
# Forge-checked freshness (#2690): attaches body_source/body_freshness
|
||||
# Forge-checked freshness: attaches body_source/body_freshness
|
||||
# when the instance has a forge; a no-forge instance sees no new fields.
|
||||
await snippets_svc.attach_live_body(note, data)
|
||||
data.update(await access_svc.describe_provenance(uid, note))
|
||||
# A "pull" is an explicit open, so it's recorded HERE rather than in
|
||||
# snippets_svc.get_snippet — the service is also reached by update/merge
|
||||
# paths, and counting those would inflate exactly the number that is
|
||||
# supposed to mean "someone chose to look at this" (#2085).
|
||||
# supposed to mean "someone chose to look at this".
|
||||
record_pulled(
|
||||
user_id=uid, note_id=int(note.id),
|
||||
source="mcp_get_snippet", project_id=project_id,
|
||||
@@ -285,8 +285,8 @@ async def get_snippet(snippet_id: int, project_id: int = 0) -> dict:
|
||||
await systems_tools.attach_systems(
|
||||
uid, note.user_id, data, note.id, note.project_id
|
||||
)
|
||||
# The structured consumer map (#2789): ledger rows judged against this
|
||||
# snippet. Attached only when non-empty (#2483) — and never for projects
|
||||
# The structured consumer map: ledger rows judged against this
|
||||
# snippet. Attached only when non-empty — and never for projects
|
||||
# the caller can't read.
|
||||
from scribe.services import shape_ledger as shape_ledger_svc
|
||||
|
||||
@@ -296,7 +296,7 @@ async def get_snippet(snippet_id: int, project_id: int = 0) -> dict:
|
||||
if consumers["variants"]:
|
||||
data["variants"] = consumers["variants"]
|
||||
if consumers.get("uses"):
|
||||
# The call sites (#2870): shapes that use this snippet, whatever
|
||||
# The call sites: shapes that use this snippet, whatever
|
||||
# shape they are themselves.
|
||||
data["uses"] = consumers["uses"]
|
||||
return data
|
||||
@@ -409,7 +409,7 @@ async def verify_snippet(
|
||||
CONSUMERS you enumerate while checking ("all N call sites still route
|
||||
through it") are ledger rows, not detail prose: classify each consuming
|
||||
definition as an `instance` of this snippet with classify_shapes — prose
|
||||
cannot be sorted, queried, or diffed (note 2786's lesson), and the rows
|
||||
cannot be sorted, queried, or diffed, and the rows
|
||||
are what make "what uses this?" answerable forever. `detail` keeps the
|
||||
WHY and what changed, nothing that belongs in a row.
|
||||
|
||||
@@ -514,7 +514,7 @@ async def update_snippet(
|
||||
if note is None:
|
||||
raise ValueError(f"snippet {snippet_id} not found")
|
||||
if system_ids is not None:
|
||||
# The CALLER, not the owner (#4249). `update_snippet` above already
|
||||
# The CALLER, not the owner. `update_snippet` above already
|
||||
# raised PermissionError if this user may not write, so the tagging
|
||||
# runs with the actor's own System visibility rather than the owner's.
|
||||
await systems_svc.set_record_systems(uid, snippet_id, system_ids)
|
||||
|
||||
@@ -23,21 +23,20 @@ from scribe.services.system_usage import record_system_pulled
|
||||
|
||||
# Below this, a project is young enough that the mild "which area is this
|
||||
# about?" question stays proportionate; at or above it, a zero-Systems project
|
||||
# has demonstrated that the question never converts (#2683 — Minstrel reached
|
||||
# 282 records without a single System) and the ask escalates.
|
||||
# has shown that the mild question does not get answered, and the ask
|
||||
# escalates.
|
||||
_BOOTSTRAP_MIN_RECORDS = 20
|
||||
_BOOTSTRAP_TITLES = 6
|
||||
|
||||
# The standard vocabulary (#2798): area names that recur across software
|
||||
# The standard vocabulary: area names that recur across software
|
||||
# projects, offered so "CI & Release" means the same thing in every project
|
||||
# on the instance. Consistency comes from the shared names — NOT from asking
|
||||
# the operator to approve each System; agents mint directly. Generic by
|
||||
# design (rule #115): archetypes any codebase could have, never one
|
||||
# install's subsystems. Mint freely beyond the list; the duplicate gate
|
||||
# guards sprawl.
|
||||
# The standard vocabulary lives in the GLOBAL canonical catalog since
|
||||
# milestone 307 — the inception seed mints it and this ask names it, one list
|
||||
# for both, now a table so a rule can reference an area by id (note 3026).
|
||||
# design: archetypes any codebase could have, never one install's
|
||||
# subsystems. Mint freely beyond the list; the duplicate gate guards sprawl.
|
||||
# The vocabulary lives in the GLOBAL canonical catalog — the inception seed
|
||||
# mints it and this ask names it, one list for both, in a table so a rule can
|
||||
# reference an area by id.
|
||||
|
||||
|
||||
async def bootstrap_systems_ask(user_id: int, project_id: int) -> str | None:
|
||||
@@ -45,13 +44,13 @@ async def bootstrap_systems_ask(user_id: int, project_id: int) -> str | None:
|
||||
|
||||
The generic zero-state question habituates: identical on every record,
|
||||
maximal in scope ("invent the taxonomy"), asked at wrap-up time — so
|
||||
organic sessions skip it forever and only audit-shaped sessions ever mint
|
||||
(#2683). What separates the nudges that convert from the prose that
|
||||
organic sessions skip it forever and only audit-shaped sessions ever mint.
|
||||
What separates the nudges that convert from the prose that
|
||||
doesn't (the duplicate gate, the prior-art "already defined in 2 files")
|
||||
is the project's OWN evidence in the ask — so this one carries the record
|
||||
count and the recent titles, and asks for a concrete deliverable: create
|
||||
a starter set directly, preferring the standard cross-project names.
|
||||
Deliberately NOT an approval flow (#2798): the operator is not a
|
||||
Deliberately NOT an approval flow: the operator is not a
|
||||
permission gate for vocabulary; the standard names carry consistency.
|
||||
|
||||
Self-retiring by construction: callers only reach for it while the
|
||||
@@ -102,12 +101,12 @@ async def untagged_systems_hint(user_id: int, project_id: int) -> str | None:
|
||||
arrives in-band at the exact moment a record is touched. It is ONE
|
||||
question regardless of vocabulary state ("which area is this about, and is
|
||||
it modelled?"); only the vocabulary listing varies, because an empty
|
||||
vocabulary is not an exemption — it is the question at its most urgent
|
||||
(#2562, #2569). Instruction prose alone demonstrably doesn't fire at write
|
||||
time; in-band behavior (the duplicate gate) does.
|
||||
vocabulary is not an exemption — it is the question at its most urgent.
|
||||
Instruction prose alone does not fire at write time; in-band behavior
|
||||
(the duplicate gate) does.
|
||||
|
||||
In a MATURE zero-Systems project the question escalates to the bootstrap
|
||||
ask instead (#2683): the mild form demonstrably never converts there, and
|
||||
ask instead: the mild form does not get answered there, and
|
||||
an evidence-carrying, deliverable-shaped ask is the form that does.
|
||||
"""
|
||||
# Fail-open like the dedup gate: a hint must never break the call.
|
||||
@@ -147,7 +146,7 @@ async def attach_systems(
|
||||
tagged record shows its areas (the touching-a-System reflex needs the
|
||||
affiliation visible on read, not just settable on write), an untagged
|
||||
project record carries the question instead. Neither field is ever
|
||||
attached empty (same reasoning as supersession_svc.attach_relations / #2483 — a
|
||||
attached empty (same reasoning as supersession_svc.attach_relations — a
|
||||
field that always says nothing trains readers to skip fields). The hint
|
||||
goes only to the record's owner: tagging someone else's record in someone
|
||||
else's project is not the caller's call to make. Fail-open — decoration
|
||||
@@ -181,7 +180,7 @@ async def create_system(
|
||||
yet — the same two-or-more test snippets use. Don't wait to be asked to
|
||||
name an area that plainly exists in the code, and don't route the
|
||||
creation through operator approval — minting vocabulary is the agent's
|
||||
call (#2798); an unmodelled area means every record about it stays
|
||||
call; an unmodelled area means every record about it stays
|
||||
untaggable. An audit or sweep that walks the codebase is a DISCOVERY
|
||||
moment: mint the Systems it names as it names them — the duplicate gate
|
||||
below, plus reviewing the existing list, is what guards against sprawl,
|
||||
@@ -220,7 +219,7 @@ async def create_system(
|
||||
tag records to that one, or update_system it if its charter needs work.
|
||||
|
||||
Also mapped against the GLOBAL area catalog, so the same word means the
|
||||
same thing in every project (milestone 307). A name that IS a catalog area
|
||||
same thing in every project. A name that IS a catalog area
|
||||
up to spelling ("CI and Release" vs "CI & Release") is mapped for you and
|
||||
the response says so. A name that merely RESEMBLES one comes back with
|
||||
`canonical_suggestion` — an offer, not a decision: apply it with
|
||||
@@ -297,7 +296,7 @@ async def get_system(system_id: int) -> dict:
|
||||
system = await systems_svc.get_system(uid, system_id)
|
||||
if system is None:
|
||||
raise ValueError(f"system {system_id} not found")
|
||||
# The pull half of the rulings arm's measurement (milestone 444): a
|
||||
# The pull half of the rulings arm's measurement: a
|
||||
# System whose rulings were shown, then opened.
|
||||
record_system_pulled(user_id=uid, system_id=system_id, source="mcp_get_system")
|
||||
records = await systems_svc.list_records_for_system(uid, system_id)
|
||||
@@ -316,7 +315,7 @@ async def get_system(system_id: int) -> dict:
|
||||
data["tasks"] = tasks
|
||||
data["notes"] = notes
|
||||
# How often this area's rulings were shown to a session and the System
|
||||
# opened (#4769). Here and not on list_systems: one lookup on a read that
|
||||
# opened. Here and not on list_systems: one lookup on a read that
|
||||
# asked for the System, rather than a column on every project handshake.
|
||||
data["usage"] = (await system_usage_svc.usage_for_systems([system.id]))[system.id]
|
||||
return data
|
||||
@@ -412,8 +411,8 @@ async def list_canonical_systems() -> dict:
|
||||
"""The GLOBAL vocabulary of area names, shared by every project.
|
||||
|
||||
These are the standard names to prefer when creating a System, so the same
|
||||
word means the same thing in every project on the instance — and, from
|
||||
milestone 307, the ids a cross-project record can point at. A project's own
|
||||
word means the same thing in every project on the instance — and the ids
|
||||
a cross-project record can point at. A project's own
|
||||
System keeps whatever name the project calls the area; mapping it here is
|
||||
an association, never a rename.
|
||||
|
||||
|
||||
@@ -4,15 +4,9 @@ Tasks are notes with a non-null `status` — same model, different filter.
|
||||
Wrappers call services/notes.py for CRUD with is_task=True and add the
|
||||
task-specific fields (status, priority, due_date, parent_id), plus
|
||||
services/task_logs.py for add_task_log AND for reading those logs back —
|
||||
`get_task` returns them and `list_tasks` counts them. For most of this
|
||||
module's life `add_task_log` wrote to a surface no agent could read: the
|
||||
entries reached the web UI and nothing else, so a session opening a task
|
||||
saw only the body — a claim written before the work — with the record
|
||||
written during it invisible beside it. A stale body then had nothing to
|
||||
contradict it, and shipped work got rebuilt (#4241).
|
||||
|
||||
There is no delete_task — matches the existing fable-mcp surface.
|
||||
Cancel by updating status to "cancelled".
|
||||
`get_task` returns them and `list_tasks` counts them. The log is the record
|
||||
written during the work; a body is a claim written before it, so a session
|
||||
opening a task needs both.
|
||||
|
||||
Sentinels (preserved from existing fable-mcp):
|
||||
- status="" / priority="" / title="" / body="" → "leave unchanged" on update
|
||||
@@ -181,8 +175,8 @@ async def get_task(
|
||||
written once, at the start, when the least is known; the log is written
|
||||
during the work and after it. A task whose body says "not started" and
|
||||
whose log records a partial ship is not a contradiction to resolve — the
|
||||
log is simply later. This block exists because its absence cost a session
|
||||
a day of rebuilding work that had already shipped (#4241).
|
||||
log is simply later; trusting the body alone is how finished work gets
|
||||
rebuilt.
|
||||
|
||||
The NEWEST entry arrives whole (to 4000 characters); older ones are
|
||||
shortened to a headline. Anything shortened is cut from the MIDDLE, so
|
||||
@@ -228,10 +222,9 @@ async def get_task(
|
||||
# Same reasoning as get_note's record_pulled, and this is the tool where it
|
||||
# matters MOST: auto-inject ranks kind-blind over a corpus that is
|
||||
# overwhelmingly tasks and issues, so tasks dominate what it surfaces. Without
|
||||
# this the surfaced→pulled loop was open exactly where the volume is — every
|
||||
# surfaced task counted as never-pulled because the tool that opens one didn't
|
||||
# say so, driving auto-inject's measured pull-through toward zero for its own
|
||||
# dominant kind. #1038 and #2085 are explicitly gated on that number (#2245).
|
||||
# this every surfaced task would count as never-pulled, driving
|
||||
# auto-inject's measured pull-through toward zero for its own dominant
|
||||
# kind.
|
||||
await systems_tools.attach_systems(
|
||||
uid, getattr(note, "user_id", uid) or uid, data, note.id, note.project_id
|
||||
)
|
||||
@@ -365,7 +358,7 @@ async def create_task(
|
||||
await systems_tools.attach_systems(uid, uid, data, note.id, project_id or None)
|
||||
data.update(dedup_svc.note_overlap_response(overlaps, "task"))
|
||||
# A task that says it is "matching" another project's work names the
|
||||
# source of a pattern — family canon's citation trigger (milestone 463).
|
||||
# source of a pattern — family canon's citation trigger.
|
||||
await family_svc.attach_family_hint(uid, data, note, created=True)
|
||||
return await placement_svc.attach_placement(uid, data, note)
|
||||
|
||||
@@ -467,8 +460,8 @@ async def update_task(
|
||||
await family_svc.attach_family_hint(uid, data, note, created=False)
|
||||
if status in _CLOSING_STATUSES:
|
||||
data["report_back"] = REPORT_BACK_CUE
|
||||
# Owed family adoptions filed while the task was open (milestone 463
|
||||
# step 6) — work now waiting in some project, which the report names.
|
||||
# Owed family adoptions filed while the task was open — work now
|
||||
# waiting in some project, which the report names.
|
||||
await family_adoption_svc.attach_owed_adoptions(uid, data, note)
|
||||
return await moment_delivery.attach_moment_rules(
|
||||
uid, "update_task", {"status": status, "project_id": project_id}, data,
|
||||
@@ -490,7 +483,7 @@ async def add_task_log(task_id: int, content: str) -> dict:
|
||||
out, and where it actually stands.
|
||||
|
||||
A log on an open task also marks it as being worked by this session — its
|
||||
`claim` (milestone 381). That is what brings the task and its newest
|
||||
`claim`. That is what brings the task and its newest
|
||||
entries back to you after a compaction; it lapses by itself once the
|
||||
session stops writing, and a status of done, cancelled or todo clears it.
|
||||
|
||||
@@ -520,8 +513,8 @@ async def add_task_log(task_id: int, content: str) -> dict:
|
||||
return data
|
||||
|
||||
|
||||
# The in-band half of milestone 409 step 3. The completion shape itself rides
|
||||
# the closing response as `reply_shape` (milestone 500); this is the one line
|
||||
# The in-band completion cue. The completion shape itself rides the closing
|
||||
# response as `reply_shape`; this is the one line
|
||||
# that says what the reply covers, for every MCP client, at the moment a piece
|
||||
# of work closes. One line on purpose: a template here would be read as the
|
||||
# reply itself.
|
||||
@@ -537,7 +530,7 @@ _ITEM_KEYS = {"title", "body", "type", "status", "priority", "kind", "tags", "sy
|
||||
def _batch_items(records: list[dict], *, what: str = "record") -> list[batch_svc.BatchItem]:
|
||||
"""Parse the door's plain dicts into BatchItems, refusing unknown keys.
|
||||
|
||||
Strict for the same reason StrictArgsMCPServer is (#2709): a misspelt key
|
||||
Strict for the same reason StrictArgsMCPServer is: a misspelt key
|
||||
silently dropped — `milestone` for a per-record milestone, `desc` for body —
|
||||
creates a record that looks right and is missing what the caller sent.
|
||||
"""
|
||||
@@ -574,7 +567,7 @@ async def _first_duplicate(
|
||||
"""The duplicate gate over a whole batch — the first hit blocks all of it.
|
||||
|
||||
`overlaps`, when given, collects each record's near matches below the copy
|
||||
band by its 1-based position (#4306), for the reply once the batch is
|
||||
band by its 1-based position, for the reply once the batch is
|
||||
created."""
|
||||
for i, item in enumerate(items, start=1):
|
||||
found: list = []
|
||||
|
||||
@@ -1,17 +1,11 @@
|
||||
"""The wide net — every candidate that might govern what you are about to do (#4103).
|
||||
"""The wide net — every candidate that might govern what you are about to do.
|
||||
|
||||
WHY THIS EXISTS
|
||||
WHY IT IS A PULL
|
||||
|
||||
Milestone 416 step 5, from the operator's compromise:
|
||||
|
||||
"if you're worried about excluding potentially important data let limit it
|
||||
to 50 entries or something like that"
|
||||
|
||||
Fifty in the PUSH would be milestone 394 with extra steps — a wall the reader
|
||||
skims, with the governing record indistinguishable from forty-nine others.
|
||||
Fifty in a PULL is a different object: it arrives only when a session asks, it
|
||||
crowds nothing out, and it is the honest home for "do not exclude potentially
|
||||
important data".
|
||||
Up to fifty candidates pushed unasked would be a wall the reader skims, with
|
||||
the governing record indistinguishable from forty-nine others. Fifty in a PULL
|
||||
is a different object: it arrives only when a session asks, it crowds nothing
|
||||
out, and it is the honest home for "do not exclude potentially important data".
|
||||
|
||||
WHY IT IS NOT `search(content_type="rule")`
|
||||
|
||||
@@ -26,15 +20,13 @@ to open it. Opposite trade-off, so it is a second tool rather than a bigger
|
||||
|
||||
WHY THE TAIL IS TRUNCATED RATHER THAN FULL
|
||||
|
||||
Because the step's premise needed correcting. The task said fifty "costs
|
||||
nothing", and `_rule_hint_line` had already measured otherwise: a line runs
|
||||
~143 tokens once its trigger is rendered, and #3855 tripled trigger lengths
|
||||
across the corpus. Fifty of those is ~7,000 tokens — cheap next to an arm that
|
||||
fires before every Bash call, but not free, and a tool that promises a free
|
||||
wide net gets reached for casually and then regretted.
|
||||
Fifty is not free. A line runs to a hundred-odd tokens once its trigger is
|
||||
rendered, so fifty full lines is several thousand — cheap next to an arm that
|
||||
fires before every Bash call, but a tool that promises a free wide net gets
|
||||
reached for casually and then regretted.
|
||||
|
||||
So this reuses the graduated shape #3851 measured for the push: the top few
|
||||
carry their trigger in full, the rest carry a cut of it. TRUNCATED, never
|
||||
So this uses the push arms' graduated shape: the top few carry their trigger
|
||||
in full, the rest carry a cut of it. TRUNCATED, never
|
||||
dropped — the trigger is what lets a reader judge relevance without opening
|
||||
the record, and a teaser without one is just an id.
|
||||
"""
|
||||
@@ -49,9 +41,8 @@ from scribe.services.retrieval_telemetry import record_retrieval
|
||||
|
||||
# The telemetry `source`, and a PULL — never added to AMBIENT_SOURCES (those
|
||||
# are deliveries nobody chose) and never confused with a push arm. Keeping it
|
||||
# separate is load-bearing right now: the push arms' near-miss distributions
|
||||
# are the evidence #4121 rests on, and a pull mixed into them would move the
|
||||
# very numbers that step is arguing from.
|
||||
# separate keeps the push arms' near-miss distributions about the push arms: a
|
||||
# pull mixed into them would move the numbers their floors are tuned from.
|
||||
SOURCE = "wide_net"
|
||||
|
||||
# No bar. That is the point of the tool rather than an oversight — the caller
|
||||
@@ -78,7 +69,7 @@ def _teaser(text: str) -> tuple[str, bool]:
|
||||
The technique is `plugin_context._goal_line`'s, and the fallback is the
|
||||
part worth copying: `textwrap.shorten` returns a bare "…" when the string
|
||||
is one unbroken word longer than the cap, and a raw slice ends mid-word
|
||||
claiming to be the whole thing (#4036). Kept local rather than shared
|
||||
claiming to be the whole thing. Kept local rather than shared
|
||||
because the two callers wrap it in different sentences; if a third appears,
|
||||
that is the moment to extract it rather than now.
|
||||
"""
|
||||
@@ -177,7 +168,7 @@ async def what_might_apply(
|
||||
# Force, not topic. See the docstring — this is never inferred.
|
||||
"kind": rule.kind,
|
||||
# A rule in a rulebook topic is global; one on a project binds
|
||||
# there alone (milestone 414). Which it is changes how far a
|
||||
# there alone. Which it is changes how far a
|
||||
# reader should generalise from it.
|
||||
"scope": "project" if rule.project_id else "global",
|
||||
"when_to_apply": trigger,
|
||||
@@ -186,7 +177,7 @@ async def what_might_apply(
|
||||
})
|
||||
|
||||
# Logged as a pull, with `searched` honoured: a search that never ran must
|
||||
# not be recorded as a ranker declining (#3765).
|
||||
# not be recorded as a ranker declining.
|
||||
record_retrieval(
|
||||
user_id=uid,
|
||||
source=SOURCE,
|
||||
@@ -208,7 +199,7 @@ async def what_might_apply(
|
||||
"detailed": min(detail, len(candidates)),
|
||||
# Present so a caller can tell "the corpus offered nothing" from "the
|
||||
# search never ran" — an empty query, an unavailable embedder and a
|
||||
# failed query all return zero rows and mean different things (#3670).
|
||||
# failed query all return zero rows and mean different things.
|
||||
"searched": bool(report.get("searched", True)),
|
||||
"open_with": "get_rule(id)",
|
||||
}
|
||||
|
||||
@@ -1463,7 +1463,7 @@ def test_hook_names_the_shapes_being_written():
|
||||
assert "scribe_defs()" in lib # one extractor, shared (#2901)
|
||||
assert "scribe_defs()" not in src # ...not a second copy here
|
||||
assert ".tool_input.old_string" in src # the Edit's anchor
|
||||
assert "| tac | scribe_defs | head -1" in src # nearest definition above
|
||||
assert '"$file_path" | scribe_defs | tail -1' in src # nearest definition above
|
||||
# The ledger feed sends NAMES, never bodies, and stays on the one GET.
|
||||
assert src.count("/api/plugin/prior-art?") == 1
|
||||
|
||||
|
||||
Reference in New Issue
Block a user