docs(6210): acceptance sweep — src, scripts and frontend leftovers described in the present tense
CI & Build / Python lint (push) Successful in 2s
CI & Build / Plugin hooks (push) Successful in 12s
CI & Build / TypeScript typecheck (push) Successful in 53s
CI & Build / integration (push) Successful in 1m9s
CI & Build / Python tests (push) Successful in 1m55s
CI & Build / Build & push image (push) Successful in 34s

The acceptance greps found what the per-area passes missed:

- src/scribe: "Phase 7/8" removal notes in projects, settings and
  user_profile; a paragraph promising an auto-summary side effect that no
  longer exists; user_profile's claim that "upcoming MCP profile tools" read
  the profile, which nothing does; an instance count and instance rule
  titles in plugin_context; "MILESTONE 323 DECIDED" in rulebooks.
- scripts/: check_plugin's header and inline comments were a history of
  the incidents behind each guard, with issue numbers in two failure
  messages; check_dangling_styles named this app's classes as its examples;
  check_design_tokens, measure_duplication and mint_plugin_version cited
  issues and checklists.
- frontend/src: forty-odd comments with four-digit issue numbers that the
  earlier frontend scan filtered out as hex colours, plus "used to" and
  "Phase N" notes.

Kept: illustrative ids (`#4181` as a bare-id example, `PR #198`), migration
Create Date headers and one migration's dated data floor, and the plugin
skills' worked examples of how to cite a record.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-21 21:54:38 -04:00
co-authored by Claude Opus 5.5
parent 4c150cbc2f
commit 067aa58fbc
40 changed files with 199 additions and 257 deletions
+4 -5
View File
@@ -13,11 +13,10 @@ import { fetchVersion } from "@/api/version";
useTheme();
const router = useRouter();
// THREE states, not two (#3127 checklist 12). `null` is "not answered yet" and
// renders nothing; a string renders; `appVersionFailed` renders its own thing.
// This used to default to the literal "dev" and swallow the error, which meant
// an instance that could not answer was indistinguishable from a local build
// that genuinely reports "dev" — a blank standing in for `unknown`, in the one
// THREE states, not two. `null` is "not answered yet" and renders nothing; a
// string renders; `appVersionFailed` renders its own thing. Defaulting to the
// literal "dev" and swallowing the error would make an instance that cannot
// answer indistinguishable from a local build that genuinely reports "dev" — a blank standing in for `unknown`, in the one
// readout whose whole job is to say what is running.
const appVersion = ref<string | null>(null);
const appVersionFailed = ref(false);
+3 -4
View File
@@ -41,8 +41,8 @@ async function handleResponse<T>(res: Response, path: string): Promise<T> {
/**
* The server's `{"error": "..."}` message from a failed call, or `fallback`
* when the failure carried none (network error, non-JSON body). The one place
* the error envelope is unpacked on the client — views used to restate this
* as a six-line `"body" in e` branch at every catch site.
* the error envelope is unpacked on the client, so no view restates it as a
* `"body" in e` branch at its catch site.
*/
export function apiErrorMessage(e: unknown, fallback: string): string {
if (e && typeof e === "object" && "body" in e) {
@@ -61,8 +61,7 @@ export function apiErrorMessage(e: unknown, fallback: string): string {
* surface can show for "pending forever" that is not a lie.
*
* 30s is chosen to be longer than anything healthy: it has to clear a cold
* embedding call and a list view under connection-pool contention (#2384 had
* /api/projects fanning 25 concurrent sessions at a 15-connection pool), so
* embedding call and a list view under connection-pool contention, so
* tripping it means something is genuinely wrong rather than merely busy. Slow
* BY DESIGN is a different case and passes its own value — see the callers in
* SettingsView that do.
+3 -3
View File
@@ -1,5 +1,5 @@
/**
* Design systems — the stylesheet held as records (milestone #254).
* Design systems — the stylesheet held as records.
*
* A design system is a named set of tokens with an optional parent. A system
* with no parent is a "family"; one with a parent holds ONLY what it changes,
@@ -82,8 +82,8 @@ export interface StarterRoleGroup {
}
/** The starter token ROLES offered at creation — names and purposes, never
* values. A default palette would be one install's taste shipped as product
* (rule #115), so the values are always the operator's to fill. */
* values. A default palette would be one install's taste shipped as product,
* so the values are always the operator's to fill. */
export const listStarterRoleGroups = () =>
apiGet<{ groups: StarterRoleGroup[]; default_prefix: string }>(
"/api/design-systems/starter-roles",
+1 -1
View File
@@ -88,7 +88,7 @@ export interface Lesson {
no_rule?: { why: string; judged_at: string | null };
/** Sent by a create or update that recorded "no rule fits" when that answer
* completes a group: lessons with no rule that keep landing in one
* situation, which may want a rule written for it (#4634). */
* situation, which may want a rule written for it. */
convergence?: { lessons: { id: number; title: string }[] };
/** Set when another user owns this record. */
shared?: boolean;
+1 -1
View File
@@ -30,7 +30,7 @@ export interface SnippetFields {
* no `locations`/`tags` predates that attribution and cannot be un-merged. */
merged_from: { id: number; locations?: SnippetLocation[]; tags?: string[] }[];
code: string;
/** Free text that is not the situation — why, history, caveats (#4378).
/** Free text that is not the situation — why, history, caveats.
* Kept out of `when_to_use`, which the snippet is ranked on. */
notes: string;
}
+2 -2
View File
@@ -24,7 +24,7 @@ export interface System {
open_issue_count: number;
/**
* How often this area's rulings were shown to a session because its files
* were touched (`surfaced_count`), and how often it was opened (#4769).
* were touched (`surfaced_count`), and how often it was opened.
* Present on the list and detail reads; optional because a System returned
* by a create or update carries none.
*/
@@ -45,7 +45,7 @@ export async function listSystems(projectId: number): Promise<System[]> {
* caller to accept or ignore.
*
* A same-named System in this project is a 409 ApiError carrying
* `{duplicate, existing_id}` — the same gate the MCP door enforces (#2482).
* `{duplicate, existing_id}` — the same gate the MCP door enforces.
*/
export interface CreatedSystem extends System {
canonical_suggestion?: CanonicalMatch;
+1 -1
View File
@@ -1,4 +1,4 @@
/* The sweep-row visual language (#3207).
/* The sweep-row visual language.
*
* A "sweep" is a pane that lists records in the order they most need a human
* — the note verification sweep, the rule sweep, preference drift — as one
+5 -6
View File
@@ -132,11 +132,10 @@ router.afterEach(() => {
}
/* Three tracks, not a flex row with an absolutely-centred overlay.
*
* The pill bar used to be `position: absolute; left: 50%`, which meant it did
* not participate in layout: when the header ran out of room it OVERLAPPED the
* brand and the utility cluster rather than pushing them, and nothing wrapped
* or scrolled to signal it. A sixth link reached that point at ~1270px, which
* is an ordinary window on any monitor.
* A pill bar at `position: absolute; left: 50%` does not participate in
* layout: when the header runs out of room it OVERLAPS the brand and the
* utility cluster rather than pushing them, and nothing wraps or scrolls to
* signal it — at an ordinary window width on any monitor.
*
* `1fr auto 1fr` fixes it structurally. A `1fr` track has an AUTO minimum, so
* neither side can be squeezed below its content, and the two side tracks stay
@@ -327,7 +326,7 @@ router.afterEach(() => {
margin-top: 0.25rem;
}
/* The grid above means running out of room can no longer cause a collision —
/* The grid above means running out of room cannot cause a collision —
but it can still make the header wider than the window, and a horizontally
scrolling header is its own defect. So shed width before that happens. The
wordmark goes first: the logo beside it says the same thing and is still the
@@ -12,8 +12,7 @@
* A COMPONENT rather than markup inside the task editor, because the same
* question is worth answering on any record a lesson can cite — an issue, a
* spike, a dev-log. One panel, mounted wherever that is true, instead of the
* shape being written a second time the first time someone wants it on notes
* (#3207).
* shape being written a second time the first time someone wants it on notes.
*
* SILENT WHEN EMPTY. Most records taught no lesson, and a panel that renders
* "None yet" on every page is a panel people learn to skip — which costs the
+2 -2
View File
@@ -1,6 +1,6 @@
<script setup lang="ts">
/**
* A project's own code, checked against the design system it is bound to (#2432).
* A project's own code, checked against the design system it is bound to.
*
* This is what the design surface is FOR: a project's recorded components
* measured against the sheet they are supposed to use. The check itself is not
@@ -38,7 +38,7 @@ async function run() {
} catch {
// Said out loud rather than rendered as an empty result. "Couldn't check"
// and "nothing to report" look identical if you let them, and that is how
// a check comes to sit dead without anyone noticing (#2419).
// a check comes to sit dead without anyone noticing.
failed.value = true;
} finally {
loading.value = false;
@@ -4,7 +4,7 @@
*
* Deliberately not StatusBadge. That component is typed to TaskStatus and
* speaks a different vocabulary; these two only ever shared a CSS class name,
* which is what made them look like one shape that had drifted (#3132).
* which is what makes them look like one shape that has drifted.
*
* Extracted because ProjectView and ProjectListView really were spelling the
* same pill twice, with the differences you get from two hands rather than
@@ -1,6 +1,6 @@
<script setup lang="ts">
/**
* The starter token ROLES offered when a design system is created (#2349).
* The starter token ROLES offered when a design system is created.
*
* WHY THIS IS A COMPONENT
* DesignSystemsView has two creation forms — the empty state and the one inside
@@ -11,7 +11,7 @@
* WHAT IT OFFERS
* Names and purposes, never values. A role is a question the operator answers
* with their own palette; a default palette would be one install's taste
* shipped as product (rule #115). Every group is individually skippable —
* shipped as product. Every group is individually skippable —
* an operator who wants three tokens should get three.
*
* All groups are checked by default. That default lives HERE rather than in the
+1 -1
View File
@@ -4,7 +4,7 @@ import type { TaskStatus } from "@/types/task";
const props = defineProps<{
status: TaskStatus;
clickable?: boolean;
/** Dense surfaces — smaller, unshouted. The canon (#2960) names compact a
/** Dense surfaces — smaller, unshouted. The canon names compact a
VARIANT of this component rather than a reason to re-spell it. */
compact?: boolean;
}>();
+6 -6
View File
@@ -72,7 +72,7 @@ function areaName(system: System): string | null {
return canon.byId(system.canonical_id)?.name ?? null;
}
/** How many times this area's rulings reached a session (#4769). Not the
/** How many times this area's rulings reached a session. Not the
* UsageBadge: its "N/M used" reads opens over showings, and rulings are
* delivered in full in the line, so an unopened System is the arm working,
* not dead weight. */
@@ -721,16 +721,16 @@ async function confirmDelete() {
word-break: break-all;
}
/* RESTORED (#2444). Both lost their base rule to a CSS sweep; only the
`--archived` modifier and the `:hover .system-actions` reveal survived.
The card WAS a flex row and every child still says so — `.system-swatch`
/* BASE RULES for the card and the list. Without them only the `--archived`
modifier and the `:hover .system-actions` reveal would remain.
The card is a flex row and every child says so — `.system-swatch`
and `.system-actions` are `flex-shrink: 0`, `.system-body` is `flex: 1`,
and `.system-form--inline` is `flex: 1`. `align-items: flex-start` is why
the swatch carries `margin-top: 0.3rem`: it is nudged onto the first line
of text rather than centred against the whole card.
The list had no rule at all, so it rendered with browser bullets and
indent — invisible to the dangling-style check, which can only see a class
Without its rule the list renders with browser bullets and indent —
invisible to the dangling-style check, which can only see a class
that is PARTLY styled. A class with no rules anywhere looks exactly like a
semantic-only hook.
+2 -2
View File
@@ -1,6 +1,6 @@
<script setup lang="ts">
/**
* A design system's tokens, drawn rather than listed (#2431).
* A design system's tokens, drawn rather than listed.
*
* WHAT MAKES THIS WORK FOR A SYSTEM YOU AREN'T RUNNING
* Every value is resolved on an offscreen probe carrying only this system's
@@ -14,7 +14,7 @@
* SPECIMENS ARE CHOSEN BY VALUE SHAPE, NEVER BY NAME
* A colour is drawn as a swatch, a length as a rule of that length, a font
* stack as text set in it. Nothing here matches `--fs-space-*` or any other
* naming convention, because the convention is the install's (rule #115) — a
* naming convention, because the convention is the install's — a
* system that calls its spacing `--gap-N` gets the same treatment.
*
* A token with no value for the chosen mode is shown as undecided rather than
@@ -13,8 +13,8 @@
* fields moved".
*
* What was genuinely shared is shared: DiffView.vue takes DiffLine[] and
* nothing note-shaped, and the LCS walk now lives in utils/diff.ts, which
* this file uses rather than copying a fourth time (#3207).
* nothing note-shaped, and the LCS walk lives in utils/diff.ts, which this
* file uses rather than carrying a copy of its own.
*/
import { computed, onMounted, ref, watch } from "vue";
import DiffView from "@/components/DiffView.vue";
@@ -62,7 +62,7 @@ ul { list-style: none; padding: 0; margin: 1rem 0; }
li { padding: 0.5rem; cursor: pointer; border-radius: 6px; }
li.active { background: var(--fs-accent-soft); }
li:hover { background: var(--fs-surface-hover); }
/* `.new-topic` is deliberately bare (#2444): it wraps a button-or-form whose
/* `.new-topic` is deliberately bare: it wraps a button-or-form whose
children style themselves, and namespaces the descendant rule below. */
.new-topic input {
width: 100%; margin-bottom: 0.5rem;
+1 -2
View File
@@ -12,8 +12,7 @@ export function relativeTime(iso: string): string {
/**
* relativeTime() for the recent past, a short date once it's a week old —
* the workspace panels' list-row timestamp ("3h ago" / "Jan 15"). Two
* panels used to carry identical copies of this.
* the workspace panels' list-row timestamp ("3h ago" / "Jan 15").
*/
export function relativeTimeOrDate(iso: string): string {
const d = new Date(iso);
+1 -1
View File
@@ -7,7 +7,7 @@ import "./assets/theme.css";
import "./assets/components.css";
import "./assets/prose.css";
// Global rather than `<style src>` per pane: plugin-vue cannot compile one
// src stylesheet shared by several SFCs (#3207 broke the image build).
// src stylesheet shared by several SFCs, and the image build fails on it.
import "./assets/sweep-shared.css";
const app = createApp(App);
+3 -4
View File
@@ -137,10 +137,9 @@ const router = createRouter({
},
{
// The design systems this install RECORDS — for the projects it tracks,
// not for the install itself. There was a sibling `/design` that read the
// running app's own stylesheet out of the browser; it could only ever
// inspect the instance it was served from, which made it a mirror rather
// than a tool (#274).
// not for the install itself. A page reading the running app's own
// stylesheet out of the browser could only inspect the instance it was
// served from, a mirror rather than a tool.
path: "/design-systems",
name: "design-systems",
component: () => import("@/views/DesignSystemsView.vue"),
+2 -3
View File
@@ -54,9 +54,8 @@ export const useTasksStore = defineStore("tasks", () => {
async function updateTask(
id: number,
// IssueFields carries `kind`, which the PATCH route now reads. It has
// always been SENT by the task editor; until #3129 the route dropped it
// and the save reported success while changing nothing.
// IssueFields carries `kind`, which the PATCH route reads. A route that
// dropped it would report success while changing nothing.
data: Partial<
Pick<Task, "title" | "body" | "tags" | "status" | "priority" | "due_date" | "project_id" | "milestone_id" | "parent_id" | "recurrence_rule">
> & IssueFields
+4 -5
View File
@@ -7,11 +7,10 @@
* position more often has a trigger that fires on the wrong work. Telling an
* operator to delete one of those would be the wrong nudge about half the time.
*
* The table lives here because the copy was about to exist in five places.
* Three already had their own: `SnippetListView`, `RuleListPane`, and
* `LessonDetailView` with the sentence inline in its template. The unified
* Knowledge browse renders notes, tasks, processes, snippets and lessons in one
* mixed feed (#4230), so it needs all of them at once — and a per-view constant
* The table lives here because several surfaces need it: `SnippetListView`,
* `RuleListPane`, `LessonDetailView`, and the unified Knowledge browse, which
* renders notes, tasks, processes, snippets and lessons in one mixed feed and
* so needs all of them at once — and a per-view constant
* is how three surfaces end up giving three different answers to the same
* question.
*
+5 -6
View File
@@ -1,12 +1,11 @@
/**
* Turning a design system's RECORDED values into ones you can look at.
*
* Replaces `designTokens.ts` and `designDrift.ts`, which between them read the
* running app's own stylesheet — names out of a bundled `theme.css`, values out
* of `getComputedStyle(document.documentElement)`. That could only ever describe
* the install serving the page, and the design surface is for the projects an
* install TRACKS (#274). What is left here works on any system's record,
* including one for an app this browser has never loaded.
* Reading the running app's own stylesheet — names out of a bundled
* `theme.css`, values out of `getComputedStyle(document.documentElement)` —
* could only describe the install serving the page, and the design surface is
* for the projects an install TRACKS. Everything here works on any system's
* record, including one for an app this browser has never loaded.
*
* Nothing in this module reads the document's own tokens or mutates the page.
*/
+8 -9
View File
@@ -1,13 +1,12 @@
<script setup lang="ts">
/**
* Design systems — the stylesheets this install RECORDS, for the projects it
* tracks (milestone #254 step 5).
* tracks.
*
* It had a sibling, `/design`, which showed the system as the BROWSER had it —
* names out of a bundled stylesheet, values out of `getComputedStyle`. That
* could only ever describe the install serving the page, so it was a mirror
* rather than a tool and was retired (#274). Everything here works on a system
* whose app this browser has never loaded.
* Showing the system as the BROWSER has it — names out of a bundled
* stylesheet, values out of `getComputedStyle` — could only describe the
* install serving the page, a mirror rather than a tool. Everything here works
* on a system whose app this browser has never loaded.
*
* The layout follows the model rather than decorating it. A system with a
* parent holds only what it changes, so this page has two lists and they are
@@ -153,7 +152,7 @@ const newTitle = ref("");
const newDescription = ref("");
const newParentId = ref<number | null>(null);
const creating = ref(false);
// Starter roles (#2349). The picker fills these on mount; empty means the
// Starter roles. The picker fills these on mount; empty means the
// operator unchecked everything, which is a real answer.
const starterGroups = ref<string[]>([]);
const tokenPrefix = ref("");
@@ -500,8 +499,8 @@ watch(selectedId, () => {
* `var()` reference states nothing on its own: rendering
* `color-mix(in srgb, var(--accent) 15%, transparent)` as a background resolves
* `--accent` against THIS app, so a system that isn't the one Scribe runs on
* would be drawn in Scribe's palette. It looked right, which is why it went
* unnoticed (#274).
* would be drawn in Scribe's palette — and look right, which is why it would
* go unnoticed.
*
* The preview above resolves values properly, on a probe carrying only that
* system's declarations. So this list draws only what needs no resolving, and
+2 -2
View File
@@ -14,8 +14,8 @@
*
* IT BINDS NOBODY, and the page says so once, plainly. That sentence is the
* difference between this kind and a rule, and it is the reason the kind
* exists (#3727) — sessions were proposing rules for things that should never
* have bound anyone.
* exists — without it, sessions propose rules for things that should never
* bind anyone.
*
* BUT IT CAN POINT AT ONE. A lesson that is an instance of a
* rule says so, and the page shows which of the three answers it holds: the
+1 -1
View File
@@ -231,7 +231,7 @@ async function save(force = false) {
? await updateLesson(lessonId.value, payload)
: await createLesson(payload);
toast.show(isEdit.value ? "Lesson updated" : "Lesson recorded");
// "No rule fits" just completed a group of lessons in one situation (#4634).
// "No rule fits" just completed a group of lessons in one situation.
// Said once, here, because the page this goes to does not recompute it.
if (saved.convergence) {
toast.show(
+3 -2
View File
@@ -116,8 +116,9 @@ function truncate(text: string | null, max = 120): string {
return text.length > max ? text.slice(0, max) + "..." : text;
}
// A card is a glance, not a report. Roundtable had ~35 milestones and its tile
// ran several viewport-heights tall, which made the grid unreadable (#2391).
// A card is a glance, not a report. A project with dozens of milestones would
// otherwise run its tile several viewport-heights tall and make the grid
// unreadable.
const MAX_MILESTONE_BARS = 10;
interface MilestoneBar extends MilestoneSummary {
-2
View File
@@ -977,8 +977,6 @@ onMounted(async () => {
// Load user profile
await loadProfile();
// Load journal config (locations, temp unit; prep/closeout UI removed in Phase 7)
// Check SearXNG status
try {
const sr = await apiGet<{ configured: boolean; searxng_url: string }>("/api/settings/search");
+1 -1
View File
@@ -35,7 +35,7 @@ function locParts(loc: { repo: string; path: string; symbol: string }): string[]
return [loc.repo, loc.path, loc.symbol].filter((p) => p && p.trim());
}
// Un-merge (#2165)
// Un-merge
const unmerging = ref<number | null>(null);
/** Only entries carrying what the source contributed can be reversed exactly.
-3
View File
@@ -119,9 +119,6 @@ async function toggleSubTask(sub: SubTask) {
const showPreview = ref(false);
const sidebarOpen = ref(true);
// reconsolidate / isBodyAutoMaintained removed in Phase 8 — the curator
// that auto-maintained task bodies is gone, so the body editor is now
// always user-controlled.
const editorRef = ref<InstanceType<typeof TiptapEditor> | null>(null);
const titleRef = ref<HTMLInputElement | null>(null);
const tiptapEditor = computed<Editor | null>(() => {
+10 -12
View File
@@ -8,13 +8,13 @@ like. Three ways it goes wrong, all of them silent:
1. A rule is deleted and its `:hover` / modifier survives. The selector still
exists, so nothing reads as unused, but the element renders with no base
styling at all. `.btn-workspace:hover` outlived `.btn-workspace` and a
router-link rendered as raw browser blue for days.
styling at all — `.btn:hover` outliving `.btn` leaves a link in raw
browser blue.
2. The parent's layout rule is deleted while the children keep theirs.
`.milestone-header` was a flex row; its children still declare `flex: 1`
and `flex-shrink: 0`. Without the parent they stack vertically, and a
milestone that was one line becomes five. Nothing errors — the page just
A header that is a flex row loses its rule; its children still declare
`flex: 1` and `flex-shrink: 0`. Without the parent they stack vertically,
and a row that was one line becomes five. Nothing errors — the page just
wastes space, which reads as a design decision.
3. A rule is removed from a comma-separated group, leaving `.a,` dangling in
@@ -35,9 +35,8 @@ Descendant selectors count as a base — `.panel .row {}` styles `.row` — beca
from the element's side there is no difference. Only the LAST compound of a
selector is what it styles, and a compound's whole class SET is what it
requires: `.pane.empty` and `td.num` are base rules for the element carrying
those classes, not modifiers. Reading them as modifiers cost this check four
false reports on its first run, and a check with false reports is one that gets
skimmed.
those classes, not modifiers. Reading them as modifiers produces false
reports, and a check with false reports is one that gets skimmed.
REPORT, NOT FAIL. Bare wrappers with no styling of their own are legitimate,
so this cannot be a gate without a suppression mechanism nobody would maintain.
@@ -46,11 +45,10 @@ say so in a comment beside its descendant rules — the remaining reports here
all carry one, so a new entry means something changed.
KNOWN BLIND SPOT: a class with NO rule anywhere is invisible to this, because
it cannot be told from a semantic-only hook. `.systems-list` had lost its
entire rule and was rendering with browser bullets; it was found by reading the
file next to a class that WAS half-styled, not by this check.
it cannot be told from a semantic-only hook. A list whose entire rule is gone
renders with browser bullets, and only reading the file finds it.
INSTANCE-AGNOSTIC (rule #115). Nothing here knows a class name, a component, or
INSTANCE-AGNOSTIC. Nothing here knows a class name, a component, or
a convention; point it at any Vue tree.
"""
from __future__ import annotations
+10 -11
View File
@@ -1,19 +1,18 @@
#!/usr/bin/env python3
"""Check the frontend's CSS against the tokens its stylesheet declares.
The gap this closes: `services/design_stylesheet.check_code_against_tokens` has
always been able to answer "does this code use the sheet correctly?", but the
only thing ever fed to it was recorded SNIPPETS. The app's own components — where
sixteen unresolvable references were found living quietly (#2319) — were checked
by nothing at all.
`services/design_stylesheet.check_code_against_tokens` answers "does this code
use the sheet correctly?", but inside the app it is fed only recorded SNIPPETS.
The app's own components, where unresolvable references live quietly, need a
check of their own.
That was structural rather than an oversight. The drift panel runs in the browser
and cannot read source files, and the server has no repo access. CI is the only
place holding both the component sources and the ability to run the check, and it
only became cheap once `theme.css` became a generated artifact — so the source of
truth is a local file, with no network and no credentials.
That is structural. The drift panel runs in the browser and cannot read source
files, and the server has no repo access. CI is the only place holding both the
component sources and the ability to run the check, and `theme.css` is a
generated artifact — so the source of truth is a local file, with no network
and no credentials.
INSTANCE-AGNOSTIC ON PURPOSE (rule #115). Nothing here knows what a token should
INSTANCE-AGNOSTIC ON PURPOSE. Nothing here knows what a token should
be called or which literals are discouraged. Both come from the stylesheet: the
declarations, and the `SUPERSEDES` block the generator emits. Point it at a
different install's sheet and it checks that install's rules.
+89 -113
View File
@@ -4,47 +4,35 @@
WHY THIS EXISTS. `plugin/` is not built into the Docker image. Installs fetch it
from this git repo via `.claude-plugin/marketplace.json`, so a push IS the
release for plugin content: no build, no gate, immediately fetchable. Two
separate defects have reached a live install through that path:
failures reach an install through that path without a sound:
- #2198 — all four hook scripts were inert (wrong env-var case, line-oriented
`jq -rR`, line-oriented `cut -c`). No CI ran, because `plugin/**` wasn't in
the workflow's `paths:` filter at all.
- #2209 — the fix for #2198 shipped to `main` and still couldn't reach an
install, because `plugin.json`'s version wasn't bumped and the installer
compares versions to decide whether to refresh its cache.
- inert hook scripts (a wrong env-var case, a line-oriented `cut -c`) — so
`plugin/**` is in the workflow's `paths:` filter and these checks run on it;
- a fix that ships and cannot reach an install, because `plugin.json`'s
version did not move and the installer compares versions to decide whether
to refresh its cache.
Both were fixed. The second was fixed TWICE — once by bumping the number, and
then properly, by removing the class it came from: `plugin.json`'s version is
no longer a value anybody chooses. `scripts/mint_plugin_version.py` derives it
from the clock (`make mint-plugin`), and `check_version_is_minted` below fails
the lane when shipped content moved and the version did not.
`plugin.json`'s version is not a value anybody chooses.
`scripts/mint_plugin_version.py` derives it from the clock (`make mint-plugin`),
and `check_version_is_minted` below fails the lane when shipped content moved
and the version did not.
State exactly what that did and did not remove, because a rationale that
overstates its own control is how the control gets trusted past its limit, and
because the paragraph this replaces was itself read that way. Gone: having to
remember which NUMBER to write, and the whole question of whether a chosen
number was the right one. Not gone: the mint still has to be RUN, and
forgetting to run it is still possible. What changed is that forgetting is now
LOUD — a red lane on the batch that forgot, instead of a silent no-op found
weeks later when somebody says "I don't think it updated" (#2220).
What that does and does not remove, stated exactly, because a rationale that
overstates its own control is how the control gets trusted past its limit.
Gone: choosing which NUMBER to write, and the question of whether a chosen
number was the right one. Not gone: the mint still has to be RUN. What the
check adds is that forgetting is LOUD — a red lane on the batch that forgot,
instead of a silent no-op found weeks later when somebody says "I don't think
it updated".
shellcheck is NOT in `ci-python` (verified against CI-runner's Dockerfile and
scripts/install-common.sh, not from memory — rule #37). CI installs it per-job,
which is what CI-runner's own docs/process.md prescribes for a dep with a
single consumer: "If only one project needs the dep, prefer that project
installing it per-job in their workflow — at least until a second consumer
arrives." Promotion into the image is filed as an issue there rather than
assumed here.
shellcheck is NOT in the `ci-python` image; CI installs it per-job, the usual
practice for a dependency with a single consumer. It is optional at runtime:
without shellcheck the lint step is SKIPPED and says so. A skipped check
announces itself loudly, because a check that quietly no-ops is the failure
mode this whole file exists to prevent.
It is optional at runtime: without shellcheck the lint step is SKIPPED and says
so. A skipped check announces itself loudly, because a check that quietly
no-ops is the failure mode this whole file exists to prevent.
jq USED TO BE IN THAT SAME SENTENCE, and the smoke tests below skipped
themselves without it. Since jq is not in `ci-python` either, that meant three
of them announced a skip on every CI run and had never actually run there. The
hooks need no jq now (#4107), so those checks run unconditionally — removing a
dependency from the product removed a permanent hole in its verification.
The hooks need no jq (it is not in `ci-python` either), so the smoke tests
below run unconditionally rather than skipping wherever it is absent.
Usage:
python3 scripts/check_plugin.py # all checks
@@ -85,7 +73,7 @@ MANIFEST = PLUGIN_DIR / ".claude-plugin" / "plugin.json"
# ── What ships, and what decides what it says about itself ─────────────────
#
# ONE definition (#3127 §3, milestone 334 step 2). It has TWO consumers that
# ONE definition. It has TWO consumers that
# need different granularities, and conflating them is the bug:
#
# the workflow's `paths:` trigger whole paths should CI run at all?
@@ -103,15 +91,14 @@ MANIFEST = PLUGIN_DIR / ".claude-plugin" / "plugin.json"
# carries description, mcpServers and userConfig, all of which reach an
# install and all of which matter. Excluding the file wholesale would mean a
# userConfig-only edit computes an unchanged version and never refreshes —
# #2209 again with a narrower trigger.
# the unreached fix again, with a narrower trigger.
SHIPPED_PATHS = ("plugin", ".claude-plugin")
# Files that decide what a published artifact SAYS ABOUT ITSELF — kept as a
# table so the next artifact is a one-line addition rather than a third
# bespoke guard (#3127 §3). The membership test is NOT "is this copied into
# the artifact?" but "can changing this file change the published bytes, or
# what the artifact says about itself?" — FC learned that twice in four days
# (#3156, #3202), and a deriver is never in the COPY list.
# bespoke guard. The membership test is NOT "is this copied into the
# artifact?" but "can changing this file change the published bytes, or what
# the artifact says about itself?" — a deriver is never in the COPY list.
#
# Note what is absent: a CHECKER does not belong here. Whatever validates a
# version decides whether the lane goes red, not what any artifact reports,
@@ -119,10 +106,10 @@ SHIPPED_PATHS = ("plugin", ".claude-plugin")
# the plugin version is.
DERIVERS: dict[str, tuple[str, ...]] = {
# The "Generate image tags and version" step computes the server image's
# name, ordering key and channel (#3298).
# name, ordering key and channel.
".forgejo/workflows/ci.yml": ("server-image",),
# Decides the plugin's version FORMAT, so it decides what every future
# manifest says about itself (milestone 334 step 3).
# manifest says about itself.
"scripts/mint_plugin_version.py": ("plugin",),
}
@@ -130,7 +117,7 @@ DERIVERS: dict[str, tuple[str, ...]] = {
def version_relevant_paths() -> tuple[str, ...]:
"""Everything a change to which must produce a NEW plugin version.
Wider than `SHIPPED_PATHS`, and #3127 §3's asymmetry is why it has to be:
Wider than `SHIPPED_PATHS`, and this asymmetry is why it has to be:
A change to how the VERSION is computed is compared against nothing at
all. Left out, the published artifact goes on reporting the OLD value
@@ -140,7 +127,7 @@ def version_relevant_paths() -> tuple[str, ...]:
and a diff over the shipped paths alone reports "no content change, the
version need not move". The manifest then keeps a value in the old format
forever and nothing ever says so. The mint script reaches no install and
belongs here anyway; that is #3156's exact shape.
belongs here anyway.
A CHECKER is deliberately not here. Whatever validates the version decides
whether the lane goes red, not what any artifact reports — so this file is
@@ -165,7 +152,7 @@ def ok(msg: str) -> None:
def skip(msg: str) -> None:
# Loud on purpose. A check that quietly does nothing is indistinguishable
# from a check that passed — the exact confusion that let #2198 survive.
# from a check that passed.
print(f"SKIP {msg}")
@@ -191,7 +178,7 @@ def check_syntax() -> None:
# --- known-bad patterns ----------------------------------------------------
# Each entry: (compiled pattern, short label, why it's wrong).
# These are the exact classes from #2198. They are deliberately specific — a
# These are the classes that make a hook inert. They are deliberately specific — a
# broad shell linter belongs in the image, not hand-rolled here.
PATTERNS: list[tuple[re.Pattern, str, str]] = [
(
@@ -202,15 +189,11 @@ PATTERNS: list[tuple[re.Pattern, str, str]] = [
"hook then does nothing, silently.",
),
(
# NEITHER OF THESE MAY COME BACK (#4107). This replaced a narrower rule
# about `jq -R` being line-oriented, which is now moot in the only way
# that rule could become moot: there is no jq left to pass flags to.
#
# The wider rule is the one worth having. jq is absent by default on
# NEITHER OF THESE BELONGS IN A HOOK. jq is absent by default on
# macOS, on the Debian/Ubuntu slim images, on Alpine and in most CI
# containers; `tac` is GNU-only and absent on macOS. Every hook used to
# guard itself with `command -v jq || exit 0`, so a machine without it
# got no context, no rules, no prior art and no process sync, silently —
# containers; `tac` is GNU-only and absent on macOS. A hook that guards
# itself with `command -v jq || exit 0` gives a machine without it no
# context, no rules, no prior art and no process sync, silently —
# `exit 0` is indistinguishable from "nothing to say". A hook may use
# only what POSIX guarantees; scribe_defs.sh has the readers.
re.compile(r"(?<![\w./-])(jq|tac)(?![\w./-])"),
@@ -219,15 +202,15 @@ PATTERNS: list[tuple[re.Pattern, str, str]] = [
"by default anywhere the plugin is likely to land, and tac is GNU-only. "
"Read JSON with scribe_json_flat / _pick / _list / _len, write it with "
"scribe_json_out, encode with scribe_urlenc; to take the LAST match of "
"a filter, pipe to `tail -1` instead of reversing with tac (#4107).",
"a filter, pipe to `tail -1` instead of reversing with tac.",
),
(
# `$(producer | head -N) || var=""` — the fallback OUTSIDE the
# substitution (#4042). Every hook runs under `pipefail`; once `head`
# substitution. Every hook runs under `pipefail`; once `head`
# (or `grep -m`/`-q`) has what it needs it exits, a producer still
# writing dies of SIGPIPE, the substitution fails, and the fallback
# wipes the output that was already captured. Silent, and only on big
# inputs — which for the duplicate arm meant the most-duplicated names.
# inputs — which for the duplicate arm means the most-duplicated names.
re.compile(r"\|\s*(?:head\b|grep\s+-[A-Za-z]*[mq])[^)]*\)\s*\|\|\s*\w+=\"\""),
"early-exit consumer with the fallback outside the substitution",
"Under pipefail, `$(x | head -N) || v=\"\"` discards head's output whenever x "
@@ -298,7 +281,7 @@ def check_shellcheck() -> None:
# always emits a static behavioural floor that needs no credentials and no
# network, so "silent" would be the wrong assertion for it.
#
# UNREACHABLE is different for the two write-path hooks since #2932: a call
# UNREACHABLE is different for the two write-path hooks: a call
# that was owed and did not come back is SAID, once per outage ("> Scribe did
# not answer …"), so a session can tell "checked, nothing there" from "never
# checked". That line — or silence, when the once-per-outage marker in
@@ -306,19 +289,17 @@ def check_shellcheck() -> None:
# is the only output allowed with no working instance; anything else is a hook
# speaking on data it cannot have.
#
# This is the contract that made #2198 invisible for weeks, so it is worth
# pinning: the bug and the healthy no-results case looked identical from
# outside. #2932 is what finally makes the failure visible at the write; this
# check makes sure the fail-open behaviour stays deliberate rather than
# accidental.
# The contract is worth pinning because an inert hook and the healthy
# no-results case look identical from outside. The outage line makes the
# failure visible at the write; this check makes sure the fail-open behaviour
# stays deliberate rather than accidental.
# A symbol that exists nowhere, ASSEMBLED rather than written literally.
# The prior-art hook's local arm (#2280) fires with no credentials, so the
# silence assertion below needs a name the repo genuinely lacks. Two traps,
# both hit while writing this:
# - `def f` matched real code, so the hook spoke and "silent" was asserting
# the wrong thing;
# - spelling the replacement out in full put `def <name>(` INTO this file,
# so the smoke event defined the very symbol it claimed was absent.
# The prior-art hook's local arm fires with no credentials, so the silence
# assertion below needs a name the repo genuinely lacks. Two traps:
# - a short name like `def f` matches real code, so the hook speaks and
# "silent" asserts the wrong thing;
# - spelling the replacement out in full puts `def <name>(` INTO this file,
# so the smoke event defines the very symbol it claims is absent.
# Concatenating keeps the contiguous string out of the source.
_ABSENT_SYM = "zz" + "_absent_" + "9f3a2b"
@@ -331,7 +312,7 @@ SMOKE_EVENTS: dict[str, str] = {
"tool_input": {"file_path": "src/x.py",
"new_string": f"def {_ABSENT_SYM}():\n pass\n"}}
),
# The pre-tool rule arm (#3476). A real Bash call, and one whose whole
# The pre-tool rule arm. A real Bash call, and one whose whole
# point is that it looks harmless: reaching for curl against the forge API
# is the reflex the arm exists to catch. With no instance it must stay
# SILENT — it is deliberately not an OUTAGE_SPEAKER, because a Bash call is
@@ -341,7 +322,7 @@ SMOKE_EVENTS: dict[str, str] = {
{"session_id": "smoke", "cwd": ".", "tool_name": "Bash",
"tool_input": {"command": "curl -s https://example.invalid/api/v1/runs"}}
),
# The moment arm (milestone 458). A command the shipped mappings send to
# The moment arm. A command the shipped mappings send to
# work.deliver, so a configured run reaches the moment-tools fetch and the
# moment request; with no instance both fail and it must stay SILENT,
# since it fires before every call that can reach a mounted rule.
@@ -351,7 +332,7 @@ SMOKE_EVENTS: dict[str, str] = {
),
"scribe_sync_processes.sh": json.dumps({"source": "startup"}),
"scribe_session_context.sh": json.dumps({"source": "startup"}),
# The after-write hook (#2901) diffs the working tree; on CI's clean
# The after-write hook diffs the working tree; on CI's clean
# checkout there is nothing to report, so silence is the right assertion.
# (On a dirty local tree with a definition just written it may speak —
# that is the hook working, not a failure of the contract.)
@@ -359,27 +340,27 @@ SMOKE_EVENTS: dict[str, str] = {
{"session_id": "smoke", "cwd": ".", "tool_name": "Bash",
"tool_input": {"command": "true"}, "tool_response": {}}
),
# The Stop-hook report check (milestone 409 step 5). A transcript that does
# The Stop-hook report check. A transcript that does
# not exist is the smoke case: nothing to read, so it must stay silent and
# never block, configured or not.
"scribe_report_check.sh": json.dumps(
{"session_id": "smoke", "transcript_path": "/nonexistent/smoke.jsonl",
"cwd": ".", "hook_event_name": "Stop", "stop_hook_active": False}
),
# The reply moment (milestone 458). No transcript, so no reply: silent and
# The reply moment. No transcript, so no reply: silent and
# never a block, configured or not.
"scribe_reply_check.sh": json.dumps(
{"session_id": "smoke", "transcript_path": "/nonexistent/smoke.jsonl",
"cwd": ".", "hook_event_name": "Stop", "stop_hook_active": False}
),
# The end-of-turn shape check (milestone 439). With no ledger for the
# The end-of-turn shape check. With no ledger for the
# session there is nothing to ask about, and with no instance there is
# nobody to ask: silent, and never a block, configured or not.
"scribe_shape_check.sh": json.dumps(
{"session_id": "smoke", "transcript_path": "/nonexistent/smoke.jsonl",
"cwd": ".", "hook_event_name": "Stop", "stop_hook_active": False}
),
# The PreCompact preserver (#3680). Its whole contract is the inverse of
# The PreCompact preserver. Its whole contract is the inverse of
# every other hook's: it must exit 0 AND print, because stdout is what
# becomes the summarizer's custom instructions. A silent success here is
# the failure mode, and it would look like every other hook's success.
@@ -387,7 +368,7 @@ SMOKE_EVENTS: dict[str, str] = {
{"session_id": "smoke", "cwd": ".", "hook_event_name": "PreCompact",
"trigger": "manual", "custom_instructions": None}
),
# The opened-ledger recorder (#4100). It writes to TMPDIR and prints
# The opened-ledger recorder. It writes to TMPDIR and prints
# nothing — a PostToolUse hook that emitted output would put a line in
# front of every get_rule call, which is the opposite of its purpose. The
# smoke case is a well-formed open: it must exit 0 and stay silent.
@@ -396,18 +377,17 @@ SMOKE_EVENTS: dict[str, str] = {
"tool_name": "mcp__scribe__get_rule", "tool_input": {"rule_id": 1},
"tool_response": {}}
),
# The acted-ledger recorder (#4216). Same shape and same reasoning as its
# sibling above: TMPDIR only, and silent, because a PostToolUse hook that
# spoke would put a line after every rule_outcome call and turn recording
# an outcome into something with a cost. A skip here would have left the
# newest of the three ledgers as the only hook the lane never runs.
# The acted-ledger recorder. Same shape and same reasoning as its sibling
# above: TMPDIR only, and silent, because a PostToolUse hook that spoke
# would put a line after every rule_outcome call and turn recording an
# outcome into something with a cost.
"scribe_record_outcome.sh": json.dumps(
{"session_id": "smoke", "cwd": ".",
"tool_name": "mcp__scribe__rule_outcome",
"tool_input": {"rule_id": 1, "outcome": "applied"},
"tool_response": {}}
),
# The claim-session binder (milestone 381). Silent like the ledgers above —
# The claim-session binder. Silent like the ledgers above —
# a PostToolUse hook that spoke would add a line after every task write —
# and with no instance configured it must exit before reaching for one.
"scribe_claim_session.sh": json.dumps(
@@ -416,7 +396,7 @@ SMOKE_EVENTS: dict[str, str] = {
"tool_input": {"task_id": 1, "content": "smoke"},
"tool_response": {}}
),
# The SessionEnd claim release (milestone 381 step 4). Silent: nobody reads
# The SessionEnd claim release. Silent: nobody reads
# a SessionEnd hook's output, and with no instance it must exit first.
"scribe_session_end.sh": json.dumps(
{"session_id": "smoke", "cwd": ".", "hook_event_name": "SessionEnd",
@@ -435,14 +415,14 @@ STATIC_EMITTERS = {
# The two-tier SessionStart design: the bundled static tier ships whatever
# the instance does, and that floor is the whole point of the split.
"scribe_session_context.sh": "static floor present",
# PreCompact's contract is INVERTED (#3680). Its stdout becomes the
# PreCompact's contract is INVERTED. Its stdout becomes the
# summarizer's custom instructions, and what must survive a summary is
# known without asking anything — so it needs no instance, and silence is
# the failure mode rather than the success one. Read as a generic hook it
# would look like a leak; it is the opposite.
"scribe_precompact_preserve.sh": "preservation instructions present",
}
# The hooks that say so when a configured instance does not answer (#2932).
# The hooks that say so when a configured instance does not answer.
OUTAGE_SPEAKERS = {"scribe_prior_art.sh", "scribe_after_write.sh"}
OUTAGE_LINE = "> Scribe did not answer the prior-art check"
@@ -458,13 +438,9 @@ def _run_hook(script: Path, event: str, env_extra: dict[str, str]) -> subprocess
def check_fail_open() -> None:
# No jq gate any more (#4107). This check used to skip itself when jq was
# missing, because without it every hook bailed at line 1 and the check
# would have passed while exercising nothing. jq is NOT in `ci-python`, so
# what that actually meant is that this smoke test announced a skip on
# every CI run and never once ran there. The hooks now need only POSIX
# tools, so it runs everywhere — which is the point of the change it is
# testing.
# No jq gate: the hooks need only POSIX tools, so this runs everywhere,
# CI's `ci-python` image included. A gate on a tool the image lacks would
# announce a skip on every run and never exercise the hooks there.
scenarios = [
("unconfigured", {}),
# Connection refused immediately — exercises the unreachable-instance
@@ -514,7 +490,7 @@ def check_fail_open() -> None:
def check_local_prior_art_needs_no_instance() -> None:
"""The prior-art hook's local arm must answer with no credentials (#2280).
"""The prior-art hook's local arm must answer with no credentials.
The other arms ask Scribe what was RECORDED. This one asks the repo what
EXISTS, which needs no instance — and that is the whole reason it catches
@@ -562,8 +538,8 @@ def check_session_context_reports_its_version() -> None:
pulls on its own, while the CACHE is what executes and refreshes only when
the manifest version changes. So a shipped fix can sit unreached while
inspecting the clone shows it present — the obvious debugging move
misleads, and twice the only detector was a human saying "I don't think it
updated" (#2209, #2220).
misleads, and otherwise the only detector is a human saying "I don't think
it updated".
Asserted WITHOUT credentials on purpose. The state most needing diagnosis
is the one where the token never arrives, and a marker that vanished there
@@ -643,8 +619,8 @@ def manifest_differs_beyond_version(a: str | None, b: str | None) -> bool:
or mcpServers-only edit demanding a new version.
Unreadable input answers True. The conservative direction is "demand a new
version": a spurious bump costs one cache refresh, while a missed one is
#2209 — the fix reaches the repo and stops there.
version": a spurious bump costs one cache refresh, while a missed one means
the fix reaches the repo and stops there.
"""
def without_version(text: str | None):
if text is None:
@@ -667,8 +643,8 @@ def shipped_content_changed(base: str) -> tuple[bool | None, list[str]]:
"""Has anything that REACHES AN INSTALL changed against `base`?
Returns `(changed, paths)`. `changed` is **None** when the question could
not be answered — a caller must never read that as "no", which is the
distinction #2663 cost weeks of zeroed telemetry to learn.
not be answered — a caller must never read that as "no": an unanswered
question read as "nothing changed" is a failure that reports success.
The manifest is special-cased, not excluded: if it is the ONLY thing that
moved and the only difference is `version`, nothing that reaches an
@@ -693,17 +669,17 @@ def shipped_content_changed(base: str) -> tuple[bool | None, list[str]]:
def check_version_is_minted(base: str = "origin/main") -> None:
"""THE control (#3127 checklist 4), replacing "somebody remembers".
"""THE control, replacing "somebody remembers".
The checklist asks, of any hand-set component: *say what happens the
release somebody forgets it.* This is the answer — the lane goes red,
deterministically, because CI can compute whether the value should have
moved. Its predecessor could only ask "did the number move at all", which
any bump satisfied and which therefore proved nothing.
moved. Asking only "did the number move at all" would be satisfied by any
bump and would therefore prove nothing.
Four verdicts:
content changed, version did not FAIL — this is #2209, exactly
content changed, version did not FAIL — the fix would not reach an install
version not in canonical shape FAIL — see below
version implausibly in the future FAIL — a bad clock or a hand-edit
version moved, content did not pass, and say so
@@ -718,10 +694,10 @@ def check_version_is_minted(base: str = "origin/main") -> None:
A malformed version is worth failing on even though the installer would
accept it. `K4` returns the manifest string verbatim, and `H == "unknown"`
sets `forceOverwrite`, so a broken value either sorts as a normal string
or reinstalls the plugin every single session (#3325). Neither is loud.
or reinstalls the plugin every single session. Neither is loud.
Stated against the BASE BRANCH rather than the last commit, as its
predecessor was: a per-commit rule would demand a fresh mint from every
Stated against the BASE BRANCH rather than the last commit: a per-commit
rule would demand a fresh mint from every
commit in a batch, when what matters is that whatever reaches an install
differs from what is cached. One mint per batch, which is also how a person
would do it.
@@ -745,8 +721,8 @@ def check_version_is_minted(base: str = "origin/main") -> None:
if not VERSION_RE.match(here):
fail(
f"the manifest version is {here!r}, which is not YYYY.MM.DD.HHMM.\n"
f" One shape for every version in the family (#3127 checklist "
f"10), zero-padded so the midnight case renders 2026.01.05.0000.\n"
f" One shape for every version in the family, zero-padded so "
f"the midnight case renders 2026.01.05.0000.\n"
f" Run `make mint-plugin`."
)
return
@@ -780,7 +756,7 @@ def check_version_is_minted(base: str = "origin/main") -> None:
f" The installer decides whether to refresh its cache by "
f"comparing this string, so an unchanged version means these edits "
f"reach the repo and stop there — the marketplace clone updates, the "
f"cache that actually executes does not (#2209, #1040, #2220).\n"
f"cache that actually executes does not.\n"
f" Run `make mint-plugin`.\n"
f" Changed:\n {files}"
)
+1 -2
View File
@@ -7,8 +7,7 @@ A DRY audit made of several passes ends with a question no single pass can
answer: did the audit as a whole reduce duplication, and did it create any? The
second half is the one that matters. A pass that shortens two statements can
leave them byte-identical to a third, and the new copy is invisible to that
pass's own scan because it did not exist when the scan ran (#4745: a retro over
a 17-pass audit found exactly this, and folded it into a view). This script is
pass's own scan because it did not exist when the scan ran. This script is
the close-out measure the DRY Pass process names, so the measure is a tool and
not something each audit improvises.
+4 -5
View File
@@ -11,7 +11,7 @@ Installs fetch it straight from this git repo via `.claude-plugin/
marketplace.json`, so **a push IS the release** — there is no build between
you committing and a user fetching, and therefore no moment at which CI could
stamp a version in. Every other artifact in the family derives its version
during a build (note #3127 §2). This one has no build to derive during.
during a build. This one has no build to derive during.
WHICH CLOCK, AND WHY IT DIFFERS FROM THE SERVER IMAGE — the divergence is
deliberate, and it lives one directory away from its opposite, so it is
@@ -27,8 +27,7 @@ one lane and no build, so that reason does not reach it and paying its cost
buys nothing. What is given up is reproducibility-from-history: you cannot
recompute this value later, only verify that it moved when it had to.
That trade is acceptable ONLY because of what #3325 established by reading the
installer's code: the refresh test is `P.version === H`, plain string
That trade is acceptable ONLY because of what the installer's code does: the refresh test is `P.version === H`, plain string
equality, with no ordering comparison anywhere. Where a comparator ORDERS, an
unreproducible version is dangerous — nothing can check it is right. Where it
only tests equality, "did it change when it should have" is the entire
@@ -52,8 +51,8 @@ ROOT = Path(__file__).resolve().parents[1]
MANIFEST = ROOT / "plugin" / ".claude-plugin" / "plugin.json"
# Four dot-separated numeric fields, zero-padded, and nothing else — one shape
# for every human-readable version in the family (#3127 checklist 10). The
# padding is load-bearing for the midnight case the checklist names by hand:
# for every human-readable version in the family. The padding is
# load-bearing for the midnight case:
# 2026.01.05.0000, which an unpadded `%-H%M` would render as `0` and silently
# shorten. Harmless while nothing orders these, wrong the moment anything does.
VERSION_RE = re.compile(r"^\d{4}\.\d{2}\.\d{2}\.\d{4}$")
+4 -13
View File
@@ -1,18 +1,9 @@
"""Project CRUD MCP tools — thin wrappers over services/projects.py.
Mirrors existing fable-mcp project tool contracts. Note: there is no
fable_delete_project here (matches existing fable-mcp surface). To stop
working on a project, update its status to 'archived'.
The LLM-era similarity-check / 'confirmed' guard from services/tools/projects.py
is intentionally NOT replicated here — Claude is the client, not a weak local
model that needs that guardrail. services.projects.create_project creates
directly with no similarity warning.
The auto-summary regeneration that services.projects.update_project triggers
async will be removed in Phase 7 (it's an LLM call). The wrapper makes no
assumption either way; once the service-layer side effect is gone, this code
keeps working.
There is no delete tool: to stop working on a project, set its status to
'archived'. create_project creates directly, with no similarity check or
'confirmed' guard — the client is a capable model that can read the project
list itself.
"""
from __future__ import annotations
+1 -5
View File
@@ -1,8 +1,4 @@
"""User settings + integrations (SearXNG status).
Chat-model picker endpoints (/models), KV-cache priming, and journal-schedule
hooks were removed in Phase 8 alongside the chat/journal subsystems.
"""
"""User settings + integrations (SearXNG status)."""
import logging
from quart import Blueprint, jsonify, request
+6 -6
View File
@@ -1,16 +1,16 @@
"""Session-context rendering for the Scribe plugin's SessionStart hook.
The plugin's hook curls `GET /api/plugin/context` at session start and injects
the returned text as `additionalContext`, giving Scribe the same push channel
that superpowers and file-memory have. This module renders that text.
the returned text as `additionalContext`, giving Scribe a push channel into
the session. This module renders that text.
Design note — altitude: we inject rule *titles* grouped by topic (a compact
index), NOT every rule's full statement. The 48 always-on statements run well
past the 10k-char `additionalContext` cap, and the push channel's job is to make
index), NOT every rule's full statement. A working rulebook's always-on statements run
well past the 10k-char `additionalContext` cap, and the push channel's job is to make
Claude *aware* the rules exist and *reach* for them — not to dump them. Full
text stays one `get_rule(id)` / `search(content_type="rule")` call away. Titles are
mostly self-describing ("`dev` is home", "No GitHub — Fabled-Git only"), so the
index alone already steers behavior.
mostly self-describing ("Run migrations before deploying"), so the index alone
already steers behavior.
"""
from __future__ import annotations
+2 -3
View File
@@ -1004,9 +1004,8 @@ async def restore_rule_version(
) -> Optional[Rule]:
"""Put a preference back to what it said. None when it is not readable.
MILESTONE 323 DECIDED THE OPPOSITE FOR RULES, and that decision stands —
`routes/rulebooks.py` still has no restore for them. Its reasoning:
"a binding instruction should not be revertible in one click", because a
RULES DELIBERATELY HAVE NO RESTORE — `routes/rulebooks.py` offers none —
because a binding instruction should not be revertible in one click: a
silent revert erases the only record of why the rewrite happened.
A preference inverts both halves of that. The rewrite was not the
+3 -4
View File
@@ -1,9 +1,8 @@
"""User profile service — structured per-user preferences.
Post-pivot the LLM-driven observation/consolidation surface is gone (curator
deleted in Phase 8). The profile model is preserved as user-managed metadata
(name, job, expertise, style, tone, interests, work schedule) — Claude can
read it via the upcoming MCP profile tools.
The profile is user-managed metadata (name, job, expertise, style, tone,
interests, work schedule), read and written through /api/profile and carried
in backups.
"""
import logging