From 8aacc1824c25de11bf5ee36c1911a364c0c46c4a Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Tue, 6 Oct 2026 10:33:30 -0400 Subject: [PATCH] feat(family): the promotion engine - triggers, the three criteria, the decision log and undo (milestone 463 step 3, #4989) The agent promotes a family idea when all three criteria hold, and no person approves it. The criteria are product text: services/family.py states them, and the promote tool's docstring names every criterion the service enforces. - Criteria: each one vetoes on its own when its reasoning is blank. Platform terms also needs an applies-when and a platform scope; proven also needs named evidence. A veto keeps the idea a candidate and is logged, so it becomes precedent. - Precedent: every promotion stores the decisions on the nearest ideas by meaning, plus any the caller names. - Promotion sets canon, the applicability test and the platform scope, and opens an unassessed ledger row for each member project the promoter can write. Re-promotion moves the version past every version the idea has held. - Retire and undo: undo reverses only the latest idea-level decision, restores its recorded before-state, and logs itself with the undone decision as its precedent. Settled here: leaving canon closes the unassessed rows but keeps the judged ones, which read as needing a recheck after a re-promotion. - Triggers open evaluations but never promote: - a cross-project lineage citation ("matching #N") on a note or task write; - a same-meaning record in another project on a shared platform, on create, at 0.80 (measured: the known pattern's builds scored 0.79-0.82, an unrelated project's best match 0.65); - a milestone closing on a platform. Each fails open and rides the response as family_hint. - Doors: seven MCP tools, /api/family REST endpoints, and a Family page (nav, /family) showing the criteria, the ideas, and the decision log with undo and retire. - utils/recordHref.ts holds the one copy of "where a record opens", now shared with LessonDetailView. Co-Authored-By: Claude Opus 5.5 --- frontend/src/api/family.ts | 78 ++ frontend/src/components/AppHeader.vue | 2 + frontend/src/components/ProjectFamilyTab.vue | 1 + frontend/src/router/index.ts | 7 + frontend/src/utils/recordHref.ts | 9 + frontend/src/views/FamilyView.vue | 360 ++++++++ frontend/src/views/LessonDetailView.vue | 11 +- src/scribe/app.py | 2 + src/scribe/mcp/server.py | 6 + src/scribe/mcp/tools/__init__.py | 3 +- src/scribe/mcp/tools/family.py | 183 ++++ src/scribe/mcp/tools/milestones.py | 13 +- src/scribe/mcp/tools/notes.py | 6 + src/scribe/mcp/tools/snippets.py | 4 + src/scribe/mcp/tools/tasks.py | 6 + src/scribe/routes/family.py | 125 +++ src/scribe/services/family.py | 838 +++++++++++++++++++ tests/conftest.py | 24 + tests/test_family.py | 163 ++++ tests/test_integration_family_promotion.py | 293 +++++++ tests/test_routes_family.py | 29 + 21 files changed, 2151 insertions(+), 12 deletions(-) create mode 100644 frontend/src/api/family.ts create mode 100644 frontend/src/utils/recordHref.ts create mode 100644 frontend/src/views/FamilyView.vue create mode 100644 src/scribe/mcp/tools/family.py create mode 100644 src/scribe/routes/family.py create mode 100644 src/scribe/services/family.py create mode 100644 tests/test_family.py create mode 100644 tests/test_integration_family_promotion.py create mode 100644 tests/test_routes_family.py diff --git a/frontend/src/api/family.ts b/frontend/src/api/family.ts new file mode 100644 index 00000000..9af9a96b --- /dev/null +++ b/frontend/src/api/family.ts @@ -0,0 +1,78 @@ +/** + * Family canon (milestone 463): ideas every project on a platform shares, and + * the log of every decision about them. + * + * The agent promotes by written criteria — no approval step. This door is for + * reading what was decided and why, and for retiring an idea or undoing a + * decision when a person disagrees. + */ +import { apiGet, apiPost } from "@/api/client"; + +export type IdeaStatus = "candidate" | "canon" | "retired"; +export type DecisionAction = "propose" | "promote" | "revise" | "retire" | "assess" | "undo"; + +export interface Criterion { + key: string; + title: string; + test: string; +} + +export interface FamilyIdea { + note_id: number; + status: IdeaStatus; + applies_when: string; + canon_version: number; + topic_id: number | null; + title: string; + note_type: string; + is_task: boolean; + project_id: number | null; + platforms: string[]; + created_at: string | null; + updated_at: string | null; +} + +/** A state snapshot a decision records — slugs, never ids. */ +export interface IdeaSnapshot { + status: IdeaStatus; + applies_when: string; + canon_version: number; + platforms: string[]; +} + +export interface FamilyDecision { + id: number; + idea_id: number; + idea_title?: string; + project_id: number | null; + action: DecisionAction; + reason: string; + before: IdeaSnapshot | null; + after: IdeaSnapshot | null; + evidence: Record; + precedent_ids: number[]; + decided_via: "agent" | "operator" | "system"; + user_id: number | null; + created_at: string | null; + undoable?: boolean; +} + +export async function listFamilyIdeas(status = ""): Promise<{ ideas: FamilyIdea[]; criteria: Criterion[] }> { + const q = status ? `?status=${encodeURIComponent(status)}` : ""; + return apiGet(`/api/family/ideas${q}`); +} + +export async function listFamilyDecisions(limit = 50, offset = 0): Promise { + const data = await apiGet<{ decisions: FamilyDecision[] }>( + `/api/family/decisions?limit=${limit}&offset=${offset}`, + ); + return data.decisions; +} + +export async function undoFamilyDecision(id: number, reason: string) { + return apiPost<{ decision: FamilyDecision }>(`/api/family/decisions/${id}/undo`, { reason }); +} + +export async function retireFamilyIdea(noteId: number, reason: string) { + return apiPost<{ decision: FamilyDecision }>(`/api/family/ideas/${noteId}/retire`, { reason }); +} diff --git a/frontend/src/components/AppHeader.vue b/frontend/src/components/AppHeader.vue index 0363f469..dfce0208 100644 --- a/frontend/src/components/AppHeader.vue +++ b/frontend/src/components/AppHeader.vue @@ -50,6 +50,7 @@ router.afterEach(() => { Projects Snippets Rulebooks + Family