feat(family): the adoption ledger - assessment, the conflict order, owed->task, recheck, the adoption matrix (milestone 463 step 4, #4990)
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
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>
This commit is contained in:
@@ -40,15 +40,29 @@ export interface IdeaSnapshot {
|
||||
platforms: string[];
|
||||
}
|
||||
|
||||
export type AdoptionStatus = "unassessed" | "adopted" | "variant" | "exempt" | "owed";
|
||||
|
||||
/** One project's answer as a decision records it. */
|
||||
export interface AdoptionSnapshot {
|
||||
status: AdoptionStatus;
|
||||
reason: string;
|
||||
canon_version: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* A decision about the idea itself (project_id null) snapshots the idea; a
|
||||
* project's assessment (project_id set) snapshots that project's answer.
|
||||
*/
|
||||
export interface FamilyDecision {
|
||||
id: number;
|
||||
idea_id: number;
|
||||
idea_title?: string;
|
||||
project_id: number | null;
|
||||
project_title?: string | null;
|
||||
action: DecisionAction;
|
||||
reason: string;
|
||||
before: IdeaSnapshot | null;
|
||||
after: IdeaSnapshot | null;
|
||||
before: IdeaSnapshot | AdoptionSnapshot | null;
|
||||
after: IdeaSnapshot | AdoptionSnapshot | null;
|
||||
evidence: Record<string, unknown>;
|
||||
precedent_ids: number[];
|
||||
decided_via: "agent" | "operator" | "system";
|
||||
@@ -57,7 +71,17 @@ export interface FamilyDecision {
|
||||
undoable?: boolean;
|
||||
}
|
||||
|
||||
export async function listFamilyIdeas(status = ""): Promise<{ ideas: FamilyIdea[]; criteria: Criterion[] }> {
|
||||
/** One ground of the conflict order, in order. */
|
||||
export interface ConflictGround {
|
||||
key: string;
|
||||
title: string;
|
||||
test: string;
|
||||
fold_as: "alternative" | "trap" | "condition";
|
||||
}
|
||||
|
||||
export async function listFamilyIdeas(
|
||||
status = "",
|
||||
): Promise<{ ideas: FamilyIdea[]; criteria: Criterion[]; conflict_order?: ConflictGround[] }> {
|
||||
const q = status ? `?status=${encodeURIComponent(status)}` : "";
|
||||
return apiGet(`/api/family/ideas${q}`);
|
||||
}
|
||||
@@ -76,3 +100,57 @@ export async function undoFamilyDecision(id: number, reason: string) {
|
||||
export async function retireFamilyIdea(noteId: number, reason: string) {
|
||||
return apiPost<{ decision: FamilyDecision }>(`/api/family/ideas/${noteId}/retire`, { reason });
|
||||
}
|
||||
|
||||
export interface Outcome {
|
||||
key: AdoptionStatus;
|
||||
title: string;
|
||||
test: string;
|
||||
}
|
||||
|
||||
/** One project's answer to one canon idea — a cell of the matrix. */
|
||||
export interface AdoptionCell {
|
||||
project_id: number;
|
||||
project_title: string;
|
||||
idea_id: number;
|
||||
idea_title: string;
|
||||
idea_version: number;
|
||||
/** The project is on one of the idea's platforms. */
|
||||
in_scope: boolean;
|
||||
/** A ledger row exists. False: the idea reaches the project, nobody asked yet. */
|
||||
reached: boolean;
|
||||
status: AdoptionStatus;
|
||||
reason: string;
|
||||
canon_version: number | null;
|
||||
assessed_at: string | null;
|
||||
decided_via: "agent" | "operator" | "system" | null;
|
||||
/** Answered against a canon version that is not the current one. */
|
||||
needs_recheck: boolean;
|
||||
owed_task: { id: number; title: string; status: string } | null;
|
||||
}
|
||||
|
||||
export interface MatrixIdea {
|
||||
note_id: number;
|
||||
title: string;
|
||||
note_type: string;
|
||||
is_task: boolean;
|
||||
canon_version: number;
|
||||
applies_when: string;
|
||||
platforms: string[];
|
||||
}
|
||||
|
||||
export interface AdoptionMatrix {
|
||||
ideas: MatrixIdea[];
|
||||
projects: { id: number; title: string; platforms: string[] }[];
|
||||
cells: AdoptionCell[];
|
||||
outcomes: Outcome[];
|
||||
}
|
||||
|
||||
export async function fetchAdoptionMatrix(
|
||||
opts: { platform?: string; projectId?: number } = {},
|
||||
): Promise<AdoptionMatrix> {
|
||||
const q = new URLSearchParams();
|
||||
if (opts.platform) q.set("platform", opts.platform);
|
||||
if (opts.projectId) q.set("project_id", String(opts.projectId));
|
||||
const qs = q.toString();
|
||||
return apiGet(`/api/family/matrix${qs ? `?${qs}` : ""}`);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,328 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* The adoption matrix (milestone 463 step 4): every project × every canon
|
||||
* family idea that reaches it, and the project's answer.
|
||||
*
|
||||
* Nobody approves an answer — the agent assesses each project against the
|
||||
* four outcomes and records why. This is where a person sees that happening:
|
||||
* which projects adopted, which depart and for what fact, which owe the work
|
||||
* (with the task filed in that project), and which answers were given
|
||||
* against an older version of the idea and need a recheck. Disagreeing is an
|
||||
* undo in the decision log below.
|
||||
*
|
||||
* `projectId` narrows it to one project's answers — the same component, so a
|
||||
* project's Family tab and this page cannot describe a cell differently.
|
||||
*/
|
||||
import { computed, onMounted, ref, watch } from "vue";
|
||||
import {
|
||||
fetchAdoptionMatrix,
|
||||
type AdoptionCell, type AdoptionMatrix, type AdoptionStatus,
|
||||
} from "@/api/family";
|
||||
import { usePlatformsStore } from "@/stores/platforms";
|
||||
import { fmtStamp } from "@/utils/dateFormat";
|
||||
import { recordHref } from "@/utils/recordHref";
|
||||
|
||||
const props = defineProps<{ projectId?: number }>();
|
||||
|
||||
const platformsStore = usePlatformsStore();
|
||||
const matrix = ref<AdoptionMatrix | null>(null);
|
||||
const platform = ref("");
|
||||
const loading = ref(true);
|
||||
const failed = ref(false);
|
||||
const selectedKey = ref<string | null>(null);
|
||||
|
||||
const LABELS: Record<AdoptionStatus, string> = {
|
||||
unassessed: "Not assessed",
|
||||
adopted: "Adopted",
|
||||
variant: "Variant",
|
||||
exempt: "Doesn't apply",
|
||||
owed: "Owed",
|
||||
};
|
||||
|
||||
async function load() {
|
||||
loading.value = true;
|
||||
failed.value = false;
|
||||
try {
|
||||
const [, data] = await Promise.all([
|
||||
platformsStore.fetchCatalog(),
|
||||
fetchAdoptionMatrix({ platform: platform.value, projectId: props.projectId }),
|
||||
]);
|
||||
matrix.value = data;
|
||||
if (selectedKey.value && !data.cells.some((c) => key(c) === selectedKey.value)) {
|
||||
selectedKey.value = null;
|
||||
}
|
||||
} catch {
|
||||
// A matrix that failed to load must not read as "nobody owes anything".
|
||||
failed.value = true;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function key(c: { project_id: number; idea_id: number }): string {
|
||||
return `${c.project_id}:${c.idea_id}`;
|
||||
}
|
||||
|
||||
const cellByKey = computed(() => {
|
||||
const out: Record<string, AdoptionCell> = {};
|
||||
for (const c of matrix.value?.cells ?? []) out[key(c)] = c;
|
||||
return out;
|
||||
});
|
||||
|
||||
const selected = computed(() =>
|
||||
selectedKey.value ? cellByKey.value[selectedKey.value] ?? null : null,
|
||||
);
|
||||
|
||||
const outcomeTest = computed(() => {
|
||||
const out: Partial<Record<AdoptionStatus, string>> = {};
|
||||
for (const o of matrix.value?.outcomes ?? []) out[o.key] = o.test;
|
||||
return out;
|
||||
});
|
||||
|
||||
/** The answers that want attention: owed, or given against an older canon. */
|
||||
const summary = computed(() => {
|
||||
const cells = matrix.value?.cells ?? [];
|
||||
return {
|
||||
owed: cells.filter((c) => c.status === "owed").length,
|
||||
recheck: cells.filter((c) => c.needs_recheck).length,
|
||||
unassessed: cells.filter((c) => c.status === "unassessed").length,
|
||||
};
|
||||
});
|
||||
|
||||
function toggle(c: AdoptionCell) {
|
||||
selectedKey.value = selectedKey.value === key(c) ? null : key(c);
|
||||
}
|
||||
|
||||
function cellTitle(c: AdoptionCell): string {
|
||||
const parts = [LABELS[c.status]];
|
||||
if (c.needs_recheck) parts.push(`answered against v${c.canon_version}, now v${c.idea_version}`);
|
||||
if (c.reason) parts.push(c.reason);
|
||||
return parts.join(" — ");
|
||||
}
|
||||
|
||||
onMounted(load);
|
||||
watch(platform, load);
|
||||
defineExpose({ load });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="fam-matrix-block">
|
||||
<div class="fam-matrix-head">
|
||||
<p class="fam-muted">
|
||||
<template v-if="projectId">This project's answer to each family idea that reaches it.</template>
|
||||
<template v-else>Each project's answer to each canon idea that reaches it.</template>
|
||||
The agent answers by the four outcomes below and records why; “recheck” means
|
||||
the idea changed after it was answered.
|
||||
</p>
|
||||
<label v-if="platformsStore.catalog.length" class="fam-select-label">
|
||||
Platform
|
||||
<select v-model="platform" class="fam-select">
|
||||
<option value="">All</option>
|
||||
<option v-for="p in platformsStore.catalog" :key="p.slug" :value="p.slug">{{ p.name }}</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<p v-if="loading && !matrix" class="fam-muted">Loading…</p>
|
||||
|
||||
<div v-else-if="failed" class="fam-note">
|
||||
<strong>The adoption matrix couldn't load.</strong>
|
||||
<p>Nothing is known either way — this is a failure, not an empty ledger.</p>
|
||||
<button class="btn-secondary btn-sm" @click="load">Try again</button>
|
||||
</div>
|
||||
|
||||
<template v-else-if="matrix">
|
||||
<p v-if="!matrix.ideas.length" class="fam-muted">
|
||||
<template v-if="platform">No canon idea is for this platform yet.</template>
|
||||
<template v-else>No idea is canon yet, so nothing has reached a project.</template>
|
||||
</p>
|
||||
<p v-else-if="!matrix.projects.length" class="fam-muted">
|
||||
<template v-if="projectId">
|
||||
No canon idea reaches this project — it is on none of their platforms.
|
||||
</template>
|
||||
<template v-else>
|
||||
No project is on a platform any canon idea is for. A project joins a platform
|
||||
on its Family tab, or by detection from a bound repo.
|
||||
</template>
|
||||
</p>
|
||||
|
||||
<template v-else>
|
||||
<p class="fam-summary">
|
||||
{{ summary.owed }} owed · {{ summary.recheck }} to recheck ·
|
||||
{{ summary.unassessed }} not assessed
|
||||
</p>
|
||||
<div class="fam-matrix-wrap" role="region" aria-label="Adoption matrix" tabindex="0">
|
||||
<table class="fam-matrix">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col" class="fam-corner">Project</th>
|
||||
<th v-for="idea in matrix.ideas" :key="idea.note_id" scope="col">
|
||||
<router-link
|
||||
:to="recordHref({ id: idea.note_id, is_task: idea.is_task, note_type: idea.note_type })"
|
||||
:title="idea.applies_when ? `Applies when: ${idea.applies_when}` : undefined"
|
||||
>{{ idea.title }}</router-link>
|
||||
<span class="fam-version">v{{ idea.canon_version }}</span>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="p in matrix.projects" :key="p.id">
|
||||
<th scope="row">
|
||||
<router-link :to="`/projects/${p.id}`">{{ p.title }}</router-link>
|
||||
</th>
|
||||
<td v-for="idea in matrix.ideas" :key="idea.note_id">
|
||||
<button
|
||||
v-if="cellByKey[`${p.id}:${idea.note_id}`]"
|
||||
type="button"
|
||||
:class="['fam-cell', `fam-cell-${cellByKey[`${p.id}:${idea.note_id}`].status}`,
|
||||
{ 'fam-cell-on': selectedKey === `${p.id}:${idea.note_id}` }]"
|
||||
:aria-pressed="selectedKey === `${p.id}:${idea.note_id}`"
|
||||
:title="cellTitle(cellByKey[`${p.id}:${idea.note_id}`])"
|
||||
@click="toggle(cellByKey[`${p.id}:${idea.note_id}`])"
|
||||
>
|
||||
{{ LABELS[cellByKey[`${p.id}:${idea.note_id}`].status] }}
|
||||
<span v-if="cellByKey[`${p.id}:${idea.note_id}`].needs_recheck" class="fam-recheck">recheck</span>
|
||||
</button>
|
||||
<span v-else class="fam-cell-none" title="Not on this idea's platforms">—</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div v-if="selected" class="fam-detail" aria-live="polite">
|
||||
<div class="fam-detail-head">
|
||||
<strong>{{ selected.project_title }} · {{ selected.idea_title }}</strong>
|
||||
<span :class="['fam-cell', `fam-cell-${selected.status}`]">{{ LABELS[selected.status] }}</span>
|
||||
<button type="button" class="btn-ghost btn-sm" @click="selectedKey = null">Close</button>
|
||||
</div>
|
||||
<p v-if="selected.reason" class="fam-detail-reason">{{ selected.reason }}</p>
|
||||
<p v-else-if="!selected.reached" class="fam-muted">
|
||||
The idea reaches this project, but nobody has been asked yet — whoever promoted
|
||||
it could not write here. The project's own agent answers it on its next pass.
|
||||
</p>
|
||||
<p v-else-if="selected.status === 'unassessed'" class="fam-muted">
|
||||
Waiting for the agent to assess it against the four outcomes.
|
||||
</p>
|
||||
<p v-if="selected.needs_recheck" class="fam-detail-recheck">
|
||||
Answered against v{{ selected.canon_version }}; the idea is now
|
||||
v{{ selected.idea_version }}, so this answer needs a recheck.
|
||||
</p>
|
||||
<p v-if="!selected.in_scope" class="fam-muted">
|
||||
This project is no longer on the idea's platforms; the answer is kept as history.
|
||||
</p>
|
||||
<p v-if="selected.owed_task" class="fam-detail-task">
|
||||
Owed task:
|
||||
<router-link :to="recordHref({ id: selected.owed_task.id, is_task: true })">
|
||||
{{ selected.owed_task.title }}
|
||||
</router-link>
|
||||
<span class="fam-muted">({{ selected.owed_task.status.replace("_", " ") }})</span>
|
||||
</p>
|
||||
<p v-if="selected.assessed_at" class="fam-muted">
|
||||
Answered {{ fmtStamp(selected.assessed_at) }}
|
||||
<template v-if="selected.decided_via">by the {{ selected.decided_via }}</template>
|
||||
<template v-if="selected.canon_version"> against v{{ selected.canon_version }}</template>.
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<details class="fam-outcomes">
|
||||
<summary>The four outcomes</summary>
|
||||
<dl>
|
||||
<template v-for="o in matrix.outcomes" :key="o.key">
|
||||
<dt>{{ LABELS[o.key] }}</dt>
|
||||
<dd>{{ outcomeTest[o.key] }}</dd>
|
||||
</template>
|
||||
</dl>
|
||||
</details>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.fam-matrix-head { display: flex; flex-wrap: wrap; align-items: flex-end; justify-content: space-between; gap: 0.5rem 1rem; margin-bottom: 0.75rem; }
|
||||
.fam-matrix-head .fam-muted { flex: 1; min-width: 16rem; 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-select-label { display: flex; align-items: center; gap: 0.4rem; font-size: 0.85rem; color: var(--fs-text-secondary); }
|
||||
.fam-select {
|
||||
padding: 0.3rem 0.5rem;
|
||||
border: 1px solid var(--fs-border-color);
|
||||
border-radius: var(--fs-radius-sm);
|
||||
background: var(--fs-surface-page);
|
||||
color: var(--fs-text-primary);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.fam-summary { margin: 0 0 0.5rem; font-size: 0.85rem; color: var(--fs-text-secondary); }
|
||||
.fam-matrix-wrap {
|
||||
overflow-x: auto;
|
||||
max-width: 100%;
|
||||
border: 1px solid var(--fs-border-color);
|
||||
border-radius: var(--fs-radius-md);
|
||||
}
|
||||
.fam-matrix-wrap:focus-visible { outline: none; box-shadow: var(--fs-focus-ring); }
|
||||
button.fam-cell:focus-visible { outline: none; box-shadow: var(--fs-focus-ring); }
|
||||
.fam-matrix { border-collapse: collapse; font-size: 0.85rem; min-width: 100%; }
|
||||
.fam-matrix th, .fam-matrix td {
|
||||
padding: 0.45rem 0.6rem;
|
||||
border-bottom: 1px solid var(--fs-border-color);
|
||||
text-align: left;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.fam-matrix thead th { font-weight: 500; min-width: 9rem; max-width: 14rem; }
|
||||
.fam-matrix tbody th { font-weight: 500; white-space: nowrap; }
|
||||
.fam-matrix tbody tr:last-child th, .fam-matrix tbody tr:last-child td { border-bottom: none; }
|
||||
.fam-corner { color: var(--fs-text-tertiary); }
|
||||
.fam-version { display: block; color: var(--fs-text-tertiary); font-size: 0.75rem; font-weight: 400; }
|
||||
.fam-cell {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
font: inherit;
|
||||
font-size: 0.78rem;
|
||||
padding: 0.1rem 0.5rem;
|
||||
border-radius: var(--fs-radius-pill);
|
||||
border: 1px solid var(--fs-border-color);
|
||||
background: transparent;
|
||||
color: var(--fs-text-secondary);
|
||||
white-space: nowrap;
|
||||
}
|
||||
button.fam-cell { cursor: pointer; }
|
||||
button.fam-cell:hover { background: var(--fs-surface-hover); }
|
||||
.fam-cell-on { outline: 2px solid var(--fs-text-secondary); outline-offset: 1px; }
|
||||
.fam-cell-adopted { background: var(--fs-status-done-bg); color: var(--fs-status-done-fg); }
|
||||
.fam-cell-owed { background: var(--fs-status-todo-bg); color: var(--fs-status-todo-fg); }
|
||||
.fam-cell-variant { background: var(--fs-status-in-progress-bg); color: var(--fs-status-in-progress-fg); }
|
||||
.fam-cell-exempt { color: var(--fs-status-cancelled-fg); }
|
||||
.fam-cell-unassessed { color: var(--fs-text-tertiary); border-style: dashed; }
|
||||
.fam-recheck {
|
||||
font-size: 0.7rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
color: var(--fs-text-primary);
|
||||
}
|
||||
.fam-cell-none { color: var(--fs-text-tertiary); }
|
||||
.fam-detail {
|
||||
margin-top: 0.75rem;
|
||||
padding: 0.75rem 1rem;
|
||||
border: 1px solid var(--fs-border-color);
|
||||
border-radius: var(--fs-radius-md);
|
||||
background: var(--fs-surface-raised);
|
||||
}
|
||||
.fam-detail-head { display: flex; flex-wrap: wrap; align-items: center; gap: 0.4rem 0.6rem; }
|
||||
.fam-detail-head .btn-ghost { margin-left: auto; }
|
||||
.fam-detail p { margin: 0.4rem 0 0; font-size: 0.875rem; }
|
||||
.fam-detail-reason { color: var(--fs-text-primary); }
|
||||
.fam-detail-recheck { color: var(--fs-text-secondary); }
|
||||
.fam-outcomes { margin-top: 0.75rem; font-size: 0.85rem; color: var(--fs-text-secondary); }
|
||||
.fam-outcomes summary { cursor: pointer; color: var(--fs-text-secondary); }
|
||||
.fam-outcomes dl { margin: 0.5rem 0 0; }
|
||||
.fam-outcomes dt { font-weight: 500; color: var(--fs-text-primary); }
|
||||
.fam-outcomes dd { margin: 0 0 0.4rem 1rem; }
|
||||
</style>
|
||||
@@ -16,6 +16,10 @@
|
||||
*
|
||||
* Each change is saved as it is made: one platform, one request, applied
|
||||
* whole or refused whole.
|
||||
*
|
||||
* Below the platforms: this project's answer to each canon idea that reaches
|
||||
* it — the adoption matrix narrowed to one row, so it reads exactly as the
|
||||
* family page does.
|
||||
*/
|
||||
import { computed, onMounted, ref, watch } from "vue";
|
||||
import { apiErrorMessage } from "@/api/client";
|
||||
@@ -24,6 +28,7 @@ import {
|
||||
type PlatformState, type ProjectPlatform, type SettablePlatformState,
|
||||
} from "@/api/platforms";
|
||||
import { usePlatformsStore } from "@/stores/platforms";
|
||||
import FamilyAdoptionMatrix from "@/components/FamilyAdoptionMatrix.vue";
|
||||
|
||||
const props = defineProps<{ projectId: number; canWrite: boolean }>();
|
||||
|
||||
@@ -150,6 +155,11 @@ watch(() => props.projectId, load);
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
|
||||
<section class="pft-answers" aria-labelledby="pft-answers-title">
|
||||
<h3 id="pft-answers-title" class="pft-title">Family ideas</h3>
|
||||
<FamilyAdoptionMatrix :key="projectId" :project-id="projectId" />
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -195,6 +205,7 @@ watch(() => props.projectId, load);
|
||||
font-size: 0.85rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.pft-answers { margin-top: 1.75rem; }
|
||||
@media (max-width: 640px) {
|
||||
.pft-row { flex-direction: column; align-items: stretch; }
|
||||
}
|
||||
|
||||
@@ -4,17 +4,21 @@
|
||||
* 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.
|
||||
* 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 Criterion, type FamilyDecision, type FamilyIdea, type IdeaStatus,
|
||||
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";
|
||||
@@ -23,6 +27,8 @@ 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>("");
|
||||
@@ -55,6 +61,7 @@ async function load() {
|
||||
]);
|
||||
ideas.value = ideaData.ideas;
|
||||
criteria.value = ideaData.criteria;
|
||||
conflictOrder.value = ideaData.conflict_order ?? [];
|
||||
decisions.value = log;
|
||||
moreDecisions.value = log.length === PAGE;
|
||||
} catch {
|
||||
@@ -103,7 +110,7 @@ async function confirmPending() {
|
||||
else await retireFamilyIdea(p.id, reason);
|
||||
toast.show(p.kind === "undo" ? "Decision undone" : "Idea retired");
|
||||
pending.value = null;
|
||||
await load();
|
||||
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 {
|
||||
@@ -144,14 +151,44 @@ function evidenceLines(d: FamilyDecision): { label: string; lines: 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 "";
|
||||
const where = a.platforms.length ? ` · ${a.platforms.join(", ")}` : "";
|
||||
return `${a.status} v${a.canon_version}${where}`;
|
||||
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);
|
||||
@@ -246,6 +283,11 @@ onMounted(load);
|
||||
</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>
|
||||
@@ -255,6 +297,10 @@ onMounted(load);
|
||||
<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>
|
||||
|
||||
Reference in New Issue
Block a user