feat(family): the promotion engine - triggers, the three criteria, the decision log and undo (milestone 463 step 3, #4989)
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 19s
CI & Build / TypeScript typecheck (push) Successful in 57s
CI & Build / integration (push) Failing after 1m5s
CI & Build / Python tests (push) Successful in 1m58s
CI & Build / Build & push image (push) Skipped

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 <noreply@anthropic.com>
This commit is contained in:
2026-10-06 10:33:30 -04:00
co-authored by Claude Opus 5.5
parent 1774ee3696
commit 8aacc1824c
21 changed files with 2151 additions and 12 deletions
+78
View File
@@ -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<string, unknown>;
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<FamilyDecision[]> {
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 });
}
+2
View File
@@ -50,6 +50,7 @@ router.afterEach(() => {
<router-link to="/projects" class="nav-link">Projects</router-link>
<router-link to="/snippets" class="nav-link">Snippets</router-link>
<router-link to="/rules" class="nav-link">Rulebooks</router-link>
<router-link to="/family" class="nav-link">Family</router-link>
<!-- A design system is a RECORD you author, not a setting. It sat in
the utility cluster with Trash and Settings while /design was a
read-only gallery, and stayed there after it became a record type
@@ -102,6 +103,7 @@ router.afterEach(() => {
<router-link to="/projects" class="nav-link">Projects</router-link>
<router-link to="/snippets" class="nav-link">Snippets</router-link>
<router-link to="/rules" class="nav-link">Rulebooks</router-link>
<router-link to="/family" class="nav-link">Family</router-link>
<router-link to="/design-systems" class="nav-link">Design</router-link>
<router-link to="/shared" class="nav-link">Shared</router-link>
<div class="mobile-divider"></div>
@@ -99,6 +99,7 @@ watch(() => props.projectId, load);
What this project is built on or ships as. Ideas the family has proven
for a platform reach every project that is one, and each project
answers them — adopts, varies with a reason, or owes the work.
<router-link to="/family">The family's ideas and decisions</router-link>
</p>
</header>
+7
View File
@@ -128,6 +128,13 @@ const router = createRouter({
name: "rules",
component: () => import("@/views/RulesView.vue"),
},
{
// Family canon (milestone 463): ideas shared across every project on a
// platform, and the log of every decision about them.
path: "/family",
name: "family",
component: () => import("@/views/FamilyView.vue"),
},
{
// The design systems this install RECORDS — for the projects it tracks,
// not for the install itself. There was a sibling `/design` that read the
+9
View File
@@ -0,0 +1,9 @@
/** Where a record opens. A task, a snippet, a lesson and a note live at
* different routes, and a link that guesses wrong is worse than one that is
* plain. One copy, for every list that links a record of any kind. */
export function recordHref(rec: { id: number; is_task?: boolean; note_type?: string }): string {
if (rec.is_task) return `/tasks/${rec.id}`;
if (rec.note_type === "snippet") return `/snippets/${rec.id}`;
if (rec.note_type === "lesson") return `/lessons/${rec.id}`;
return `/notes/${rec.id}`;
}
+360
View File
@@ -0,0 +1,360 @@
<script setup lang="ts">
/**
* Family canon (milestone 463): the ideas every project on a platform shares,
* and the log of every decision about them.
*
* Nobody approves a promotion — the agent decides against the three criteria
* shown at the top, and records why. This page is where a person reads those
* decisions, and the two places they can disagree: retire an idea, or undo
* the latest decision on one. Both ask for a reason, because the reason is
* what the next decision about something similar is checked against.
*/
import { computed, onMounted, ref } from "vue";
import { apiErrorMessage } from "@/api/client";
import {
listFamilyDecisions, listFamilyIdeas, retireFamilyIdea, undoFamilyDecision,
type Criterion, type FamilyDecision, type FamilyIdea, type IdeaStatus,
} from "@/api/family";
import { useToastStore } from "@/stores/toast";
import { fmtStamp } from "@/utils/dateFormat";
import { recordHref } from "@/utils/recordHref";
const PAGE = 50;
const toast = useToastStore();
const criteria = ref<Criterion[]>([]);
const ideas = ref<FamilyIdea[]>([]);
const decisions = ref<FamilyDecision[]>([]);
const statusFilter = ref<"" | IdeaStatus>("");
const loading = ref(true);
const failed = ref(false);
const moreDecisions = ref(false);
const loadingMore = ref(false);
// One pending reversal at a time: which row, which act, and its reason.
const pending = ref<{ kind: "undo" | "retire"; id: number } | null>(null);
const pendingReason = ref("");
const saving = ref(false);
const ACTION_LABELS: Record<string, string> = {
propose: "Proposed",
promote: "Promoted",
revise: "Revised",
retire: "Retired",
assess: "Assessed",
undo: "Undid",
};
async function load() {
loading.value = true;
failed.value = false;
try {
const [ideaData, log] = await Promise.all([
listFamilyIdeas(statusFilter.value),
listFamilyDecisions(PAGE, 0),
]);
ideas.value = ideaData.ideas;
criteria.value = ideaData.criteria;
decisions.value = log;
moreDecisions.value = log.length === PAGE;
} catch {
// Said out loud: a log that failed to load must not read as "nothing
// has been decided".
failed.value = true;
} finally {
loading.value = false;
}
}
async function loadMore() {
loadingMore.value = true;
try {
const next = await listFamilyDecisions(PAGE, decisions.value.length);
decisions.value.push(...next);
moreDecisions.value = next.length === PAGE;
} catch (e: unknown) {
toast.show(apiErrorMessage(e, "Could not load more decisions"), "error");
} finally {
loadingMore.value = false;
}
}
async function filterIdeas(value: "" | IdeaStatus) {
statusFilter.value = value;
try {
ideas.value = (await listFamilyIdeas(value)).ideas;
} catch (e: unknown) {
toast.show(apiErrorMessage(e, "Could not load ideas"), "error");
}
}
function startPending(kind: "undo" | "retire", id: number) {
pending.value = { kind, id };
pendingReason.value = "";
}
async function confirmPending() {
const p = pending.value;
const reason = pendingReason.value.trim();
if (!p || !reason || saving.value) return;
saving.value = true;
try {
if (p.kind === "undo") await undoFamilyDecision(p.id, reason);
else await retireFamilyIdea(p.id, reason);
toast.show(p.kind === "undo" ? "Decision undone" : "Idea retired");
pending.value = null;
await load();
} catch (e: unknown) {
toast.show(apiErrorMessage(e, p.kind === "undo" ? "Could not undo" : "Could not retire"), "error");
} finally {
saving.value = false;
}
}
const ideaById = computed(() => {
const out: Record<number, FamilyIdea> = {};
for (const i of ideas.value) out[i.note_id] = i;
return out;
});
function ideaLink(d: FamilyDecision): string {
const idea = ideaById.value[d.idea_id];
return idea ? recordHref({ id: idea.note_id, is_task: idea.is_task, note_type: idea.note_type })
: `/notes/${d.idea_id}`;
}
/** The parts of a decision's evidence worth reading, as label → lines. */
function evidenceLines(d: FamilyDecision): { label: string; lines: string[] }[] {
const ev = d.evidence || {};
const out: { label: string; lines: string[] }[] = [];
const criteriaReasons = ev.criteria as Record<string, string> | undefined;
if (criteriaReasons) {
const lines = criteria.value
.map((c) => `${c.title}: ${criteriaReasons[c.key] || "— not supported"}`);
out.push({ label: "Criteria", lines });
}
if (Array.isArray(ev.vetoed_by) && ev.vetoed_by.length) {
const titles = (ev.vetoed_by as string[])
.map((k) => criteria.value.find((c) => c.key === k)?.title ?? k);
out.push({ label: "Held back by", lines: titles });
}
if (Array.isArray(ev.evidence) && ev.evidence.length) {
out.push({ label: "Evidence", lines: ev.evidence as string[] });
}
if (typeof ev.trigger === "string") {
out.push({ label: "Trigger", lines: [ev.trigger] });
}
return out;
}
function stateLine(d: FamilyDecision): string {
const a = d.after;
if (!a) return "";
const where = a.platforms.length ? ` · ${a.platforms.join(", ")}` : "";
return `${a.status} v${a.canon_version}${where}`;
}
onMounted(load);
</script>
<template>
<main class="page-container">
<div class="page-header">
<h1>Family</h1>
</div>
<p class="fam-lede">
Ideas every project on a platform shares — the stance, the shape and the holes
already found, not the code. The agent promotes an idea when all three criteria
hold, with no approval step; every decision and its reasons are below, and any
of them can be undone.
</p>
<p v-if="loading" class="fam-muted">Loading…</p>
<div v-else-if="failed" class="fam-note">
<strong>The family record couldn't load.</strong>
<p>Nothing is known either way — this is a failure, not an empty record.</p>
<button class="btn-secondary btn-sm" @click="load">Try again</button>
</div>
<template v-else>
<section class="fam-section" aria-labelledby="fam-criteria">
<h2 id="fam-criteria">What gets promoted</h2>
<ol class="fam-criteria">
<li v-for="c in criteria" :key="c.key">
<strong>{{ c.title }}.</strong> {{ c.test }}
</li>
</ol>
</section>
<section class="fam-section" aria-labelledby="fam-ideas">
<div class="fam-section-head">
<h2 id="fam-ideas">Ideas</h2>
<div class="fam-filter" role="group" aria-label="Filter ideas by state">
<button
v-for="opt in (['', 'candidate', 'canon', 'retired'] as const)"
:key="opt"
:class="['btn-ghost', 'btn-sm', { active: statusFilter === opt }]"
:aria-pressed="statusFilter === opt"
@click="filterIdeas(opt)"
>{{ opt || "All" }}</button>
</div>
</div>
<p v-if="!ideas.length" class="fam-muted">
<template v-if="statusFilter">No {{ statusFilter }} ideas.</template>
<template v-else>
No family ideas yet. One appears when a record cites another project's
work as its source, when two projects on a platform build the same thing,
or when the agent proposes one.
</template>
</p>
<ul v-else class="fam-list">
<li v-for="idea in ideas" :key="idea.note_id" class="fam-row">
<div class="fam-row-main">
<div class="fam-row-title">
<router-link :to="recordHref({ id: idea.note_id, is_task: idea.is_task, note_type: idea.note_type })">
{{ idea.title }}
</router-link>
<span :class="['fam-status', `fam-${idea.status}`]">{{ idea.status }}</span>
<span v-if="idea.status !== 'candidate'" class="fam-muted">v{{ idea.canon_version }}</span>
</div>
<p v-if="idea.applies_when" class="fam-applies">Applies when: {{ idea.applies_when }}</p>
<div v-if="idea.platforms.length" class="fam-platforms">
<code v-for="p in idea.platforms" :key="p" class="fam-chip">{{ p }}</code>
</div>
</div>
<button
v-if="idea.status !== 'retired'"
class="btn-ghost btn-sm"
@click="startPending('retire', idea.note_id)"
>Retire</button>
<form
v-if="pending?.kind === 'retire' && pending.id === idea.note_id"
class="fam-reason"
@submit.prevent="confirmPending"
>
<input
v-model="pendingReason"
class="fs-input"
placeholder="Why retire it? The next decision is checked against this."
aria-label="Reason for retiring"
/>
<button type="submit" class="btn-danger btn-sm" :disabled="!pendingReason.trim() || saving">Retire</button>
<button type="button" class="btn-ghost btn-sm" @click="pending = null">Cancel</button>
</form>
</li>
</ul>
</section>
<section class="fam-section" aria-labelledby="fam-log">
<h2 id="fam-log">Decision log</h2>
<p v-if="!decisions.length" class="fam-muted">Nothing has been decided yet.</p>
<ul v-else class="fam-list">
<li v-for="d in decisions" :key="d.id" class="fam-row fam-decision">
<div class="fam-row-main">
<div class="fam-row-title">
<span class="fam-action">{{ ACTION_LABELS[d.action] ?? d.action }}</span>
<router-link :to="ideaLink(d)">{{ d.idea_title ?? `#${d.idea_id}` }}</router-link>
<span class="fam-muted">
#{{ d.id }} · {{ d.decided_via }} · {{ d.created_at ? fmtStamp(d.created_at) : "" }}
</span>
</div>
<p class="fam-reason-text">{{ d.reason }}</p>
<p v-if="stateLine(d)" class="fam-muted">Now: {{ stateLine(d) }}</p>
<dl v-for="part in evidenceLines(d)" :key="part.label" class="fam-evidence">
<dt>{{ part.label }}</dt>
<dd v-for="line in part.lines" :key="line">{{ line }}</dd>
</dl>
<p v-if="d.precedent_ids.length" class="fam-muted">
Consistent with decision{{ d.precedent_ids.length === 1 ? "" : "s" }}
{{ d.precedent_ids.map((p) => `#${p}`).join(", ") }}
</p>
</div>
<button
v-if="d.undoable"
class="btn-ghost btn-sm"
@click="startPending('undo', d.id)"
>Undo</button>
<form
v-if="pending?.kind === 'undo' && pending.id === d.id"
class="fam-reason"
@submit.prevent="confirmPending"
>
<input
v-model="pendingReason"
class="fs-input"
placeholder="Why undo it? The next decision is checked against this."
aria-label="Reason for undoing"
/>
<button type="submit" class="btn-danger btn-sm" :disabled="!pendingReason.trim() || saving">Undo</button>
<button type="button" class="btn-ghost btn-sm" @click="pending = null">Cancel</button>
</form>
</li>
</ul>
<button v-if="moreDecisions" class="btn-secondary btn-sm" :disabled="loadingMore" @click="loadMore">
{{ loadingMore ? "Loading…" : "Older decisions" }}
</button>
</section>
</template>
</main>
</template>
<style scoped>
.fam-lede { color: var(--fs-text-secondary); font-size: 0.9rem; margin: 0 0 1.5rem; max-width: 52rem; }
.fam-muted { color: var(--fs-text-tertiary); font-size: 0.85rem; margin: 0; }
.fam-note {
background: var(--fs-surface-raised);
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-md);
padding: 0.85rem 1rem;
}
.fam-note p { margin: 0.35rem 0 0.6rem; color: var(--fs-text-secondary); font-size: 0.875rem; }
.fam-section { margin-bottom: 2rem; }
.fam-section h2 { font-size: 1.05rem; margin: 0 0 0.6rem; }
.fam-section-head { display: flex; align-items: baseline; justify-content: space-between; gap: 1rem; flex-wrap: wrap; }
.fam-filter { display: flex; gap: 0.25rem; }
.fam-filter .active { color: var(--fs-text-primary); border-color: var(--fs-border-color); }
.fam-criteria { margin: 0; padding-left: 1.25rem; color: var(--fs-text-secondary); font-size: 0.9rem; }
.fam-criteria li { margin-bottom: 0.35rem; }
.fam-list { list-style: none; margin: 0 0 0.75rem; padding: 0; }
.fam-row {
display: flex;
flex-wrap: wrap;
align-items: flex-start;
justify-content: space-between;
gap: 0.5rem 1rem;
padding: 0.75rem 0;
border-bottom: 1px solid var(--fs-border-color);
}
.fam-row-main { flex: 1; min-width: 0; }
.fam-row-title { display: flex; flex-wrap: wrap; align-items: baseline; gap: 0.25rem 0.5rem; }
.fam-applies { margin: 0.3rem 0 0; font-size: 0.85rem; color: var(--fs-text-secondary); }
.fam-platforms { display: flex; flex-wrap: wrap; gap: 0.3rem; margin-top: 0.35rem; }
.fam-chip {
font-family: var(--fs-font-mono);
font-size: 0.72rem;
color: var(--fs-text-secondary);
background: var(--fs-surface-code-inline);
border-radius: var(--fs-radius-sm);
padding: 0.05rem 0.35rem;
}
.fam-status {
font-size: 0.72rem;
padding: 0.05rem 0.45rem;
border-radius: var(--fs-radius-pill);
border: 1px solid var(--fs-border-color);
}
.fam-candidate { background: var(--fs-status-todo-bg); color: var(--fs-status-todo-fg); }
.fam-canon { background: var(--fs-status-done-bg); color: var(--fs-status-done-fg); }
.fam-retired { color: var(--fs-status-cancelled-fg); }
.fam-action { font-weight: 500; color: var(--fs-text-primary); }
.fam-reason-text { margin: 0.3rem 0; font-size: 0.9rem; color: var(--fs-text-primary); }
.fam-evidence { margin: 0.35rem 0 0; font-size: 0.82rem; }
.fam-evidence dt { color: var(--fs-text-tertiary); }
.fam-evidence dd { margin: 0 0 0 1rem; color: var(--fs-text-secondary); }
.fam-reason { flex-basis: 100%; display: flex; gap: 0.4rem; align-items: center; }
.fam-reason .fs-input { flex: 1; min-width: 0; }
@media (max-width: 640px) {
.fam-reason { flex-wrap: wrap; }
}
</style>
+2 -9
View File
@@ -41,6 +41,7 @@ import { DEAD_WEIGHT_ADVICE } from "@/utils/deadWeight";
import { useToastStore } from "@/stores/toast";
import { renderMarkdown } from "@/utils/markdown";
import { canWriteRecord } from "@/utils/permission";
import { recordHref } from "@/utils/recordHref";
const route = useRoute();
const router = useRouter();
@@ -59,14 +60,6 @@ const insightHtml = computed(() =>
lesson.value?.insight ? renderMarkdown(lesson.value.insight) : "",
);
/** Where each source opens. A task and a note live at different routes, and a
* link that guesses wrong is worse than one that is plain. */
function sourceHref(rec: { id: number; is_task?: boolean; note_type?: string }) {
if (rec.is_task) return `/tasks/${rec.id}`;
if (rec.note_type === "snippet") return `/snippets/${rec.id}`;
if (rec.note_type === "lesson") return `/lessons/${rec.id}`;
return `/notes/${rec.id}`;
}
const canWrite = computed(() => canWriteRecord(lesson.value?.permission));
@@ -193,7 +186,7 @@ onMounted(load);
<h2 class="ld-panel-label">Learned from</h2>
<ul class="ld-sources">
<li v-for="rec in lesson.learned_from_records" :key="rec.id">
<router-link :to="sourceHref(rec)" class="ld-link">
<router-link :to="recordHref(rec)" class="ld-link">
{{ rec.title }}
</router-link>
<span v-if="rec.status" class="ld-source-status">{{ rec.status }}</span>