CI & Build / Plugin hooks (push) Successful in 15s
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 1m0s
CI & Build / integration (push) Successful in 1m8s
CI & Build / Python tests (push) Failing after 1m28s
CI & Build / Build & push image (push) Skipped
- services/family_adoption.py: assess one project against one canon idea by the four outcomes in order (exempt, variant, adopted, owed). Every outcome needs a reason and adopted needs evidence. The engine records the precedents itself: this idea's answers elsewhere, and this project's answers to the nearest ideas. The same answer given twice records nothing. - owed files a task in the OWING project, tagged to the System matching the idea's canonical area, naming the gap and the reference for that project's language. The task follows the answer: adopted closes it, exempt or variant cancels it, owed again reopens it. Each move is logged on the task. - the conflict order is enforced: every ground above the deciding one must say why it did not decide. The losing side is folded into the idea's note as a trap, an alternative or a condition branch, the version moves, and both rows are answered against the revision. - recheck is derived (row version != idea version). family.revise moves the version when substance changes. undo covers a project's latest answer too. - set_family_references names the reference implementations. - MCP: get/list/assess adoption, resolve_family_conflict, revise_family_idea, set_family_references. Web: GET /api/family/matrix. - UI: an adoption matrix on /family (platform filter, cell detail with reason, recheck and owed-task link) and the same matrix narrowed to one project on its Family tab. The decision log now reads project-level decisions. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
407 lines
16 KiB
Vue
407 lines
16 KiB
Vue
<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 — and nobody approves a project's answer
|
|
* either: the adoption matrix shows each project's answer to each canon idea
|
|
* as the agent gave it. 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 (or on one project's answer). Both ask for a reason,
|
|
* because the reason is what the next similar decision is checked against.
|
|
*/
|
|
import { computed, onMounted, ref } from "vue";
|
|
import { apiErrorMessage } from "@/api/client";
|
|
import {
|
|
listFamilyDecisions, listFamilyIdeas, retireFamilyIdea, undoFamilyDecision,
|
|
type AdoptionSnapshot, type ConflictGround, type Criterion, type FamilyDecision,
|
|
type FamilyIdea, type IdeaSnapshot, type IdeaStatus,
|
|
} from "@/api/family";
|
|
import FamilyAdoptionMatrix from "@/components/FamilyAdoptionMatrix.vue";
|
|
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 conflictOrder = ref<ConflictGround[]>([]);
|
|
const matrixRef = ref<InstanceType<typeof FamilyAdoptionMatrix> | null>(null);
|
|
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;
|
|
conflictOrder.value = ideaData.conflict_order ?? [];
|
|
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 Promise.all([load(), matrixRef.value?.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] });
|
|
}
|
|
const conflict = ev.conflict as {
|
|
ground: string; grounds_checked: Record<string, string>; canon: string; other: string;
|
|
folded_as: string; conditions: { canon: string; other: string } | null;
|
|
} | undefined;
|
|
if (conflict) {
|
|
const title = (k: string) => conflictOrder.value.find((g) => g.key === k)?.title ?? k;
|
|
const lines = [
|
|
`Decided by: ${title(conflict.ground)}`,
|
|
...Object.entries(conflict.grounds_checked || {})
|
|
.map(([k, why]) => `${title(k)} did not decide — ${why}`),
|
|
`Canon: ${conflict.canon}; the other side (${conflict.other}) was folded into the note as ${
|
|
conflict.folded_as === "condition" ? "the branch for its condition" : `a ${conflict.folded_as}`}`,
|
|
];
|
|
if (conflict.conditions) {
|
|
lines.push(`When ${conflict.conditions.canon}: ${conflict.canon}`,
|
|
`When ${conflict.conditions.other}: ${conflict.other}`);
|
|
}
|
|
out.push({ label: "Conflict", lines });
|
|
}
|
|
return out;
|
|
}
|
|
|
|
const ADOPTION_LABELS: Record<string, string> = {
|
|
unassessed: "not assessed", adopted: "adopted", variant: "variant",
|
|
exempt: "doesn't apply", owed: "owed",
|
|
};
|
|
|
|
function stateLine(d: FamilyDecision): string {
|
|
const a = d.after;
|
|
if (!a) return "";
|
|
if (d.project_id !== null) {
|
|
const row = a as AdoptionSnapshot;
|
|
const version = row.canon_version ? ` against v${row.canon_version}` : "";
|
|
return `${ADOPTION_LABELS[row.status] ?? row.status}${version}`;
|
|
}
|
|
const idea = a as IdeaSnapshot;
|
|
const where = idea.platforms.length ? ` · ${idea.platforms.join(", ")}` : "";
|
|
return `${idea.status} v${idea.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-matrix">
|
|
<h2 id="fam-matrix">Adoption</h2>
|
|
<FamilyAdoptionMatrix ref="matrixRef" />
|
|
</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>
|
|
<template v-if="d.project_id !== null">
|
|
<span class="fam-muted">for</span>
|
|
<router-link :to="`/projects/${d.project_id}`">{{ d.project_title ?? `project ${d.project_id}` }}</router-link>
|
|
</template>
|
|
<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>
|