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
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:
@@ -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 });
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -33,6 +33,7 @@ from scribe.routes.dashboard import dashboard_bp
|
||||
from scribe.routes.systems import systems_bp
|
||||
from scribe.routes.canonical_systems import canonical_systems_bp
|
||||
from scribe.routes.platforms import platforms_bp
|
||||
from scribe.routes.family import family_bp
|
||||
from scribe.routes.lessons import lessons_bp
|
||||
from scribe.routes.snippets import snippets_bp
|
||||
from scribe.routes.webhooks import webhooks_bp
|
||||
@@ -103,6 +104,7 @@ def create_app() -> Quart:
|
||||
app.register_blueprint(systems_bp)
|
||||
app.register_blueprint(canonical_systems_bp)
|
||||
app.register_blueprint(platforms_bp)
|
||||
app.register_blueprint(family_bp)
|
||||
app.register_blueprint(snippets_bp)
|
||||
app.register_blueprint(webhooks_bp)
|
||||
|
||||
|
||||
@@ -163,6 +163,9 @@ _READ_ONLY_TOOLS = frozenset({
|
||||
# The platform catalog and a project's answers (milestone 463). A pure
|
||||
# read; set_project_platforms is the write.
|
||||
"list_platforms",
|
||||
# Family canon (milestone 463): ideas, one idea with its precedents, and
|
||||
# the decision log. Reads of records the caller can read.
|
||||
"list_family_ideas", "get_family_idea", "list_family_decisions",
|
||||
# The pass over the corpus and its queue (milestone 458 step 7): which
|
||||
# rules are unjudged and which proposals wait. Reads of the caller's own
|
||||
# rules, as list_rules is.
|
||||
@@ -187,6 +190,9 @@ _WRITE_TOOLS = frozenset({
|
||||
"create_project", "update_project", "delete_project", "decide_project_inception",
|
||||
"create_system", "update_system", "delete_system", "map_system_to_canonical",
|
||||
"set_project_platforms",
|
||||
# family canon — the promotion engine
|
||||
"propose_family_idea", "promote_family_idea", "retire_family_idea",
|
||||
"undo_family_decision",
|
||||
"bind_repo", "unbind_repo",
|
||||
# snippets, processes, the shape ledger
|
||||
"create_snippet", "update_snippet", "delete_snippet", "verify_snippet",
|
||||
|
||||
@@ -5,7 +5,7 @@ to an MCPServer instance. `register_all(mcp)` is the single entry point called
|
||||
from `mcp.server.build_mcp_server`.
|
||||
"""
|
||||
from scribe.mcp.tools import (
|
||||
design_systems, lessons, milestones, notes, processes, projects, recent, repos,
|
||||
design_systems, family, lessons, milestones, notes, processes, projects, recent, repos,
|
||||
moments, platforms, retrieval_review, retrieval_tuning,
|
||||
wide_net,
|
||||
rulebooks, search, shapes, snippets, systems, tags, tasks, trash,
|
||||
@@ -25,6 +25,7 @@ def register_all(mcp) -> None:
|
||||
milestones.register(mcp)
|
||||
systems.register(mcp)
|
||||
platforms.register(mcp)
|
||||
family.register(mcp)
|
||||
design_systems.register(mcp)
|
||||
tags.register(mcp)
|
||||
recent.register(mcp)
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
"""Family canon MCP tools — the promotion engine's agent door (milestone 463).
|
||||
|
||||
Thin wrappers over services/family.py. The agent is the decider here: no
|
||||
person approves a promotion, so the criteria are stated in these docstrings
|
||||
and enforced by the service, and every decision is logged with its reason.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from scribe.mcp._context import current_user_id
|
||||
from scribe.services import family as family_svc
|
||||
|
||||
|
||||
async def list_family_ideas(
|
||||
status: str = "", platform: str = "", limit: int = 50, offset: int = 0,
|
||||
) -> dict:
|
||||
"""Family ideas — records that every project on a platform shares — with
|
||||
their state: `candidate` (waiting to be evaluated, or held back by a
|
||||
criterion), `canon` (promoted; every project on its platforms answers it)
|
||||
or `retired`.
|
||||
|
||||
Args:
|
||||
status: candidate | canon | retired. Empty = all.
|
||||
platform: a platform slug (list_platforms). Empty = all.
|
||||
limit / offset: page through the list.
|
||||
"""
|
||||
ideas = await family_svc.list_ideas(
|
||||
current_user_id(), status=status or None, platform=platform or None,
|
||||
limit=max(1, min(limit, 200)), offset=max(0, offset),
|
||||
)
|
||||
return {"ideas": ideas, "limit": limit, "offset": offset}
|
||||
|
||||
|
||||
async def get_family_idea(note_id: int) -> dict:
|
||||
"""One family idea with what an evaluation needs: its state, platforms,
|
||||
full decision history, ledger counts, THE THREE CRITERIA, and the
|
||||
`precedents` — the decisions on the ideas nearest this one by meaning.
|
||||
Read the precedents before deciding, and decide consistently with them
|
||||
unless this idea differs in a way you can name.
|
||||
|
||||
Args:
|
||||
note_id: the idea's record id.
|
||||
"""
|
||||
uid = current_user_id()
|
||||
idea = await family_svc.get_idea(uid, note_id)
|
||||
if idea is None:
|
||||
raise ValueError(f"#{note_id} is not a family idea you can read")
|
||||
idea["precedents"] = await family_svc.precedents(uid, note_id)
|
||||
return idea
|
||||
|
||||
|
||||
async def propose_family_idea(note_id: int, reason: str, applies_when: str = "") -> dict:
|
||||
"""Record a note, snippet or lesson as a family-idea CANDIDATE: something
|
||||
you believe every project on some platform will face. It reaches no
|
||||
project's ledger until it is promoted.
|
||||
|
||||
Proposing is cheap and does not promote. When the record already meets
|
||||
the three criteria, call promote_family_idea directly instead.
|
||||
|
||||
Args:
|
||||
note_id: the record that carries the idea.
|
||||
reason: why this looks like a family idea.
|
||||
applies_when: optional draft of when it applies, in platform terms.
|
||||
"""
|
||||
idea, created = await family_svc.propose(
|
||||
current_user_id(), note_id, reason=reason, applies_when=applies_when or None,
|
||||
)
|
||||
return {"idea": idea, "created": created}
|
||||
|
||||
|
||||
async def promote_family_idea(
|
||||
note_id: int,
|
||||
applies_when: str,
|
||||
platforms: list[str],
|
||||
platform_terms: str,
|
||||
platform_problem: str,
|
||||
proven: str,
|
||||
evidence: list[str],
|
||||
reason: str,
|
||||
precedent_ids: list[int] | None = None,
|
||||
) -> dict:
|
||||
"""Evaluate a record against the three criteria and, if all hold, promote
|
||||
it to family canon. YOU decide; nobody approves. The record of why is what
|
||||
makes the decision reviewable and the next one consistent.
|
||||
|
||||
An idea is promoted only when ALL THREE hold, and each is answered with
|
||||
your reasoning:
|
||||
|
||||
1. platform_terms — its 'when it applies' is stated in terms of a
|
||||
platform (what a project is built on or ships as), not one app's
|
||||
domain. Any project on the platform could read it and know whether it
|
||||
applies.
|
||||
2. platform_problem — it answers a problem the platform itself causes, or
|
||||
a stance the operator holds across projects. One app's preference does
|
||||
not qualify.
|
||||
3. proven — it has worked for real at least once (CI green, verified on a
|
||||
device, shipped). Name that in `evidence`.
|
||||
|
||||
Any criterion left blank — or `applies_when`, `platforms` or `evidence`
|
||||
left empty — VETOES the promotion on its own. A veto is not an error: the
|
||||
idea stays a candidate and the veto is logged, so the next evaluation of
|
||||
something like it sees why this one was held. That is also how you record
|
||||
"evaluated, and it does not qualify": leave the failing criterion blank
|
||||
and say why in `reason`.
|
||||
|
||||
Before deciding, read get_family_idea's `precedents`. The engine also
|
||||
finds the nearest earlier decisions itself and stores them as consulted.
|
||||
|
||||
On promotion every project that is on one of `platforms` and that you can
|
||||
write gets an `unassessed` row in its adoption ledger. If the idea was
|
||||
canon before, its version moves, so earlier answers read as needing a
|
||||
recheck.
|
||||
|
||||
Args:
|
||||
note_id: the record that carries the idea.
|
||||
applies_when: when it applies, in platform terms.
|
||||
platforms: platform slugs it is for (list_platforms).
|
||||
platform_terms: why criterion 1 holds (blank = it does not).
|
||||
platform_problem: why criterion 2 holds (blank = it does not).
|
||||
proven: why criterion 3 holds (blank = it does not).
|
||||
evidence: what proved it — a CI run, a task, a commit, a device check.
|
||||
reason: the decision in one or two sentences.
|
||||
precedent_ids: earlier family decisions you followed, if any.
|
||||
"""
|
||||
return await family_svc.promote(
|
||||
current_user_id(), note_id,
|
||||
applies_when=applies_when, platforms=platforms or [],
|
||||
criteria={
|
||||
"platform_terms": platform_terms,
|
||||
"platform_problem": platform_problem,
|
||||
"proven": proven,
|
||||
},
|
||||
evidence=evidence or [], reason=reason, precedent_ids=precedent_ids or [],
|
||||
)
|
||||
|
||||
|
||||
async def retire_family_idea(note_id: int, reason: str) -> dict:
|
||||
"""Demote a family idea — it no longer applies, or a better one replaces
|
||||
it. Its history and every judged ledger answer are kept; rows nobody had
|
||||
judged yet are closed. Undoable with undo_family_decision.
|
||||
|
||||
Args:
|
||||
note_id: the idea.
|
||||
reason: why it is retired.
|
||||
"""
|
||||
return await family_svc.retire(current_user_id(), note_id, reason=reason)
|
||||
|
||||
|
||||
async def undo_family_decision(decision_id: int, reason: str) -> dict:
|
||||
"""Reverse a family decision — a promotion, a retirement or a proposal —
|
||||
restoring the state it recorded as `before`. Only the latest idea-level
|
||||
decision on an idea can be undone; the undo is logged too, so the history
|
||||
keeps both.
|
||||
|
||||
Args:
|
||||
decision_id: the decision (list_family_decisions).
|
||||
reason: why it is undone.
|
||||
"""
|
||||
return await family_svc.undo(current_user_id(), decision_id, reason=reason)
|
||||
|
||||
|
||||
async def list_family_decisions(note_id: int = 0, limit: int = 50, offset: int = 0) -> dict:
|
||||
"""The family decision log, newest first: every proposal, promotion,
|
||||
veto, retirement and undo, with its reason, evidence and the precedents
|
||||
it followed. `undoable` marks the decision an undo would reverse.
|
||||
|
||||
Args:
|
||||
note_id: only this idea's decisions. 0 = all.
|
||||
limit / offset: page through the log.
|
||||
"""
|
||||
rows = await family_svc.list_decisions(
|
||||
current_user_id(), idea_id=note_id or None,
|
||||
limit=max(1, min(limit, 200)), offset=max(0, offset),
|
||||
)
|
||||
return {"decisions": rows, "limit": limit, "offset": offset}
|
||||
|
||||
|
||||
def register(mcp) -> None:
|
||||
for fn in (
|
||||
list_family_ideas, get_family_idea, propose_family_idea,
|
||||
promote_family_idea, retire_family_idea, undo_family_decision,
|
||||
list_family_decisions,
|
||||
):
|
||||
mcp.tool(name=fn.__name__)(fn)
|
||||
@@ -13,6 +13,7 @@ from __future__ import annotations
|
||||
|
||||
from scribe.mcp._context import current_user_id
|
||||
from scribe.services import dedup as dedup_svc
|
||||
from scribe.services import family as family_svc
|
||||
from scribe.services import milestones as milestones_svc
|
||||
from scribe.services import notes as notes_svc
|
||||
from scribe.services import task_logs as task_logs_svc
|
||||
@@ -172,12 +173,20 @@ async def update_milestone(
|
||||
if order_index >= 0:
|
||||
fields["order_index"] = order_index
|
||||
await refuse_guessed_ids(title, description, body)
|
||||
# Asked BEFORE the write: only the transition into done is a closing.
|
||||
closing = status == "done" and await family_svc.milestone_is_open(uid, milestone_id)
|
||||
milestone = await milestones_svc.update_milestone(uid, milestone_id, **fields)
|
||||
if milestone is None:
|
||||
raise ValueError(f"milestone {milestone_id} not found")
|
||||
data = milestone.to_dict()
|
||||
if closing:
|
||||
# Family canon's milestone trigger (milestone 463): a plan closing on
|
||||
# a platform is the moment to ask whether what it built is shared.
|
||||
hint = await family_svc.milestone_trigger(uid, milestone)
|
||||
if hint:
|
||||
data["family_hint"] = hint
|
||||
return await moment_delivery.attach_moment_rules(
|
||||
uid, "update_milestone", {"status": status, "project_id": project_id},
|
||||
milestone.to_dict(),
|
||||
uid, "update_milestone", {"status": status, "project_id": project_id}, data,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ from scribe.mcp._context import current_user_id
|
||||
from scribe.mcp.tools import systems as systems_tools
|
||||
from scribe.services import access as access_svc
|
||||
from scribe.services import dedup as dedup_svc
|
||||
from scribe.services import family as family_svc
|
||||
from scribe.services import notes as notes_svc
|
||||
from scribe.services import supersession as supersession_svc
|
||||
from scribe.services import systems as systems_svc
|
||||
@@ -237,6 +238,9 @@ async def create_note(
|
||||
await systems_tools.attach_systems(uid, uid, data, note.id, project_id or None)
|
||||
await supersession_svc.attach_relations(uid, note.id, data, hint=True)
|
||||
data.update(dedup_svc.note_overlap_response(overlaps, "note"))
|
||||
# Family canon's write-time triggers (milestone 463): a cited source of a
|
||||
# pattern, or the same idea in another project on a shared platform.
|
||||
await family_svc.attach_family_hint(uid, data, note, created=True)
|
||||
return await moment_delivery.attach_moment_rules(uid, "create_note", {"project_id": project_id}, data)
|
||||
|
||||
|
||||
@@ -319,6 +323,8 @@ async def update_note(
|
||||
uid, getattr(note, "user_id", uid) or uid, data, note_id, note.project_id
|
||||
)
|
||||
await supersession_svc.attach_relations(uid, note_id, data, hint=True)
|
||||
if body:
|
||||
await family_svc.attach_family_hint(uid, data, note, created=False)
|
||||
return data
|
||||
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ from scribe.mcp._context import current_user_id
|
||||
from scribe.mcp.tools import systems as systems_tools
|
||||
from scribe.services import access as access_svc
|
||||
from scribe.services import dedup as dedup_svc
|
||||
from scribe.services import family as family_svc
|
||||
from scribe.services import snippets as snippets_svc
|
||||
from scribe.services.note_usage import attach_usage, record_pulled
|
||||
from scribe.services import systems as systems_svc
|
||||
@@ -224,6 +225,9 @@ async def create_snippet(
|
||||
advice = snippets_svc.trigger_advice(when_to_use)
|
||||
if advice:
|
||||
data["trigger_advice"] = advice
|
||||
# Family canon (milestone 463): the same shape recorded in another project
|
||||
# on a shared platform opens an evaluation of the earlier one.
|
||||
await family_svc.attach_family_hint(uid, data, note, created=True)
|
||||
return await moment_delivery.attach_moment_rules(uid, "create_snippet", {"project_id": project_id}, data)
|
||||
|
||||
|
||||
|
||||
@@ -28,6 +28,7 @@ from scribe.mcp._context import current_user_id
|
||||
from scribe.mcp.tools import systems as systems_tools
|
||||
from scribe.services import access as access_svc
|
||||
from scribe.services import dedup as dedup_svc
|
||||
from scribe.services import family as family_svc
|
||||
from scribe.services import milestones as milestones_svc
|
||||
from scribe.services import notes as notes_svc
|
||||
# Imported by NAME, not reached through notes_svc: minted_kind is pure
|
||||
@@ -363,6 +364,9 @@ async def create_task(
|
||||
data = note.to_dict()
|
||||
await systems_tools.attach_systems(uid, uid, data, note.id, project_id or None)
|
||||
data.update(dedup_svc.note_overlap_response(overlaps, "task"))
|
||||
# A task that says it is "matching" another project's work names the
|
||||
# source of a pattern — family canon's citation trigger (milestone 463).
|
||||
await family_svc.attach_family_hint(uid, data, note, created=True)
|
||||
return await placement_svc.attach_placement(uid, data, note)
|
||||
|
||||
|
||||
@@ -457,6 +461,8 @@ async def update_task(
|
||||
uid, getattr(note, "user_id", uid) or uid, data, task_id, note.project_id
|
||||
)
|
||||
await placement_svc.attach_placement(uid, data, note)
|
||||
if body:
|
||||
await family_svc.attach_family_hint(uid, data, note, created=False)
|
||||
if status in _CLOSING_STATUSES:
|
||||
data["report_back"] = REPORT_BACK_CUE
|
||||
# The operator's own adjustments to the completion report, retrieved
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
"""Family canon routes — the web door to the promotion engine (milestone 463).
|
||||
|
||||
The agent decides promotions through the MCP tools; this door exists so a
|
||||
person can READ every decision and its reasons, and retire an idea or undo a
|
||||
decision when they disagree. The promote and propose endpoints are here for
|
||||
parity with the agent's door (rule 33), and are recorded as the operator's.
|
||||
Every write is gated on the idea's note in the service (rule 78).
|
||||
"""
|
||||
import logging
|
||||
|
||||
from quart import Blueprint, jsonify, request
|
||||
|
||||
from scribe.auth import get_current_user_id, login_required
|
||||
from scribe.routes.utils import not_found
|
||||
from scribe.services import family as family_svc
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
family_bp = Blueprint("family", __name__, url_prefix="/api/family")
|
||||
|
||||
|
||||
def _page() -> tuple[int, int]:
|
||||
try:
|
||||
limit = max(1, min(int(request.args.get("limit", 50)), 200))
|
||||
offset = max(0, int(request.args.get("offset", 0)))
|
||||
except ValueError:
|
||||
limit, offset = 50, 0
|
||||
return limit, offset
|
||||
|
||||
|
||||
@family_bp.route("/ideas", methods=["GET"])
|
||||
@login_required
|
||||
async def list_ideas_route():
|
||||
limit, offset = _page()
|
||||
ideas = await family_svc.list_ideas(
|
||||
get_current_user_id(),
|
||||
status=request.args.get("status") or None,
|
||||
platform=request.args.get("platform") or None,
|
||||
limit=limit, offset=offset,
|
||||
)
|
||||
return jsonify({"ideas": ideas, "criteria": list(family_svc.CRITERIA)})
|
||||
|
||||
|
||||
@family_bp.route("/ideas/<int:note_id>", methods=["GET"])
|
||||
@login_required
|
||||
async def get_idea_route(note_id: int):
|
||||
uid = get_current_user_id()
|
||||
idea = await family_svc.get_idea(uid, note_id)
|
||||
if idea is None:
|
||||
return not_found("Family idea")
|
||||
idea["precedents"] = await family_svc.precedents(uid, note_id)
|
||||
return jsonify(idea)
|
||||
|
||||
|
||||
@family_bp.route("/ideas/<int:note_id>/propose", methods=["POST"])
|
||||
@login_required
|
||||
async def propose_route(note_id: int):
|
||||
data = await request.get_json() or {}
|
||||
try:
|
||||
idea, created = await family_svc.propose(
|
||||
get_current_user_id(), note_id, reason=data.get("reason") or "",
|
||||
applies_when=data.get("applies_when") or None, decided_via="operator",
|
||||
)
|
||||
except ValueError as exc:
|
||||
return jsonify({"error": str(exc)}), 400
|
||||
return jsonify({"idea": idea, "created": created}), (201 if created else 200)
|
||||
|
||||
|
||||
@family_bp.route("/ideas/<int:note_id>/promote", methods=["POST"])
|
||||
@login_required
|
||||
async def promote_route(note_id: int):
|
||||
data = await request.get_json() or {}
|
||||
try:
|
||||
result = await family_svc.promote(
|
||||
get_current_user_id(), note_id,
|
||||
applies_when=data.get("applies_when") or "",
|
||||
platforms=data.get("platforms") or [],
|
||||
criteria=data.get("criteria") or {},
|
||||
evidence=data.get("evidence") or [],
|
||||
reason=data.get("reason") or "",
|
||||
precedent_ids=data.get("precedent_ids") or [],
|
||||
decided_via="operator",
|
||||
)
|
||||
except ValueError as exc:
|
||||
return jsonify({"error": str(exc)}), 400
|
||||
return jsonify(result)
|
||||
|
||||
|
||||
@family_bp.route("/ideas/<int:note_id>/retire", methods=["POST"])
|
||||
@login_required
|
||||
async def retire_route(note_id: int):
|
||||
data = await request.get_json() or {}
|
||||
try:
|
||||
result = await family_svc.retire(
|
||||
get_current_user_id(), note_id, reason=data.get("reason") or "",
|
||||
decided_via="operator",
|
||||
)
|
||||
except ValueError as exc:
|
||||
return jsonify({"error": str(exc)}), 400
|
||||
return jsonify(result)
|
||||
|
||||
|
||||
@family_bp.route("/decisions", methods=["GET"])
|
||||
@login_required
|
||||
async def list_decisions_route():
|
||||
limit, offset = _page()
|
||||
idea_id = request.args.get("idea_id", type=int)
|
||||
rows = await family_svc.list_decisions(
|
||||
get_current_user_id(), idea_id=idea_id or None, limit=limit, offset=offset,
|
||||
)
|
||||
return jsonify({"decisions": rows, "limit": limit, "offset": offset})
|
||||
|
||||
|
||||
@family_bp.route("/decisions/<int:decision_id>/undo", methods=["POST"])
|
||||
@login_required
|
||||
async def undo_route(decision_id: int):
|
||||
data = await request.get_json() or {}
|
||||
try:
|
||||
result = await family_svc.undo(
|
||||
get_current_user_id(), decision_id, reason=data.get("reason") or "",
|
||||
decided_via="operator",
|
||||
)
|
||||
except ValueError as exc:
|
||||
return jsonify({"error": str(exc)}), 400
|
||||
return jsonify(result)
|
||||
@@ -0,0 +1,838 @@
|
||||
"""Family canon's promotion engine (milestone 463 step 3).
|
||||
|
||||
A family idea moves between three states — candidate, canon, retired — and
|
||||
every move is a decision with a reason, written to `family_decisions`. No
|
||||
person approves a promotion: the agent decides against the written criteria
|
||||
below, and the log is what keeps one decision consistent with the last similar
|
||||
one, and what lets a person read or undo any of them afterwards.
|
||||
|
||||
WHO DECIDES WHAT
|
||||
|
||||
- TRIGGERS (`citation_trigger`, `repeat_trigger`, `milestone_trigger`) only
|
||||
ever OPEN an evaluation. The first two record the source record as a
|
||||
`candidate` (decided_via "system") and hand the writer an in-band hint; a
|
||||
closed milestone is not a record an idea can hang on, so it only hints. None
|
||||
of them promotes.
|
||||
- THE AGENT evaluates a candidate against the three criteria and either
|
||||
promotes it or leaves it a candidate with the reason. A criterion with no
|
||||
support vetoes on its own; the veto is logged too, because a held candidate
|
||||
is precedent for the next one like it.
|
||||
- A PERSON reads the log, and may retire an idea or undo a decision from the
|
||||
web door. Those are the only operator acts, and neither is required.
|
||||
|
||||
PRECEDENT
|
||||
|
||||
Every promotion records the earlier decisions it was consistent with. The
|
||||
engine finds them itself — the decisions on the ideas nearest this one by
|
||||
meaning — and stores them alongside any the caller names, so "which precedents
|
||||
were consulted" is a fact about the call, not a sentence a session might skip.
|
||||
|
||||
LEDGER ROWS AND UNDO (settled here, as the milestone asked)
|
||||
|
||||
Promotion opens an `unassessed` row for every project that is a member of one
|
||||
of the idea's platforms and that the promoter can write. Leaving canon —
|
||||
by retirement, or by undoing the promotion — deletes the `unassessed` rows,
|
||||
because nobody judged them and they would only be noise. Rows somebody DID
|
||||
judge (adopted, variant, exempt, owed) are kept: each is a decision with its
|
||||
reason, and if the idea is promoted again its version moves, so every kept
|
||||
row reads as needing a recheck rather than as still agreeing.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import delete, func, select
|
||||
|
||||
from scribe.models import async_session
|
||||
from scribe.models.family import (
|
||||
FamilyAdoption, FamilyDecision, FamilyIdea, FamilyIdeaPlatform, Platform,
|
||||
ProjectPlatform,
|
||||
)
|
||||
from scribe.models.note import Note
|
||||
from scribe.models.project import Project
|
||||
from scribe.services import access
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# --- the criteria, as product text --------------------------------------------
|
||||
#
|
||||
# These are what an evaluation is judged against, on every install. They live
|
||||
# here — and in the tool docstrings that quote them — rather than in any
|
||||
# instance's rulebook (rules 115, 119).
|
||||
|
||||
CRITERIA = (
|
||||
{
|
||||
"key": "platform_terms",
|
||||
"title": "Stated in platform terms",
|
||||
"test": (
|
||||
"Its 'when it applies' is stated in terms of a platform — what the "
|
||||
"project is built on or ships as — not one app's domain. Any project "
|
||||
"on the platform could read it and know whether it applies."
|
||||
),
|
||||
},
|
||||
{
|
||||
"key": "platform_problem",
|
||||
"title": "Answers a platform problem or a cross-project stance",
|
||||
"test": (
|
||||
"It answers a problem the platform itself causes, or a stance the "
|
||||
"operator holds across projects. One app's preference does not "
|
||||
"qualify."
|
||||
),
|
||||
},
|
||||
{
|
||||
"key": "proven",
|
||||
"title": "Proven at least once",
|
||||
"test": (
|
||||
"It has worked for real at least once — CI green, verified on a "
|
||||
"device, or shipped — and the evidence is named. An unproven idea "
|
||||
"stays a candidate."
|
||||
),
|
||||
},
|
||||
)
|
||||
CRITERIA_KEYS = tuple(c["key"] for c in CRITERIA)
|
||||
|
||||
TRIGGERS = ("citation", "repeat", "milestone")
|
||||
|
||||
# The repeat trigger's similarity floor. MEASURED, not guessed (2026-10-06,
|
||||
# #4989): against a description of one pattern known to have been built four
|
||||
# times in four projects, the records that implement it scored 0.79-0.82, and
|
||||
# the best match in a project that never built it scored 0.65. A trigger only
|
||||
# opens an evaluation, so a false positive costs one judgment, not a wrong
|
||||
# promotion. Step 5 measures the cross-language threshold against known pairs
|
||||
# and supersedes this.
|
||||
REPEAT_THRESHOLD = 0.80
|
||||
|
||||
# Words that say a citation is the SOURCE of a pattern rather than background.
|
||||
# "see #12" is a pointer; "matching #12" and "ported from #12" are lineage.
|
||||
_LINEAGE = re.compile(
|
||||
r"\b(match(?:es|ing)?|mirror(?:s|ing|ed)?|same (?:shape|pattern|approach|design) as|"
|
||||
r"ported from|copied from|borrowed from|lifted from|taken from|based on|"
|
||||
r"modell?ed on|follow(?:s|ing)? the (?:shape|pattern|approach) of|as (?:built|done) in|"
|
||||
r"reuses?|re-?implement(?:s|ing)?|like)\b",
|
||||
re.I,
|
||||
)
|
||||
# How far before a `#N` the lineage word may sit: the same clause, roughly.
|
||||
_LINEAGE_WINDOW = 80
|
||||
|
||||
# Note kinds the repeat trigger compares: recorded knowledge and shapes, not
|
||||
# the to-do list.
|
||||
_REPEAT_KINDS = ("snippet", "note")
|
||||
|
||||
MEMBER_STATES = ("declared", "detected")
|
||||
_IDEA_ACTIONS = ("propose", "promote", "revise", "retire")
|
||||
|
||||
|
||||
def _now() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
# --- reads --------------------------------------------------------------------
|
||||
|
||||
async def _platform_slugs(session, note_id: int) -> list[str]:
|
||||
rows = await session.execute(
|
||||
select(Platform.slug)
|
||||
.join(FamilyIdeaPlatform, FamilyIdeaPlatform.platform_id == Platform.id)
|
||||
.where(FamilyIdeaPlatform.note_id == note_id)
|
||||
.order_by(Platform.order_index.asc(), Platform.slug.asc())
|
||||
)
|
||||
return list(rows.scalars().all())
|
||||
|
||||
|
||||
async def _snapshot(session, idea: FamilyIdea | None) -> dict | None:
|
||||
"""The idea's state, as a decision's before/after. Slugs, never ids: an id
|
||||
inside JSON cannot be remapped by a restore (the #3182 trap)."""
|
||||
if idea is None:
|
||||
return None
|
||||
return {
|
||||
"status": idea.status,
|
||||
"applies_when": idea.applies_when or "",
|
||||
"canon_version": idea.canon_version,
|
||||
"platforms": await _platform_slugs(session, idea.note_id),
|
||||
}
|
||||
|
||||
|
||||
def _decision_dict(d: FamilyDecision, title: str | None = None) -> dict:
|
||||
out = d.to_dict()
|
||||
if title is not None:
|
||||
out["idea_title"] = title
|
||||
return out
|
||||
|
||||
|
||||
async def get_idea(user_id: int, note_id: int) -> dict | None:
|
||||
"""One idea with everything an evaluation reads: its state, platforms,
|
||||
decision history (newest first), its ledger counts and the criteria.
|
||||
None when the caller cannot read the note or it is not an idea."""
|
||||
if not await access.can_read_note(user_id, note_id):
|
||||
return None
|
||||
async with async_session() as session:
|
||||
idea = await session.get(FamilyIdea, note_id)
|
||||
note = await session.get(Note, note_id)
|
||||
if idea is None or note is None:
|
||||
return None
|
||||
decisions = (await session.execute(
|
||||
select(FamilyDecision).where(FamilyDecision.idea_id == note_id)
|
||||
.order_by(FamilyDecision.id.desc())
|
||||
)).scalars().all()
|
||||
counts = dict((await session.execute(
|
||||
select(FamilyAdoption.status, func.count())
|
||||
.where(FamilyAdoption.idea_id == note_id)
|
||||
.group_by(FamilyAdoption.status)
|
||||
)).all())
|
||||
out = idea.to_dict()
|
||||
out.update({
|
||||
"title": note.title,
|
||||
"note_type": note.note_type or "note",
|
||||
"is_task": note.is_task,
|
||||
"project_id": note.project_id,
|
||||
"platforms": await _platform_slugs(session, note_id),
|
||||
"adoptions": counts,
|
||||
"decisions": [_decision_dict(d) for d in decisions],
|
||||
"undoable_decision_id": _undoable_id(decisions),
|
||||
})
|
||||
out["criteria"] = list(CRITERIA)
|
||||
return out
|
||||
|
||||
|
||||
async def list_ideas(
|
||||
user_id: int, *, status: str | None = None, platform: str | None = None,
|
||||
limit: int = 50, offset: int = 0,
|
||||
) -> list[dict]:
|
||||
"""Ideas whose note the caller can read, newest change first."""
|
||||
async with async_session() as session:
|
||||
query = (
|
||||
select(FamilyIdea, Note.title, Note.note_type, Note.project_id, Note.status)
|
||||
.join(Note, Note.id == FamilyIdea.note_id)
|
||||
.where(access.readable_notes_clause(user_id), Note.deleted_at.is_(None))
|
||||
)
|
||||
if status:
|
||||
query = query.where(FamilyIdea.status == status)
|
||||
if platform:
|
||||
query = query.where(FamilyIdea.note_id.in_(
|
||||
select(FamilyIdeaPlatform.note_id)
|
||||
.join(Platform, Platform.id == FamilyIdeaPlatform.platform_id)
|
||||
.where(Platform.slug == platform)
|
||||
))
|
||||
rows = (await session.execute(
|
||||
query.order_by(FamilyIdea.updated_at.desc()).limit(limit).offset(offset)
|
||||
)).all()
|
||||
out = []
|
||||
for idea, title, note_type, project_id, note_status in rows:
|
||||
d = idea.to_dict()
|
||||
d.update({
|
||||
"title": title,
|
||||
"note_type": note_type or "note",
|
||||
"is_task": note_status is not None,
|
||||
"project_id": project_id,
|
||||
"platforms": await _platform_slugs(session, idea.note_id),
|
||||
})
|
||||
out.append(d)
|
||||
return out
|
||||
|
||||
|
||||
async def list_decisions(
|
||||
user_id: int, *, idea_id: int | None = None, limit: int = 50, offset: int = 0,
|
||||
) -> list[dict]:
|
||||
"""The decision log, newest first, over ideas the caller can read. Each
|
||||
row says whether it is the one an undo would reverse."""
|
||||
async with async_session() as session:
|
||||
query = (
|
||||
select(FamilyDecision, Note.title)
|
||||
.join(Note, Note.id == FamilyDecision.idea_id)
|
||||
.where(access.readable_notes_clause(user_id))
|
||||
)
|
||||
if idea_id:
|
||||
query = query.where(FamilyDecision.idea_id == idea_id)
|
||||
rows = (await session.execute(
|
||||
query.order_by(FamilyDecision.id.desc()).limit(limit).offset(offset)
|
||||
)).all()
|
||||
latest = await _latest_idea_decisions(session, {d.idea_id for d, _ in rows})
|
||||
out = []
|
||||
for d, title in rows:
|
||||
item = _decision_dict(d, title)
|
||||
item["undoable"] = latest.get(d.idea_id) == d.id and _can_undo(d)
|
||||
out.append(item)
|
||||
return out
|
||||
|
||||
|
||||
def _can_undo(d: FamilyDecision) -> bool:
|
||||
"""An idea-level decision that changed something. A veto (a `propose`
|
||||
whose before and after agree) changed nothing, and an undo is undone by
|
||||
deciding again, not by undoing the undo."""
|
||||
return d.project_id is None and d.action in _IDEA_ACTIONS and d.before != d.after
|
||||
|
||||
|
||||
def _undoable_id(decisions_newest_first) -> int | None:
|
||||
for d in decisions_newest_first:
|
||||
if d.project_id is None:
|
||||
return d.id if _can_undo(d) else None
|
||||
return None
|
||||
|
||||
|
||||
async def _latest_idea_decisions(session, idea_ids: set[int]) -> dict[int, int]:
|
||||
if not idea_ids:
|
||||
return {}
|
||||
rows = await session.execute(
|
||||
select(FamilyDecision.idea_id, func.max(FamilyDecision.id))
|
||||
.where(FamilyDecision.idea_id.in_(idea_ids), FamilyDecision.project_id.is_(None))
|
||||
.group_by(FamilyDecision.idea_id)
|
||||
)
|
||||
return dict(rows.all())
|
||||
|
||||
|
||||
async def precedents(user_id: int, note_id: int, limit: int = 5) -> list[dict]:
|
||||
"""The decisions on the ideas nearest this one by meaning — what a new
|
||||
decision about it should be consistent with. Each idea contributes its
|
||||
latest idea-level decision. Empty when nothing similar has been decided,
|
||||
or when the embedder is unavailable."""
|
||||
from scribe.services.embeddings import embedding_text, semantic_search_notes
|
||||
|
||||
async with async_session() as session:
|
||||
note = await session.get(Note, note_id)
|
||||
if note is None:
|
||||
return []
|
||||
idea_ids = set((await session.execute(select(FamilyIdea.note_id))).scalars().all())
|
||||
idea_ids.discard(note_id)
|
||||
if not idea_ids:
|
||||
return []
|
||||
try:
|
||||
hits = await semantic_search_notes(
|
||||
user_id, embedding_text(note.title, note.body), exclude_ids={note_id},
|
||||
limit=40, threshold=0.0, scope="read", include_global_kinds=True,
|
||||
demote_superseded=False,
|
||||
)
|
||||
except Exception:
|
||||
logger.warning("precedent search failed for idea %s", note_id, exc_info=True)
|
||||
return []
|
||||
ranked = [(score, n) for score, n in hits if n.id in idea_ids][:limit]
|
||||
if not ranked:
|
||||
return []
|
||||
async with async_session() as session:
|
||||
latest = await _latest_idea_decisions(session, {n.id for _, n in ranked})
|
||||
decisions = {
|
||||
d.id: d for d in (await session.execute(
|
||||
select(FamilyDecision).where(FamilyDecision.id.in_(list(latest.values())))
|
||||
)).scalars().all()
|
||||
}
|
||||
out = []
|
||||
for score, n in ranked:
|
||||
d = decisions.get(latest.get(n.id))
|
||||
if d is not None:
|
||||
item = _decision_dict(d, n.title)
|
||||
item["similarity"] = round(float(score), 3)
|
||||
out.append(item)
|
||||
return out
|
||||
|
||||
|
||||
# --- writes ---------------------------------------------------------------------
|
||||
|
||||
def _log(session, *, idea_id: int, action: str, reason: str, before, after,
|
||||
evidence: dict | None, precedent_ids: list[int] | None, decided_via: str,
|
||||
user_id: int | None, project_id: int | None = None) -> FamilyDecision:
|
||||
row = FamilyDecision(
|
||||
idea_id=idea_id, project_id=project_id, action=action, reason=reason.strip(),
|
||||
before=before, after=after, evidence=evidence or {},
|
||||
precedent_ids=list(dict.fromkeys(precedent_ids or [])),
|
||||
decided_via=decided_via, user_id=user_id,
|
||||
)
|
||||
session.add(row)
|
||||
return row
|
||||
|
||||
|
||||
async def _resolve_platforms(session, slugs: list[str]) -> dict[str, int]:
|
||||
wanted = list(dict.fromkeys(s.strip() for s in slugs if s and s.strip()))
|
||||
if not wanted:
|
||||
return {}
|
||||
rows = (await session.execute(
|
||||
select(Platform.slug, Platform.id)
|
||||
.where(Platform.slug.in_(wanted), Platform.deleted_at.is_(None))
|
||||
)).all()
|
||||
known = dict(rows)
|
||||
unknown = [s for s in wanted if s not in known]
|
||||
if unknown:
|
||||
raise ValueError(f"unknown platform(s): {', '.join(unknown)} (list_platforms)")
|
||||
return known
|
||||
|
||||
|
||||
async def _set_platforms(session, note_id: int, platform_ids) -> None:
|
||||
await session.execute(delete(FamilyIdeaPlatform).where(FamilyIdeaPlatform.note_id == note_id))
|
||||
for pid in dict.fromkeys(platform_ids):
|
||||
session.add(FamilyIdeaPlatform(note_id=note_id, platform_id=pid))
|
||||
|
||||
|
||||
async def _open_ledger(session, user_id: int, note_id: int) -> int:
|
||||
"""An `unassessed` row for every member project of the idea's platforms
|
||||
that the promoter can write and that has no row yet. Returns how many."""
|
||||
project_ids = set((await session.execute(
|
||||
select(ProjectPlatform.project_id)
|
||||
.join(FamilyIdeaPlatform, FamilyIdeaPlatform.platform_id == ProjectPlatform.platform_id)
|
||||
.join(Project, Project.id == ProjectPlatform.project_id)
|
||||
.where(
|
||||
FamilyIdeaPlatform.note_id == note_id,
|
||||
ProjectPlatform.state.in_(MEMBER_STATES),
|
||||
Project.deleted_at.is_(None),
|
||||
)
|
||||
)).scalars().all())
|
||||
answered = set((await session.execute(
|
||||
select(FamilyAdoption.project_id).where(FamilyAdoption.idea_id == note_id)
|
||||
)).scalars().all())
|
||||
opened = 0
|
||||
for pid in sorted(project_ids - answered):
|
||||
# Rule 78: a promotion reaches only the projects its promoter could
|
||||
# have written an answer into themselves.
|
||||
if await access.can_write_project(user_id, pid):
|
||||
session.add(FamilyAdoption(project_id=pid, idea_id=note_id, status="unassessed"))
|
||||
opened += 1
|
||||
return opened
|
||||
|
||||
|
||||
async def _close_unassessed(session, note_id: int) -> int:
|
||||
result = await session.execute(
|
||||
delete(FamilyAdoption).where(
|
||||
FamilyAdoption.idea_id == note_id, FamilyAdoption.status == "unassessed",
|
||||
)
|
||||
)
|
||||
return result.rowcount or 0
|
||||
|
||||
|
||||
async def propose(
|
||||
user_id: int, note_id: int, *, reason: str, trigger: str = "agent",
|
||||
evidence: dict | None = None, applies_when: str | None = None,
|
||||
decided_via: str = "agent",
|
||||
) -> tuple[dict, bool]:
|
||||
"""Record a note as a family-idea CANDIDATE. Returns (idea, created).
|
||||
|
||||
Idempotent: an existing idea comes back unchanged and nothing is logged —
|
||||
a trigger firing on every edit of a record must not grow the log. Write-
|
||||
gated on the note: an idea is state on someone's record (rule 78).
|
||||
"""
|
||||
if not (reason or "").strip():
|
||||
raise ValueError("a proposal needs a reason — what makes this a family idea?")
|
||||
if not await access.can_write_note(user_id, note_id):
|
||||
raise ValueError(f"note {note_id} not found or no write access")
|
||||
async with async_session() as session:
|
||||
idea = await session.get(FamilyIdea, note_id)
|
||||
if idea is not None:
|
||||
return idea.to_dict(), False
|
||||
idea = FamilyIdea(note_id=note_id, status="candidate",
|
||||
applies_when=(applies_when or "").strip() or None)
|
||||
session.add(idea)
|
||||
await session.flush()
|
||||
_log(
|
||||
session, idea_id=note_id, action="propose", reason=reason, before=None,
|
||||
after=await _snapshot(session, idea),
|
||||
evidence={"trigger": trigger, **(evidence or {})},
|
||||
precedent_ids=None, decided_via=decided_via, user_id=user_id,
|
||||
)
|
||||
await session.commit()
|
||||
return idea.to_dict(), True
|
||||
|
||||
|
||||
def vetoes(*, applies_when: str, platforms: list[str], criteria: dict, evidence: list) -> list[str]:
|
||||
"""The criteria a promotion fails, by key. Pure.
|
||||
|
||||
Each fails ON ITS OWN when its support is missing:
|
||||
- platform_terms — no reasoning, no `applies_when`, or no platform scope;
|
||||
- platform_problem — no reasoning;
|
||||
- proven — no reasoning, or no named evidence.
|
||||
"""
|
||||
def said(key: str) -> bool:
|
||||
return bool(str(criteria.get(key) or "").strip())
|
||||
|
||||
failed = []
|
||||
if not said("platform_terms") or not (applies_when or "").strip() or not platforms:
|
||||
failed.append("platform_terms")
|
||||
if not said("platform_problem"):
|
||||
failed.append("platform_problem")
|
||||
if not said("proven") or not [e for e in evidence or [] if str(e).strip()]:
|
||||
failed.append("proven")
|
||||
return failed
|
||||
|
||||
|
||||
async def promote(
|
||||
user_id: int, note_id: int, *, applies_when: str, platforms: list[str],
|
||||
criteria: dict, evidence: list[str], reason: str,
|
||||
precedent_ids: list[int] | None = None, decided_via: str = "agent",
|
||||
) -> dict:
|
||||
"""Evaluate a record against the three criteria and promote it to canon.
|
||||
|
||||
A record nobody proposed may be promoted directly — the candidate row is
|
||||
created on the way. Any criterion without support vetoes the promotion:
|
||||
the idea stays (or becomes) a candidate and the veto is logged as a
|
||||
`propose` decision naming what failed, so it is precedent too.
|
||||
|
||||
On promotion: status canon, `applies_when` and the platform scope set, the
|
||||
version bumped if this idea has been promoted before (so rows judged
|
||||
against the earlier canon read as needing a recheck), an `unassessed`
|
||||
ledger row opened per member project, and a decision logged with the
|
||||
criteria reasoning, the evidence and the precedents consulted.
|
||||
|
||||
Raises ValueError on a malformed call (no reason, unknown platform, no
|
||||
write access, already canon) — before anything is written.
|
||||
"""
|
||||
if not (reason or "").strip():
|
||||
raise ValueError("a promotion needs a reason")
|
||||
if not await access.can_write_note(user_id, note_id):
|
||||
raise ValueError(f"note {note_id} not found or no write access")
|
||||
criteria = {k: str((criteria or {}).get(k) or "").strip() for k in CRITERIA_KEYS}
|
||||
evidence = [str(e).strip() for e in evidence or [] if str(e).strip()]
|
||||
consulted = await precedents(user_id, note_id)
|
||||
named = await _existing_decision_ids(precedent_ids or [])
|
||||
precedent_list = list(dict.fromkeys(named + [p["id"] for p in consulted]))
|
||||
|
||||
async with async_session() as session:
|
||||
known = await _resolve_platforms(session, platforms or [])
|
||||
idea = await session.get(FamilyIdea, note_id)
|
||||
if idea is not None and idea.status == "canon":
|
||||
raise ValueError(
|
||||
f"#{note_id} is already canon (version {idea.canon_version}); "
|
||||
"retire it first, or record a revision"
|
||||
)
|
||||
created_now = idea is None
|
||||
if created_now:
|
||||
idea = FamilyIdea(note_id=note_id, status="candidate")
|
||||
session.add(idea)
|
||||
await session.flush()
|
||||
# A row made by this call had no prior state: `before` says so, which
|
||||
# also makes a veto that created a candidate undoable (it changed
|
||||
# something — a record became a candidate).
|
||||
before = None if created_now else await _snapshot(session, idea)
|
||||
failed = vetoes(
|
||||
applies_when=applies_when, platforms=list(known), criteria=criteria,
|
||||
evidence=evidence,
|
||||
)
|
||||
record = {"criteria": criteria, "evidence": evidence}
|
||||
if failed:
|
||||
decision = _log(
|
||||
session, idea_id=note_id, action="propose",
|
||||
reason=f"held as a candidate — fails {', '.join(failed)}: {reason.strip()}",
|
||||
before=before, after=await _snapshot(session, idea),
|
||||
evidence={**record, "vetoed_by": failed},
|
||||
precedent_ids=precedent_list, decided_via=decided_via, user_id=user_id,
|
||||
)
|
||||
await session.commit()
|
||||
await session.refresh(decision)
|
||||
return {
|
||||
"promoted": False, "vetoed_by": failed, "idea": idea.to_dict(),
|
||||
"decision": decision.to_dict(), "precedents": consulted,
|
||||
}
|
||||
|
||||
# Re-promotion moves the version PAST every version this idea has ever
|
||||
# held — including one an undo rolled back from — so no row judged
|
||||
# against an earlier canon can read as agreeing with this one.
|
||||
history = (await session.execute(
|
||||
select(FamilyDecision.action, FamilyDecision.after)
|
||||
.where(FamilyDecision.idea_id == note_id)
|
||||
)).all()
|
||||
if any(action == "promote" for action, _ in history):
|
||||
seen = [idea.canon_version or 1] + [
|
||||
int(after.get("canon_version") or 1) for _, after in history if after
|
||||
]
|
||||
idea.canon_version = max(seen) + 1
|
||||
idea.status = "canon"
|
||||
idea.applies_when = applies_when.strip()
|
||||
idea.updated_at = _now()
|
||||
await _set_platforms(session, note_id, known.values())
|
||||
await session.flush()
|
||||
opened = await _open_ledger(session, user_id, note_id)
|
||||
decision = _log(
|
||||
session, idea_id=note_id, action="promote", reason=reason,
|
||||
before=before, after=await _snapshot(session, idea),
|
||||
evidence={**record, "ledger_rows_opened": opened},
|
||||
precedent_ids=precedent_list, decided_via=decided_via, user_id=user_id,
|
||||
)
|
||||
await session.commit()
|
||||
await session.refresh(decision)
|
||||
return {
|
||||
"promoted": True, "idea": idea.to_dict(), "ledger_rows_opened": opened,
|
||||
"decision": decision.to_dict(), "precedents": consulted,
|
||||
}
|
||||
|
||||
|
||||
async def _existing_decision_ids(ids: list[int]) -> list[int]:
|
||||
ids = [int(i) for i in ids if i]
|
||||
if not ids:
|
||||
return []
|
||||
async with async_session() as session:
|
||||
found = set((await session.execute(
|
||||
select(FamilyDecision.id).where(FamilyDecision.id.in_(ids))
|
||||
)).scalars().all())
|
||||
missing = [i for i in ids if i not in found]
|
||||
if missing:
|
||||
raise ValueError(f"no such family decision(s): {', '.join(map(str, missing))}")
|
||||
return ids
|
||||
|
||||
|
||||
async def retire(user_id: int, note_id: int, *, reason: str, decided_via: str = "agent") -> dict:
|
||||
"""Demote an idea. Its history and its judged ledger rows are kept; the
|
||||
rows nobody judged are closed. Undoable."""
|
||||
if not (reason or "").strip():
|
||||
raise ValueError("retiring an idea needs a reason")
|
||||
if not await access.can_write_note(user_id, note_id):
|
||||
raise ValueError(f"note {note_id} not found or no write access")
|
||||
async with async_session() as session:
|
||||
idea = await session.get(FamilyIdea, note_id)
|
||||
if idea is None:
|
||||
raise ValueError(f"#{note_id} is not a family idea")
|
||||
if idea.status == "retired":
|
||||
raise ValueError(f"#{note_id} is already retired")
|
||||
before = await _snapshot(session, idea)
|
||||
idea.status = "retired"
|
||||
idea.updated_at = _now()
|
||||
closed = await _close_unassessed(session, note_id)
|
||||
decision = _log(
|
||||
session, idea_id=note_id, action="retire", reason=reason, before=before,
|
||||
after=await _snapshot(session, idea), evidence={"ledger_rows_closed": closed},
|
||||
precedent_ids=None, decided_via=decided_via, user_id=user_id,
|
||||
)
|
||||
await session.commit()
|
||||
await session.refresh(decision)
|
||||
return {"idea": idea.to_dict(), "decision": decision.to_dict()}
|
||||
|
||||
|
||||
async def undo(user_id: int, decision_id: int, *, reason: str, decided_via: str = "agent") -> dict:
|
||||
"""Reverse an idea-level decision, restoring the state it recorded as
|
||||
`before`. Only the LATEST idea-level decision on an idea can be undone —
|
||||
undoing an older one would rewrite a state later decisions were built on.
|
||||
The undo is itself a decision, naming the one it reverses as its
|
||||
precedent, so the history keeps both.
|
||||
|
||||
Undoing the proposal that created an idea retires it rather than deleting
|
||||
it: deleting the idea would take its decision log with it.
|
||||
"""
|
||||
if not (reason or "").strip():
|
||||
raise ValueError("an undo needs a reason")
|
||||
async with async_session() as session:
|
||||
target = await session.get(FamilyDecision, decision_id)
|
||||
if target is None:
|
||||
raise ValueError(f"no such family decision: {decision_id}")
|
||||
if not await access.can_write_note(user_id, target.idea_id):
|
||||
raise ValueError(f"decision {decision_id} not found or no write access")
|
||||
async with async_session() as session:
|
||||
latest_id = (await _latest_idea_decisions(session, {target.idea_id})).get(target.idea_id)
|
||||
if latest_id != target.id or not _can_undo(target):
|
||||
if not _can_undo(target):
|
||||
why = ("it changed nothing" if target.before == target.after
|
||||
else f"a '{target.action}' decision is not undone this way")
|
||||
else:
|
||||
why = f"decision {latest_id} came after it — undo that one first"
|
||||
raise ValueError(f"decision {decision_id} cannot be undone: {why}")
|
||||
idea = await session.get(FamilyIdea, target.idea_id)
|
||||
before = await _snapshot(session, idea)
|
||||
prior = target.before or {
|
||||
"status": "retired", "applies_when": idea.applies_when or "",
|
||||
"canon_version": idea.canon_version, "platforms": before["platforms"],
|
||||
}
|
||||
if prior["status"] == "canon" and not (prior.get("applies_when") or "").strip():
|
||||
raise ValueError("the recorded prior state is canon with no 'applies when'")
|
||||
known = await _resolve_platforms(session, prior.get("platforms") or [])
|
||||
idea.status = prior["status"]
|
||||
idea.applies_when = (prior.get("applies_when") or "").strip() or None
|
||||
idea.canon_version = prior.get("canon_version") or idea.canon_version
|
||||
idea.updated_at = _now()
|
||||
await _set_platforms(session, idea.note_id, known.values())
|
||||
await session.flush()
|
||||
ledger: dict = {}
|
||||
if idea.status == "canon":
|
||||
ledger["ledger_rows_opened"] = await _open_ledger(session, user_id, idea.note_id)
|
||||
else:
|
||||
ledger["ledger_rows_closed"] = await _close_unassessed(session, idea.note_id)
|
||||
decision = _log(
|
||||
session, idea_id=idea.note_id, action="undo", reason=reason,
|
||||
before=before, after=await _snapshot(session, idea),
|
||||
evidence={"undid_action": target.action, **ledger},
|
||||
precedent_ids=[target.id], decided_via=decided_via, user_id=user_id,
|
||||
)
|
||||
await session.commit()
|
||||
await session.refresh(decision)
|
||||
return {"idea": idea.to_dict(), "decision": decision.to_dict()}
|
||||
|
||||
|
||||
# --- triggers -------------------------------------------------------------------
|
||||
|
||||
def lineage_citations(text: str | None) -> list[int]:
|
||||
"""The `#N`s in a text that are cited as the SOURCE of a pattern — a
|
||||
lineage word ("matching", "ported from", "same shape as", …) in the same
|
||||
clause just before the reference. Pure, in order, de-duplicated."""
|
||||
from scribe.services.record_refs import REF_RE
|
||||
|
||||
found: list[int] = []
|
||||
for m in REF_RE.finditer(text or ""):
|
||||
start = max(0, m.start() - _LINEAGE_WINDOW)
|
||||
window = text[start:m.start()]
|
||||
window = re.split(r"[\n.;]", window)[-1]
|
||||
if _LINEAGE.search(window):
|
||||
n = int(m.group(1))
|
||||
if n not in found:
|
||||
found.append(n)
|
||||
return found
|
||||
|
||||
|
||||
async def _member_platform_ids(session, project_id: int) -> set[int]:
|
||||
return set((await session.execute(
|
||||
select(ProjectPlatform.platform_id).where(
|
||||
ProjectPlatform.project_id == project_id,
|
||||
ProjectPlatform.state.in_(MEMBER_STATES),
|
||||
)
|
||||
)).scalars().all())
|
||||
|
||||
|
||||
def _evaluate_line(note_id: int) -> str:
|
||||
return (
|
||||
f"Evaluate it now: get_family_idea({note_id}) shows the three criteria and "
|
||||
"the nearest precedents; then promote_family_idea if all three hold, or "
|
||||
"leave it a candidate (promote_family_idea records which criterion "
|
||||
"failed). No one approves this — the criteria decide."
|
||||
)
|
||||
|
||||
|
||||
async def citation_trigger(user_id: int, note) -> str | None:
|
||||
"""A record that cites ANOTHER project's record as the source of its
|
||||
pattern opens an evaluation of that source as a family idea. Silent on a
|
||||
citation without lineage words, and on a citation within one project."""
|
||||
if not getattr(note, "project_id", None):
|
||||
return None
|
||||
cited = [n for n in lineage_citations(note.body) if n != note.id]
|
||||
if not cited:
|
||||
return None
|
||||
async with async_session() as session:
|
||||
rows = (await session.execute(
|
||||
select(Note.id, Note.title, Note.project_id, Project.title)
|
||||
.join(Project, Project.id == Note.project_id)
|
||||
.where(
|
||||
Note.id.in_(cited), Note.deleted_at.is_(None),
|
||||
Note.project_id.is_not(None), Note.project_id != note.project_id,
|
||||
)
|
||||
)).all()
|
||||
for source_id, source_title, _pid, project_title in sorted(rows, key=lambda r: cited.index(r[0])):
|
||||
if not await access.can_write_note(user_id, source_id):
|
||||
continue
|
||||
idea, created = await propose(
|
||||
user_id, source_id, trigger="citation", decided_via="system",
|
||||
reason=f"#{note.id} cites it as the source of its pattern, across projects",
|
||||
evidence={"cited_by": {"id": note.id, "title": note.title}},
|
||||
)
|
||||
if idea["status"] == "canon":
|
||||
return (
|
||||
f"This record follows #{source_id} \"{source_title}\" ({project_title}), "
|
||||
"which is already family canon. This project answers it through "
|
||||
"its adoption ledger rather than by copying it."
|
||||
)
|
||||
lead = "is now a family-idea candidate" if created else "is already a candidate"
|
||||
return (
|
||||
f"This record cites #{source_id} \"{source_title}\" ({project_title}) as "
|
||||
f"the source of its pattern — an idea carried between projects, which "
|
||||
f"{lead}. {_evaluate_line(source_id)}"
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
async def repeat_trigger(user_id: int, note) -> str | None:
|
||||
"""A new record whose meaning repeats a record in ANOTHER project that
|
||||
shares a platform with this one opens an evaluation of the earlier record.
|
||||
Silent when the projects share no platform, below the threshold, or when
|
||||
the project has no platforms yet."""
|
||||
from scribe.services.embeddings import embedding_text, semantic_search_notes
|
||||
|
||||
if not getattr(note, "project_id", None) or note.is_task:
|
||||
return None
|
||||
async with async_session() as session:
|
||||
mine = await _member_platform_ids(session, note.project_id)
|
||||
if not mine:
|
||||
return None
|
||||
hits = await semantic_search_notes(
|
||||
user_id, embedding_text(note.title, note.body), exclude_ids={note.id},
|
||||
limit=8, threshold=REPEAT_THRESHOLD, scope="read", note_type=_REPEAT_KINDS,
|
||||
is_task=False, demote_superseded=False,
|
||||
)
|
||||
for score, other in hits:
|
||||
if not other.project_id or other.project_id == note.project_id:
|
||||
continue
|
||||
async with async_session() as session:
|
||||
shared = mine & await _member_platform_ids(session, other.project_id)
|
||||
if not shared or not await access.can_write_note(user_id, other.id):
|
||||
continue
|
||||
idea, created = await propose(
|
||||
user_id, other.id, trigger="repeat", decided_via="system",
|
||||
reason=(f"#{note.id} repeats it in another project on a shared platform "
|
||||
f"(similarity {score:.2f})"),
|
||||
evidence={"repeated_by": {"id": note.id, "title": note.title},
|
||||
"similarity": round(float(score), 3)},
|
||||
)
|
||||
if idea["status"] == "canon":
|
||||
return (
|
||||
f"This record repeats #{other.id} \"{other.title}\", which is already "
|
||||
"family canon on a platform this project shares. Build from it."
|
||||
)
|
||||
lead = "is now a family-idea candidate" if created else "is already a candidate"
|
||||
return (
|
||||
f"This record repeats #{other.id} \"{other.title}\" from another project "
|
||||
f"on a platform this one shares (similarity {score:.2f}). Two projects "
|
||||
f"building the same idea is what family canon is for; #{other.id} {lead}. "
|
||||
f"{_evaluate_line(other.id)}"
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
async def milestone_is_open(user_id: int, milestone_id: int) -> bool:
|
||||
"""Whether a milestone is open right now — read before a status write, so
|
||||
the trigger fires on the transition into done and not on every re-save of
|
||||
a closed one. Fail-open to False: a hint must never break the write."""
|
||||
from scribe.services import milestones as milestones_svc
|
||||
|
||||
try:
|
||||
milestone = await milestones_svc.get_milestone(user_id, milestone_id)
|
||||
except Exception:
|
||||
logger.warning("milestone %s status read failed", milestone_id, exc_info=True)
|
||||
return False
|
||||
return milestone is not None and milestone.status != "done"
|
||||
|
||||
|
||||
async def milestone_trigger(user_id: int, milestone) -> str | None:
|
||||
"""A milestone closing in a project that is on a platform: the moment to
|
||||
ask whether what it built is something every project on that platform
|
||||
will face. Records nothing — a milestone is not a record an idea can hang
|
||||
on — so the hint asks for the note that would carry the idea."""
|
||||
project_id = getattr(milestone, "project_id", None)
|
||||
try:
|
||||
if not project_id or not await access.can_read_project(user_id, project_id):
|
||||
return None
|
||||
async with async_session() as session:
|
||||
names = (await session.execute(
|
||||
select(Platform.name)
|
||||
.join(ProjectPlatform, ProjectPlatform.platform_id == Platform.id)
|
||||
.where(ProjectPlatform.project_id == project_id,
|
||||
ProjectPlatform.state.in_(MEMBER_STATES),
|
||||
Platform.deleted_at.is_(None))
|
||||
.order_by(Platform.order_index.asc())
|
||||
)).scalars().all()
|
||||
except Exception:
|
||||
# Fail-open: the milestone is already closed; the hint is decoration.
|
||||
logger.warning("milestone trigger failed for %s", getattr(milestone, "id", None),
|
||||
exc_info=True)
|
||||
return None
|
||||
if not names:
|
||||
return None
|
||||
return (
|
||||
f"This milestone closed on {', '.join(names)}. If it solved something every "
|
||||
"project on those platforms will face — a problem the platform causes, or a "
|
||||
"stance held across projects — the idea belongs in the family: write it up "
|
||||
"as a note (when it applies, the traps, what proved it) and "
|
||||
"propose_family_idea it, or promote_family_idea if it already meets the "
|
||||
"three criteria. If what it built is this project's alone, nothing to do."
|
||||
)
|
||||
|
||||
|
||||
async def attach_family_hint(user_id: int, data: dict, note, *, created: bool) -> None:
|
||||
"""Ride the trigger hints on a write's response — fail-open: a hint must
|
||||
never break the write it rides on. Citation first (an explicit claim of
|
||||
lineage), then, on a create, the repeat check."""
|
||||
try:
|
||||
hint = await citation_trigger(user_id, note)
|
||||
if hint is None and created:
|
||||
hint = await repeat_trigger(user_id, note)
|
||||
if hint:
|
||||
data["family_hint"] = hint
|
||||
except Exception:
|
||||
logger.warning("family trigger failed for note %s", getattr(note, "id", None), exc_info=True)
|
||||
@@ -215,6 +215,30 @@ def _no_lesson_rule_links(request):
|
||||
yield
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _no_family_triggers(request):
|
||||
"""Stub family canon's write-time triggers (milestone 463 step 3).
|
||||
|
||||
create_note, create_task, create_snippet, the updates that change a body,
|
||||
and update_milestone(status="done") now look for a pattern carried between
|
||||
projects, and each look is a database read (the repeat check also embeds).
|
||||
All are fail-open, so unstubbed they would cost a slow failed connect per
|
||||
unit test rather than a failure. Patched at the trigger functions, beneath
|
||||
`attach_family_hint`, so the attach itself — its order and its fail-open —
|
||||
still runs. tests/test_family.py binds the real pure parts; the triggers
|
||||
themselves are exercised against Postgres in
|
||||
tests/test_integration_family_promotion.py, which this skips.
|
||||
"""
|
||||
if request.node.get_closest_marker("integration"):
|
||||
yield
|
||||
return
|
||||
with patch("scribe.services.family.citation_trigger", AsyncMock(return_value=None)), \
|
||||
patch("scribe.services.family.repeat_trigger", AsyncMock(return_value=None)), \
|
||||
patch("scribe.services.family.milestone_trigger", AsyncMock(return_value=None)), \
|
||||
patch("scribe.services.family.milestone_is_open", AsyncMock(return_value=False)):
|
||||
yield
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _no_moment_delivery(request):
|
||||
"""Stub the moment lookup the mapped MCP tools attach (milestone 458).
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
"""The promotion engine without a database (milestone 463 step 3).
|
||||
|
||||
The parts that decide — which citations claim lineage, which criteria veto —
|
||||
are pure and pinned here, beside the doors' wiring: the triggers ride the
|
||||
write tools, fail open, and the criteria the service enforces are the ones
|
||||
the agent is told. The state machine against Postgres is in
|
||||
tests/test_integration_family_promotion.py.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from scribe.mcp.server import _READ_ONLY_TOOLS, _WRITE_TOOLS
|
||||
from scribe.mcp.tools import family as family_tools
|
||||
from scribe.mcp.tools.milestones import update_milestone
|
||||
from scribe.services import family as family_svc
|
||||
from scribe.services.family import CRITERIA_KEYS, lineage_citations, vetoes
|
||||
from tests.helpers import fake_milestone
|
||||
|
||||
pytestmark = pytest.mark.usefixtures("_bind_user")
|
||||
|
||||
|
||||
# --- which citations claim lineage ---------------------------------------------
|
||||
|
||||
@pytest.mark.parametrize("text", [
|
||||
"Signed release lane, matching roundtable-android (task #1615).",
|
||||
"Ported from #1615, with the keystore step moved first.",
|
||||
"Same shape as #1615: the APK is baked into the image.",
|
||||
"This mirrors #1615 for the desktop client.",
|
||||
"Modelled on #1615.",
|
||||
])
|
||||
def test_a_citation_with_a_lineage_word_names_its_source(text):
|
||||
assert lineage_citations(text) == [1615]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("text", [
|
||||
"See #1615 for context.",
|
||||
"#1615 is related.",
|
||||
"Closes #1615.",
|
||||
# The lineage word is in the PREVIOUS sentence — a different claim.
|
||||
"This is matching the old flow. Unrelated: #1615.",
|
||||
"Matching the old flow\nsee #1615",
|
||||
"",
|
||||
])
|
||||
def test_a_citation_without_lineage_is_silent(text):
|
||||
assert lineage_citations(text) == []
|
||||
|
||||
|
||||
def test_lineage_citations_are_in_order_and_unique():
|
||||
text = "Based on #20; ported from #10. Same pattern as #20."
|
||||
assert lineage_citations(text) == [20, 10]
|
||||
|
||||
|
||||
# --- each criterion vetoes on its own -----------------------------------------------
|
||||
|
||||
GOOD = dict(
|
||||
applies_when="any app that installs its own updates",
|
||||
platforms=["android-app"],
|
||||
criteria={
|
||||
"platform_terms": "stated as an Android distribution concern",
|
||||
"platform_problem": "Play Protect and signature checks are the platform's",
|
||||
"proven": "shipped in two apps",
|
||||
},
|
||||
evidence=["CI run 8274 green", "#4775"],
|
||||
)
|
||||
|
||||
|
||||
def test_all_three_criteria_held_means_no_veto():
|
||||
assert vetoes(**GOOD) == []
|
||||
|
||||
|
||||
@pytest.mark.parametrize("key", CRITERIA_KEYS)
|
||||
def test_each_criterion_left_unsupported_vetoes_on_its_own(key):
|
||||
case = {**GOOD, "criteria": {**GOOD["criteria"], key: " "}}
|
||||
assert vetoes(**case) == [key]
|
||||
|
||||
|
||||
def test_platform_terms_needs_an_applicability_test_and_a_scope():
|
||||
assert vetoes(**{**GOOD, "applies_when": ""}) == ["platform_terms"]
|
||||
assert vetoes(**{**GOOD, "platforms": []}) == ["platform_terms"]
|
||||
|
||||
|
||||
def test_proven_needs_named_evidence():
|
||||
assert vetoes(**{**GOOD, "evidence": []}) == ["proven"]
|
||||
assert vetoes(**{**GOOD, "evidence": [" "]}) == ["proven"]
|
||||
|
||||
|
||||
def test_the_criteria_the_agent_is_told_are_the_ones_enforced():
|
||||
"""Rule 119: the criteria are product text. The promote tool's docstring
|
||||
must name every criterion the service checks, by its parameter name."""
|
||||
doc = family_tools.promote_family_idea.__doc__
|
||||
for key in CRITERIA_KEYS:
|
||||
assert key in doc, f"promote_family_idea's docstring never names {key}"
|
||||
assert len(family_svc.CRITERIA) == 3
|
||||
|
||||
|
||||
# --- the doors ----------------------------------------------------------------------
|
||||
|
||||
def test_every_family_tool_is_classified():
|
||||
reads = {"list_family_ideas", "get_family_idea", "list_family_decisions"}
|
||||
writes = {"propose_family_idea", "promote_family_idea", "retire_family_idea",
|
||||
"undo_family_decision"}
|
||||
assert reads <= _READ_ONLY_TOOLS
|
||||
assert writes <= _WRITE_TOOLS
|
||||
|
||||
|
||||
def _note(**kw):
|
||||
base = dict(id=7, project_id=2, title="t", body="b", is_task=False)
|
||||
return SimpleNamespace(**{**base, **kw})
|
||||
|
||||
|
||||
async def test_a_citation_hint_wins_and_repeat_is_not_asked():
|
||||
data: dict = {}
|
||||
repeat = AsyncMock(return_value="repeat")
|
||||
with patch.object(family_svc, "citation_trigger", AsyncMock(return_value="cited")), \
|
||||
patch.object(family_svc, "repeat_trigger", repeat):
|
||||
await family_svc.attach_family_hint(1, data, _note(), created=True)
|
||||
assert data == {"family_hint": "cited"}
|
||||
repeat.assert_not_awaited()
|
||||
|
||||
|
||||
async def test_the_repeat_check_runs_only_on_a_create():
|
||||
repeat = AsyncMock(return_value="repeat")
|
||||
with patch.object(family_svc, "citation_trigger", AsyncMock(return_value=None)), \
|
||||
patch.object(family_svc, "repeat_trigger", repeat):
|
||||
edited: dict = {}
|
||||
await family_svc.attach_family_hint(1, edited, _note(), created=False)
|
||||
created: dict = {}
|
||||
await family_svc.attach_family_hint(1, created, _note(), created=True)
|
||||
assert edited == {}
|
||||
assert created == {"family_hint": "repeat"}
|
||||
|
||||
|
||||
async def test_a_failing_trigger_never_breaks_the_write():
|
||||
data: dict = {"id": 7}
|
||||
with patch.object(family_svc, "citation_trigger", AsyncMock(side_effect=RuntimeError("db down"))):
|
||||
await family_svc.attach_family_hint(1, data, _note(), created=True)
|
||||
assert data == {"id": 7}
|
||||
|
||||
|
||||
async def test_closing_a_milestone_on_a_platform_carries_the_hint():
|
||||
closed = fake_milestone(id=5, project_id=3, status="done")
|
||||
with patch("scribe.mcp.tools.milestones.milestones_svc.update_milestone",
|
||||
AsyncMock(return_value=closed)), \
|
||||
patch.object(family_svc, "milestone_is_open", AsyncMock(return_value=True)), \
|
||||
patch.object(family_svc, "milestone_trigger", AsyncMock(return_value="evaluate")):
|
||||
out = await update_milestone(project_id=3, milestone_id=5, status="done")
|
||||
assert out["family_hint"] == "evaluate"
|
||||
|
||||
|
||||
async def test_re_saving_a_closed_milestone_asks_nothing():
|
||||
closed = fake_milestone(id=5, project_id=3, status="done")
|
||||
trigger = AsyncMock(return_value="evaluate")
|
||||
with patch("scribe.mcp.tools.milestones.milestones_svc.update_milestone",
|
||||
AsyncMock(return_value=closed)), \
|
||||
patch.object(family_svc, "milestone_is_open", AsyncMock(return_value=False)), \
|
||||
patch.object(family_svc, "milestone_trigger", trigger):
|
||||
out = await update_milestone(project_id=3, milestone_id=5, status="done")
|
||||
assert "family_hint" not in out
|
||||
trigger.assert_not_awaited()
|
||||
@@ -0,0 +1,293 @@
|
||||
"""The promotion engine against real Postgres (milestone 463 step 3).
|
||||
|
||||
What the unit lane can't show: a promotion opens the right ledger rows and
|
||||
no others, a veto changes nothing but the log, an undo puts back exactly the
|
||||
recorded prior state, and each trigger fires on its fixture and stays silent
|
||||
on the near-miss beside it.
|
||||
|
||||
Precedent search and the repeat trigger both rank by meaning, and the lane
|
||||
has no embedding model. The search is stubbed to "nothing similar" for every
|
||||
test here (`_no_meaning`); the repeat tests stub it to one hit, to pin what
|
||||
the trigger does WITH a hit. The ranking itself is the shared semantic
|
||||
search, tested where that lives.
|
||||
"""
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from sqlalchemy import select
|
||||
|
||||
from scribe.models import async_session
|
||||
from scribe.models.family import (
|
||||
FamilyAdoption, FamilyDecision, FamilyIdea, Platform, ProjectPlatform,
|
||||
)
|
||||
from scribe.models.milestone import Milestone
|
||||
from scribe.models.note import Note
|
||||
from scribe.models.project import Project
|
||||
from scribe.models.user import User
|
||||
from scribe.services import family as family_svc
|
||||
from tests.helpers import ensure_user
|
||||
|
||||
pytestmark = [pytest.mark.integration, pytest.mark.usefixtures("_dispose_engine")]
|
||||
|
||||
OWNER = "family_promotion_owner"
|
||||
OUTSIDER = "family_promotion_outsider"
|
||||
|
||||
CRITERIA = {
|
||||
"platform_terms": "stated for any Android app that distributes its own APK",
|
||||
"platform_problem": "signature continuity is the platform's rule, not one app's",
|
||||
"proven": "shipped and updated in place on a device",
|
||||
}
|
||||
|
||||
|
||||
async def _purge(username: str) -> None:
|
||||
"""SETUP ONLY, as the backup round-trip siblings do: a database call
|
||||
after a `yield` in an autouse fixture orphans a pooled connection."""
|
||||
async with async_session() as s:
|
||||
for user in (await s.execute(select(User).where(User.username == username))).scalars():
|
||||
for note in (await s.execute(select(Note).where(Note.user_id == user.id))).scalars():
|
||||
await s.delete(note)
|
||||
for project in (await s.execute(select(Project).where(Project.user_id == user.id))).scalars():
|
||||
await s.delete(project)
|
||||
await s.commit()
|
||||
|
||||
|
||||
@pytest_asyncio.fixture(autouse=True)
|
||||
async def _clean():
|
||||
await _purge(OWNER)
|
||||
await _purge(OUTSIDER)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _no_meaning():
|
||||
with patch("scribe.services.embeddings.semantic_search_notes", AsyncMock(return_value=[])):
|
||||
yield
|
||||
|
||||
|
||||
async def _platform_id(slug: str) -> int:
|
||||
async with async_session() as s:
|
||||
return await s.scalar(select(Platform.id).where(
|
||||
Platform.slug == slug, Platform.deleted_at.is_(None)))
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def family():
|
||||
"""Three projects: two Android apps and a Go service, the pattern note
|
||||
in the first, and an outsider's Android app the owner cannot write."""
|
||||
android, go = await _platform_id("android-app"), await _platform_id("go")
|
||||
async with async_session() as s:
|
||||
owner = await ensure_user(s, OWNER)
|
||||
outsider = await ensure_user(s, OUTSIDER)
|
||||
a = Project(user_id=owner.id, title="android one")
|
||||
b = Project(user_id=owner.id, title="android two")
|
||||
c = Project(user_id=owner.id, title="go service")
|
||||
theirs = Project(user_id=outsider.id, title="someone else's android app")
|
||||
s.add_all([a, b, c, theirs])
|
||||
await s.flush()
|
||||
s.add_all([
|
||||
ProjectPlatform(project_id=a.id, platform_id=android, state="declared"),
|
||||
ProjectPlatform(project_id=b.id, platform_id=android, state="detected"),
|
||||
ProjectPlatform(project_id=c.id, platform_id=go, state="declared"),
|
||||
ProjectPlatform(project_id=theirs.id, platform_id=android, state="declared"),
|
||||
])
|
||||
pattern = Note(user_id=owner.id, project_id=a.id, title="Signed APK lane",
|
||||
body="One keystore, two channels, in-place update.")
|
||||
s.add(pattern)
|
||||
await s.commit()
|
||||
return {"owner": owner.id, "outsider": outsider.id, "a": a.id, "b": b.id,
|
||||
"c": c.id, "theirs": theirs.id, "note": pattern.id}
|
||||
|
||||
|
||||
async def _promote(f, **overrides):
|
||||
kw = dict(
|
||||
applies_when="any Android app that ships its own APK",
|
||||
platforms=["android-app"], criteria=CRITERIA,
|
||||
evidence=["CI green on the release lane", "verified on a device"],
|
||||
reason="built twice, proven, stated for the platform",
|
||||
)
|
||||
kw.update(overrides)
|
||||
return await family_svc.promote(f["owner"], f["note"], **kw)
|
||||
|
||||
|
||||
async def _ledger(note_id: int) -> dict[int, str]:
|
||||
async with async_session() as s:
|
||||
rows = (await s.execute(
|
||||
select(FamilyAdoption.project_id, FamilyAdoption.status)
|
||||
.where(FamilyAdoption.idea_id == note_id)
|
||||
)).all()
|
||||
return dict(rows)
|
||||
|
||||
|
||||
async def _idea(note_id: int) -> FamilyIdea:
|
||||
async with async_session() as s:
|
||||
return await s.get(FamilyIdea, note_id)
|
||||
|
||||
|
||||
# --- promotion ------------------------------------------------------------------
|
||||
|
||||
async def test_promotion_opens_a_row_for_every_member_project_the_promoter_can_write(family):
|
||||
out = await _promote(family)
|
||||
assert out["promoted"] is True
|
||||
# Declared and detected both count as membership; the Go service is not
|
||||
# on the platform; the outsider's app is not the promoter's to write.
|
||||
assert await _ledger(family["note"]) == {family["a"]: "unassessed",
|
||||
family["b"]: "unassessed"}
|
||||
idea = await _idea(family["note"])
|
||||
assert idea.status == "canon" and idea.canon_version == 1
|
||||
decision = out["decision"]
|
||||
assert decision["action"] == "promote"
|
||||
assert decision["after"]["platforms"] == ["android-app"]
|
||||
assert decision["evidence"]["criteria"]["proven"] == CRITERIA["proven"]
|
||||
assert decision["evidence"]["ledger_rows_opened"] == 2
|
||||
|
||||
|
||||
@pytest.mark.parametrize("key", list(family_svc.CRITERIA_KEYS))
|
||||
async def test_each_criterion_vetoes_on_its_own_and_the_veto_is_logged(family, key):
|
||||
out = await _promote(family, criteria={**CRITERIA, key: ""})
|
||||
assert out["promoted"] is False and out["vetoed_by"] == [key]
|
||||
assert (await _idea(family["note"])).status == "candidate"
|
||||
assert await _ledger(family["note"]) == {}
|
||||
assert out["decision"]["action"] == "propose"
|
||||
assert out["decision"]["evidence"]["vetoed_by"] == [key]
|
||||
|
||||
|
||||
async def test_an_unknown_platform_is_refused_before_anything_is_written(family):
|
||||
with pytest.raises(ValueError, match="unknown platform"):
|
||||
await _promote(family, platforms=["android-app", "no-such-platform"])
|
||||
assert await _idea(family["note"]) is None
|
||||
|
||||
|
||||
async def test_someone_who_cannot_write_the_note_cannot_promote_it(family):
|
||||
with pytest.raises(ValueError, match="no write access"):
|
||||
await family_svc.promote(
|
||||
family["outsider"], family["note"], applies_when="x", platforms=["android-app"],
|
||||
criteria=CRITERIA, evidence=["e"], reason="r",
|
||||
)
|
||||
|
||||
|
||||
# --- undo and retirement ------------------------------------------------------------
|
||||
|
||||
async def test_undoing_a_promotion_restores_the_prior_state_and_keeps_judged_rows(family):
|
||||
out = await _promote(family)
|
||||
async with async_session() as s:
|
||||
row = (await s.execute(select(FamilyAdoption).where(
|
||||
FamilyAdoption.idea_id == family["note"],
|
||||
FamilyAdoption.project_id == family["a"]))).scalars().one()
|
||||
row.status, row.canon_version, row.decided_via = "adopted", 1, "agent"
|
||||
await s.commit()
|
||||
|
||||
await family_svc.undo(family["owner"], out["decision"]["id"], reason="promoted too early")
|
||||
idea = await _idea(family["note"])
|
||||
# Promoted directly, with no proposal before it: the prior state was "not
|
||||
# an idea", which an undo records as retired rather than deleting the
|
||||
# idea and its log with it.
|
||||
assert idea.status == "retired" and idea.applies_when is None
|
||||
# The unjudged row goes; the judged one stays as history.
|
||||
assert await _ledger(family["note"]) == {family["a"]: "adopted"}
|
||||
|
||||
again = await _promote(family)
|
||||
# Re-promotion moves PAST every version this idea has held, so the kept
|
||||
# answer reads as needing a recheck rather than as still agreeing.
|
||||
assert again["idea"]["canon_version"] == 2
|
||||
assert await _ledger(family["note"]) == {family["a"]: "adopted", family["b"]: "unassessed"}
|
||||
|
||||
|
||||
async def test_retiring_closes_unjudged_rows_and_undoing_it_reopens_them(family):
|
||||
await _promote(family)
|
||||
out = await family_svc.retire(family["owner"], family["note"], reason="superseded")
|
||||
assert (await _idea(family["note"])).status == "retired"
|
||||
assert await _ledger(family["note"]) == {}
|
||||
await family_svc.undo(family["owner"], out["decision"]["id"], reason="not superseded after all")
|
||||
assert (await _idea(family["note"])).status == "canon"
|
||||
assert set((await _ledger(family["note"])).values()) == {"unassessed"}
|
||||
|
||||
|
||||
async def test_only_the_latest_idea_decision_can_be_undone(family):
|
||||
first = await _promote(family)
|
||||
await family_svc.retire(family["owner"], family["note"], reason="superseded")
|
||||
with pytest.raises(ValueError, match="came after it"):
|
||||
await family_svc.undo(family["owner"], first["decision"]["id"], reason="r")
|
||||
|
||||
|
||||
async def test_an_undo_names_what_it_reversed_as_its_precedent(family):
|
||||
out = await _promote(family)
|
||||
undo = await family_svc.undo(family["owner"], out["decision"]["id"], reason="r")
|
||||
assert undo["decision"]["action"] == "undo"
|
||||
assert undo["decision"]["precedent_ids"] == [out["decision"]["id"]]
|
||||
async with async_session() as s:
|
||||
actions = (await s.execute(select(FamilyDecision.action).where(
|
||||
FamilyDecision.idea_id == family["note"]).order_by(FamilyDecision.id))).scalars().all()
|
||||
assert actions == ["promote", "undo"]
|
||||
|
||||
|
||||
# --- triggers -------------------------------------------------------------------------
|
||||
|
||||
async def _note_in(project_id: int, owner_id: int, body: str) -> Note:
|
||||
async with async_session() as s:
|
||||
note = Note(user_id=owner_id, project_id=project_id, title="the second build", body=body)
|
||||
s.add(note)
|
||||
await s.commit()
|
||||
await s.refresh(note)
|
||||
return note
|
||||
|
||||
|
||||
async def test_a_cross_project_lineage_citation_opens_an_evaluation(family):
|
||||
citing = await _note_in(family["b"], family["owner"],
|
||||
f"Release lane, matching the first app (#{family['note']}).")
|
||||
hint = await family_svc.citation_trigger(family["owner"], citing)
|
||||
assert hint and f"#{family['note']}" in hint
|
||||
idea = await _idea(family["note"])
|
||||
assert idea is not None and idea.status == "candidate"
|
||||
async with async_session() as s:
|
||||
d = (await s.execute(select(FamilyDecision).where(
|
||||
FamilyDecision.idea_id == family["note"]))).scalars().one()
|
||||
assert d.decided_via == "system" and d.evidence["trigger"] == "citation"
|
||||
|
||||
|
||||
async def test_a_citation_without_lineage_or_within_one_project_is_silent(family):
|
||||
pointer = await _note_in(family["b"], family["owner"], f"See #{family['note']} for context.")
|
||||
same_project = await _note_in(family["a"], family["owner"], f"Matching #{family['note']}.")
|
||||
assert await family_svc.citation_trigger(family["owner"], pointer) is None
|
||||
assert await family_svc.citation_trigger(family["owner"], same_project) is None
|
||||
assert await _idea(family["note"]) is None
|
||||
|
||||
|
||||
async def test_a_repeat_on_a_shared_platform_opens_an_evaluation(family):
|
||||
"""The meaning match is stubbed — the lane has no model — so this pins
|
||||
what the trigger does with a hit: only a hit in ANOTHER project that
|
||||
shares a platform counts."""
|
||||
repeat = await _note_in(family["b"], family["owner"], "One keystore, two channels.")
|
||||
async with async_session() as s:
|
||||
original = await s.get(Note, family["note"])
|
||||
with patch("scribe.services.embeddings.semantic_search_notes",
|
||||
AsyncMock(return_value=[(0.86, original)])):
|
||||
hint = await family_svc.repeat_trigger(family["owner"], repeat)
|
||||
assert hint and f"#{family['note']}" in hint
|
||||
assert (await _idea(family["note"])).status == "candidate"
|
||||
|
||||
|
||||
async def test_a_repeat_with_no_shared_platform_is_silent(family):
|
||||
unrelated = await _note_in(family["c"], family["owner"], "One keystore, two channels.")
|
||||
async with async_session() as s:
|
||||
original = await s.get(Note, family["note"])
|
||||
with patch("scribe.services.embeddings.semantic_search_notes",
|
||||
AsyncMock(return_value=[(0.86, original)])):
|
||||
assert await family_svc.repeat_trigger(family["owner"], unrelated) is None
|
||||
assert await _idea(family["note"]) is None
|
||||
|
||||
|
||||
async def test_a_milestone_closing_on_a_platform_asks_and_one_off_platform_does_not(family):
|
||||
async with async_session() as s:
|
||||
on_platform = Milestone(user_id=family["owner"], project_id=family["a"], title="m1")
|
||||
bare = Project(user_id=family["owner"], title="no platforms")
|
||||
s.add_all([on_platform, bare])
|
||||
await s.flush()
|
||||
off_platform = Milestone(user_id=family["owner"], project_id=bare.id, title="m2")
|
||||
s.add(off_platform)
|
||||
await s.commit()
|
||||
await s.refresh(on_platform)
|
||||
await s.refresh(off_platform)
|
||||
hint = await family_svc.milestone_trigger(family["owner"], on_platform)
|
||||
assert hint and "Android app" in hint
|
||||
assert await family_svc.milestone_trigger(family["owner"], off_platform) is None
|
||||
assert await family_svc.milestone_is_open(family["owner"], on_platform.id) is True
|
||||
@@ -0,0 +1,29 @@
|
||||
"""Structural tests for the family blueprint (milestone 463 step 3) — every
|
||||
endpoint is routed, and every write hands the service its caller, where the
|
||||
note's write gate lives. What the writes do is in
|
||||
tests/test_integration_family_promotion.py."""
|
||||
import inspect
|
||||
|
||||
|
||||
def test_family_blueprint_routes_every_endpoint():
|
||||
from scribe.app import create_app
|
||||
app = create_app()
|
||||
assert "family" in app.blueprints
|
||||
rules = {(r.rule, m) for r in app.url_map.iter_rules() for m in r.methods}
|
||||
for rule, method in (
|
||||
("/api/family/ideas", "GET"),
|
||||
("/api/family/ideas/<int:note_id>", "GET"),
|
||||
("/api/family/ideas/<int:note_id>/propose", "POST"),
|
||||
("/api/family/ideas/<int:note_id>/promote", "POST"),
|
||||
("/api/family/ideas/<int:note_id>/retire", "POST"),
|
||||
("/api/family/decisions", "GET"),
|
||||
("/api/family/decisions/<int:decision_id>/undo", "POST"),
|
||||
):
|
||||
assert (rule, method) in rules, f"{method} {rule} is not routed"
|
||||
|
||||
|
||||
def test_every_engine_write_takes_the_caller_and_who_decided():
|
||||
from scribe.services import family as svc
|
||||
for name in ("propose", "promote", "retire", "undo"):
|
||||
params = inspect.signature(getattr(svc, name)).parameters
|
||||
assert "user_id" in params and "decided_via" in params, name
|
||||
Reference in New Issue
Block a user