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(); useTheme();
const router = useRouter(); const router = useRouter();
// THREE states, not two (#3127 checklist 12). `null` is "not answered yet" and // THREE states, not two. `null` is "not answered yet" and renders nothing; a
// renders nothing; a string renders; `appVersionFailed` renders its own thing. // string renders; `appVersionFailed` renders its own thing. Defaulting to the
// This used to default to the literal "dev" and swallow the error, which meant // literal "dev" and swallowing the error would make an instance that cannot
// an instance that could not answer was indistinguishable from a local build // answer indistinguishable from a local build that genuinely reports "dev" — a blank standing in for `unknown`, in the one
// that genuinely reports "dev" — a blank standing in for `unknown`, in the one
// readout whose whole job is to say what is running. // readout whose whole job is to say what is running.
const appVersion = ref<string | null>(null); const appVersion = ref<string | null>(null);
const appVersionFailed = ref(false); 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` * The server's `{"error": "..."}` message from a failed call, or `fallback`
* when the failure carried none (network error, non-JSON body). The one place * 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 * the error envelope is unpacked on the client, so no view restates it as a
* as a six-line `"body" in e` branch at every catch site. * `"body" in e` branch at its catch site.
*/ */
export function apiErrorMessage(e: unknown, fallback: string): string { export function apiErrorMessage(e: unknown, fallback: string): string {
if (e && typeof e === "object" && "body" in e) { 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. * 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 * 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 * embedding call and a list view under connection-pool contention, so
* /api/projects fanning 25 concurrent sessions at a 15-connection pool), so
* tripping it means something is genuinely wrong rather than merely busy. Slow * 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 * BY DESIGN is a different case and passes its own value — see the callers in
* SettingsView that do. * 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 * 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, * 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 /** The starter token ROLES offered at creation — names and purposes, never
* values. A default palette would be one install's taste shipped as product * 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. */ * so the values are always the operator's to fill. */
export const listStarterRoleGroups = () => export const listStarterRoleGroups = () =>
apiGet<{ groups: StarterRoleGroup[]; default_prefix: string }>( apiGet<{ groups: StarterRoleGroup[]; default_prefix: string }>(
"/api/design-systems/starter-roles", "/api/design-systems/starter-roles",
+1 -1
View File
@@ -88,7 +88,7 @@ export interface Lesson {
no_rule?: { why: string; judged_at: string | null }; no_rule?: { why: string; judged_at: string | null };
/** Sent by a create or update that recorded "no rule fits" when that answer /** 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 * 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 }[] }; convergence?: { lessons: { id: number; title: string }[] };
/** Set when another user owns this record. */ /** Set when another user owns this record. */
shared?: boolean; shared?: boolean;
+1 -1
View File
@@ -30,7 +30,7 @@ export interface SnippetFields {
* no `locations`/`tags` predates that attribution and cannot be un-merged. */ * no `locations`/`tags` predates that attribution and cannot be un-merged. */
merged_from: { id: number; locations?: SnippetLocation[]; tags?: string[] }[]; merged_from: { id: number; locations?: SnippetLocation[]; tags?: string[] }[];
code: 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. */ * Kept out of `when_to_use`, which the snippet is ranked on. */
notes: string; notes: string;
} }
+2 -2
View File
@@ -24,7 +24,7 @@ export interface System {
open_issue_count: number; open_issue_count: number;
/** /**
* How often this area's rulings were shown to a session because its files * 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 * Present on the list and detail reads; optional because a System returned
* by a create or update carries none. * by a create or update carries none.
*/ */
@@ -45,7 +45,7 @@ export async function listSystems(projectId: number): Promise<System[]> {
* caller to accept or ignore. * caller to accept or ignore.
* *
* A same-named System in this project is a 409 ApiError carrying * 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 { export interface CreatedSystem extends System {
canonical_suggestion?: CanonicalMatch; 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 * 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 * — 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. /* 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 * A pill bar at `position: absolute; left: 50%` does not participate in
* not participate in layout: when the header ran out of room it OVERLAPPED the * layout: when the header runs out of room it OVERLAPS the brand and the
* brand and the utility cluster rather than pushing them, and nothing wrapped * utility cluster rather than pushing them, and nothing wraps or scrolls to
* or scrolled to signal it. A sixth link reached that point at ~1270px, which * signal it — at an ordinary window width on any monitor.
* is an ordinary window on any monitor.
* *
* `1fr auto 1fr` fixes it structurally. A `1fr` track has an AUTO minimum, so * `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 * neither side can be squeezed below its content, and the two side tracks stay
@@ -327,7 +326,7 @@ router.afterEach(() => {
margin-top: 0.25rem; 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 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 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 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 * 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 * 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 * 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 * shape being written a second time the first time someone wants it on notes.
* (#3207).
* *
* SILENT WHEN EMPTY. Most records taught no lesson, and a panel that renders * 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 * "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"> <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 * 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 * measured against the sheet they are supposed to use. The check itself is not
@@ -38,7 +38,7 @@ async function run() {
} catch { } catch {
// Said out loud rather than rendered as an empty result. "Couldn't check" // 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 // 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; failed.value = true;
} finally { } finally {
loading.value = false; loading.value = false;
@@ -4,7 +4,7 @@
* *
* Deliberately not StatusBadge. That component is typed to TaskStatus and * Deliberately not StatusBadge. That component is typed to TaskStatus and
* speaks a different vocabulary; these two only ever shared a CSS class name, * 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 * Extracted because ProjectView and ProjectListView really were spelling the
* same pill twice, with the differences you get from two hands rather than * same pill twice, with the differences you get from two hands rather than
@@ -1,6 +1,6 @@
<script setup lang="ts"> <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 * WHY THIS IS A COMPONENT
* DesignSystemsView has two creation forms — the empty state and the one inside * DesignSystemsView has two creation forms — the empty state and the one inside
@@ -11,7 +11,7 @@
* WHAT IT OFFERS * WHAT IT OFFERS
* Names and purposes, never values. A role is a question the operator answers * 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 * 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. * an operator who wants three tokens should get three.
* *
* All groups are checked by default. That default lives HERE rather than in the * 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<{ const props = defineProps<{
status: TaskStatus; status: TaskStatus;
clickable?: boolean; 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. */ VARIANT of this component rather than a reason to re-spell it. */
compact?: boolean; compact?: boolean;
}>(); }>();
+6 -6
View File
@@ -72,7 +72,7 @@ function areaName(system: System): string | null {
return canon.byId(system.canonical_id)?.name ?? 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 * 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, * delivered in full in the line, so an unopened System is the arm working,
* not dead weight. */ * not dead weight. */
@@ -721,16 +721,16 @@ async function confirmDelete() {
word-break: break-all; word-break: break-all;
} }
/* RESTORED (#2444). Both lost their base rule to a CSS sweep; only the /* BASE RULES for the card and the list. Without them only the `--archived`
`--archived` modifier and the `:hover .system-actions` reveal survived. modifier and the `:hover .system-actions` reveal would remain.
The card WAS a flex row and every child still says so — `.system-swatch` 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-actions` are `flex-shrink: 0`, `.system-body` is `flex: 1`,
and `.system-form--inline` is `flex: 1`. `align-items: flex-start` is why 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 the swatch carries `margin-top: 0.3rem`: it is nudged onto the first line
of text rather than centred against the whole card. of text rather than centred against the whole card.
The list had no rule at all, so it rendered with browser bullets and Without its rule the list renders with browser bullets and indent —
indent — invisible to the dangling-style check, which can only see a class 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 that is PARTLY styled. A class with no rules anywhere looks exactly like a
semantic-only hook. semantic-only hook.
+2 -2
View File
@@ -1,6 +1,6 @@
<script setup lang="ts"> <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 * WHAT MAKES THIS WORK FOR A SYSTEM YOU AREN'T RUNNING
* Every value is resolved on an offscreen probe carrying only this system's * 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 * 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 * 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 * 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. * 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 * A token with no value for the chosen mode is shown as undecided rather than
@@ -13,8 +13,8 @@
* fields moved". * fields moved".
* *
* What was genuinely shared is shared: DiffView.vue takes DiffLine[] and * 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 * nothing note-shaped, and the LCS walk lives in utils/diff.ts, which this
* this file uses rather than copying a fourth time (#3207). * file uses rather than carrying a copy of its own.
*/ */
import { computed, onMounted, ref, watch } from "vue"; import { computed, onMounted, ref, watch } from "vue";
import DiffView from "@/components/DiffView.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 { padding: 0.5rem; cursor: pointer; border-radius: 6px; }
li.active { background: var(--fs-accent-soft); } li.active { background: var(--fs-accent-soft); }
li:hover { background: var(--fs-surface-hover); } 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. */ children style themselves, and namespaces the descendant rule below. */
.new-topic input { .new-topic input {
width: 100%; margin-bottom: 0.5rem; 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 — * 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 * the workspace panels' list-row timestamp ("3h ago" / "Jan 15").
* panels used to carry identical copies of this.
*/ */
export function relativeTimeOrDate(iso: string): string { export function relativeTimeOrDate(iso: string): string {
const d = new Date(iso); const d = new Date(iso);
+1 -1
View File
@@ -7,7 +7,7 @@ import "./assets/theme.css";
import "./assets/components.css"; import "./assets/components.css";
import "./assets/prose.css"; import "./assets/prose.css";
// Global rather than `<style src>` per pane: plugin-vue cannot compile one // 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"; import "./assets/sweep-shared.css";
const app = createApp(App); 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, // The design systems this install RECORDS — for the projects it tracks,
// not for the install itself. There was a sibling `/design` that read the // not for the install itself. A page reading the running app's own
// running app's own stylesheet out of the browser; it could only ever // stylesheet out of the browser could only inspect the instance it was
// inspect the instance it was served from, which made it a mirror rather // served from, a mirror rather than a tool.
// than a tool (#274).
path: "/design-systems", path: "/design-systems",
name: "design-systems", name: "design-systems",
component: () => import("@/views/DesignSystemsView.vue"), component: () => import("@/views/DesignSystemsView.vue"),
+2 -3
View File
@@ -54,9 +54,8 @@ export const useTasksStore = defineStore("tasks", () => {
async function updateTask( async function updateTask(
id: number, id: number,
// IssueFields carries `kind`, which the PATCH route now reads. It has // IssueFields carries `kind`, which the PATCH route reads. A route that
// always been SENT by the task editor; until #3129 the route dropped it // dropped it would report success while changing nothing.
// and the save reported success while changing nothing.
data: Partial< data: Partial<
Pick<Task, "title" | "body" | "tags" | "status" | "priority" | "due_date" | "project_id" | "milestone_id" | "parent_id" | "recurrence_rule"> Pick<Task, "title" | "body" | "tags" | "status" | "priority" | "due_date" | "project_id" | "milestone_id" | "parent_id" | "recurrence_rule">
> & IssueFields > & IssueFields
+4 -5
View File
@@ -7,11 +7,10 @@
* position more often has a trigger that fires on the wrong work. Telling an * 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. * 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. * The table lives here because several surfaces need it: `SnippetListView`,
* Three already had their own: `SnippetListView`, `RuleListPane`, and * `RuleListPane`, `LessonDetailView`, and the unified Knowledge browse, which
* `LessonDetailView` with the sentence inline in its template. The unified * renders notes, tasks, processes, snippets and lessons in one mixed feed and
* Knowledge browse renders notes, tasks, processes, snippets and lessons in one * so needs all of them at once — and a per-view constant
* mixed feed (#4230), so it needs all of them at once — and a per-view constant
* is how three surfaces end up giving three different answers to the same * is how three surfaces end up giving three different answers to the same
* question. * question.
* *
+5 -6
View File
@@ -1,12 +1,11 @@
/** /**
* Turning a design system's RECORDED values into ones you can look at. * Turning a design system's RECORDED values into ones you can look at.
* *
* Replaces `designTokens.ts` and `designDrift.ts`, which between them read the * Reading the running app's own stylesheet — names out of a bundled
* running app's own stylesheet — names out of a bundled `theme.css`, values out * `theme.css`, values out of `getComputedStyle(document.documentElement)` —
* of `getComputedStyle(document.documentElement)`. That could only ever describe * could only describe the install serving the page, and the design surface is
* the install serving the page, and the design surface is for the projects an * for the projects an install TRACKS. Everything here works on any system's
* install TRACKS (#274). What is left here works on any system's record, * record, including one for an app this browser has never loaded.
* including one for an app this browser has never loaded.
* *
* Nothing in this module reads the document's own tokens or mutates the page. * 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"> <script setup lang="ts">
/** /**
* Design systems — the stylesheets this install RECORDS, for the projects it * 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 — * Showing the system as the BROWSER has it — names out of a bundled
* names out of a bundled stylesheet, values out of `getComputedStyle`. That * stylesheet, values out of `getComputedStyle` — could only describe the
* could only ever describe the install serving the page, so it was a mirror * install serving the page, a mirror rather than a tool. Everything here works
* rather than a tool and was retired (#274). Everything here works on a system * on a system whose app this browser has never loaded.
* whose app this browser has never loaded.
* *
* The layout follows the model rather than decorating it. A system with a * 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 * 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 newDescription = ref("");
const newParentId = ref<number | null>(null); const newParentId = ref<number | null>(null);
const creating = ref(false); 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. // operator unchecked everything, which is a real answer.
const starterGroups = ref<string[]>([]); const starterGroups = ref<string[]>([]);
const tokenPrefix = ref(""); const tokenPrefix = ref("");
@@ -500,8 +499,8 @@ watch(selectedId, () => {
* `var()` reference states nothing on its own: rendering * `var()` reference states nothing on its own: rendering
* `color-mix(in srgb, var(--accent) 15%, transparent)` as a background resolves * `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 * `--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 * would be drawn in Scribe's palette — and look right, which is why it would
* unnoticed (#274). * go unnoticed.
* *
* The preview above resolves values properly, on a probe carrying only that * 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 * 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 * 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 * 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 * exists — without it, sessions propose rules for things that should never
* have bound anyone. * bind anyone.
* *
* BUT IT CAN POINT AT ONE. A lesson that is an instance of a * 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 * 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 updateLesson(lessonId.value, payload)
: await createLesson(payload); : await createLesson(payload);
toast.show(isEdit.value ? "Lesson updated" : "Lesson recorded"); 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. // Said once, here, because the page this goes to does not recompute it.
if (saved.convergence) { if (saved.convergence) {
toast.show( 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; return text.length > max ? text.slice(0, max) + "..." : text;
} }
// A card is a glance, not a report. Roundtable had ~35 milestones and its tile // A card is a glance, not a report. A project with dozens of milestones would
// ran several viewport-heights tall, which made the grid unreadable (#2391). // otherwise run its tile several viewport-heights tall and make the grid
// unreadable.
const MAX_MILESTONE_BARS = 10; const MAX_MILESTONE_BARS = 10;
interface MilestoneBar extends MilestoneSummary { interface MilestoneBar extends MilestoneSummary {
-2
View File
@@ -977,8 +977,6 @@ onMounted(async () => {
// Load user profile // Load user profile
await loadProfile(); await loadProfile();
// Load journal config (locations, temp unit; prep/closeout UI removed in Phase 7)
// Check SearXNG status // Check SearXNG status
try { try {
const sr = await apiGet<{ configured: boolean; searxng_url: string }>("/api/settings/search"); 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()); return [loc.repo, loc.path, loc.symbol].filter((p) => p && p.trim());
} }
// Un-merge (#2165) // Un-merge
const unmerging = ref<number | null>(null); const unmerging = ref<number | null>(null);
/** Only entries carrying what the source contributed can be reversed exactly. /** 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 showPreview = ref(false);
const sidebarOpen = ref(true); 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 editorRef = ref<InstanceType<typeof TiptapEditor> | null>(null);
const titleRef = ref<HTMLInputElement | null>(null); const titleRef = ref<HTMLInputElement | null>(null);
const tiptapEditor = computed<Editor | 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 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 exists, so nothing reads as unused, but the element renders with no base
styling at all. `.btn-workspace:hover` outlived `.btn-workspace` and a styling at all — `.btn:hover` outliving `.btn` leaves a link in raw
router-link rendered as raw browser blue for days. browser blue.
2. The parent's layout rule is deleted while the children keep theirs. 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` A header that is a flex row loses its rule; its children still declare
and `flex-shrink: 0`. Without the parent they stack vertically, and a `flex: 1` and `flex-shrink: 0`. Without the parent they stack vertically,
milestone that was one line becomes five. Nothing errors — the page just and a row that was one line becomes five. Nothing errors — the page just
wastes space, which reads as a design decision. wastes space, which reads as a design decision.
3. A rule is removed from a comma-separated group, leaving `.a,` dangling in 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 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 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 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 those classes, not modifiers. Reading them as modifiers produces false
false reports on its first run, and a check with false reports is one that gets reports, and a check with false reports is one that gets skimmed.
skimmed.
REPORT, NOT FAIL. Bare wrappers with no styling of their own are legitimate, 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. 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. all carry one, so a new entry means something changed.
KNOWN BLIND SPOT: a class with NO rule anywhere is invisible to this, because 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 it cannot be told from a semantic-only hook. A list whose entire rule is gone
entire rule and was rendering with browser bullets; it was found by reading the renders with browser bullets, and only reading the file finds it.
file next to a class that WAS half-styled, not by this check.
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. a convention; point it at any Vue tree.
""" """
from __future__ import annotations from __future__ import annotations
+10 -11
View File
@@ -1,19 +1,18 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""Check the frontend's CSS against the tokens its stylesheet declares. """Check the frontend's CSS against the tokens its stylesheet declares.
The gap this closes: `services/design_stylesheet.check_code_against_tokens` has `services/design_stylesheet.check_code_against_tokens` answers "does this code
always been able to answer "does this code use the sheet correctly?", but the use the sheet correctly?", but inside the app it is fed only recorded SNIPPETS.
only thing ever fed to it was recorded SNIPPETS. The app's own components — where The app's own components, where unresolvable references live quietly, need a
sixteen unresolvable references were found living quietly (#2319) — were checked check of their own.
by nothing at all.
That was structural rather than an oversight. The drift panel runs in the browser That is structural. The drift panel runs in the browser and cannot read source
and cannot read source files, and the server has no repo access. CI is the only files, and the server has no repo access. CI is the only place holding both the
place holding both the component sources and the ability to run the check, and it component sources and the ability to run the check, and `theme.css` is a
only became cheap once `theme.css` became a generated artifact — so the source of generated artifact — so the source of truth is a local file, with no network
truth is a local file, with no network and no credentials. 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 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 declarations, and the `SUPERSEDES` block the generator emits. Point it at a
different install's sheet and it checks that install's rules. 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 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 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 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 - inert hook scripts (a wrong env-var case, a line-oriented `cut -c`) — so
`jq -rR`, line-oriented `cut -c`). No CI ran, because `plugin/**` wasn't in `plugin/**` is in the workflow's `paths:` filter and these checks run on it;
the workflow's `paths:` filter at all. - a fix that ships and cannot reach an install, because `plugin.json`'s
- #2209 — the fix for #2198 shipped to `main` and still couldn't reach an version did not move and the installer compares versions to decide whether
install, because `plugin.json`'s version wasn't bumped and the installer to refresh its cache.
compares versions to decide whether to refresh its cache.
Both were fixed. The second was fixed TWICE — once by bumping the number, and `plugin.json`'s version is not a value anybody chooses.
then properly, by removing the class it came from: `plugin.json`'s version is `scripts/mint_plugin_version.py` derives it from the clock (`make mint-plugin`),
no longer a value anybody chooses. `scripts/mint_plugin_version.py` derives it and `check_version_is_minted` below fails the lane when shipped content moved
from the clock (`make mint-plugin`), and `check_version_is_minted` below fails and the version did not.
the lane when shipped content moved and the version did not.
State exactly what that did and did not remove, because a rationale that 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, and overstates its own control is how the control gets trusted past its limit.
because the paragraph this replaces was itself read that way. Gone: having to Gone: choosing which NUMBER to write, and the question of whether a chosen
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. What the
number was the right one. Not gone: the mint still has to be RUN, and check adds is that forgetting is LOUD — a red lane on the batch that forgot,
forgetting to run it is still possible. What changed is that forgetting is now instead of a silent no-op found weeks later when somebody says "I don't think
LOUD — a red lane on the batch that forgot, instead of a silent no-op found it updated".
weeks later when somebody says "I don't think it updated" (#2220).
shellcheck is NOT in `ci-python` (verified against CI-runner's Dockerfile and shellcheck is NOT in the `ci-python` image; CI installs it per-job, the usual
scripts/install-common.sh, not from memory — rule #37). CI installs it per-job, practice for a dependency with a single consumer. It is optional at runtime:
which is what CI-runner's own docs/process.md prescribes for a dep with a without shellcheck the lint step is SKIPPED and says so. A skipped check
single consumer: "If only one project needs the dep, prefer that project announces itself loudly, because a check that quietly no-ops is the failure
installing it per-job in their workflow — at least until a second consumer mode this whole file exists to prevent.
arrives." Promotion into the image is filed as an issue there rather than
assumed here.
It is optional at runtime: without shellcheck the lint step is SKIPPED and says The hooks need no jq (it is not in `ci-python` either), so the smoke tests
so. A skipped check announces itself loudly, because a check that quietly below run unconditionally rather than skipping wherever it is absent.
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.
Usage: Usage:
python3 scripts/check_plugin.py # all checks 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 ───────────────── # ── 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: # need different granularities, and conflating them is the bug:
# #
# the workflow's `paths:` trigger whole paths should CI run at all? # 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 # carries description, mcpServers and userConfig, all of which reach an
# install and all of which matter. Excluding the file wholesale would mean a # install and all of which matter. Excluding the file wholesale would mean a
# userConfig-only edit computes an unchanged version and never refreshes — # 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") SHIPPED_PATHS = ("plugin", ".claude-plugin")
# Files that decide what a published artifact SAYS ABOUT ITSELF — kept as a # 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 # 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 # bespoke guard. The membership test is NOT "is this copied into the
# the artifact?" but "can changing this file change the published bytes, or # artifact?" but "can changing this file change the published bytes, or what
# what the artifact says about itself?" — FC learned that twice in four days # the artifact says about itself?" — a deriver is never in the COPY list.
# (#3156, #3202), and a deriver is never in the COPY list.
# #
# Note what is absent: a CHECKER does not belong here. Whatever validates a # 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, # 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. # the plugin version is.
DERIVERS: dict[str, tuple[str, ...]] = { DERIVERS: dict[str, tuple[str, ...]] = {
# The "Generate image tags and version" step computes the server image's # 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",), ".forgejo/workflows/ci.yml": ("server-image",),
# Decides the plugin's version FORMAT, so it decides what every future # 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",), "scripts/mint_plugin_version.py": ("plugin",),
} }
@@ -130,7 +117,7 @@ DERIVERS: dict[str, tuple[str, ...]] = {
def version_relevant_paths() -> tuple[str, ...]: def version_relevant_paths() -> tuple[str, ...]:
"""Everything a change to which must produce a NEW plugin version. """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 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 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 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 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 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 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 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: def skip(msg: str) -> None:
# Loud on purpose. A check that quietly does nothing is indistinguishable # 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}") print(f"SKIP {msg}")
@@ -191,7 +178,7 @@ def check_syntax() -> None:
# --- known-bad patterns ---------------------------------------------------- # --- known-bad patterns ----------------------------------------------------
# Each entry: (compiled pattern, short label, why it's wrong). # 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. # broad shell linter belongs in the image, not hand-rolled here.
PATTERNS: list[tuple[re.Pattern, str, str]] = [ PATTERNS: list[tuple[re.Pattern, str, str]] = [
( (
@@ -202,15 +189,11 @@ PATTERNS: list[tuple[re.Pattern, str, str]] = [
"hook then does nothing, silently.", "hook then does nothing, silently.",
), ),
( (
# NEITHER OF THESE MAY COME BACK (#4107). This replaced a narrower rule # NEITHER OF THESE BELONGS IN A HOOK. jq is absent by default on
# 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
# macOS, on the Debian/Ubuntu slim images, on Alpine and in most CI # 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 # containers; `tac` is GNU-only and absent on macOS. A hook that guards
# guard itself with `command -v jq || exit 0`, so a machine without it # itself with `command -v jq || exit 0` gives a machine without it no
# got no context, no rules, no prior art and no process sync, silently — # context, no rules, no prior art and no process sync, silently —
# `exit 0` is indistinguishable from "nothing to say". A hook may use # `exit 0` is indistinguishable from "nothing to say". A hook may use
# only what POSIX guarantees; scribe_defs.sh has the readers. # only what POSIX guarantees; scribe_defs.sh has the readers.
re.compile(r"(?<![\w./-])(jq|tac)(?![\w./-])"), 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. " "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 " "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 " "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 # `$(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 # (or `grep -m`/`-q`) has what it needs it exits, a producer still
# writing dies of SIGPIPE, the substitution fails, and the fallback # writing dies of SIGPIPE, the substitution fails, and the fallback
# wipes the output that was already captured. Silent, and only on big # 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+=\"\""), re.compile(r"\|\s*(?:head\b|grep\s+-[A-Za-z]*[mq])[^)]*\)\s*\|\|\s*\w+=\"\""),
"early-exit consumer with the fallback outside the substitution", "early-exit consumer with the fallback outside the substitution",
"Under pipefail, `$(x | head -N) || v=\"\"` discards head's output whenever x " "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 # always emits a static behavioural floor that needs no credentials and no
# network, so "silent" would be the wrong assertion for it. # 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 # 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 # not answer …"), so a session can tell "checked, nothing there" from "never
# checked". That line — or silence, when the once-per-outage marker in # 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 # is the only output allowed with no working instance; anything else is a hook
# speaking on data it cannot have. # speaking on data it cannot have.
# #
# This is the contract that made #2198 invisible for weeks, so it is worth # The contract is worth pinning because an inert hook and the healthy
# pinning: the bug and the healthy no-results case looked identical from # no-results case look identical from outside. The outage line makes the
# outside. #2932 is what finally makes the failure visible at the write; this # failure visible at the write; this check makes sure the fail-open behaviour
# check makes sure the fail-open behaviour stays deliberate rather than # stays deliberate rather than accidental.
# accidental.
# A symbol that exists nowhere, ASSEMBLED rather than written literally. # A symbol that exists nowhere, ASSEMBLED rather than written literally.
# The prior-art hook's local arm (#2280) fires with no credentials, so the # The prior-art hook's local arm fires with no credentials, so the silence
# silence assertion below needs a name the repo genuinely lacks. Two traps, # assertion below needs a name the repo genuinely lacks. Two traps:
# both hit while writing this: # - a short name like `def f` matches real code, so the hook speaks and
# - `def f` matched real code, so the hook spoke and "silent" was asserting # "silent" asserts the wrong thing;
# the wrong thing; # - spelling the replacement out in full puts `def <name>(` INTO this file,
# - spelling the replacement out in full put `def <name>(` INTO this file, # so the smoke event defines the very symbol it claims is absent.
# so the smoke event defined the very symbol it claimed was absent.
# Concatenating keeps the contiguous string out of the source. # Concatenating keeps the contiguous string out of the source.
_ABSENT_SYM = "zz" + "_absent_" + "9f3a2b" _ABSENT_SYM = "zz" + "_absent_" + "9f3a2b"
@@ -331,7 +312,7 @@ SMOKE_EVENTS: dict[str, str] = {
"tool_input": {"file_path": "src/x.py", "tool_input": {"file_path": "src/x.py",
"new_string": f"def {_ABSENT_SYM}():\n pass\n"}} "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 # 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 # 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 # 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", {"session_id": "smoke", "cwd": ".", "tool_name": "Bash",
"tool_input": {"command": "curl -s https://example.invalid/api/v1/runs"}} "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 # 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, # moment request; with no instance both fail and it must stay SILENT,
# since it fires before every call that can reach a mounted rule. # 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_sync_processes.sh": json.dumps({"source": "startup"}),
"scribe_session_context.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. # 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 — # (On a dirty local tree with a definition just written it may speak —
# that is the hook working, not a failure of the contract.) # 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", {"session_id": "smoke", "cwd": ".", "tool_name": "Bash",
"tool_input": {"command": "true"}, "tool_response": {}} "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 # not exist is the smoke case: nothing to read, so it must stay silent and
# never block, configured or not. # never block, configured or not.
"scribe_report_check.sh": json.dumps( "scribe_report_check.sh": json.dumps(
{"session_id": "smoke", "transcript_path": "/nonexistent/smoke.jsonl", {"session_id": "smoke", "transcript_path": "/nonexistent/smoke.jsonl",
"cwd": ".", "hook_event_name": "Stop", "stop_hook_active": False} "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. # never a block, configured or not.
"scribe_reply_check.sh": json.dumps( "scribe_reply_check.sh": json.dumps(
{"session_id": "smoke", "transcript_path": "/nonexistent/smoke.jsonl", {"session_id": "smoke", "transcript_path": "/nonexistent/smoke.jsonl",
"cwd": ".", "hook_event_name": "Stop", "stop_hook_active": False} "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 # session there is nothing to ask about, and with no instance there is
# nobody to ask: silent, and never a block, configured or not. # nobody to ask: silent, and never a block, configured or not.
"scribe_shape_check.sh": json.dumps( "scribe_shape_check.sh": json.dumps(
{"session_id": "smoke", "transcript_path": "/nonexistent/smoke.jsonl", {"session_id": "smoke", "transcript_path": "/nonexistent/smoke.jsonl",
"cwd": ".", "hook_event_name": "Stop", "stop_hook_active": False} "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 # 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 # becomes the summarizer's custom instructions. A silent success here is
# the failure mode, and it would look like every other hook's success. # 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", {"session_id": "smoke", "cwd": ".", "hook_event_name": "PreCompact",
"trigger": "manual", "custom_instructions": None} "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 # 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 # 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. # 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_name": "mcp__scribe__get_rule", "tool_input": {"rule_id": 1},
"tool_response": {}} "tool_response": {}}
), ),
# The acted-ledger recorder (#4216). Same shape and same reasoning as its # The acted-ledger recorder. Same shape and same reasoning as its sibling
# sibling above: TMPDIR only, and silent, because a PostToolUse hook that # above: TMPDIR only, and silent, because a PostToolUse hook that spoke
# spoke would put a line after every rule_outcome call and turn recording # would put a line after every rule_outcome call and turn recording an
# an outcome into something with a cost. A skip here would have left the # outcome into something with a cost.
# newest of the three ledgers as the only hook the lane never runs.
"scribe_record_outcome.sh": json.dumps( "scribe_record_outcome.sh": json.dumps(
{"session_id": "smoke", "cwd": ".", {"session_id": "smoke", "cwd": ".",
"tool_name": "mcp__scribe__rule_outcome", "tool_name": "mcp__scribe__rule_outcome",
"tool_input": {"rule_id": 1, "outcome": "applied"}, "tool_input": {"rule_id": 1, "outcome": "applied"},
"tool_response": {}} "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 — # 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. # and with no instance configured it must exit before reaching for one.
"scribe_claim_session.sh": json.dumps( "scribe_claim_session.sh": json.dumps(
@@ -416,7 +396,7 @@ SMOKE_EVENTS: dict[str, str] = {
"tool_input": {"task_id": 1, "content": "smoke"}, "tool_input": {"task_id": 1, "content": "smoke"},
"tool_response": {}} "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. # a SessionEnd hook's output, and with no instance it must exit first.
"scribe_session_end.sh": json.dumps( "scribe_session_end.sh": json.dumps(
{"session_id": "smoke", "cwd": ".", "hook_event_name": "SessionEnd", {"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 two-tier SessionStart design: the bundled static tier ships whatever
# the instance does, and that floor is the whole point of the split. # the instance does, and that floor is the whole point of the split.
"scribe_session_context.sh": "static floor present", "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 # summarizer's custom instructions, and what must survive a summary is
# known without asking anything — so it needs no instance, and silence 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 # the failure mode rather than the success one. Read as a generic hook it
# would look like a leak; it is the opposite. # would look like a leak; it is the opposite.
"scribe_precompact_preserve.sh": "preservation instructions present", "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_SPEAKERS = {"scribe_prior_art.sh", "scribe_after_write.sh"}
OUTAGE_LINE = "> Scribe did not answer the prior-art check" 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: def check_fail_open() -> None:
# No jq gate any more (#4107). This check used to skip itself when jq was # No jq gate: the hooks need only POSIX tools, so this runs everywhere,
# missing, because without it every hook bailed at line 1 and the check # CI's `ci-python` image included. A gate on a tool the image lacks would
# would have passed while exercising nothing. jq is NOT in `ci-python`, so # announce a skip on every run and never exercise the hooks there.
# 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.
scenarios = [ scenarios = [
("unconfigured", {}), ("unconfigured", {}),
# Connection refused immediately — exercises the unreachable-instance # 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: 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 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 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 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 the manifest version changes. So a shipped fix can sit unreached while
inspecting the clone shows it present — the obvious debugging move 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 misleads, and otherwise the only detector is a human saying "I don't think
updated" (#2209, #2220). it updated".
Asserted WITHOUT credentials on purpose. The state most needing diagnosis Asserted WITHOUT credentials on purpose. The state most needing diagnosis
is the one where the token never arrives, and a marker that vanished there 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. or mcpServers-only edit demanding a new version.
Unreadable input answers True. The conservative direction is "demand a new Unreadable input answers True. The conservative direction is "demand a new
version": a spurious bump costs one cache refresh, while a missed one is version": a spurious bump costs one cache refresh, while a missed one means
#2209 — the fix reaches the repo and stops there. the fix reaches the repo and stops there.
""" """
def without_version(text: str | None): def without_version(text: str | None):
if text is 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`? """Has anything that REACHES AN INSTALL changed against `base`?
Returns `(changed, paths)`. `changed` is **None** when the question could Returns `(changed, paths)`. `changed` is **None** when the question could
not be answered — a caller must never read that as "no", which is the not be answered — a caller must never read that as "no": an unanswered
distinction #2663 cost weeks of zeroed telemetry to learn. 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 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 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: 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 The checklist asks, of any hand-set component: *say what happens the
release somebody forgets it.* This is the answer — the lane goes red, release somebody forgets it.* This is the answer — the lane goes red,
deterministically, because CI can compute whether the value should have deterministically, because CI can compute whether the value should have
moved. Its predecessor could only ask "did the number move at all", which moved. Asking only "did the number move at all" would be satisfied by any
any bump satisfied and which therefore proved nothing. bump and would therefore prove nothing.
Four verdicts: 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 not in canonical shape FAIL — see below
version implausibly in the future FAIL — a bad clock or a hand-edit version implausibly in the future FAIL — a bad clock or a hand-edit
version moved, content did not pass, and say so 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 A malformed version is worth failing on even though the installer would
accept it. `K4` returns the manifest string verbatim, and `H == "unknown"` accept it. `K4` returns the manifest string verbatim, and `H == "unknown"`
sets `forceOverwrite`, so a broken value either sorts as a normal string 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 Stated against the BASE BRANCH rather than the last commit: a per-commit
predecessor was: a per-commit rule would demand a fresh mint from every rule would demand a fresh mint from every
commit in a batch, when what matters is that whatever reaches an install 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 differs from what is cached. One mint per batch, which is also how a person
would do it. would do it.
@@ -745,8 +721,8 @@ def check_version_is_minted(base: str = "origin/main") -> None:
if not VERSION_RE.match(here): if not VERSION_RE.match(here):
fail( fail(
f"the manifest version is {here!r}, which is not YYYY.MM.DD.HHMM.\n" 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" One shape for every version in the family, zero-padded so "
f"10), zero-padded so the midnight case renders 2026.01.05.0000.\n" f"the midnight case renders 2026.01.05.0000.\n"
f" Run `make mint-plugin`." f" Run `make mint-plugin`."
) )
return 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" The installer decides whether to refresh its cache by "
f"comparing this string, so an unchanged version means these edits " f"comparing this string, so an unchanged version means these edits "
f"reach the repo and stop there — the marketplace clone updates, the " 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" Run `make mint-plugin`.\n"
f" Changed:\n {files}" 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 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 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 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 pass's own scan because it did not exist when the scan ran. This script is
a 17-pass audit found exactly this, and folded it into a view). This script is
the close-out measure the DRY Pass process names, so the measure is a tool and the close-out measure the DRY Pass process names, so the measure is a tool and
not something each audit improvises. 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 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 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 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 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 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 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. 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 That trade is acceptable ONLY because of what the installer's code does: the refresh test is `P.version === H`, plain string
installer's code: the refresh test is `P.version === H`, plain string
equality, with no ordering comparison anywhere. Where a comparator ORDERS, an equality, with no ordering comparison anywhere. Where a comparator ORDERS, an
unreproducible version is dangerous — nothing can check it is right. Where it 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 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" MANIFEST = ROOT / "plugin" / ".claude-plugin" / "plugin.json"
# Four dot-separated numeric fields, zero-padded, and nothing else — one shape # Four dot-separated numeric fields, zero-padded, and nothing else — one shape
# for every human-readable version in the family (#3127 checklist 10). The # for every human-readable version in the family. The padding is
# padding is load-bearing for the midnight case the checklist names by hand: # load-bearing for the midnight case:
# 2026.01.05.0000, which an unpadded `%-H%M` would render as `0` and silently # 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. # shorten. Harmless while nothing orders these, wrong the moment anything does.
VERSION_RE = re.compile(r"^\d{4}\.\d{2}\.\d{2}\.\d{4}$") 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. """Project CRUD MCP tools — thin wrappers over services/projects.py.
Mirrors existing fable-mcp project tool contracts. Note: there is no There is no delete tool: to stop working on a project, set its status to
fable_delete_project here (matches existing fable-mcp surface). To stop 'archived'. create_project creates directly, with no similarity check or
working on a project, update its status to 'archived'. 'confirmed' guard — the client is a capable model that can read the project
list itself.
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.
""" """
from __future__ import annotations from __future__ import annotations
+1 -5
View File
@@ -1,8 +1,4 @@
"""User settings + integrations (SearXNG status). """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.
"""
import logging import logging
from quart import Blueprint, jsonify, request from quart import Blueprint, jsonify, request
+6 -6
View File
@@ -1,16 +1,16 @@
"""Session-context rendering for the Scribe plugin's SessionStart hook. """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 plugin's hook curls `GET /api/plugin/context` at session start and injects
the returned text as `additionalContext`, giving Scribe the same push channel the returned text as `additionalContext`, giving Scribe a push channel into
that superpowers and file-memory have. This module renders that text. the session. This module renders that text.
Design note — altitude: we inject rule *titles* grouped by topic (a compact 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 index), NOT every rule's full statement. A working rulebook's always-on statements run
past the 10k-char `additionalContext` cap, and the push channel's job is to make 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 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 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 mostly self-describing ("Run migrations before deploying"), so the index alone
index alone already steers behavior. already steers behavior.
""" """
from __future__ import annotations from __future__ import annotations
+2 -3
View File
@@ -1004,9 +1004,8 @@ async def restore_rule_version(
) -> Optional[Rule]: ) -> Optional[Rule]:
"""Put a preference back to what it said. None when it is not readable. """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 — RULES DELIBERATELY HAVE NO RESTORE — `routes/rulebooks.py` offers none —
`routes/rulebooks.py` still has no restore for them. Its reasoning: because a binding instruction should not be revertible in one click: a
"a binding instruction should not be revertible in one click", because a
silent revert erases the only record of why the rewrite happened. silent revert erases the only record of why the rewrite happened.
A preference inverts both halves of that. The rewrite was not the 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. """User profile service — structured per-user preferences.
Post-pivot the LLM-driven observation/consolidation surface is gone (curator The profile is user-managed metadata (name, job, expertise, style, tone,
deleted in Phase 8). The profile model is preserved as user-managed metadata interests, work schedule), read and written through /api/profile and carried
(name, job, expertise, style, tone, interests, work schedule) — Claude can in backups.
read it via the upcoming MCP profile tools.
""" """
import logging import logging