Merge dev: family canon, milestone 463 steps 1-6 #206

Merged
bvandeusen merged 10 commits from dev into main 2026-10-06 14:11:29 -04:00
72 changed files with 9131 additions and 130 deletions
+288
View File
@@ -0,0 +1,288 @@
"""family canon — platforms, family ideas, the adoption ledger and its decision
log (milestone 463 step 1)
Revision ID: 0120
Revises: 0119
Create Date: 2026-10-06
When one project solves something every project on the same platform will
meet, that solution becomes FAMILY CANON, and every other project on the
platform answers it: adopted, a variant (with a reason), exempt (with a
reason), or owed. These tables hold that.
- `platforms` — a GLOBAL catalog, the canonical_systems shape: no owner, slug
is the match key. Seeded with generic technology names only. Nothing here
names an app, a repo or a house convention: the catalog ships to every
install (rule 115).
- `project_platforms` — which platforms a project is (declared / detected /
rejected). Membership is what makes "is this in family?" a lookup.
- `family_ideas` — a NOTE's family state. No new record type: any notes row
(a note, a snippet, a lesson) becomes an idea by gaining one of these.
- `family_idea_platforms` — the platforms an idea is for; the only scope
source. A linked rule topic takes its scope from the idea.
- `family_idea_references` — reference implementations (snippets).
- `family_adoptions` — one row per (project, idea): the project's answer.
- `family_decisions` — append-only log of every promotion and ledger change.
No backfill. No project has declared a platform and no note is an idea yet;
inventing either would assert a judgment nobody made. The seed rows are
written here verbatim rather than imported, so the migration keeps running
unchanged after any service-side list moves on.
"""
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
from alembic import op
revision = "0120"
down_revision = "0119"
branch_labels = None
depends_on = None
# One place per whitelist, so each CHECK and the model's tuple cannot drift
# (rule 36: a new value later means DROP + ADD CONSTRAINT in one migration).
_MEMBERSHIP = ("declared", "detected", "rejected")
_IDEA_STATUSES = ("candidate", "canon", "retired")
_ADOPTION_STATUSES = ("unassessed", "adopted", "variant", "exempt", "owed")
_REASONED = ("variant", "exempt")
_ACTIONS = ("propose", "promote", "revise", "retire", "assess", "undo")
_DECIDERS = ("agent", "operator", "system")
def _in(column: str, values: tuple[str, ...]) -> str:
return f"{column} IN (" + ", ".join(f"'{v}'" for v in values) + ")"
# (name, slug, description, markers). A marker with no slash matches a file's
# basename anywhere in a repo; one with a slash matches the repo-relative
# path. Empty markers = declare-only, for a platform that leaves no reliable
# file behind. Generic on purpose — an install adds its own.
_SEED = (
("Android app", "android-app",
"A native Android client: an APK or app bundle installed on phones and tablets.",
["AndroidManifest.xml"]),
("iOS app", "ios-app",
"A native iOS or iPadOS client built with Xcode.",
["project.pbxproj"]),
("Web frontend", "web-frontend",
"A browser client built with a frontend toolchain: single-page apps and server-rendered frontends.",
["vite.config.*", "svelte.config.*", "vue.config.*", "next.config.*",
"nuxt.config.*", "angular.json"]),
("Browser extension", "browser-extension",
"An extension installed into a web browser and distributed through its add-on store or by file.",
[]),
("Desktop app", "desktop-app",
"A packaged desktop application for Windows, macOS or Linux.",
["tauri.conf.json", "electron-builder.*"]),
("Container image", "container-image",
"Ships as an OCI/Docker image that a host pulls and runs.",
["Dockerfile", "Containerfile", "*.Dockerfile"]),
("Go", "go",
"Written in Go.",
["go.mod"]),
("Python", "python",
"Written in Python.",
["pyproject.toml", "setup.py", "requirements.txt"]),
("Rust", "rust",
"Written in Rust.",
["Cargo.toml"]),
("PostgreSQL", "postgresql",
"Keeps its data in PostgreSQL.",
[]),
("GitHub Actions", "github-actions",
"Verified and released by GitHub Actions workflows.",
[".github/workflows/*"]),
("Gitea / Forgejo Actions", "gitea-actions",
"Verified and released by Gitea or Forgejo Actions workflows.",
[".gitea/workflows/*", ".forgejo/workflows/*"]),
)
def upgrade() -> None:
platforms = op.create_table(
"platforms",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("name", sa.Text(), nullable=False),
sa.Column("slug", sa.Text(), nullable=False),
sa.Column("description", sa.Text(), nullable=True),
sa.Column("markers", postgresql.JSONB(), nullable=False,
server_default=sa.text("'[]'::jsonb")),
sa.Column("order_index", sa.Integer(), nullable=False, server_default="0"),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("now()")),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("now()")),
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("deleted_batch_id", sa.Text(), nullable=True),
)
# Unique among LIVE rows only (the canonical_systems convention).
op.create_index(
"uq_platforms_slug", "platforms", ["slug"],
unique=True, postgresql_where=sa.text("deleted_at IS NULL"),
)
op.bulk_insert(
platforms,
[
{"name": name, "slug": slug, "description": description,
"markers": markers, "order_index": index}
for index, (name, slug, description, markers) in enumerate(_SEED)
],
)
op.create_table(
"project_platforms",
sa.Column("project_id", sa.Integer(),
sa.ForeignKey("projects.id", ondelete="CASCADE"), primary_key=True),
sa.Column("platform_id", sa.Integer(),
sa.ForeignKey("platforms.id", ondelete="CASCADE"), primary_key=True),
sa.Column("state", sa.Text(), nullable=False, server_default="declared"),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("now()")),
)
op.create_check_constraint(
"ck_project_platforms_state", "project_platforms", _in("state", _MEMBERSHIP),
)
op.create_index("ix_project_platforms_platform_id", "project_platforms", ["platform_id"])
op.create_table(
"family_ideas",
sa.Column("note_id", sa.Integer(),
sa.ForeignKey("notes.id", ondelete="CASCADE"), primary_key=True),
sa.Column("status", sa.Text(), nullable=False, server_default="candidate"),
sa.Column("applies_when", sa.Text(), nullable=True),
sa.Column("canon_version", sa.Integer(), nullable=False, server_default="1"),
# SET NULL: deleting a topic must not delete the standard it served.
sa.Column("topic_id", sa.BigInteger(),
sa.ForeignKey("rulebook_topics.id", ondelete="SET NULL"), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("now()")),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("now()")),
)
op.create_check_constraint(
"ck_family_ideas_status", "family_ideas", _in("status", _IDEA_STATUSES),
)
# The first promotion criterion, held by the schema: an idea is not canon
# until it says, in platform terms, when it applies.
op.create_check_constraint(
"ck_family_ideas_canon_applies", "family_ideas",
"status <> 'canon' OR length(btrim(coalesce(applies_when, ''))) > 0",
)
op.create_check_constraint(
"ck_family_ideas_canon_version", "family_ideas", "canon_version >= 1",
)
op.create_index(
"uq_family_ideas_topic", "family_ideas", ["topic_id"],
unique=True, postgresql_where=sa.text("topic_id IS NOT NULL"),
)
op.create_index("ix_family_ideas_status", "family_ideas", ["status"])
op.create_table(
"family_idea_platforms",
sa.Column("note_id", sa.Integer(),
sa.ForeignKey("family_ideas.note_id", ondelete="CASCADE"), primary_key=True),
sa.Column("platform_id", sa.Integer(),
sa.ForeignKey("platforms.id", ondelete="CASCADE"), primary_key=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("now()")),
)
op.create_index("ix_family_idea_platforms_platform_id", "family_idea_platforms", ["platform_id"])
op.create_table(
"family_idea_references",
sa.Column("idea_id", sa.Integer(),
sa.ForeignKey("family_ideas.note_id", ondelete="CASCADE"), primary_key=True),
sa.Column("snippet_id", sa.Integer(),
sa.ForeignKey("notes.id", ondelete="CASCADE"), primary_key=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("now()")),
)
op.create_index("ix_family_idea_references_snippet_id", "family_idea_references", ["snippet_id"])
op.create_table(
"family_adoptions",
sa.Column("id", sa.BigInteger(), primary_key=True),
sa.Column("project_id", sa.Integer(),
sa.ForeignKey("projects.id", ondelete="CASCADE"), nullable=False),
sa.Column("idea_id", sa.Integer(),
sa.ForeignKey("family_ideas.note_id", ondelete="CASCADE"), nullable=False),
sa.Column("status", sa.Text(), nullable=False, server_default="unassessed"),
sa.Column("reason", sa.Text(), nullable=True),
sa.Column("canon_version", sa.Integer(), nullable=True),
sa.Column("assessed_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("decided_via", sa.Text(), nullable=True),
sa.Column("owed_task_id", sa.Integer(),
sa.ForeignKey("notes.id", ondelete="SET NULL"), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("now()")),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("now()")),
)
op.create_check_constraint(
"ck_family_adoptions_status", "family_adoptions", _in("status", _ADOPTION_STATUSES),
)
# A departure is only a record if it says why.
op.create_check_constraint(
"ck_family_adoptions_reason", "family_adoptions",
"NOT (" + _in("status", _REASONED) + ") "
"OR length(btrim(coalesce(reason, ''))) > 0",
)
op.create_check_constraint(
"ck_family_adoptions_decided_via", "family_adoptions",
"decided_via IS NULL OR " + _in("decided_via", _DECIDERS),
)
op.create_index(
"uq_family_adoptions_pair", "family_adoptions", ["project_id", "idea_id"], unique=True,
)
op.create_index("ix_family_adoptions_project_id", "family_adoptions", ["project_id"])
op.create_index("ix_family_adoptions_idea_id", "family_adoptions", ["idea_id"])
op.create_table(
"family_decisions",
sa.Column("id", sa.BigInteger(), primary_key=True),
sa.Column("idea_id", sa.Integer(),
sa.ForeignKey("family_ideas.note_id", ondelete="CASCADE"), nullable=False),
sa.Column("project_id", sa.Integer(),
sa.ForeignKey("projects.id", ondelete="CASCADE"), nullable=True),
sa.Column("action", sa.Text(), nullable=False),
sa.Column("reason", sa.Text(), nullable=False),
sa.Column("before", postgresql.JSONB(), nullable=True),
sa.Column("after", postgresql.JSONB(), nullable=True),
sa.Column("evidence", postgresql.JSONB(), nullable=True),
sa.Column("precedent_ids", postgresql.JSONB(), nullable=False,
server_default=sa.text("'[]'::jsonb")),
sa.Column("decided_via", sa.Text(), nullable=False, server_default="agent"),
sa.Column("user_id", sa.Integer(),
sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("now()")),
)
op.create_check_constraint(
"ck_family_decisions_action", "family_decisions", _in("action", _ACTIONS),
)
op.create_check_constraint(
"ck_family_decisions_decided_via", "family_decisions", _in("decided_via", _DECIDERS),
)
# The reason IS the record — the agent decides with no approval step, so
# a decision that cannot say why is not one anybody can review or follow.
op.create_check_constraint(
"ck_family_decisions_reason", "family_decisions", "length(btrim(reason)) > 0",
)
op.create_index("ix_family_decisions_idea_id", "family_decisions", ["idea_id"])
op.create_index("ix_family_decisions_project_id", "family_decisions", ["project_id"])
def downgrade() -> None:
op.drop_index("ix_family_decisions_project_id", table_name="family_decisions")
op.drop_index("ix_family_decisions_idea_id", table_name="family_decisions")
op.drop_table("family_decisions")
op.drop_index("ix_family_adoptions_idea_id", table_name="family_adoptions")
op.drop_index("ix_family_adoptions_project_id", table_name="family_adoptions")
op.drop_index("uq_family_adoptions_pair", table_name="family_adoptions")
op.drop_table("family_adoptions")
op.drop_index("ix_family_idea_references_snippet_id", table_name="family_idea_references")
op.drop_table("family_idea_references")
op.drop_index("ix_family_idea_platforms_platform_id", table_name="family_idea_platforms")
op.drop_table("family_idea_platforms")
op.drop_index("ix_family_ideas_status", table_name="family_ideas")
op.drop_index("uq_family_ideas_topic", table_name="family_ideas")
op.drop_table("family_ideas")
op.drop_index("ix_project_platforms_platform_id", table_name="project_platforms")
op.drop_table("project_platforms")
op.drop_index("uq_platforms_slug", table_name="platforms")
op.drop_table("platforms")
+158
View File
@@ -0,0 +1,158 @@
/**
* 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 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 | AdoptionSnapshot | null;
after: IdeaSnapshot | AdoptionSnapshot | null;
evidence: Record<string, unknown>;
precedent_ids: number[];
decided_via: "agent" | "operator" | "system";
user_id: number | null;
created_at: string | null;
undoable?: boolean;
}
/** 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}`);
}
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 });
}
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;
/** Live shapes in the project classified against one of the idea's references. */
shapes: { path: string; symbol: string; status: "canonical" | "instance" | "variant" }[];
}
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}` : ""}`);
}
+15 -2
View File
@@ -1,9 +1,14 @@
/** Project inception (milestone 297): what a project was decided to inherit. */
import { apiGet, apiPost } from "@/api/client";
import type { ProjectPlatform } from "@/api/platforms";
export interface InceptionChoices {
design_system_id: number | null;
seed_systems: boolean;
/** Platform slugs (milestone 463). null = not answered; a list is the
* whole answer — members left out of it are recorded as "not this".
* Absent on records decided before the question existed. */
platforms?: string[] | null;
}
export interface InceptionRecord {
@@ -17,16 +22,24 @@ export interface InceptionDefaults {
design_system_id: number | null;
design_systems: { id: number; title: string }[];
systems: number;
platforms: { slug: string; name: string }[];
/** What detection (or an earlier answer) already says about the project. */
project_platforms: ProjectPlatform[];
}
export interface InceptionDecision {
project_id: number;
inception: InceptionRecord;
effects: { design_system_id: number | null; systems_seeded: string[] };
effects: {
design_system_id: number | null;
systems_seeded: string[];
/** The project's answers after the decision; null when left unstated. */
platforms: ProjectPlatform[] | null;
};
}
export const emptyChoices = (): InceptionChoices => ({
design_system_id: null, seed_systems: false,
design_system_id: null, seed_systems: false, platforms: null,
});
export const fetchInceptionDefaults = (projectId: number) =>
+77
View File
@@ -0,0 +1,77 @@
/**
* Platforms — what a project is built on or ships as (milestone 463).
*
* The catalog is GLOBAL, like the canonical areas, and admin-written. A
* project's answer per platform is `declared` (a person said so), `detected`
* (a marker file in a bound repo said so) or `rejected` (a person said no —
* kept, so the next refresh cannot detect it back). Only declared and
* detected make a project a member, and membership is what decides which
* family ideas reach it.
*/
import { apiGet, apiPatch, apiPost, apiPut } from "@/api/client";
export interface Platform {
id: number;
name: string;
/** The match key, and the name every door takes (stable across restores). */
slug: string;
description: string | null;
/** Repo-relative globs. A bare name matches a basename anywhere; one with
* a slash matches the whole path. Empty = declare-only. */
markers: string[];
order_index: number;
created_at: string | null;
updated_at: string | null;
}
export type PlatformState = "declared" | "detected" | "rejected";
/** What a person may set. `null` withdraws the answer. */
export type SettablePlatformState = "declared" | "rejected" | null;
export interface ProjectPlatform {
id: number;
slug: string;
name: string;
state: PlatformState;
}
export const MEMBER_STATES: PlatformState[] = ["declared", "detected"];
export async function listPlatforms(): Promise<Platform[]> {
const data = await apiGet<{ platforms: Platform[] }>("/api/platforms");
return data.platforms;
}
/** Admin only. A name that reduces to an existing slug answers 409. */
export async function createPlatform(data: {
name: string;
description?: string;
markers?: string[];
}): Promise<Platform> {
return apiPost("/api/platforms", data);
}
export async function updatePlatform(
id: number,
data: Partial<{ name: string; description: string; order_index: number; markers: string[] }>,
): Promise<Platform> {
return apiPatch(`/api/platforms/${id}`, data);
}
export async function fetchProjectPlatforms(projectId: number): Promise<ProjectPlatform[]> {
const data = await apiGet<{ project_platforms: ProjectPlatform[] }>(
`/api/projects/${projectId}/platforms`,
);
return data.project_platforms;
}
/** Only the slugs named change; the update applies whole or not at all. */
export async function setProjectPlatforms(
projectId: number,
platforms: Record<string, SettablePlatformState>,
): Promise<ProjectPlatform[]> {
const data = await apiPut<{ project_platforms: ProjectPlatform[] }>(
`/api/projects/${projectId}/platforms`, { platforms },
);
return data.project_platforms;
}
+2
View File
@@ -50,6 +50,7 @@ router.afterEach(() => {
<router-link to="/projects" class="nav-link">Projects</router-link>
<router-link to="/snippets" class="nav-link">Snippets</router-link>
<router-link to="/rules" class="nav-link">Rulebooks</router-link>
<router-link to="/family" class="nav-link">Family</router-link>
<!-- A design system is a RECORD you author, not a setting. It sat in
the utility cluster with Trash and Settings while /design was a
read-only gallery, and stayed there after it became a record type
@@ -102,6 +103,7 @@ router.afterEach(() => {
<router-link to="/projects" class="nav-link">Projects</router-link>
<router-link to="/snippets" class="nav-link">Snippets</router-link>
<router-link to="/rules" class="nav-link">Rulebooks</router-link>
<router-link to="/family" class="nav-link">Family</router-link>
<router-link to="/design-systems" class="nav-link">Design</router-link>
<router-link to="/shared" class="nav-link">Shared</router-link>
<div class="mobile-divider"></div>
@@ -0,0 +1,340 @@
<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>
<div v-if="selected.shapes.length" class="fam-detail-shapes">
<p>In this project's code — the shape ledger classifies:</p>
<ul>
<li v-for="s in selected.shapes" :key="`${s.path}::${s.symbol}`">
<code>{{ s.path }} · {{ s.symbol }}</code>
<span class="fam-muted">{{ s.status === "variant" ? "variant" : "instance" }}</span>
</li>
</ul>
</div>
<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-detail-shapes ul { margin: 0.25rem 0 0; padding-left: 1.1rem; font-size: 0.85rem; }
.fam-detail-shapes li { display: flex; flex-wrap: wrap; align-items: baseline; gap: 0 0.5rem; overflow-wrap: anywhere; }
.fam-detail-shapes code { font-family: var(--fs-font-mono); }
.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>
+71 -2
View File
@@ -6,10 +6,16 @@
* second step and only emits the choices (the project does not exist yet);
* mode="decide" sits on ProjectView for an undecided project, loads that
* project's current defaults, and records the decision itself.
*
* Platforms (milestone 463) start UNANSWERED (null). Ticking any box makes
* the list the whole answer; in decide mode the boxes start from what
* detection already found, so recording confirms it.
*/
import { computed, onMounted, ref, watch } from "vue";
import { apiErrorMessage } from "@/api/client";
import { fetchDesignSystems } from "@/api/designSystems";
import { MEMBER_STATES } from "@/api/platforms";
import { usePlatformsStore } from "@/stores/platforms";
import {
decideInception, emptyChoices, fetchInceptionDefaults,
type InceptionChoices, type InceptionDecision, type InceptionDefaults,
@@ -29,6 +35,9 @@ const emit = defineEmits<{
const local = ref<InceptionChoices>(props.choices ? { ...props.choices } : emptyChoices());
const designSystems = ref<{ id: number; title: string }[]>([]);
const systemsCount = ref(0);
const platforms = ref<{ slug: string; name: string }[]>([]);
const detected = ref<Set<string>>(new Set());
const platformsStore = usePlatformsStore();
const loading = ref(true);
const saving = ref(false);
const error = ref("");
@@ -46,11 +55,25 @@ async function load() {
const d: InceptionDefaults = await fetchInceptionDefaults(props.projectId);
designSystems.value = d.design_systems;
systemsCount.value = d.systems;
platforms.value = d.platforms;
const members = d.project_platforms
.filter((p) => MEMBER_STATES.includes(p.state))
.map((p) => p.slug);
detected.value = new Set(
d.project_platforms.filter((p) => p.state === "detected").map((p) => p.slug),
);
// Start from what stands today, so "record" without changes keeps it.
local.value = { design_system_id: d.design_system_id, seed_systems: false };
local.value = {
design_system_id: d.design_system_id,
seed_systems: false,
platforms: members.length ? members : null,
};
} else {
const ds = await fetchDesignSystems();
const [ds, catalog] = await Promise.all([
fetchDesignSystems(), platformsStore.fetchCatalog(),
]);
designSystems.value = ds.design_systems.map((d) => ({ id: d.id, title: d.title }));
platforms.value = catalog.map((p) => ({ slug: p.slug, name: p.name }));
}
} catch (e: unknown) {
error.value = apiErrorMessage(e, "Could not load what this project could inherit");
@@ -61,6 +84,22 @@ async function load() {
const nothingToDecide = computed(() => !designSystems.value.length);
function isChecked(slug: string): boolean {
return (local.value.platforms ?? []).includes(slug);
}
function togglePlatform(slug: string, on: boolean) {
const current = new Set(local.value.platforms ?? []);
if (on) current.add(slug);
else current.delete(slug);
local.value.platforms = [...current].sort();
}
/** Back to "not answered" — distinct from an empty list, which says "none". */
function clearPlatforms() {
local.value.platforms = null;
}
async function record() {
if (!props.projectId) return;
saving.value = true;
@@ -105,6 +144,31 @@ onMounted(load);
</span>
</label>
</div>
<div v-if="platforms.length" class="inception-group">
<h4>Platforms</h4>
<p class="inception-muted">
What it is built on or ships as. Ideas the family has proven for a
platform reach every project that is one.
<template v-if="local.platforms === null"> Not answered yet.</template>
<template v-else>
Anything left unticked is recorded as "not this project".
<button type="button" class="btn-ghost btn-sm" @click="clearPlatforms">Leave unanswered</button>
</template>
</p>
<div class="inception-platforms">
<label v-for="p in platforms" :key="p.slug" class="inception-choice">
<input
type="checkbox"
:checked="isChecked(p.slug)"
@change="togglePlatform(p.slug, ($event.target as HTMLInputElement).checked)"
/>
<span>
{{ p.name }}
<em v-if="detected.has(p.slug)" class="inception-muted"> — detected</em>
</span>
</label>
</div>
</div>
<p v-if="nothingToDecide" class="inception-muted">
No design systems on this install yet — recording still settles the question.
</p>
@@ -132,6 +196,11 @@ onMounted(load);
.inception-group h4 { margin: 0 0 0.35rem; font-size: 0.9rem; font-weight: 500; }
.inception-choice { display: flex; align-items: flex-start; gap: 0.5rem; font-size: 0.9rem; margin: 0.25rem 0; }
.inception-choice input { margin-top: 0.2rem; accent-color: var(--fs-accent); }
.inception-platforms {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(12rem, 1fr));
gap: 0 1rem;
}
.inception-select {
padding: 0.45rem 0.7rem;
border: 1px solid var(--fs-border-color);
@@ -0,0 +1,212 @@
<script setup lang="ts">
/**
* A project's place in the family (milestone 463): which platforms it is.
*
* Membership is what makes "does this family idea reach this project?" a
* lookup rather than a judgment — an idea is for some platforms, and it
* reaches every project that is one of them. So the answers here matter, and
* each kind of answer is shown as what it is:
*
* - Yes — a person said so.
* - Detected — a marker file in a bound repo said so. Detection only adds,
* and only where nobody has answered; it never overrides a Yes or a No.
* - No — a person said this project is NOT that platform. Kept as a row, so
* the next coverage refresh cannot detect it straight back.
* - No answer — detection may decide on the next refresh.
*
* 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";
import {
fetchProjectPlatforms, setProjectPlatforms,
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 }>();
const platformsStore = usePlatformsStore();
const answers = ref<ProjectPlatform[]>([]);
const loading = ref(false);
const failed = ref(false);
const saving = ref<string | null>(null);
const error = ref("");
const LABELS: Record<PlatformState, string> = {
declared: "Yes",
detected: "Detected",
rejected: "No",
};
async function load() {
loading.value = true;
failed.value = false;
try {
const [, rows] = await Promise.all([
platformsStore.fetchCatalog(),
fetchProjectPlatforms(props.projectId),
]);
answers.value = rows;
} catch {
// Said out loud: "couldn't load" must not read as "no platforms".
failed.value = true;
} finally {
loading.value = false;
}
}
const stateBySlug = computed(() => {
const out: Record<string, PlatformState> = {};
for (const a of answers.value) out[a.slug] = a.state;
return out;
});
const rows = computed(() =>
platformsStore.catalog.map((p) => ({
...p,
state: stateBySlug.value[p.slug] ?? null,
})),
);
const memberCount = computed(
() => answers.value.filter((a) => a.state !== "rejected").length,
);
async function answer(slug: string, value: string) {
const state: SettablePlatformState = value === "" ? null : (value as SettablePlatformState);
saving.value = slug;
error.value = "";
try {
answers.value = await setProjectPlatforms(props.projectId, { [slug]: state });
} catch (e: unknown) {
error.value = apiErrorMessage(e, "Could not save that answer");
} finally {
saving.value = null;
}
}
onMounted(load);
watch(() => props.projectId, load);
</script>
<template>
<div class="pft">
<header class="pft-head">
<h3 class="pft-title">Platforms</h3>
<p class="pft-muted">
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>
<p v-if="loading" class="pft-muted">Loading…</p>
<div v-else-if="failed" class="pft-note">
<strong>The platforms couldn't load.</strong>
<p>Nothing is known either way — this is a failure, not an empty answer.</p>
<button class="btn-secondary btn-sm" @click="load">Try again</button>
</div>
<p v-else-if="!rows.length" class="pft-muted">
This install has no platforms in its catalog yet. An administrator adds
them in Settings.
</p>
<template v-else>
<p class="pft-summary">
<template v-if="memberCount">
A member of {{ memberCount }} platform{{ memberCount === 1 ? "" : "s" }}.
</template>
<template v-else>
Not a member of any platform yet — bind a repo and refresh coverage
to detect them, or answer below.
</template>
</p>
<p v-if="error" class="error-msg">{{ error }}</p>
<ul class="pft-list">
<li v-for="p in rows" :key="p.id" class="pft-row">
<div class="pft-name">
<span>{{ p.name }}</span>
<span v-if="p.state" :class="['pft-tag', `pft-${p.state}`]">{{ LABELS[p.state] }}</span>
<span v-if="p.description" class="pft-desc">{{ p.description }}</span>
</div>
<select
v-if="canWrite"
class="pft-select"
:value="p.state === 'detected' ? 'detected' : (p.state ?? '')"
:disabled="saving === p.slug"
:aria-label="`Is this project ${p.name}?`"
@change="answer(p.slug, ($event.target as HTMLSelectElement).value)"
>
<option v-if="p.state === 'detected'" value="detected" disabled>Detected</option>
<option value="declared">Yes</option>
<option value="rejected">No</option>
<option value="">No answer</option>
</select>
</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>
<style scoped>
.pft { padding: 0.25rem 0; }
.pft-head { margin-bottom: 1rem; }
.pft-title { margin: 0 0 0.35rem; font-size: 1rem; }
.pft-muted { color: var(--fs-text-tertiary); font-size: 0.875rem; margin: 0; }
.pft-summary { color: var(--fs-text-secondary); font-size: 0.9rem; margin: 0 0 0.75rem; }
.pft-note {
background: var(--fs-surface-raised);
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-md);
padding: 0.85rem 1rem;
}
.pft-note p { margin: 0.35rem 0 0.6rem; color: var(--fs-text-secondary); font-size: 0.875rem; }
.pft-list { list-style: none; margin: 0; padding: 0; }
.pft-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 0.6rem 0;
border-bottom: 1px solid var(--fs-border-color);
}
.pft-name { display: flex; flex-wrap: wrap; align-items: baseline; gap: 0.25rem 0.5rem; min-width: 0; }
.pft-desc { flex-basis: 100%; color: var(--fs-text-tertiary); font-size: 0.8rem; }
.pft-tag {
font-size: 0.75rem;
padding: 0.05rem 0.45rem;
border-radius: var(--fs-radius-sm);
border: 1px solid var(--fs-border-color);
color: var(--fs-text-secondary);
}
.pft-declared { color: var(--fs-text-primary); }
.pft-rejected { color: var(--fs-text-tertiary); text-decoration: line-through; }
.pft-select {
padding: 0.35rem 0.6rem;
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;
flex-shrink: 0;
}
.pft-answers { margin-top: 1.75rem; }
@media (max-width: 640px) {
.pft-row { flex-direction: column; align-items: stretch; }
}
</style>
@@ -414,6 +414,12 @@ watch(() => props.ruleId, load);
</div>
</template>
<!-- `.rule-chip` for the "suggested" marker on a lesson — the chip the rule
panes already use, loaded here because the slide-over can open with no
pane that loads it (a link straight to ?rule=N).
Block 0, as in every pane that loads it: a shared src must sit at
one index everywhere (tests/test_frontend_shared_styles.py). -->
<style src="@/assets/rules-shared.css" />
<style scoped>
.kind { border: 1px solid var(--fs-border-color); border-radius: var(--fs-radius-md); padding: var(--fs-space-3); margin: var(--fs-space-3) 0; }
.kind legend { font-size: var(--fs-size-tiny); text-transform: uppercase; letter-spacing: var(--fs-tracking-tiny); color: var(--fs-text-tertiary); padding: 0 var(--fs-space-2); }
@@ -518,8 +524,11 @@ legend { padding: 0 0.35rem; font-size: 0.8rem; color: var(--fs-text-tertiary);
.trash:hover, .close:hover { opacity: 1; }
</style>
<!-- `.rule-chip` for the "suggested" marker on a lesson — the chip the rule
panes already use, loaded here because the slide-over can open with no
pane that loads it (a link straight to ?rule=N). -->
<style src="@/assets/rules-shared.css" />
<style src="@/assets/moments-shared.css" />
<!-- An @import, not a second <style src>: plugin-vue caches ONE descriptor per
src file, and every other importer of moments-shared.css has it as style
block 1. Here it would be block 2, so whichever SFC registered last decided
the lookup and the production build failed with "reading 'scoped'" when
the transform order changed (tests/test_frontend_shared_styles.py). -->
<style>
@import "@/assets/moments-shared.css";
</style>
+7
View File
@@ -128,6 +128,13 @@ const router = createRouter({
name: "rules",
component: () => import("@/views/RulesView.vue"),
},
{
// Family canon (milestone 463): ideas shared across every project on a
// platform, and the log of every decision about them.
path: "/family",
name: "family",
component: () => import("@/views/FamilyView.vue"),
},
{
// The design systems this install RECORDS — for the projects it tracks,
// not for the install itself. There was a sibling `/design` that read the
+48
View File
@@ -0,0 +1,48 @@
import { ref } from "vue";
import { defineStore } from "pinia";
import * as api from "@/api/platforms";
import type { Platform } from "@/api/platforms";
/**
* The global platform catalog (milestone 463). Shared by every project, so
* it is fetched once per session — as the canonical-area catalog is.
*/
export const usePlatformsStore = defineStore("platforms", () => {
const catalog = ref<Platform[]>([]);
const loaded = ref(false);
const loading = ref(false);
async function fetchCatalog(force = false) {
if (loaded.value && !force) return catalog.value;
loading.value = true;
try {
catalog.value = await api.listPlatforms();
loaded.value = true;
} catch {
// The catalog is a vocabulary for a form; an empty one degrades the
// form rather than failing the screen it sits on.
catalog.value = [];
} finally {
loading.value = false;
}
return catalog.value;
}
async function createEntry(data: { name: string; description?: string; markers?: string[] }) {
const entry = await api.createPlatform(data);
catalog.value.push(entry);
return entry;
}
async function updateEntry(
id: number,
data: Partial<{ name: string; description: string; order_index: number; markers: string[] }>,
) {
const entry = await api.updatePlatform(id, data);
const idx = catalog.value.findIndex((p) => p.id === id);
if (idx >= 0) catalog.value[idx] = entry;
return entry;
}
return { catalog, loaded, loading, fetchCatalog, createEntry, updateEntry };
});
+9
View File
@@ -0,0 +1,9 @@
/** Where a record opens. A task, a snippet, a lesson and a note live at
* different routes, and a link that guesses wrong is worse than one that is
* plain. One copy, for every list that links a record of any kind. */
export function recordHref(rec: { id: number; is_task?: boolean; note_type?: string }): string {
if (rec.is_task) return `/tasks/${rec.id}`;
if (rec.note_type === "snippet") return `/snippets/${rec.id}`;
if (rec.note_type === "lesson") return `/lessons/${rec.id}`;
return `/notes/${rec.id}`;
}
+406
View File
@@ -0,0 +1,406 @@
<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>
+7 -13
View File
@@ -41,6 +41,7 @@ import { DEAD_WEIGHT_ADVICE } from "@/utils/deadWeight";
import { useToastStore } from "@/stores/toast";
import { renderMarkdown } from "@/utils/markdown";
import { canWriteRecord } from "@/utils/permission";
import { recordHref } from "@/utils/recordHref";
const route = useRoute();
const router = useRouter();
@@ -59,14 +60,6 @@ const insightHtml = computed(() =>
lesson.value?.insight ? renderMarkdown(lesson.value.insight) : "",
);
/** Where each source opens. A task and a note live at different routes, and a
* link that guesses wrong is worse than one that is plain. */
function sourceHref(rec: { id: number; is_task?: boolean; note_type?: string }) {
if (rec.is_task) return `/tasks/${rec.id}`;
if (rec.note_type === "snippet") return `/snippets/${rec.id}`;
if (rec.note_type === "lesson") return `/lessons/${rec.id}`;
return `/notes/${rec.id}`;
}
const canWrite = computed(() => canWriteRecord(lesson.value?.permission));
@@ -193,7 +186,7 @@ onMounted(load);
<h2 class="ld-panel-label">Learned from</h2>
<ul class="ld-sources">
<li v-for="rec in lesson.learned_from_records" :key="rec.id">
<router-link :to="sourceHref(rec)" class="ld-link">
<router-link :to="recordHref(rec)" class="ld-link">
{{ rec.title }}
</router-link>
<span v-if="rec.status" class="ld-source-status">{{ rec.status }}</span>
@@ -313,6 +306,11 @@ onMounted(load);
</div>
</template>
<!-- `.rule-chip` and its preference variant: the marker a rule's kind wears
everywhere else, reused rather than re-spelled.
Block 0, as in every pane that loads it: a shared src must sit at
one index everywhere (tests/test_frontend_shared_styles.py). -->
<style src="@/assets/rules-shared.css" />
<style scoped>
.lesson-detail {
max-width: 780px;
@@ -459,7 +457,3 @@ onMounted(load);
font-size: 0.88rem;
}
</style>
<!-- `.rule-chip` and its preference variant: the marker a rule's kind wears
everywhere else, reused rather than re-spelled. -->
<style src="@/assets/rules-shared.css" />
+5 -4
View File
@@ -459,6 +459,11 @@ onMounted(() => {
</div>
</template>
<!-- `.rule-chip-preference` marks a preference among the rules offered, as it
does everywhere else a rule's kind is shown.
Block 0, as in every pane that loads it: a shared src must sit at
one index everywhere (tests/test_frontend_shared_styles.py). -->
<style src="@/assets/rules-shared.css" />
<style scoped>
.lesson-editor {
max-width: 820px;
@@ -606,7 +611,3 @@ onMounted(() => {
.le-small { padding: 0.2rem 0.7rem; font-size: var(--fs-size-label); }
.le-ghost:disabled { opacity: var(--fs-disabled-opacity); cursor: default; }
</style>
<!-- `.rule-chip-preference` marks a preference among the rules offered, as it
does everywhere else a rule's kind is shown. -->
<style src="@/assets/rules-shared.css" />
+17 -1
View File
@@ -12,6 +12,8 @@ import KindBadge from "@/components/KindBadge.vue";
import ProjectStatusBadge from "@/components/ProjectStatusBadge.vue";
import type { TaskKind } from "@/types/note";
import ProjectDesignTab from "@/components/ProjectDesignTab.vue";
import ProjectFamilyTab from "@/components/ProjectFamilyTab.vue";
import { canWriteRecord } from "@/utils/permission";
import ProjectRulesTab from "@/components/rules/ProjectRulesTab.vue";
import SystemsSection from "@/components/SystemsSection.vue";
import InceptionCard from "@/components/InceptionCard.vue";
@@ -125,7 +127,7 @@ async function confirmStartPlanning() {
const saving = ref(false);
const error = ref<string | null>(null);
const activeTab = ref<"tasks" | "notes" | "systems" | "rules" | "design">("tasks");
const activeTab = ref<"tasks" | "notes" | "systems" | "rules" | "design" | "family">("tasks");
const tasks = ref<NoteItem[]>([]);
const notes = ref<NoteItem[]>([]);
@@ -747,6 +749,10 @@ async function confirmDelete() {
Inheritance decided {{ fmtDate(project.inception.decided_at) }} via {{ project.inception.via }}
· design system {{ project.inception.choices.design_system_id ? "#" + project.inception.choices.design_system_id : "none" }}
<template v-if="project.inception.choices.seed_systems"> · Systems seeded</template>
<!-- What was said AT inception; the live answers are on the Family tab. -->
<template v-if="project.inception.choices.platforms?.length">
· platforms {{ project.inception.choices.platforms.join(", ") }}
</template>
</p>
<!-- Summary stat chips -->
@@ -945,6 +951,9 @@ async function confirmDelete() {
<button :class="['tab-btn', { active: activeTab === 'design' }]" @click="activeTab = 'design'">
Design
</button>
<button :class="['tab-btn', { active: activeTab === 'family' }]" @click="activeTab = 'family'">
Family
</button>
</div>
<!-- Tasks tab — milestone-grouped kanban -->
@@ -1190,6 +1199,13 @@ async function confirmDelete() {
:project-id="projectId"
:design-system-id="project.design_system_id ?? null"
/>
<!-- Family tab (milestone 463): which platforms this project is. -->
<ProjectFamilyTab
v-if="activeTab === 'family'"
:project-id="projectId"
:can-write="canWriteRecord(project.permission)"
/>
</div>
</div>
</template>
+168 -2
View File
@@ -4,6 +4,7 @@ import { useSettingsStore } from "@/stores/settings";
import { useAuthStore } from "@/stores/auth";
import { useToastStore } from "@/stores/toast";
import { useCanonicalSystemsStore } from "@/stores/canonicalSystems";
import { usePlatformsStore } from "@/stores/platforms";
import { apiGet, apiPost, apiPut, apiDelete, listGroups, createGroup, deleteGroup, listGroupMembers, addGroupMember, removeGroupMember, searchUsers, listApiKeys, createApiKey as apiCreateApiKey, revokeApiKey as apiRevokeApiKey, getProfile, updateProfile, type ApiKeyEntry, type GroupEntry, type GroupMember, type UserSearchResult, type UserProfile, apiErrorMessage } from "@/api/client";
import type { User } from "@/types/auth";
import PaginationBar from "@/components/PaginationBar.vue";
@@ -71,6 +72,75 @@ async function saveArea() {
savingArea.value = false;
}
}
// ── Platforms (milestone 463) ───────────────────────────────────────────
// The global list of what a project can be built on or ship as. Same split
// as the areas: admin-only to write, readable by everyone. Markers are the
// repo-relative globs that let a coverage refresh DETECT a platform; they
// are edited one per line, since a glob may itself contain a comma.
const platformsStore = usePlatformsStore();
const newPlatformName = ref("");
const newPlatformDescription = ref("");
const newPlatformMarkers = ref("");
const creatingPlatform = ref(false);
const editingPlatformId = ref<number | null>(null);
const editPlatformName = ref("");
const editPlatformDescription = ref("");
const editPlatformMarkers = ref("");
const savingPlatform = ref(false);
function markerLines(text: string): string[] {
return text.split("\n").map((m) => m.trim()).filter(Boolean);
}
async function createPlatform() {
const name = newPlatformName.value.trim();
if (!name || creatingPlatform.value) return;
creatingPlatform.value = true;
try {
await platformsStore.createEntry({
name,
description: newPlatformDescription.value.trim() || undefined,
markers: markerLines(newPlatformMarkers.value),
});
newPlatformName.value = "";
newPlatformDescription.value = "";
newPlatformMarkers.value = "";
toastStore.show("Platform added");
} catch (e) {
// A 409 names the existing platform the new name reduces to.
toastStore.show(apiErrorMessage(e, "Failed to add platform"), "error");
} finally {
creatingPlatform.value = false;
}
}
function startEditPlatform(id: number, name: string, description: string | null, markers: string[]) {
editingPlatformId.value = id;
editPlatformName.value = name;
editPlatformDescription.value = description ?? "";
editPlatformMarkers.value = markers.join("\n");
}
async function savePlatform() {
const id = editingPlatformId.value;
const name = editPlatformName.value.trim();
if (id == null || !name || savingPlatform.value) return;
savingPlatform.value = true;
try {
await platformsStore.updateEntry(id, {
name,
description: editPlatformDescription.value.trim(),
markers: markerLines(editPlatformMarkers.value),
});
editingPlatformId.value = null;
toastStore.show("Platform updated");
} catch (e) {
toastStore.show(apiErrorMessage(e, "Failed to update platform"), "error");
} finally {
savingPlatform.value = false;
}
}
const userTimezone = ref("");
const savingTimezone = ref(false);
const timezoneSaved = ref(false);
@@ -479,7 +549,7 @@ async function copyCommit() {
const restoreFileInput = ref<HTMLInputElement | null>(null);
// Migrate stored "admin" → "config"; unknown tabs fall back to "general"
const VALID_TABS = new Set(["general", "account", "profile", "notifications", "integrations", "data", "apikeys", "config", "users", "logs", "groups", "areas"]);
const VALID_TABS = new Set(["general", "account", "profile", "notifications", "integrations", "data", "apikeys", "config", "users", "logs", "groups", "areas", "platforms"]);
const _stored = localStorage.getItem("settings_tab") ?? "general";
const activeTab = ref(VALID_TABS.has(_stored) ? (_stored === "admin" ? "config" : _stored) : "general");
@@ -489,6 +559,7 @@ function _loadTabContent(tab: string) {
else if (tab === "logs") loadLogsPanel();
else if (tab === "groups") loadGroupsPanel();
else if (tab === "areas") canonStore.fetchCatalog(true);
else if (tab === "platforms") platformsStore.fetchCatalog(true);
else if (tab === "config" && !versionInfo.value) loadVersionPanel();
}
if (tab === "apikeys") { fetchApiKeys(); }
@@ -1611,7 +1682,7 @@ async function deleteUser(userId: number) {
<div v-if="authStore.isAdmin" class="sidebar-group">
<div class="sidebar-group-label">Admin</div>
<button
v-for="tab in ['config', 'areas', 'users', 'groups', 'logs']"
v-for="tab in ['config', 'areas', 'platforms', 'users', 'groups', 'logs']"
:key="tab"
:class="['sidebar-item', { active: activeTab === tab }]"
@click="activeTab = tab"
@@ -3179,6 +3250,99 @@ async function deleteUser(userId: number) {
</section>
</div>
<!-- ── Platforms ── -->
<div v-if="authStore.isAdmin" v-show="activeTab === 'platforms'" class="settings-grid">
<section class="settings-section full-width">
<h2>Platforms</h2>
<p class="field-hint">
What a project can be built on or ship as. A project's platforms decide which family
ideas reach it. Markers are repo-relative file patterns, one per line: a bare name
(<code>go.mod</code>) matches that file anywhere in a bound repo, and a pattern with a
slash (<code>.github/workflows/*</code>) matches the whole path. A platform with no
markers is never detected — projects declare it themselves.
</p>
<ul class="area-admin-list">
<li v-for="entry in platformsStore.catalog" :key="entry.id" class="area-admin-row">
<template v-if="editingPlatformId === entry.id">
<form class="area-admin-form" @submit.prevent="savePlatform">
<input v-model="editPlatformName" class="fs-input" aria-label="Platform name" />
<textarea
v-model="editPlatformDescription"
class="fs-input"
rows="2"
aria-label="Platform description"
></textarea>
<textarea
v-model="editPlatformMarkers"
class="fs-input area-admin-markers"
rows="3"
aria-label="Detection markers, one per line"
></textarea>
<div class="area-admin-actions">
<button type="submit" class="btn-primary btn-compact" :disabled="!editPlatformName.trim() || savingPlatform">
{{ savingPlatform ? "Saving…" : "Save" }}
</button>
<button type="button" class="btn-ghost btn-compact" @click="editingPlatformId = null">Cancel</button>
</div>
</form>
</template>
<template v-else>
<div class="area-admin-body">
<div class="area-admin-name-row">
<span class="area-admin-name">{{ entry.name }}</span>
<code class="area-admin-slug" title="The match key, and the name tools and backups use.">{{ entry.slug }}</code>
</div>
<p v-if="entry.description" class="area-admin-desc">{{ entry.description }}</p>
<p class="area-admin-desc">
<template v-if="entry.markers.length">
Detected by
<code v-for="m in entry.markers" :key="m" class="area-admin-slug">{{ m }}</code>
</template>
<template v-else>Declare-only — never detected.</template>
</p>
</div>
<button
class="btn-ghost btn-compact"
@click="startEditPlatform(entry.id, entry.name, entry.description, entry.markers)"
>Edit</button>
</template>
</li>
</ul>
<p v-if="!platformsStore.catalog.length && !platformsStore.loading" class="settings-empty">
No platforms yet.
</p>
<form class="area-admin-form area-admin-create" @submit.prevent="createPlatform">
<input
v-model="newPlatformName"
class="fs-input"
placeholder="New platform name (e.g. Flutter app)"
aria-label="New platform name"
/>
<textarea
v-model="newPlatformDescription"
class="fs-input"
rows="2"
placeholder="What makes a project this platform? One sentence."
aria-label="New platform description"
></textarea>
<textarea
v-model="newPlatformMarkers"
class="fs-input area-admin-markers"
rows="2"
placeholder="Markers, one per line (e.g. pubspec.yaml) — leave empty for declare-only"
aria-label="New platform markers, one per line"
></textarea>
<div class="area-admin-actions">
<button type="submit" class="btn-primary btn-compact" :disabled="!newPlatformName.trim() || creatingPlatform">
{{ creatingPlatform ? "Adding…" : "Add platform" }}
</button>
</div>
</form>
</section>
</div>
<div v-if="authStore.isAdmin" v-show="activeTab === 'users'" class="settings-grid">
<section class="settings-section full-width">
@@ -4386,6 +4550,8 @@ async function deleteUser(userId: number) {
.area-admin-form { display: flex; flex-direction: column; gap: 0.5rem; flex: 1; }
.area-admin-create { margin-top: var(--fs-space-4); }
.area-admin-actions { display: flex; gap: 0.4rem; }
.area-admin-markers { font-family: var(--fs-font-mono); font-size: 0.82rem; }
.area-admin-desc .area-admin-slug { margin-right: 0.25rem; }
/* The retrieval tuning trail (#4102). Reads as a record, not a control panel:
the operator is reviewing what was done, and the reason is the part worth
+2 -2
View File
@@ -1,7 +1,7 @@
{
"name": "scribe",
"description": "Scribe for Claude Code: connects the scribe MCP server, adds the hooks that deliver live project state and relevant records at the right moment, ships the shared client-neutral Scribe skills (using-scribe, writing-plans, reporting-back, systematic-debugging, verification, brainstorming, reusing-code, shape-accounting), and syncs your saved Scribe Processes as skills (/scribe:sync).",
"version": "2026.10.05.2219",
"description": "Scribe for Claude Code: connects the scribe MCP server, adds the hooks that deliver live project state and relevant records at the right moment, ships the shared client-neutral Scribe skills (using-scribe, writing-plans, reporting-back, systematic-debugging, verification, brainstorming, reusing-code, shape-accounting, family-canon), and syncs your saved Scribe Processes as skills (/scribe:sync).",
"version": "2026.10.06.1726",
"author": {
"name": "Bryan Van Deusen"
},
+1 -1
View File
@@ -6,7 +6,7 @@ once, in the **`using-scribe`** skill: reach for it at the start of the session
and whenever you are unsure what Scribe expects. Each tool's contract is in its
description, and the process skills (writing-plans, reporting-back,
reusing-code, systematic-debugging, verification, brainstorming,
shape-accounting) carry their arcs.
shape-accounting, family-canon) carry their arcs.
What only Claude Code needs said:
+93
View File
@@ -0,0 +1,93 @@
---
name: family-canon
description: Use when family canon reaches the session — a `family_hint` on a create (a record may be a pattern every project on a platform will need), a `family` key from enter_project (ideas this project has not answered, owes, or must recheck), a `family_owed` list when a task closes, or the operator asks whether a pattern is shared between projects. Triggers on "family", "shared between projects", "promote", "canon idea", "owed", "adopt", "every Android app", "every project on".
metadata:
moments: work.record
---
# Family canon — good ideas spread by criteria, not by approval
Some patterns are not one app's: every project on a platform meets the same
problem (an Android app that ships its own APK, a Go service behind a
reverse proxy). Family canon is how such an idea is recorded once, reaches
every project on that platform, and gets an answer from each. Nobody
approves anything. **You decide, against written criteria, and the decision
log keeps you consistent with the last decision like it.** The tools quote
the criteria, the outcomes and the conflict order in full; this skill is when
to reach for them and what a good answer looks like.
The shape is the point, not identical code: the same idea in Kotlin and in
Go is one idea with two reference implementations.
## When an idea is evaluated for promotion
A `family_hint` on a create_note / create_snippet response means a trigger
opened an evaluation: the record cites another project's record as its
source, or it repeats a record in a project on a shared platform. The source
is now a `candidate`. Evaluate it in the same turn, while you know why you
wrote what you wrote:
1. `get_family_idea(id)` — the candidate, its decisions, and the precedents
(decisions on the ideas nearest it).
2. Judge the three criteria from `promote_family_idea`'s description. Each
needs support you can name; **one criterion with no support vetoes it**.
3. Promote it, or leave it a candidate with the reason. A held candidate is
precedent too, so the reason is the record — never skip writing it.
A milestone closing on a platform project hints without recording anything:
ask whether what it built is something every project on the platform will
face, and if so write the note that carries the idea.
## Answering an idea in a project
enter_project's `family` key counts what this project owes the family:
`unassessed`, `owed`, `needs_recheck`, each with the call that lists them.
Answer an idea **when your work reaches its area**, not as a chore list at
session start — the count is there so it is never invisible, not so it
preempts the operator's task.
- `get_family_adoption(project_id, idea_id)` first: the idea, the project's
current answer, the precedents, and the reference implementations with the
project's languages.
- Judge the outcomes **in the order `assess_family_adoption` lists them**,
stopping at the first that holds. Every outcome needs a reason; `adopted`
needs evidence naming where.
- **When the code is in the shape ledger, answer there instead:**
`classify_shapes` the shape with `idea_id=` as an `instance` or a
`variant`, and the adoption answer moves with it. An answer the shapes
contradict is refused — fix the shapes, not the answer.
- `owed` files a task in THAT project and stops. Nothing here edits another
repository; the project's own session picks the task up.
### What counts as a reason
A reason names a **fact about this project**: "the app is installed by a
managed store, never sideloaded", "the service has no user accounts". A
preference, a taste, or "we already did it another way" is not a reason —
that is `owed`, or, if this project's way is better rather than different, a
conflict. Write the reason so a session in another project can tell whether
the same fact holds there; that is how it becomes precedent.
### The precedent reflex
Before answering, read what was answered before: the same idea in other
projects, and this project's answers to the nearest ideas. Answer
consistently unless this project differs in a way you can name — and then
the difference IS the reason. The engine records the precedents it found
whether or not you cite them, so an inconsistent answer is visible later.
## When two projects disagree
Two projects solving one idea differently, each sure of its way, is a
conflict — settle it with `resolve_family_conflict`, which states the order.
The first ground that applies wins, and **every ground above it must be said
not to apply** — you cannot skip to the one you like. The losing side's
reasoning is folded into the idea's note (as a trap, an alternative, or the
branch for its condition) and never dropped; the version moves, so every
other project's answer reads as needing a recheck.
## Reporting it
A `family_owed` list on a closing task is work you filed into other projects
while it was open: name each one in the report, by project and idea — it is
waiting there now.
+2
View File
@@ -207,6 +207,8 @@ Notes on each section:
the task alone is enough.
- **What now works** — outcomes the operator would notice: "You can now…",
"X no longer…". The files and steps behind them belong in the task's log.
A `family_owed` list on the closing response is work you filed into other
projects: name each by project and idea (the family-canon skill).
- **How / why** — only the decisions worth knowing, plus **how it was
verified**. If something could not be verified, say what and why here rather
than letting it read as passed.
+4
View File
@@ -32,6 +32,8 @@ from scribe.routes.trash import trash_bp
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
@@ -101,6 +103,8 @@ def create_app() -> Quart:
app.register_blueprint(dashboard_bp)
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)
+22 -8
View File
@@ -43,13 +43,12 @@ from quart import Quart
# decision #4027 and the notes it supersedes.
_INSTRUCTIONS = """
Scribe is the operator's system of record, and yours: recall before acting,
record as you go, keep one copy here, not in local memory files. Each
practice is stated in full in the using-scribe skill and each tool's
description.
record as you go, keep one copy here, not in local memory. Each
practice is stated in full in a skill or a tool's description.
- Start with enter_project(id): the project, open work, Systems and design
system. An `inception` key: ask what it inherits, then
decide_project_inception.
- Start with enter_project(id): the project, open work, Systems, design
system. `inception`: ask what it inherits, then decide_project_inception.
`family`: shared ideas to answer (family-canon skill).
- Rules are not preloaded; one arrives when your work matches it or reaches
a moment it is mounted on (list_moments; mount rules about WHEN). Before a
consequential act, what_might_apply("what you are about to do");
@@ -65,8 +64,8 @@ description.
(search(content_type="milestone")) before start_planning.
- IDs exist only once a create returns them; records citing each other go
through create_records, writing {{ref:N}} for the Nth.
- In UI work the project's design system binds: resolve_design_system before
hand-writing a value.
- In UI work the design system binds: resolve_design_system before
writing a value.
Creates are duplicate-gated: a near-match returns the existing id to update.
shared:true records are another user's suggestion, not settled practice.
@@ -160,6 +159,14 @@ _READ_ONLY_TOOLS = frozenset({
# retrieval_telemetry's reason, and needed by a read key so that a line
# naming a moment can be understood by whoever was shown it.
"list_moments",
# 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, the
# decision log, and the adoption ledger. Reads of records the caller can
# read.
"list_family_ideas", "get_family_idea", "list_family_decisions",
"get_family_adoption", "list_family_adoptions",
# 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.
@@ -183,6 +190,13 @@ _WRITE_TOOLS = frozenset({
# projects, Systems, repos
"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",
# family canon — the adoption ledger
"revise_family_idea", "assess_family_adoption", "resolve_family_conflict",
"set_family_references",
"bind_repo", "unbind_repo",
# snippets, processes, the shape ledger
"create_snippet", "update_snippet", "delete_snippet", "verify_snippet",
+4 -2
View File
@@ -5,8 +5,8 @@ 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,
moments, retrieval_review, retrieval_tuning,
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,
)
@@ -24,6 +24,8 @@ def register_all(mcp) -> None:
projects.register(mcp)
milestones.register(mcp)
systems.register(mcp)
platforms.register(mcp)
family.register(mcp)
design_systems.register(mcp)
tags.register(mcp)
recent.register(mcp)
+383
View File
@@ -0,0 +1,383 @@
"""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
from scribe.services import family_adoption as adoption_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, the `ledger`
(every project's answer, with `needs_recheck`), 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)
idea["ledger"] = await adoption_svc.list_adoptions(uid, idea_id=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 revision, a retirement, a
proposal, or one project's assessment — restoring the state it recorded
as `before`. Only the latest decision on an idea (or on one project's
answer to it) can be undone; the undo is logged too, so the history
keeps both. Undoing an owed answer closes its task; undoing back to owed
reopens it.
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, project_id: int = 0, limit: int = 50, offset: int = 0,
) -> dict:
"""The family decision log, newest first: every proposal, promotion,
veto, revision, retirement, assessment and undo, with its reason,
evidence and the precedents it followed. `undoable` marks the decision an
undo would reverse — per idea, and per project's answer.
Args:
note_id: only this idea's decisions. 0 = all.
project_id: only this project's assessments. 0 = all.
limit / offset: page through the log.
"""
rows = await family_svc.list_decisions(
current_user_id(), idea_id=note_id or None, project_id=project_id or None,
limit=max(1, min(limit, 200)), offset=max(0, offset),
)
return {"decisions": rows, "limit": limit, "offset": offset}
async def revise_family_idea(
note_id: int, reason: str, applies_when: str = "", platforms: list[str] | None = None,
evidence: list[str] | None = None,
) -> dict:
"""Record that a canon idea's SUBSTANCE changed — you rewrote its note's
approach, its traps or its checklist, or its applicability or platforms
moved. The canon version moves, so every project's answer given against
the old version reads `needs_recheck` until it is assessed again.
A typo fix is not a revision. A change a project that adopted the old
version would need to act on is.
Args:
note_id: the canon idea.
reason: what changed, in a sentence.
applies_when: a new 'when it applies'. Empty = keep the current one.
platforms: new platform slugs. Omit = keep the current ones.
evidence: what prompted the change, if anything.
"""
return await family_svc.revise(
current_user_id(), note_id, reason=reason,
applies_when=applies_when or None, platforms=platforms, evidence=evidence,
)
async def get_family_adoption(project_id: int, idea_id: int) -> dict:
"""Everything one assessment needs: the idea (its 'when it applies',
platforms and version), this project's current answer, THE FOUR
OUTCOMES, the `precedents` — this idea's answers in other projects and
this project's answers to the nearest ideas — and the reference
implementations beside this project's languages. Read it before
assess_family_adoption.
Args:
project_id: the project answering.
idea_id: the canon idea.
"""
return await adoption_svc.get_adoption(current_user_id(), project_id, idea_id)
async def list_family_adoptions(
project_id: int = 0, idea_id: int = 0, status: str = "", needs_recheck: bool = False,
platform: str = "",
) -> dict:
"""The adoption ledger: each project's answer to each canon idea that
reaches it — `unassessed`, `adopted`, `variant`, `exempt` or `owed`, with
its reason, the canon version it was given against, `needs_recheck` when
the idea has moved on since, and the owed task.
Args:
project_id: one project's answers. 0 = all you can read.
idea_id: one idea's answers. 0 = all canon.
status: one outcome. Empty = all.
needs_recheck: only answers given against an older canon version.
platform: only ideas for this platform slug.
"""
rows = await adoption_svc.list_adoptions(
current_user_id(), project_id=project_id or None, idea_id=idea_id or None,
status=status or None, recheck_only=needs_recheck, platform=platform or None,
)
return {"adoptions": rows}
async def assess_family_adoption(
project_id: int,
idea_id: int,
outcome: str,
reason: str,
evidence: list[str] | None = None,
precedent_ids: list[int] | None = None,
) -> dict:
"""Answer one canon family idea for one project. YOU decide; nobody
approves. Judge in this order and stop at the first that holds:
1. exempt — the idea's 'when it applies' is false for this project.
`reason` names the fact about the project that makes it false.
2. variant — it applies, and the project departs for a reason that names
a FACT about itself the canon did not account for. A preference, a
taste, or "we already did it another way" is not a reason: that is
owed — or, if this project's way is better rather than different, a
conflict (resolve_family_conflict).
3. adopted — it applies and the project does it. `evidence` names where
(a file, a commit, a task, a CI run).
4. owed — none of the above. A task is filed in THIS project naming the
gap and the reference implementation for its language, filed under
the project's System matching the idea's area (retag it with
update_task if that guess is wrong). Nothing edits another
repository; the project picks the task up itself.
Read get_family_adoption first: answer consistently with its precedents
unless this project differs in a way you can name in `reason`. The
engine also records the precedents it found itself.
The owed task follows the answer: adopted closes it as done, exempt or
variant cancels it, owed again reopens it. The same answer given twice
records nothing the second time.
When the project's code is in the shape ledger, prefer answering THERE:
classify_shapes the shape against the idea (idea_id=…) as an instance or
a variant, and this answer moves with it. An answer the shapes already
give otherwise is refused — the two ledgers never disagree; reclassify
the shapes if they are wrong.
Args:
project_id: the project answering.
idea_id: the canon idea.
outcome: exempt | variant | adopted | owed.
reason: why — required for every outcome.
evidence: where it is done (required for adopted), or what you checked.
precedent_ids: earlier family decisions you followed, if any.
"""
return await adoption_svc.assess(
current_user_id(), project_id, idea_id, outcome=outcome, reason=reason,
evidence=evidence, precedent_ids=precedent_ids,
)
async def resolve_family_conflict(
idea_id: int,
canon_project_id: int,
other_project_id: int,
ground: str,
fold: str,
reason: str,
grounds_checked: dict | None = None,
evidence: list[str] | None = None,
conditions: dict | None = None,
precedent_ids: list[int] | None = None,
) -> dict:
"""Settle two projects that solve the same canon idea differently, each
for reasons it believes. YOU decide, by THE CONFLICT ORDER — the first
ground that applies wins, and for every ground above it you say in
`grounds_checked` why it did not decide:
1. operator_stance — one side follows a stance the operator stated (a
rule, a preference, a recorded decision). Name it in `evidence`.
2. covers_failure — one side covers a recorded failure (an incident, an
issue, a lesson) the other does not. Name it in `evidence`. The other
side becomes owed.
3. split_by_condition — both are right under different conditions. The
canon splits: `conditions={"canon": …, "other": …}`, and each side is
canon where its condition holds.
4. most_recent_complete — none of the above: the side verified most
recently and covering the most wins. Name the verification.
`canon_project_id` is the side whose approach the idea's note states
after this. If the note says something else now, rewrite it first
(update_note) — the note is the canon.
The losing side's reasoning, `fold`, is appended to the idea's note as a
trap (ground 2), an alternative (1, 4) or the branch for its condition
(3). It is never dropped. The version moves, so every other project's
answer reads `needs_recheck`. The canon side is answered adopted; the
other owed (with a task in its project) or, in a split, adopted.
Args:
idea_id: the canon idea.
canon_project_id: the side whose approach is canon after this.
other_project_id: the side that loses, or the split's other branch.
ground: operator_stance | covers_failure | split_by_condition | most_recent_complete.
fold: the other side's reasoning, as it should read in the note.
reason: the decision in one or two sentences.
grounds_checked: {earlier ground: why it did not decide}.
evidence: the stance, the failure or the verification.
conditions: for a split, {"canon": condition, "other": condition}.
precedent_ids: earlier family decisions you followed, if any.
"""
return await adoption_svc.resolve_conflict(
current_user_id(), idea_id, canon_project_id=canon_project_id,
other_project_id=other_project_id, ground=ground, grounds_checked=grounds_checked,
fold=fold, evidence=evidence, reason=reason, conditions=conditions,
precedent_ids=precedent_ids,
)
async def set_family_references(note_id: int, snippet_ids: list[int]) -> dict:
"""Set a family idea's reference implementations — the snippets an owed
task points a project at, ideally one per language. Replaces the list.
The idea is what transfers; a reference is where to start, not code to
copy.
Args:
note_id: the family idea.
snippet_ids: snippets implementing it. [] clears the list.
"""
refs = await adoption_svc.set_references(current_user_id(), note_id, snippet_ids or [])
return {"references": refs}
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, revise_family_idea, get_family_adoption,
list_family_adoptions, assess_family_adoption, resolve_family_conflict,
set_family_references,
):
mcp.tool(name=fn.__name__)(fn)
+11 -2
View File
@@ -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,
)
+10 -1
View File
@@ -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
@@ -203,7 +204,10 @@ async def create_note(
"existing_id": ..., "message": ...} and nothing is created. A tagged
record shows its `systems`; created untagged in a project, the response
carries the `systems_hint` question instead — answer it: tag the record,
create the missing System, or deliberately leave it untagged.
create the missing System, or deliberately leave it untagged. A
`family_hint` means the note may carry an idea every project on a shared
platform will need, and an evaluation was opened — the family-canon skill
says how to judge it.
"""
uid = current_user_id()
await refuse_guessed_ids(title, body)
@@ -237,6 +241,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 +326,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
+78
View File
@@ -0,0 +1,78 @@
"""Platform MCP tools — what a project is built on or ships as (milestone 463).
Thin wrappers over services/platforms.py. The catalog is global; a project's
platforms decide which family ideas reach it.
"""
from __future__ import annotations
from scribe.mcp._context import current_user_id
from scribe.services import platforms as platforms_svc
async def list_platforms(project_id: int = 0) -> dict:
"""The GLOBAL platform catalog — runtimes, delivery channels and
toolchains a project can be built on or ship as (Android app, container
image, Go, …) — and, with a project_id, that project's answer for each.
A project's platforms decide which family ideas reach it: an idea is for
some platforms, and every project that is one of them answers it. Pass
slugs from here to set_project_platforms and decide_project_inception.
Each platform's `markers` are the file patterns that let the coverage
refresh DETECT it in a bound repo. A project's `state` per platform is
`declared` (a person said so), `detected` (a marker said so) or `rejected`
(a person said no — kept so detection cannot add it back). Only declared
and detected are membership.
Args:
project_id: also return this project's answers. 0 = catalog only.
"""
catalog = await platforms_svc.list_platforms()
out: dict = {"platforms": [p.to_dict() for p in catalog]}
if project_id:
rows = await platforms_svc.project_platforms(current_user_id(), project_id)
if rows is None:
raise ValueError(f"project {project_id} not found")
out["project_platforms"] = rows
return out
async def set_project_platforms(
project_id: int,
declared: list[str] | None = None,
rejected: list[str] | None = None,
withdrawn: list[str] | None = None,
) -> dict:
"""Say which platforms a project is — or is not. Only the platforms named
change; everything else is left exactly as it is.
Use it when the operator corrects what detection found, or when a project
starts or stops shipping something (it gains an Android client; it drops
its container image). At project creation, decide_project_inception's
`platforms` is the place instead — it records the answer with the rest of
what the project inherits.
Args:
project_id: the project.
declared: slugs the project IS (list_platforms).
rejected: slugs it is NOT — recorded as a "no", so the coverage
refresh never detects it back.
withdrawn: slugs whose answer to drop entirely, so detection may
decide again on the next refresh.
"""
updates: dict[str, str | None] = {}
for slug in withdrawn or []:
updates[slug] = None
for slug in rejected or []:
updates[slug] = "rejected"
for slug in declared or []:
updates[slug] = "declared"
if not updates:
raise ValueError("name at least one platform to declare, reject or withdraw")
rows = await platforms_svc.set_project_platforms(current_user_id(), project_id, updates)
return {"project_id": project_id, "project_platforms": rows}
def register(mcp) -> None:
for fn in (list_platforms, set_project_platforms):
mcp.tool(name=fn.__name__)(fn)
+56 -11
View File
@@ -16,13 +16,17 @@ keeps working.
"""
from __future__ import annotations
import logging
from scribe.mcp._context import current_user_id
from scribe.mcp.tools import systems as systems_tools
from scribe.services import coverage as coverage_svc
from scribe.services import design_systems as design_systems_svc
from scribe.services import family_adoption as family_adoption_svc
from scribe.services import inception as inception_svc
from scribe.services import milestones as milestones_svc
from scribe.services import notes as notes_svc
from scribe.services import platforms as platforms_svc
from scribe.services import projects as projects_svc
from scribe.services import rulebooks as rulebooks_svc
from scribe.services import systems as systems_svc
@@ -30,6 +34,8 @@ from scribe.services import trash as trash_svc
from scribe.services.background import spawn
from scribe.services.note_usage import record_surfaced
logger = logging.getLogger(__name__)
async def list_projects() -> dict:
"""List all Scribe projects for the current user.
@@ -69,8 +75,8 @@ async def enter_project(project_id: int) -> dict:
Returns a dict with keys: project, milestone_summary, open_tasks, systems,
design_system, project_rules, pattern_coverage —
plus unplanned_milestones, milestone_summary_omitted,
unplanned_milestones_omitted, inception and systems_bootstrap, each
present only when it applies (see below).
unplanned_milestones_omitted, family, inception and systems_bootstrap,
each present only when it applies (see below).
`project` is id, title, status and the full goal. get_project has the
whole record.
@@ -125,9 +131,23 @@ async def enter_project(project_id: int) -> dict:
create it with create_system rather than leaving the area unmodelled. Read
a subsystem's accumulated records with list_system_records. Each is id and name; get_system has the charter.
`platforms` (milestone 463) is what the project is built on or ships as
— Android app, container image, Go, … — each with how it is known
(`declared` by a person, `detected` from a bound repo). They decide which
family ideas reach this project. An empty list on a project that plainly
ships something is worth correcting with set_project_platforms.
`family` (milestone 463) appears ONLY when this project has family canon
to answer: how many canon ideas on its platforms are `unassessed`, how
many it `owed`s, and how many answers were given against an older canon
version (`needs_recheck`) — each with the list_family_adoptions call that
lists them. Answer an unassessed idea when your work reaches its area
(get_family_adoption, then assess_family_adoption, or classify the code
against it); the family-canon skill has the order.
`inception` (milestone 297) appears ONLY when the project is yours and
nobody has decided what it inherits: it carries the current defaults
(design system, Systems), what to ask the
(design system, Systems, platforms), what to ask the
operator — once — and the decide_project_inception call that answers it;
it repeats on every enter until a decision is recorded.
@@ -188,6 +208,9 @@ async def enter_project(project_id: int) -> dict:
# three days of the feature landing (#2546's audit). Untagged writes now
# also ask with the vocabulary listed; this copy lets the first write tag.
systems = await systems_svc.list_systems(uid, project_id)
platforms = platforms_svc.members(
await platforms_svc.project_platforms(uid, project_id) or []
)
# The arrival-moment half of the bootstrap ask (#2683): session start is
# when the agent has just read the project map and is not yet deep in a
@@ -258,6 +281,7 @@ async def enter_project(project_id: int) -> dict:
},
"pattern_coverage": coverage_svc.coverage_line(coverage) if coverage else None,
"systems": [{"id": s.id, "name": s.name} for s in systems],
"platforms": platforms,
"design_system": design_system,
"milestone_summary": milestone_summary,
**rulebooks_svc.rules_payload(
@@ -288,6 +312,15 @@ async def enter_project(project_id: int) -> dict:
)
if systems_bootstrap:
out["systems_bootstrap"] = systems_bootstrap
# Family canon (milestone 463 step 6): counts only, attached when one is
# non-zero. A readout must never fail the handshake it rides on.
try:
family = await family_adoption_svc.family_readout(uid, project_id)
except Exception:
logger.warning("family readout failed for project %s", project_id, exc_info=True)
family = None
if family:
out["family"] = family
if inception_ask:
out["inception"] = inception_ask
return out
@@ -302,12 +335,15 @@ async def get_project(project_id: int) -> dict:
rules (project_rules), and applicable_rules: the
global rules tagged to an area this project works in. Every other global
rule applies too and arrives by retrieval when the work matches it.
`platforms` is every platform the project has an answer for, with its
state — rejected ones included, unlike enter_project's brief list.
"""
uid = current_user_id()
project = await projects_svc.get_project(uid, project_id)
if project is None:
raise ValueError(f"project {project_id} not found")
data = project.to_dict()
data["platforms"] = await platforms_svc.project_platforms(uid, project_id) or []
rows = await milestones_svc.get_project_milestone_summary(uid, project_id)
data["milestone_summary"], _ = milestones_svc.brief_milestone_summary(rows)
applicable = await rulebooks_svc.get_applicable_rules(
@@ -317,17 +353,21 @@ async def get_project(project_id: int) -> dict:
return data
def _inception_choices(design_system_id, seed_systems) -> dict | None:
def _inception_choices(design_system_id, seed_systems, platforms=None) -> dict | None:
"""The tool args → an inception choices object, or None when no inception
arg was given at all (a bare create stays undecided and enter_project
asks). design_system_id: 0 = not stated, -1 = explicitly none, n = that
system."""
if not design_system_id and seed_systems is None:
system. platforms: None = not stated (memberships untouched), a list =
the whole answer."""
if not design_system_id and seed_systems is None and platforms is None:
return None
return {
choices = {
"design_system_id": None if design_system_id in (0, -1) else design_system_id,
"seed_systems": bool(seed_systems),
}
if platforms is not None:
choices["platforms"] = list(platforms)
return choices
async def create_project(
@@ -338,11 +378,12 @@ async def create_project(
color: str = "",
design_system_id: int = 0,
seed_systems: bool | None = None,
platforms: list[str] | None = None,
) -> dict:
"""Create a new project in Scribe — and decide what it inherits.
A project's inheritance is a decision, not a default (milestone 297):
before calling, ask the operator the two inception questions and pass
before calling, ask the operator the three inception questions and pass
the answers; a project created without either is UNDECIDED and
enter_project will ask until decide_project_inception records it.
Defaults if nobody decides: no design system, no Systems. Rules are not
@@ -359,6 +400,9 @@ async def create_project(
(list_design_systems); -1 = explicitly none; 0 = not stated.
seed_systems: true mints the standard starter Systems (CI & Release,
Auth & Access, …) so records can be tagged from day one.
platforms: slugs of what the project is built on or ships as
(list_platforms) — they decide which family ideas reach it. The
list is the whole answer; omit it to leave the question open.
"""
uid = current_user_id()
project = await projects_svc.create_project(
@@ -370,7 +414,7 @@ async def create_project(
color=color or None,
)
data = project.to_dict()
choices = _inception_choices(design_system_id, seed_systems)
choices = _inception_choices(design_system_id, seed_systems, platforms)
if choices is not None:
decided = await inception_svc.decide(uid, project.id, choices=choices, via="mcp")
data["inception"] = decided["inception"]
@@ -388,6 +432,7 @@ async def decide_project_inception(
project_id: int,
design_system_id: int = 0,
seed_systems: bool | None = None,
platforms: list[str] | None = None,
) -> dict:
"""Record what a project inherits — answer enter_project's `inception` ask,
or re-decide later (milestone 297).
@@ -400,10 +445,10 @@ async def decide_project_inception(
Args: as create_project's inception args. Passing nothing records a
decision to take nothing (no design system, no seed) — a valid answer,
stated.
stated — and leaves the project's platforms as they are.
"""
uid = current_user_id()
choices = _inception_choices(design_system_id, seed_systems) or {}
choices = _inception_choices(design_system_id, seed_systems, platforms) or {}
decided = await inception_svc.decide(uid, project_id, choices=choices, via="mcp")
return {"project_id": project_id, **decided}
+35 -13
View File
@@ -37,10 +37,15 @@ async def classify_shapes(
Args:
project_id: The project whose ledger is being judged.
classifications: Objects of {path, symbol, status, kind?, snippet_id?,
reason?, reason_code?}. path+symbol name the shape exactly as
idea_id?, reason?, reason_code?}. path+symbol name the shape exactly as
list_shapes shows it; kind ("sym"/"css") narrows when one file
defines both. snippet_id is required for canonical/instance/
variant; reason is required for variant/exempt. reason_code is
variant — or idea_id, a family idea: the shape is then judged
against that idea's reference implementation in the shape's
language, or its first reference when none is in that language
(an idea is shared across languages: a Python shape can be an
instance of an idea whose reference is Go). reason is required
for variant/exempt. reason_code is
an OPTIONAL index beside the prose (one of: scoped-css,
one-off-handler, test-helper, convention-plumbing, pure-helper,
generated, script, typed-record) so the ledger can be filtered
@@ -60,11 +65,20 @@ async def classify_shapes(
confirms it. Must be bound to the project. Omit it to judge only
synced rows.
FAMILY CANON FOLLOWS. A shape judged against a canon family idea's
reference IS the project's answer to that idea: an instance answers it
`adopted`, a variant `variant` (with the shape's reason), and
withdrawing the shapes that gave an answer returns it to `unassessed`.
The adoption ledger is moved for you — `family` in the result lists the
answers that moved — and assess_family_adoption refuses an answer the
shapes contradict. The family-canon skill has when to answer an idea.
All-or-nothing: a structural error, a missing snippet target, an unbound
repo, or no write access applies NOTHING. Returns {"classified": N,
"unmatched": [...], "provisional": N?} — unmatched names shapes no live
ledger row matches (the tree may have moved since you listed; pass `repo`
for a shape you just wrote, or re-run the project's coverage refresh).
"unmatched": [...], "provisional": N?, "family": [...]?} — unmatched names
shapes no live ledger row matches (the tree may have moved since you
listed; pass `repo` for a shape you just wrote, or re-run the project's
coverage refresh).
"""
uid = current_user_id()
return await shape_ledger_svc.classify_shapes(
@@ -116,7 +130,10 @@ async def list_shapes(
machine thinks are an instance of a snippet: `proposal` carries
snippet_id, basis, score), "derive" (rows that repeat with NO
canon: `proposal.group` names the family), or one basis
(symbol/text/reference/signature/semantic).
(symbol/text/reference/signature/semantic/family). "family" is
a canon family idea's reference — another project's, often
another language's — that the body means: the top match over
every snippet you can read, on a platform the project shares.
flag: the divergence readout (#2793) — "divergence": shapes new
since the previous refresh in a directory where one canon
dominates the judged siblings and NOT proposed as that canon
@@ -145,7 +162,8 @@ async def list_shapes(
THE FAST PATH through a big todo is the proposer's queue: every coverage
refresh matches unclassified shapes against canon (strongest basis
first: same symbol elsewhere → textual containment → body references
the canon → signature resemblance → semantic) and attaches a
the canon → signature resemblance → semantic, and a family idea's
reference in any language) and attaches a
`proposal` to each row it can speak for. Review `proposal="canon"` by
snippet or directory, then confirm_shape_proposals the ones that hold —
hundreds at a time — and classify_shapes the rest (variant/exempt, or
@@ -214,9 +232,11 @@ async def classify_shapes_by_rule(
revise a family you judged earlier).
One transaction: applies whole or not at all. Returns
{"classified": N, "sample": ["path::symbol", ...]} (first 12, sorted)
so you can see what the rule reached; N = 0 means the rule matched
nothing live and unclassified — widen the pattern or refresh coverage.
{"classified": N, "sample": ["path::symbol", ...], "family": [...]?}
(sample: first 12, sorted) so you can see what the rule reached; N = 0
means the rule matched nothing live and unclassified — widen the pattern
or refresh coverage. As with classify_shapes, a family idea's reference
judged here moves the project's answer to that idea (`family`).
"""
uid = current_user_id()
try:
@@ -317,12 +337,14 @@ async def confirm_shape_proposals(
snippet_id (confirm one canon's whole queue after reading its
`list_shapes(proposal="canon", ...)` page), path (a directory you
audited), or basis (e.g. "symbol" and "reference" are near-certain;
"semantic" deserves a look first) is required — a bare confirm-all is
not a judgment. min_score trims a basis's tail.
"semantic" and "family" deserve a look first) is required — a bare
confirm-all is not a judgment. min_score trims a basis's tail.
Proposals you do NOT confirm are judged with classify_shapes (variant,
exempt, or instance of a different snippet) — any judgment retires the
proposal. Requires write access. Returns {"confirmed": N}.
proposal. A confirmed family reference answers that idea `adopted` in
the project (`family` lists the answers that moved). Requires write
access. Returns {"confirmed": N, "family": [...]?}.
"""
uid = current_user_id()
try:
+7 -1
View File
@@ -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
@@ -177,7 +178,9 @@ async def create_snippet(
rather than forcing a second copy with force=true. A tagged record shows
its `systems`; created untagged in a project, the response carries the
`systems_hint` question instead — answer it: tag, create the missing
System, or deliberately skip.
System, or deliberately skip. A `family_hint` means the shape may be one
every project on a shared platform will need, and an evaluation was
opened — the family-canon skill says how to judge it.
WHAT THE GATE MATCHES ON. Exact identity first — an existing snippet at the
same repo · path · symbol, or holding byte-identical code. Those are certain,
@@ -224,6 +227,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)
+14 -1
View File
@@ -28,6 +28,8 @@ 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 family_adoption as family_adoption_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 +365,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)
@@ -418,7 +423,10 @@ async def update_task(
by their `when_to_apply`, so a preference whose trigger is writing the
report after finishing a task is the one that arrives here. Where one
differs from the default shape, the preference is what the operator
asked for.
asked for. When family adoptions were answered `owed` while the task was
open, they come back as `family_owed` ({idea_id, idea_title, project_id,
project_title, owed_task_id}): name each in the report — it is work
filed into that project.
"""
uid = current_user_id()
fields: dict = {}
@@ -457,6 +465,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
@@ -467,6 +477,9 @@ async def update_task(
if prefs:
data["reply_preferences"] = prefs
data["report_back"] = REPORT_BACK_CUE + " " + REPLY_PREFERENCES_CUE
# Owed family adoptions filed while the task was open (milestone 463
# step 6) — work now waiting in some project, which the report names.
await family_adoption_svc.attach_owed_adoptions(uid, data, note)
return await moment_delivery.attach_moment_rules(
uid, "update_task", {"status": status, "project_id": project_id}, data,
)
+6
View File
@@ -89,3 +89,9 @@ from scribe.models.forge_connection import ForgeConnection # noqa: E402, F401
from scribe.models.code_shape import CodeShape, CodeShapeConsumer, CodeShapeEvent, CodeShapeUse # noqa: E402, F401
from scribe.models.system import System, RecordSystem # noqa: E402, F401
from scribe.models.design_system import DesignSystem, DesignToken # noqa: E402, F401
# After notes, projects and rulebook topics: family canon foreign-keys all
# three (milestone 463).
from scribe.models.family import ( # noqa: E402, F401
FamilyAdoption, FamilyDecision, FamilyIdea, FamilyIdeaPlatform,
FamilyIdeaReference, Platform, ProjectPlatform,
)
+357
View File
@@ -0,0 +1,357 @@
"""Family canon (milestone 463): ideas that every project on a platform shares,
and each project's recorded answer to them.
A FAMILY IDEA is not a new kind of record. It is an existing `notes` row — a
note, a snippet, a lesson — that has been given a platform scope. The note
carries the idea (when it applies, the traps, the checklist, the incidents
behind them); an optional rulebook topic carries the norms that bind; snippets
are the reference implementations, one per language. A new record type would
be one more thing reached for interchangeably with rules, processes, snippets
and design systems, and nothing here needs one.
What is shared is the IDEA, not the code. Each project implements it in its own
language; identical code is a by-product and never the test.
The tables, and the one job each does:
- `platforms` — the global catalog of what a project can be built on or ship
as. Same shape and reasoning as `canonical_systems`: no owner, so the same
word means the same thing in every project on the install.
- `project_platforms` — which platforms a project is. Membership is how
"is this in family?" stops being a judgment made per idea and becomes a
lookup.
- `family_ideas` — a note's family state: candidate, canon or retired, its
applicability test, its canon version, its linked rule topic.
- `family_idea_platforms` — the platforms an idea is for. The ONLY scope
source: a linked topic takes its scope from here rather than carrying its
own, so the two can never disagree.
- `family_idea_references` — the reference implementations.
- `family_adoptions` — one row per (project, idea): the project's answer.
- `family_decisions` — the append-only log of every promotion and every
ledger change, with its reason and the earlier decisions it followed. The
agent makes these calls with no approval step, so the log is how they stay
consistent (precedent) and how a person reviews or undoes one.
"""
from datetime import datetime
from sqlalchemy import (
BigInteger, DateTime, ForeignKey, Index, Integer, Text, text,
)
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column
from scribe.models import Base
from scribe.models.base import CreatedAtMixin, SoftDeleteMixin, TimestampMixin, iso
# Each tuple is the whitelist its CHECK constraint enforces (migration 0120).
# Rule 36: a new value means DROP + ADD CONSTRAINT in the same migration.
MEMBERSHIP_STATES = ("declared", "detected", "rejected")
IDEA_STATUSES = ("candidate", "canon", "retired")
ADOPTION_STATUSES = ("unassessed", "adopted", "variant", "exempt", "owed")
# The two adoption answers that are a departure, and so must say why.
REASONED_STATUSES = ("variant", "exempt")
DECISION_ACTIONS = ("propose", "promote", "revise", "retire", "assess", "undo")
DECIDERS = ("agent", "operator", "system")
class Platform(Base, TimestampMixin, SoftDeleteMixin):
"""What a project is built on or ships as — a runtime, a delivery channel
or a toolchain whose own behaviour causes the problems a family idea
answers.
FLAT, deliberately. A project declares several, and an idea is for
several, so "Android app and container image" needs no hierarchy to
express. Applicability narrower than a platform (an idea that only matters
to a sideloaded APK, not a store-distributed one) belongs in the idea's
`applies_when`, where a project it does not fit answers `exempt` with the
reason. Splitting platforms to carry that would grow the catalog every
time an idea got more specific.
GLOBAL, like `canonical_systems`: no owner, so a shared project inherits
the vocabulary rather than re-earning it. `slug` is the match key and the
form a backup carries, since ids are per-install.
`markers` are glob patterns over a bound repo's paths that suggest a
project is this platform (step 2's detection). A pattern without a slash
matches a file's basename anywhere in the tree; one with a slash matches
the repo-relative path. Empty means declare-only: some platforms leave no
reliable file behind.
"""
__tablename__ = "platforms"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
name: Mapped[str] = mapped_column(Text, nullable=False)
slug: Mapped[str] = mapped_column(Text, nullable=False)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
markers: Mapped[list] = mapped_column(
JSONB, default=list, server_default=text("'[]'::jsonb"),
)
order_index: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
__table_args__ = (
# Unique among LIVE rows, so a retired entry doesn't block recreating
# the same platform (the canonical_systems convention).
Index(
"uq_platforms_slug", "slug",
unique=True, postgresql_where=text("deleted_at IS NULL"),
),
)
def to_dict(self) -> dict:
return {
"id": self.id,
"name": self.name,
"slug": self.slug,
"description": self.description,
"markers": list(self.markers or []),
"order_index": self.order_index,
"created_at": iso(self.created_at),
"updated_at": iso(self.updated_at),
}
class ProjectPlatform(Base, CreatedAtMixin):
"""A project's answer to "are you this platform?".
Three states, because detection is automatic and must not be able to
override a person:
- ``declared`` — someone said so (at inception or in settings).
- ``detected`` — a marker in a bound repo said so.
- ``rejected`` — someone said NO. Kept as a row rather than deleted, so the
next refresh does not detect it straight back. Not membership.
Only `declared` and `detected` make a project a member of the platform's
family.
"""
__tablename__ = "project_platforms"
project_id: Mapped[int] = mapped_column(
Integer, ForeignKey("projects.id", ondelete="CASCADE"), primary_key=True,
)
platform_id: Mapped[int] = mapped_column(
Integer, ForeignKey("platforms.id", ondelete="CASCADE"), primary_key=True,
index=True,
)
# CHECK ck_project_platforms_state (migration 0120).
state: Mapped[str] = mapped_column(Text, default="declared", server_default="declared")
def to_dict(self) -> dict:
return {
"project_id": self.project_id,
"platform_id": self.platform_id,
"state": self.state,
"created_at": iso(self.created_at),
}
class FamilyIdea(Base, TimestampMixin):
"""A note's family state. Its presence is what makes the note a family
idea; the note itself is unchanged.
- ``candidate`` — recorded, not yet proven or not yet stated in platform
terms. Reaches nobody's ledger.
- ``canon`` — promoted. Every project on its platforms owes it an answer.
- ``retired`` — demoted. Kept, with its ledger, so the history reads.
`applies_when` is the applicability test every assessment starts from —
stated in platform terms, which is the first promotion criterion. A CHECK
refuses canon without one, so that criterion is the schema's to enforce
and not a sentence a session might skip.
`canon_version` moves whenever the idea's substance changes. An adoption
assessed against an older version needs rechecking — which is DERIVED by
comparing the two numbers, never stored as a flag that could go stale.
`topic_id` is the rule topic holding the idea's binding norms, if it has
any (the note↔topic link #3236 asked for). The topic takes its scope from
this idea; it never carries platforms of its own.
"""
__tablename__ = "family_ideas"
note_id: Mapped[int] = mapped_column(
Integer, ForeignKey("notes.id", ondelete="CASCADE"), primary_key=True,
)
# CHECK ck_family_ideas_status (migration 0120).
status: Mapped[str] = mapped_column(Text, default="candidate", server_default="candidate")
# CHECK ck_family_ideas_canon_applies (migration 0120).
applies_when: Mapped[str | None] = mapped_column(Text, nullable=True)
canon_version: Mapped[int] = mapped_column(Integer, default=1, server_default="1")
topic_id: Mapped[int | None] = mapped_column(
BigInteger, ForeignKey("rulebook_topics.id", ondelete="SET NULL"), nullable=True,
)
__table_args__ = (
# One idea per topic: a topic's norms belong to one standard.
Index(
"uq_family_ideas_topic", "topic_id",
unique=True, postgresql_where=text("topic_id IS NOT NULL"),
),
Index("ix_family_ideas_status", "status"),
)
def to_dict(self) -> dict:
return {
"note_id": self.note_id,
"status": self.status,
"applies_when": self.applies_when or "",
"canon_version": self.canon_version,
"topic_id": self.topic_id,
"created_at": iso(self.created_at),
"updated_at": iso(self.updated_at),
}
class FamilyIdeaPlatform(Base, CreatedAtMixin):
"""A platform an idea is for. A join table with a model so the backup's
column guard can see it, and so the reverse lookup — every idea for a
platform — has an index."""
__tablename__ = "family_idea_platforms"
note_id: Mapped[int] = mapped_column(
Integer, ForeignKey("family_ideas.note_id", ondelete="CASCADE"), primary_key=True,
)
platform_id: Mapped[int] = mapped_column(
Integer, ForeignKey("platforms.id", ondelete="CASCADE"), primary_key=True,
index=True,
)
class FamilyIdeaReference(Base, CreatedAtMixin):
"""A reference implementation of an idea: a snippet a project can start
from. Explicit rather than inferred, because an owed task names "the
reference for your language", and a guess there sends a project to copy
the wrong thing. The snippet's language is read from the snippet."""
__tablename__ = "family_idea_references"
idea_id: Mapped[int] = mapped_column(
Integer, ForeignKey("family_ideas.note_id", ondelete="CASCADE"), primary_key=True,
)
snippet_id: Mapped[int] = mapped_column(
Integer, ForeignKey("notes.id", ondelete="CASCADE"), primary_key=True,
index=True,
)
class FamilyAdoption(Base, TimestampMixin):
"""One project's answer to one family idea — the adoption ledger.
- ``unassessed`` — the idea reached the project and nobody has judged it.
- ``adopted`` — the project does it.
- ``variant`` — the project departs, for a reason that names a fact about
itself the canon did not account for. A preference is not a reason.
- ``exempt`` — the idea's `applies_when` is false for this project.
- ``owed`` — it applies, there is no reason to depart, and it is not done
yet. `owed_task_id` is the task filed in THIS project to do it.
`reason` is required for variant and exempt (CHECK), because those two are
the departures and the reason is the whole record. `canon_version` is the
idea version this answer was given against; NULL while unassessed.
The row is the CURRENT answer. How it got there — the reasons at each step
and the precedents each followed — is in `family_decisions`.
"""
__tablename__ = "family_adoptions"
id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
project_id: Mapped[int] = mapped_column(
Integer, ForeignKey("projects.id", ondelete="CASCADE"), index=True,
)
idea_id: Mapped[int] = mapped_column(
Integer, ForeignKey("family_ideas.note_id", ondelete="CASCADE"), index=True,
)
# CHECK ck_family_adoptions_status and ck_family_adoptions_reason (0120).
status: Mapped[str] = mapped_column(Text, default="unassessed", server_default="unassessed")
reason: Mapped[str | None] = mapped_column(Text, nullable=True)
canon_version: Mapped[int | None] = mapped_column(Integer, nullable=True)
assessed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
# CHECK ck_family_adoptions_decided_via (0120). NULL while unassessed.
decided_via: Mapped[str | None] = mapped_column(Text, nullable=True)
owed_task_id: Mapped[int | None] = mapped_column(
Integer, ForeignKey("notes.id", ondelete="SET NULL"), nullable=True,
)
__table_args__ = (
Index("uq_family_adoptions_pair", "project_id", "idea_id", unique=True),
)
def to_dict(self) -> dict:
return {
"id": self.id,
"project_id": self.project_id,
"idea_id": self.idea_id,
"status": self.status,
"reason": self.reason or "",
"canon_version": self.canon_version,
"assessed_at": iso(self.assessed_at),
"decided_via": self.decided_via,
"owed_task_id": self.owed_task_id,
"created_at": iso(self.created_at),
"updated_at": iso(self.updated_at),
}
class FamilyDecision(Base, CreatedAtMixin):
"""One decision about family canon — append-only.
`project_id` is NULL for a decision about the idea itself (propose,
promote, revise, retire) and set for a decision about one project's
answer (assess). `undo` reverses an earlier decision and names it in
`evidence`.
`before` and `after` are STATE snapshots — status, version, reason — and
deliberately hold no foreign ids: an id inside JSON cannot be remapped by a
restore, and would come back pointing at whatever took that number (the
#3182 trap). The ids this row needs are columns.
`precedent_ids` are the earlier decisions this one followed. They are what
keeps a call consistent with the last similar one when no person approves
either, so they are a list of ids into this same table, remapped at
restore through the decision map.
"""
__tablename__ = "family_decisions"
id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
idea_id: Mapped[int] = mapped_column(
Integer, ForeignKey("family_ideas.note_id", ondelete="CASCADE"), index=True,
)
project_id: Mapped[int | None] = mapped_column(
Integer, ForeignKey("projects.id", ondelete="CASCADE"), nullable=True, index=True,
)
# CHECK ck_family_decisions_action (0120).
action: Mapped[str] = mapped_column(Text)
reason: Mapped[str] = mapped_column(Text)
before: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
after: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
evidence: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
precedent_ids: Mapped[list] = mapped_column(
JSONB, default=list, server_default=text("'[]'::jsonb"),
)
# CHECK ck_family_decisions_decided_via (0120).
decided_via: Mapped[str] = mapped_column(Text, default="agent", server_default="agent")
# Who was acting — the session's user. The decider KIND is decided_via.
user_id: Mapped[int | None] = mapped_column(
Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True,
)
def to_dict(self) -> dict:
return {
"id": self.id,
"idea_id": self.idea_id,
"project_id": self.project_id,
"action": self.action,
"reason": self.reason,
"before": self.before,
"after": self.after,
"evidence": self.evidence or {},
"precedent_ids": list(self.precedent_ids or []),
"decided_via": self.decided_via,
"user_id": self.user_id,
"created_at": iso(self.created_at),
}
+146
View File
@@ -0,0 +1,146 @@
"""Family canon routes — the web door to the promotion engine (milestone 463).
The agent decides promotions and each project's answers through the MCP
tools; this door exists so a person can READ every decision and its reasons
— the ideas, the adoption matrix, the log — 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
from scribe.services import family_adoption as adoption_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),
"conflict_order": list(adoption_svc.CONFLICT_ORDER),
})
@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("/matrix", methods=["GET"])
@login_required
async def matrix_route():
"""Projects × canon ideas — the adoption matrix. Read-only: the agent
answers through assess_family_adoption; a person reads the answers here
and undoes one from the decision log if they disagree."""
matrix = await adoption_svc.adoption_matrix(
get_current_user_id(),
platform=request.args.get("platform") or None,
project_id=request.args.get("project_id", type=int) or None,
)
return jsonify(matrix)
@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)
project_id = request.args.get("project_id", type=int)
rows = await family_svc.list_decisions(
get_current_user_id(), idea_id=idea_id or None, project_id=project_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)
+96
View File
@@ -0,0 +1,96 @@
"""Platform routes — the GLOBAL platform catalog, and which platforms a
project is (milestone 463 step 2).
Two shapes, as with the canonical areas:
- `/api/platforms` — the catalog. Readable by any signed-in user; writable
only by an admin, since a vocabulary anyone extends stops being shared.
- `/api/projects/<id>/platforms` — a project's answers, authorised by the
PROJECT (read to see them, write to change them). The service enforces
both; these are thin wrappers.
"""
import logging
from quart import Blueprint, jsonify, request
from scribe.auth import admin_required, get_current_user_id, login_required
from scribe.routes.utils import not_found
from scribe.services import platforms as platforms_svc
logger = logging.getLogger(__name__)
platforms_bp = Blueprint("platforms", __name__, url_prefix="/api")
_EDITABLE = ("name", "description", "order_index", "markers")
@platforms_bp.route("/platforms", methods=["GET"])
@login_required
async def list_platforms_route():
entries = await platforms_svc.list_platforms()
return jsonify({"platforms": [e.to_dict() for e in entries]})
@platforms_bp.route("/platforms", methods=["POST"])
@admin_required
async def create_platform_route():
uid = get_current_user_id()
data = await request.get_json() or {}
if not (data.get("name") or "").strip():
return jsonify({"error": "name is required"}), 400
try:
entry = await platforms_svc.create_platform(
uid, data["name"], description=data.get("description"),
markers=data.get("markers"),
)
except ValueError as exc:
return jsonify({"error": str(exc)}), 400
if entry is None:
return jsonify({"error": "Permission denied"}), 403
# Duplicate-gated on the slug: the entry that already is this platform
# comes back as a 409 rather than a second spelling of it.
if isinstance(entry, dict):
return jsonify(entry), 409
return jsonify(entry.to_dict()), 201
@platforms_bp.route("/platforms/<int:platform_id>", methods=["PATCH"])
@admin_required
async def update_platform_route(platform_id: int):
uid = get_current_user_id()
data = await request.get_json() or {}
fields = {k: v for k, v in data.items() if k in _EDITABLE}
try:
entry = await platforms_svc.update_platform(uid, platform_id, **fields)
except ValueError as exc:
return jsonify({"error": str(exc)}), 400
if entry is None:
return not_found("Platform")
return jsonify(entry.to_dict())
@platforms_bp.route("/projects/<int:project_id>/platforms", methods=["GET"])
@login_required
async def project_platforms_route(project_id: int):
"""Every platform the project has an answer for — rejected included, so
a screen can show "no" as well as "yes"."""
rows = await platforms_svc.project_platforms(get_current_user_id(), project_id)
if rows is None:
return not_found("Project")
return jsonify({"project_platforms": rows})
@platforms_bp.route("/projects/<int:project_id>/platforms", methods=["PUT"])
@login_required
async def set_project_platforms_route(project_id: int):
"""Body: {"platforms": {<slug>: "declared" | "rejected" | null}}. Only
the slugs named change; null withdraws the answer. Applies whole or not
at all."""
data = await request.get_json() or {}
try:
rows = await platforms_svc.set_project_platforms(
get_current_user_id(), project_id, data.get("platforms"),
)
except ValueError as exc:
return jsonify({"error": str(exc)}), 400
return jsonify({"project_platforms": rows})
+9
View File
@@ -103,6 +103,15 @@ async def can_admin_project(user_id: int, project_id: int) -> bool:
return perm in ("admin", "owner")
async def is_instance_admin(user_id: int) -> bool:
"""Whether the user administers the INSTANCE (users.role == "admin") —
the gate on the global catalogs (canonical areas, platforms), which belong
to no user and no project, so no share can grant a write to them."""
async with async_session() as session:
role = await session.scalar(select(User.role).where(User.id == user_id))
return role == "admin"
# ---------------------------------------------------------------------------
# Note / task permissions
# ---------------------------------------------------------------------------
+404 -1
View File
@@ -17,6 +17,10 @@ from scribe.models.system_usage import SystemUsageEvent
from scribe.models.moment_mapping import MomentMapping
from scribe.models.retrieval_tuning import RetrievalTuningEvent
from scribe.models.canonical_system import CanonicalSystem
from scribe.models.family import (
FamilyAdoption, FamilyDecision, FamilyIdea, FamilyIdeaPlatform,
FamilyIdeaReference, Platform, ProjectPlatform,
)
from scribe.models.rulebook import (
RuleRelation, rule_moments as rule_moments_t, rule_systems as rule_systems_t,
)
@@ -112,8 +116,14 @@ logger = logging.getLogger(__name__)
# v24 (2026-10) added rule_moment_judgments.misfire (milestone 458 step 7b):
# the reports that a mounted rule arrived where it did not apply, and the
# operator's "keep it" that stops them being proposed as an unmount again.
# v25 (2026-10) added family canon (milestone 463): the platform catalog, each
# project's platforms, family ideas with their platforms and reference
# implementations, the adoption ledger and the decision log. The ledger is
# every project's recorded answer to a shared idea, and the log is the
# precedent each later answer follows. Lose either and every assessment is
# owed again, made fresh, with nothing to keep it consistent with the last.
# Bump when the serialized schema changes.
BACKUP_VERSION = 24
BACKUP_VERSION = 25
# Every table this backup carries, by its REAL name. Paired with _NOT_INCLUDED
# below, these two lists must together account for the entire schema — which is
@@ -164,6 +174,12 @@ _BACKED_UP = [
"rule_moments",
# v23 (2026-10): proposals and judgments about those mounts (step 7).
"rule_moment_judgments",
# v25 (2026-10): family canon (milestone 463). `platforms` is global like
# canonical_systems and rides every export for the same reason: the rows
# below name platforms by slug, and a partial catalog restores partial
# memberships.
"platforms", "project_platforms", "family_ideas", "family_idea_platforms",
"family_idea_references", "family_adoptions", "family_decisions",
]
# Tables intentionally NOT in the backup, surfaced in the payload so the gap is
@@ -298,6 +314,19 @@ _COLUMN_EXCLUSIONS: dict[str, set[str]] = {
},
"code_shape_events": set(),
"code_shape_uses": set(),
# Matched on SLUG at restore, like canonical_systems: a target install
# already seeded the standard platforms from its migrations.
"platforms": {"deleted_at", "deleted_batch_id", "id", "created_at", "updated_at"},
# The platform travels as `platform_slug` — ids are per-install.
"project_platforms": {"platform_id"},
"family_idea_platforms": {"platform_id"},
# Every column travels: the note and topic ids are SOURCE ids, remapped.
"family_ideas": set(),
"family_idea_references": set(),
"family_adoptions": {"id"},
# The id travels, unlike the other surrogate keys: precedent_ids point at
# it, so the restore needs the source id to build the decision map.
"family_decisions": set(),
}
@@ -381,6 +410,15 @@ _IMPORT_COLUMN_EXCLUSIONS: dict[str, set[str]] = {
},
"code_shape_events": {"id"},
"code_shape_uses": {"id"},
"platforms": {"id", "deleted_at", "deleted_batch_id", "created_at", "updated_at"},
# `platform_id` IS set, from the exported slug.
"project_platforms": set(),
"family_idea_platforms": set(),
"family_ideas": set(),
"family_idea_references": set(),
"family_adoptions": {"id"},
# Reason 2: re-issued. The source id only builds the precedent map.
"family_decisions": {"id"},
}
@@ -829,6 +867,99 @@ def _lesson_no_rule_rows(rows) -> list[dict]:
]
def _platform_rows(rows) -> list[dict]:
"""The global platform catalog (v25). Carried WITHOUT ids, matched on slug
at restore — the canonical_systems reasoning."""
return [
{
"name": r.name, "slug": r.slug, "description": r.description,
"markers": list(r.markers or []), "order_index": r.order_index,
}
for r in rows
]
def _project_platform_rows(rows, platform_slugs: dict[int, str]) -> list[dict]:
"""A project's platforms, by SLUG. A `rejected` row travels too: it is
someone's "no", and without it the first refresh detects it straight back."""
return [
{
"project_id": r.project_id,
"platform_slug": platform_slugs.get(r.platform_id or 0),
"state": r.state,
"created_at": r.created_at.isoformat() if r.created_at else None,
}
for r in rows
]
def _family_idea_rows(rows) -> list[dict]:
"""A note's family state (v25). note_id and topic_id are SOURCE ids."""
return [
{
"note_id": r.note_id, "status": r.status,
"applies_when": r.applies_when, "canon_version": r.canon_version,
"topic_id": r.topic_id,
"created_at": r.created_at.isoformat() if r.created_at else None,
"updated_at": r.updated_at.isoformat() if r.updated_at else None,
}
for r in rows
]
def _family_idea_platform_rows(rows, platform_slugs: dict[int, str]) -> list[dict]:
return [
{
"note_id": r.note_id,
"platform_slug": platform_slugs.get(r.platform_id or 0),
"created_at": r.created_at.isoformat() if r.created_at else None,
}
for r in rows
]
def _family_idea_reference_rows(rows) -> list[dict]:
return [
{
"idea_id": r.idea_id, "snippet_id": r.snippet_id,
"created_at": r.created_at.isoformat() if r.created_at else None,
}
for r in rows
]
def _family_adoption_rows(rows) -> list[dict]:
"""The adoption ledger (v25). Project, idea and owed task are SOURCE ids."""
return [
{
"project_id": r.project_id, "idea_id": r.idea_id,
"status": r.status, "reason": r.reason,
"canon_version": r.canon_version,
"assessed_at": r.assessed_at.isoformat() if r.assessed_at else None,
"decided_via": r.decided_via, "owed_task_id": r.owed_task_id,
"created_at": r.created_at.isoformat() if r.created_at else None,
"updated_at": r.updated_at.isoformat() if r.updated_at else None,
}
for r in rows
]
def _family_decision_rows(rows) -> list[dict]:
"""The decision log (v25), oldest first. The source `id` travels because
`precedent_ids` point at it; the restore rebuilds that map as it goes."""
return [
{
"id": r.id, "idea_id": r.idea_id, "project_id": r.project_id,
"action": r.action, "reason": r.reason,
"before": r.before, "after": r.after, "evidence": r.evidence,
"precedent_ids": list(r.precedent_ids or []),
"decided_via": r.decided_via, "user_id": r.user_id,
"created_at": r.created_at.isoformat() if r.created_at else None,
}
for r in rows
]
def _rule_rows(rows) -> list[dict]:
return [
{
@@ -923,6 +1054,24 @@ async def export_full_backup() -> dict:
rulebooks = (await session.execute(select(Rulebook))).scalars().all()
topics = (await session.execute(select(RulebookTopic))).scalars().all()
rules = (await session.execute(select(Rule))).scalars().all()
platforms = (await session.execute(
select(Platform).where(Platform.deleted_at.is_(None))
.order_by(Platform.order_index)
)).scalars().all()
project_platforms = (await session.execute(select(ProjectPlatform))).scalars().all()
family_ideas = (await session.execute(select(FamilyIdea))).scalars().all()
family_idea_platforms = (await session.execute(
select(FamilyIdeaPlatform)
)).scalars().all()
family_idea_references = (await session.execute(
select(FamilyIdeaReference)
)).scalars().all()
family_adoptions = (await session.execute(select(FamilyAdoption))).scalars().all()
# Oldest first: a precedent is always an earlier decision, so a restore
# in this order has every precedent mapped before anything cites it.
family_decisions = (await session.execute(
select(FamilyDecision).order_by(FamilyDecision.id)
)).scalars().all()
return {
"version": BACKUP_VERSION,
@@ -970,6 +1119,28 @@ async def export_full_backup() -> dict:
"code_shapes": _code_shape_rows(code_shapes),
"code_shape_events": _code_shape_event_rows(code_shape_events),
"code_shape_uses": _code_shape_use_rows(code_shape_uses),
**_family_sections(
platforms, project_platforms, family_ideas, family_idea_platforms,
family_idea_references, family_adoptions, family_decisions,
),
}
def _family_sections(
platforms, project_platforms, ideas, idea_platforms, references,
adoptions, decisions,
) -> dict:
"""The v25 payload sections, shared by both export scopes so the two
cannot serialise family canon differently."""
slugs = {p.id: p.slug for p in platforms}
return {
"platforms": _platform_rows(platforms),
"project_platforms": _project_platform_rows(project_platforms, slugs),
"family_ideas": _family_idea_rows(ideas),
"family_idea_platforms": _family_idea_platform_rows(idea_platforms, slugs),
"family_idea_references": _family_idea_reference_rows(references),
"family_adoptions": _family_adoption_rows(adoptions),
"family_decisions": _family_decision_rows(decisions),
}
@@ -1145,6 +1316,47 @@ async def export_user_backup(user_id: int) -> dict:
lesson_no_rule = (await session.execute(
select(LessonNoRule).where(LessonNoRule.lesson_id.in_(note_ids))
)).scalars().all() if note_ids else []
# Family canon (v25). The catalog is global and taken whole, for the
# canonical_systems reason. Everything else is scoped so that BOTH
# ends of every row restore: an idea is this user's note, a ledger row
# needs this user's project AND idea, a reference needs both notes.
platforms = (await session.execute(
select(Platform).where(Platform.deleted_at.is_(None))
.order_by(Platform.order_index)
)).scalars().all()
project_platforms = (await session.execute(
select(ProjectPlatform).where(ProjectPlatform.project_id.in_(project_ids))
)).scalars().all() if project_ids else []
family_ideas = (await session.execute(
select(FamilyIdea).where(FamilyIdea.note_id.in_(note_ids))
)).scalars().all() if note_ids else []
idea_ids = [i.note_id for i in family_ideas]
family_idea_platforms = (await session.execute(
select(FamilyIdeaPlatform).where(FamilyIdeaPlatform.note_id.in_(idea_ids))
)).scalars().all() if idea_ids else []
family_idea_references = (await session.execute(
select(FamilyIdeaReference).where(
FamilyIdeaReference.idea_id.in_(idea_ids),
FamilyIdeaReference.snippet_id.in_(note_ids),
)
)).scalars().all() if idea_ids else []
family_adoptions = (await session.execute(
select(FamilyAdoption).where(
FamilyAdoption.idea_id.in_(idea_ids),
FamilyAdoption.project_id.in_(project_ids),
)
)).scalars().all() if (idea_ids and project_ids) else []
# A decision about the idea itself has no project; one about a ledger
# row needs that row's project in this export too.
family_decisions = (await session.execute(
select(FamilyDecision).where(
FamilyDecision.idea_id.in_(idea_ids),
or_(
FamilyDecision.project_id.is_(None),
FamilyDecision.project_id.in_(project_ids or [0]),
),
).order_by(FamilyDecision.id)
)).scalars().all() if idea_ids else []
return {
"version": BACKUP_VERSION,
@@ -1194,6 +1406,10 @@ async def export_user_backup(user_id: int) -> dict:
"code_shapes": _code_shape_rows(code_shapes),
"code_shape_events": _code_shape_event_rows(code_shape_events),
"code_shape_uses": _code_shape_use_rows(code_shape_uses),
**_family_sections(
platforms, project_platforms, family_ideas, family_idea_platforms,
family_idea_references, family_adoptions, family_decisions,
),
}
@@ -1240,6 +1456,7 @@ class _Maps:
__slots__ = (
"users", "projects", "milestones", "notes", "rulebooks", "topics",
"rules", "systems", "design_systems", "shapes", "canonical_by_slug",
"platform_by_slug", "decisions",
)
def __init__(self) -> None:
@@ -1254,6 +1471,8 @@ class _Maps:
self.design_systems: dict[int, int] = {}
self.shapes: dict[int, int] = {}
self.canonical_by_slug: dict[str, int] = {}
self.platform_by_slug: dict[str, int] = {}
self.decisions: dict[int, int] = {}
def _build_user(row: dict, maps: _Maps) -> User:
@@ -1592,6 +1811,127 @@ def _build_rule_moment_judgment(row: dict, maps: _Maps) -> RuleMomentJudgment |
)
def _build_platform(row: dict, maps: _Maps) -> Platform | None:
"""Matched on SLUG, like a canonical system: this install seeded the
standard platforms from its migrations, so the common case creates nothing
and only an entry added on the source instance is built."""
slug = row.get("slug") or ""
if not slug or slug in maps.platform_by_slug:
return None
return Platform(
name=row.get("name", ""),
slug=slug,
description=row.get("description"),
markers=list(row.get("markers") or []),
order_index=row.get("order_index", 0),
)
def _build_project_platform(row: dict, maps: _Maps) -> ProjectPlatform | None:
"""Skipped unless both the project and the platform resolve."""
project = maps.projects.get(row.get("project_id", 0))
platform = maps.platform_by_slug.get(row.get("platform_slug") or "")
if project is None or platform is None:
return None
return ProjectPlatform(
project_id=project,
platform_id=platform,
state=row.get("state") or "declared",
created_at=_dt(row.get("created_at")),
)
def _build_family_idea(row: dict, maps: _Maps) -> FamilyIdea | None:
"""Skipped when its note did not restore — the state is that note's. The
topic DEGRADES to None: a standard whose rule topic did not come across is
still a standard, only without its binding half."""
note = maps.notes.get(row.get("note_id", 0))
if note is None:
return None
return FamilyIdea(
note_id=note,
status=row.get("status") or "candidate",
applies_when=row.get("applies_when"),
canon_version=row.get("canon_version") or 1,
topic_id=maps.topics.get(row.get("topic_id") or 0),
created_at=_dt(row.get("created_at")),
updated_at=_dt(row.get("updated_at")),
)
def _build_family_idea_platform(row: dict, maps: _Maps) -> FamilyIdeaPlatform | None:
note = maps.notes.get(row.get("note_id", 0))
platform = maps.platform_by_slug.get(row.get("platform_slug") or "")
if note is None or platform is None:
return None
return FamilyIdeaPlatform(
note_id=note, platform_id=platform, created_at=_dt(row.get("created_at")),
)
def _build_family_idea_reference(row: dict, maps: _Maps) -> FamilyIdeaReference | None:
idea = maps.notes.get(row.get("idea_id", 0))
snippet = maps.notes.get(row.get("snippet_id", 0))
if idea is None or snippet is None:
return None
return FamilyIdeaReference(
idea_id=idea, snippet_id=snippet, created_at=_dt(row.get("created_at")),
)
def _build_family_adoption(row: dict, maps: _Maps) -> FamilyAdoption | None:
"""Both the project and the idea must map — the row IS that pair. The owed
task degrades to None: the answer still stands without its task."""
project = maps.projects.get(row.get("project_id", 0))
idea = maps.notes.get(row.get("idea_id", 0))
if project is None or idea is None:
return None
return FamilyAdoption(
project_id=project,
idea_id=idea,
status=row.get("status") or "unassessed",
reason=row.get("reason"),
canon_version=row.get("canon_version"),
assessed_at=_dt_or_none(row.get("assessed_at")),
decided_via=row.get("decided_via"),
owed_task_id=maps.notes.get(row.get("owed_task_id") or 0),
created_at=_dt(row.get("created_at")),
updated_at=_dt(row.get("updated_at")),
)
def _build_family_decision(row: dict, maps: _Maps) -> FamilyDecision | None:
"""Skipped when its idea did not restore, or when it is about a project
that did not — an assessment without its project says nothing. The acting
user degrades to None. Precedents are remapped through the decision map
and a precedent that did not restore is dropped from the list rather than
left pointing at whatever took its number."""
idea = maps.notes.get(row.get("idea_id", 0))
if idea is None:
return None
project = None
if row.get("project_id") is not None:
project = maps.projects.get(row["project_id"])
if project is None:
return None
return FamilyDecision(
idea_id=idea,
project_id=project,
action=row.get("action") or "assess",
reason=row.get("reason") or "",
before=row.get("before"),
after=row.get("after"),
evidence=row.get("evidence"),
precedent_ids=[
maps.decisions[p] for p in (row.get("precedent_ids") or [])
if p in maps.decisions
],
decided_via=row.get("decided_via") or "agent",
user_id=maps.users.get(row.get("user_id") or 0),
created_at=_dt(row.get("created_at")),
)
def _build_rule_version(row: dict, maps: _Maps) -> RuleVersion | None:
rid = maps.rules.get(row.get("rule_id", 0))
if rid is None:
@@ -2010,6 +2350,9 @@ async def _restore_v2(data: dict) -> dict:
"rule_relations": 0, "rule_versions": 0,
"retrieval_tuning_events": 0, "lesson_rule_links": 0,
"lesson_no_rule": 0, "moment_mappings": 0,
"platforms": 0, "project_platforms": 0, "family_ideas": 0,
"family_idea_platforms": 0, "family_idea_references": 0,
"family_adoptions": 0, "family_decisions": 0,
}
async with async_session() as session:
@@ -2250,6 +2593,66 @@ async def _restore_v2(data: dict) -> dict:
session.add(answer)
stats["lesson_no_rule"] += 1
# Family canon (v25). Here because it needs projects, notes and topics
# all mapped. The platform catalog first, matched on slug and seeded
# from what this install already has — the 14c shape.
existing_platforms = (await session.execute(
select(Platform).where(Platform.deleted_at.is_(None))
)).scalars().all()
for platform in existing_platforms:
maps.platform_by_slug[platform.slug] = platform.id
for p_data in data.get("platforms", []):
platform = _build_platform(p_data, maps)
if platform is None:
continue
session.add(platform)
await session.flush()
maps.platform_by_slug[platform.slug] = platform.id
stats["platforms"] += 1
for pp in data.get("project_platforms", []):
membership = _build_project_platform(pp, maps)
if membership is None:
continue
session.add(membership)
stats["project_platforms"] += 1
for fi in data.get("family_ideas", []):
idea = _build_family_idea(fi, maps)
if idea is None:
continue
session.add(idea)
stats["family_ideas"] += 1
# The ideas must exist before anything foreign-keys them.
await session.flush()
for fp in data.get("family_idea_platforms", []):
scope = _build_family_idea_platform(fp, maps)
if scope is None:
continue
session.add(scope)
stats["family_idea_platforms"] += 1
for fr in data.get("family_idea_references", []):
ref = _build_family_idea_reference(fr, maps)
if ref is None:
continue
session.add(ref)
stats["family_idea_references"] += 1
for fa in data.get("family_adoptions", []):
adoption = _build_family_adoption(fa, maps)
if adoption is None:
continue
session.add(adoption)
stats["family_adoptions"] += 1
# Oldest first, flushed one at a time: each decision's new id goes into
# the map before a later decision can name it as a precedent.
for fd in sorted(data.get("family_decisions", []), key=lambda r: r.get("id") or 0):
decision = _build_family_decision(fd, maps)
if decision is None:
continue
session.add(decision)
await session.flush()
if fd.get("id"):
maps.decisions[int(fd["id"])] = decision.id
stats["family_decisions"] += 1
# A rule's edit history (milestone 323). Must come after the rules
# themselves — the rule map is only populated above — and both ids are
# ids in the SOURCE database, which is #3182's arose_from_id trap.
+1 -4
View File
@@ -30,7 +30,6 @@ from sqlalchemy import select
from scribe.models import async_session
from scribe.models.canonical_system import CanonicalSystem
from scribe.models.system import System
from scribe.models.user import User
from scribe.services import access
logger = logging.getLogger(__name__)
@@ -60,9 +59,7 @@ def _tokens(slug: str) -> frozenset[str]:
async def _is_admin(user_id: int) -> bool:
async with async_session() as session:
role = await session.scalar(select(User.role).where(User.id == user_id))
return role == "admin"
return await access.is_instance_admin(user_id)
async def list_canonical_systems() -> list[CanonicalSystem]:
+32 -1
View File
@@ -660,6 +660,11 @@ class ArchiveScan(NamedTuple):
definitions: list[ArchiveShape]
references: dict[str, dict[str, int]] # path → class token → count
# EVERY file's repo-relative path, scannable or not (milestone 463). The
# platform markers are files this scan otherwise skips — go.mod,
# AndroidManifest.xml, a Dockerfile — so detection reads the names here
# rather than re-walking the tarball.
paths: tuple[str, ...] = ()
def definitions_from_archive(blob: bytes) -> list[ArchiveShape]:
@@ -678,11 +683,14 @@ def scan_archive(blob: bytes) -> ArchiveScan:
"""
shapes: list[ArchiveShape] = []
references: dict[str, dict[str, int]] = {}
paths: list[str] = []
with tarfile.open(fileobj=io.BytesIO(blob), mode="r:gz") as tar:
for member in tar:
if not member.isfile() or "/" not in member.name:
continue
path = member.name.split("/", 1)[1]
if path:
paths.append(path)
if not path or not scannable(path) or member.size > _MAX_FILE_BYTES:
continue
handle = tar.extractfile(member)
@@ -708,7 +716,7 @@ def scan_archive(blob: bytes) -> ArchiveScan:
)
if refs:
references[path] = refs
return ArchiveScan(shapes, references)
return ArchiveScan(shapes, references, tuple(paths))
# --- matching shapes against recorded locations ------------------------------
@@ -806,6 +814,8 @@ async def compute_coverage(
return None
served: list[tuple[str, str]] = []
# Every file path across the project's repos, for platform detection.
tree_paths: list[str] = []
recorded = await _recorded_locations(user_id, project_id)
# The proposer's canon catalog, read once per refresh and shared across
# the project's repos (#2792).
@@ -822,6 +832,7 @@ async def compute_coverage(
ref = binding.ref or await forge.default_branch(api_repo)
scan = scan_archive(await forge.archive(api_repo, ref))
definitions = scan.definitions
tree_paths.extend(scan.paths)
# The head commit is provenance sugar on the ledger rows; failing to
# learn it must not fail the sync — the ref names the point well
# enough and the row timestamps carry the when.
@@ -857,7 +868,27 @@ async def compute_coverage(
if not served:
return None
# Platform detection (milestone 463) rides the same walk: the paths are in
# hand once. It only ever ADDS membership the project has no answer for,
# and it must not be able to fail the refresh it rides on.
try:
from scribe.services import platforms as platforms_svc
await platforms_svc.detect_for_project(project_id, tree_paths)
except Exception:
logger.warning("platform detection failed for project %s", project_id, exc_info=True)
await shape_ledger.mark_canonicals(project_id, recorded)
# The adoption ledger follows the shapes (milestone 463 step 5). The
# judge paths sync the ideas they touch; the refresh is the one place
# that sees canonical stamps land and shapes vanish, so it answers every
# canon idea reaching the project. It must not fail the refresh.
try:
from scribe.services import family_adoption
await family_adoption.sync_from_shapes(user_id, project_id)
except Exception:
logger.warning("family adoption sync failed for project %s", project_id, exc_info=True)
try:
await shape_ledger.apply_derive_groups(project_id)
except Exception:
+16
View File
@@ -1047,6 +1047,22 @@ async def semantic_search_notes(
in_project = or_(
in_project, Note.note_type.in_(GLOBAL_NOTE_TYPES)
)
# Family canon (milestone 463 step 6) crosses the project
# line the same way a lesson does, but only to a project
# on one of the idea's platforms: the canon ideas reaching
# it, and their references in its languages. Membership
# is decided here, readability by the visibility clause
# above. Read on its own session, so a failure narrows
# the search and cannot abort this one's transaction.
try:
from scribe.services.family_adoption import family_reach_ids
reach = await family_reach_ids(project_id)
except Exception:
logger.debug("family reach unavailable", exc_info=True)
reach = set()
if reach:
in_project = or_(in_project, Note.id.in_(sorted(reach)))
stmt = stmt.where(in_project)
# Narrow to records tagged to one System (subsystem/area). An
# association filter, not a ranking signal — membership in the
+976
View File
@@ -0,0 +1,976 @@
"""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, project_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; a project's
assessment also carries the project's title."""
async with async_session() as session:
query = (
select(FamilyDecision, Note.title, Project.title)
.join(Note, Note.id == FamilyDecision.idea_id)
.outerjoin(Project, Project.id == FamilyDecision.project_id)
.where(access.readable_notes_clause(user_id))
)
if idea_id:
query = query.where(FamilyDecision.idea_id == idea_id)
if project_id:
query = query.where(FamilyDecision.project_id == project_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 if d.project_id is None})
latest_pair = await _latest_pair_decisions(
session, {(d.idea_id, d.project_id) for d, _, _ in rows if d.project_id is not None})
out = []
for d, title, project_title in rows:
item = _decision_dict(d, title)
if d.project_id is None:
item["undoable"] = latest.get(d.idea_id) == d.id and _can_undo(d)
else:
item["project_title"] = project_title
item["undoable"] = latest_pair.get((d.idea_id, d.project_id)) == d.id and _can_undo(d)
out.append(item)
return out
def _can_undo(d: FamilyDecision) -> bool:
"""A decision that changed something: an idea-level one, or one
project's assessment. A veto (a `propose` whose before and after agree)
changed nothing, and an undo is undone by deciding again, not by undoing
the undo."""
if d.before == d.after:
return False
if d.project_id is None:
return d.action in _IDEA_ACTIONS
return d.action == "assess"
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_pair_decisions(session, pairs: set[tuple[int, int]]) -> dict[tuple[int, int], int]:
"""The latest decision about each (idea, project) answer — the only one of
a project's decisions on an idea that an undo may reverse."""
if not pairs:
return {}
rows = await session.execute(
select(FamilyDecision.idea_id, FamilyDecision.project_id, func.max(FamilyDecision.id))
.where(
FamilyDecision.idea_id.in_({i for i, _ in pairs}),
FamilyDecision.project_id.in_({p for _, p in pairs}),
)
.group_by(FamilyDecision.idea_id, FamilyDecision.project_id)
)
return {(i, p): d for i, p, d in rows.all() if (i, p) in pairs}
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 nearest_ideas(user_id: int, note_id: int, limit: int = 5) -> list[tuple[float, Note]]:
"""The family ideas nearest this record by meaning, best first, as
(score, note). Empty when there are no other ideas or the embedder is
unavailable — precedent is a help to a decision, never a gate on it."""
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 []
return [(score, n) for score, n in hits if n.id in idea_ids][:limit]
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."""
ranked = await nearest_ideas(user_id, note_id, 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.
if await _promoted_before(session, note_id):
idea.canon_version = await next_version(session, idea)
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 _promoted_before(session, note_id: int) -> bool:
return bool(await session.scalar(
select(func.count()).select_from(FamilyDecision)
.where(FamilyDecision.idea_id == note_id, FamilyDecision.action == "promote")
))
async def next_version(session, idea: FamilyIdea) -> int:
"""One past every canon version this idea has ever held — including one
an undo rolled back from — so no answer given against an earlier canon
can read as agreeing with the new one. Only idea-level snapshots carry an
idea version; an assessment's `after` holds the version it was judged
against, which is never ahead of the idea's."""
rows = (await session.execute(
select(FamilyDecision.after).where(
FamilyDecision.idea_id == idea.note_id, FamilyDecision.project_id.is_(None),
)
)).scalars().all()
seen = [idea.canon_version or 1] + [int(a.get("canon_version") or 1) for a in rows if a]
return max(seen) + 1
async def _close_unreached(session, note_id: int) -> int:
"""Delete the `unassessed` rows of projects no longer on any of the
idea's platforms — nobody judged them, and the idea no longer reaches
them. Judged rows stay, as history."""
members = (
select(ProjectPlatform.project_id)
.join(FamilyIdeaPlatform, FamilyIdeaPlatform.platform_id == ProjectPlatform.platform_id)
.where(FamilyIdeaPlatform.note_id == note_id, ProjectPlatform.state.in_(MEMBER_STATES))
)
result = await session.execute(
delete(FamilyAdoption).where(
FamilyAdoption.idea_id == note_id, FamilyAdoption.status == "unassessed",
FamilyAdoption.project_id.not_in(members),
)
)
return result.rowcount or 0
async def revise(
user_id: int, note_id: int, *, reason: str, applies_when: str | None = None,
platforms: list[str] | None = None, evidence: list[str] | None = None,
decided_via: str = "agent",
) -> dict:
"""Record that a canon idea's SUBSTANCE changed — its note was rewritten,
its applicability narrowed or widened, or its platforms changed.
The version moves, so every project's answer given against the earlier
version reads as needing a recheck (derived, never a stored flag). A
project newly in scope gets an `unassessed` row; an `unassessed` row of a
project no longer in scope is closed. Undoable like any idea-level
decision.
`applies_when` and `platforms` are left alone when None; given, they
replace the old ones and may not be empty (canon is always scoped).
"""
if not (reason or "").strip():
raise ValueError("a revision needs a reason — what changed in the idea?")
if not await access.can_write_note(user_id, note_id):
raise ValueError(f"note {note_id} not found or no write access")
if applies_when is not None and not applies_when.strip():
raise ValueError("canon needs an 'applies when' — leave it out to keep the current one")
if platforms is not None and not [p for p in platforms if (p or "").strip()]:
raise ValueError("canon needs at least one platform — leave it out to keep the current ones")
evidence = [str(e).strip() for e in evidence or [] if str(e).strip()]
async with async_session() as session:
idea = await session.get(FamilyIdea, note_id)
if idea is None or idea.status != "canon":
raise ValueError(f"#{note_id} is not family canon — only canon is revised")
known = await _resolve_platforms(session, platforms) if platforms is not None else None
before = await _snapshot(session, idea)
idea.canon_version = await next_version(session, idea)
if applies_when is not None:
idea.applies_when = applies_when.strip()
if known is not None:
await _set_platforms(session, note_id, known.values())
idea.updated_at = _now()
await session.flush()
opened = await _open_ledger(session, user_id, note_id)
closed = await _close_unreached(session, note_id)
decision = _log(
session, idea_id=note_id, action="revise", reason=reason, before=before,
after=await _snapshot(session, idea),
evidence={"evidence": evidence, "ledger_rows_opened": opened,
"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(),
"ledger_rows_opened": opened, "ledger_rows_closed": closed}
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 a decision, restoring the state it recorded as `before`. A
project's assessment is handed to the adoption ledger
(family_adoption.undo_assessment); the rest of this is idea-level.
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 target.project_id is not None:
# One project's answer: the adoption ledger restores the row.
from scribe.services import family_adoption
return await family_adoption.undo_assessment(
user_id, target, reason=reason, decided_via=decided_via)
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)
# No recorded prior state means the decision CREATED the idea: before
# it, the record had no applicability test and no scope. It comes back
# as retired, with neither, rather than being deleted with its log.
prior = target.before or {
"status": "retired", "applies_when": "",
"canon_version": idea.canon_version, "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)
File diff suppressed because it is too large Load Diff
+84 -12
View File
@@ -8,7 +8,8 @@ A project's inheritance is a decision, not a default. The record lives on
"via": "mcp" | "ui" | "legacy",
"choices": {
"design_system_id": <id> | null,
"seed_systems": bool
"seed_systems": bool,
"platforms": [<slug>, ...] | null
}
}
@@ -29,6 +30,15 @@ applies the effects (each idempotent), and writes the record LAST, so a
half-applied decision is re-runnable rather than recorded as done.
``current_defaults`` is what the enter_project ask shows: what binds today
if nobody decides.
``platforms`` (milestone 463) is which platforms the project IS — the answer
that decides which family ideas reach it. A list of catalog SLUGS, not ids:
the record is JSON, and a slug survives a backup restore onto an install whose
ids differ, where an id inside JSON would come back naming another platform.
NULL means the question was not answered here, and the project's memberships
are left exactly as they are; a list is the full answer — every platform in it
is declared, and any platform detection had added that is NOT in it is
recorded as a "no", so the next refresh cannot put it back.
"""
from __future__ import annotations
@@ -38,7 +48,7 @@ from scribe.models import async_session
from scribe.models.project import Project
INCEPTION_VIAS = ("mcp", "ui", "legacy")
CHOICE_KEYS = ("design_system_id", "seed_systems")
CHOICE_KEYS = ("design_system_id", "seed_systems", "platforms")
def validate_inception(choices) -> str | None:
@@ -46,8 +56,9 @@ def validate_inception(choices) -> str | None:
None. Pure and checked BEFORE any effect is applied: a decision either
applies whole or errors whole (the StrictArgs lesson, #2709).
Accepts two keys, each optional: ``design_system_id`` an int or None,
``seed_systems`` a bool. Unknown keys are an error — a typo, or a choice
Accepts three keys, each optional: ``design_system_id`` an int or None,
``seed_systems`` a bool, ``platforms`` a list of slugs or None. Unknown
keys are an error — a typo, or a choice
the product no longer offers, must not become a silently ignored one."""
if not isinstance(choices, dict):
return "choices must be an object"
@@ -60,16 +71,26 @@ def validate_inception(choices) -> str | None:
seed = choices.get("seed_systems", False)
if not isinstance(seed, bool):
return "seed_systems must be true or false"
platforms = choices.get("platforms")
if platforms is not None and (
not isinstance(platforms, list)
or not all(isinstance(p, str) and p.strip() for p in platforms)
):
return "platforms must be a list of platform slugs (list_platforms), or null"
return None
def normalize_choices(choices: dict | None) -> dict:
"""Both keys, always present, in canonical form — what gets stored
"""Every key, always present, in canonical form — what gets stored
and what the UI/agent reads back. Call after validate_inception."""
choices = choices or {}
platforms = choices.get("platforms")
return {
"design_system_id": choices.get("design_system_id"),
"seed_systems": bool(choices.get("seed_systems", False)),
"platforms": (
sorted({p.strip() for p in platforms}) if platforms is not None else None
),
}
@@ -81,11 +102,15 @@ def is_decided(project) -> bool:
async def current_defaults(user_id: int, project_id: int) -> dict:
"""What the project inherits if nobody decides — the ask's payload.
{design_system_id, design_systems: [{id,title}], systems: <count>}.
{design_system_id, design_systems: [{id,title}], systems: <count>,
platforms: [{slug,name}], project_platforms: [{slug,name,state}]}.
Instance-agnostic: an install with no design systems shows an empty list,
and the ask says so rather than inventing a default.
and the ask says so rather than inventing a default. `project_platforms`
is what detection has already found (and anything already answered), so
the form can start from it.
"""
from scribe.services import design_systems as design_systems_svc
from scribe.services import platforms as platforms_svc
from scribe.services import projects as projects_svc
from scribe.services import systems as systems_svc
@@ -94,10 +119,13 @@ async def current_defaults(user_id: int, project_id: int) -> dict:
raise ValueError(f"project {project_id} not found")
designs = await design_systems_svc.list_design_systems(user_id)
systems = await systems_svc.list_systems(user_id, project_id, include_archived=True)
catalog = await platforms_svc.list_platforms()
return {
"design_system_id": project.design_system_id,
"design_systems": [{"id": d.id, "title": d.title} for d in designs],
"systems": len(systems),
"platforms": [{"slug": p.slug, "name": p.name} for p in catalog],
"project_platforms": await platforms_svc.project_platforms(user_id, project_id) or [],
}
@@ -106,9 +134,15 @@ async def _check_targets(user_id: int, choices: dict) -> None:
effect lands — a decision applies whole or errors whole."""
from scribe.services import access
from scribe.services import platforms as platforms_svc
ds = choices["design_system_id"]
if ds is not None and not await access.can_read_design_system(user_id, ds):
raise ValueError(f"design system {ds} not found (or not readable)")
if choices["platforms"]:
_, unknown = await platforms_svc.resolve_slugs(choices["platforms"])
if unknown:
raise ValueError(f"unknown platform(s): {', '.join(unknown)} (list_platforms)")
async def decide(
@@ -127,7 +161,8 @@ async def decide(
replaces the design system and re-seeds nothing a project already has.
Returns {"inception": <record>, "effects": {design_system_id,
systems_seeded}}.
systems_seeded, platforms}} — `platforms` is the project's answers after
the decision, or None when the choice was left unstated.
"""
from scribe.services import design_systems as design_systems_svc
from scribe.services import projects as projects_svc
@@ -152,6 +187,9 @@ async def decide(
await systems_svc.seed_standard_systems(user_id, project_id)
if choices["seed_systems"] else []
)
platforms = None
if choices["platforms"] is not None:
platforms = await _apply_platforms(user_id, project_id, choices["platforms"])
record = {
"decided_at": datetime.now(timezone.utc).isoformat(),
@@ -169,10 +207,41 @@ async def decide(
"effects": {
"design_system_id": choices["design_system_id"],
"systems_seeded": [sy.name for sy in seeded],
"platforms": platforms,
},
}
async def _apply_platforms(user_id: int, project_id: int, slugs: list[str]) -> list[dict]:
"""The platforms answer as membership. The list is the WHOLE answer:
every slug in it is declared, and every platform the project was a member
of (declared or detected) that is left out becomes rejected — so the next
refresh cannot detect back what the person just said the project isn't.
Platforms already rejected stay rejected; platforms never answered stay
unanswered."""
from scribe.services import platforms as platforms_svc
named = set(slugs)
current = await platforms_svc.project_platforms(user_id, project_id) or []
updates: dict[str, str | None] = {slug: "declared" for slug in named}
for row in current:
if row["slug"] not in named and row["state"] in platforms_svc.MEMBER_STATES:
updates[row["slug"]] = "rejected"
return await platforms_svc.set_project_platforms(user_id, project_id, updates)
def _platform_line(defaults: dict) -> str:
"""What the ask says about platforms: what detection already found, and
the catalog to choose from."""
found = [
p["slug"] for p in defaults.get("project_platforms", [])
if p["state"] in ("declared", "detected")
]
catalog = ", ".join(p["slug"] for p in defaults.get("platforms", [])) or "none"
lead = f"detected so far: {', '.join(found)}; " if found else ""
return f"{lead}catalog: {catalog}"
async def inception_ask(user_id: int, project_id: int) -> dict:
"""The enter_project ask for an undecided project (milestone 297) — the
sibling of the systems-bootstrap ask (#2683): the project's OWN current
@@ -190,13 +259,16 @@ async def inception_ask(user_id: int, project_id: int) -> dict:
"inherits. Design system — "
f"{'#' + str(defaults['design_system_id']) if defaults['design_system_id'] else 'none'} "
f"(available: {designs}); Systems — {defaults['systems']}. Ask the operator, "
"once: which design system (or none), and whether to seed "
"the standard starter Systems — then record the answers. This ask repeats on "
"every enter_project until a decision is recorded."
"once: which design system (or none), whether to seed "
"the standard starter Systems, and which platforms the project is "
f"built on or ships as ({_platform_line(defaults)}) — then record the "
"answers. This ask repeats on every enter_project until a decision "
"is recorded."
),
"call": (
f"decide_project_inception(project_id={project_id}, "
"design_system_id=<id | -1 for none>, seed_systems=<true|false>)"
"design_system_id=<id | -1 for none>, seed_systems=<true|false>, "
"platforms=[<slug>, ...])"
),
}
+1
View File
@@ -80,6 +80,7 @@ BUNDLED_SKILL_MOMENTS: dict[str, tuple[str, ...]] = {
"verification": ("work.verify",),
"shape-accounting": ("work.record",),
"reporting-back": ("reply.report",),
"family-canon": ("work.record",),
}
# A stored process arrives as the skill `scribe-proc-<slug>`
+335
View File
@@ -0,0 +1,335 @@
"""Platforms — what a project is built on or ships as, and which projects are
which (milestone 463 step 2).
Membership is what makes "is this idea in family for that project?" a lookup
rather than a judgment: a family idea is for some platforms, and it reaches
every project that is a member of one of them.
Three ways a project becomes, or refuses to become, a member:
- **declared** — a person said so: at inception, or in the project's settings.
- **detected** — a marker file in a bound repo said so, found by the coverage
refresh that already walks the repo archive.
- **rejected** — a person said NO. Kept as a row, so the next refresh does not
detect it straight back.
The one invariant everything here protects: **detection only ever ADDS, and
only where nobody has answered.** It never overwrites a declared or rejected
row, and it never removes anything — not even a detected row whose marker has
since disappeared, because membership is what adoption rows hang off and a
platform flickering in and out with a repo's file tree would churn a ledger
of decisions nobody re-made.
The catalog itself is global, like the canonical area catalog, and for the
same reason writes to it are admin-only: a shared vocabulary anyone can extend
stops being shared. Reads are open to any signed-in user.
"""
from __future__ import annotations
import fnmatch
import logging
from datetime import datetime, timezone
from sqlalchemy import select
from scribe.models import async_session
from scribe.models.family import Platform, ProjectPlatform
from scribe.services import access
from scribe.services.canonical_systems import canonical_slug
logger = logging.getLogger(__name__)
# The states that make a project a member. `rejected` is an answer, not
# membership.
MEMBER_STATES = ("declared", "detected")
# What a person may set from a door. `detected` is the refresh's to write.
SETTABLE_STATES = ("declared", "rejected")
# --- the catalog -------------------------------------------------------------
async def list_platforms() -> list[Platform]:
"""The whole live catalog, in display order. Global — no owner filter."""
async with async_session() as session:
result = await session.execute(
select(Platform)
.where(Platform.deleted_at.is_(None))
.order_by(Platform.order_index.asc(), Platform.name.asc())
)
return list(result.scalars().all())
def _clean_markers(markers) -> list[str]:
"""Markers as stored: stripped, non-empty strings, de-duplicated in order.
Anything else is refused by the caller before it gets here."""
seen: list[str] = []
for m in markers or []:
m = str(m).strip()
if m and m not in seen:
seen.append(m)
return seen
def validate_markers(markers) -> str | None:
"""The error a markers value would earn, or None. Pure."""
if markers is None:
return None
if not isinstance(markers, list) or not all(isinstance(m, str) for m in markers):
return "markers must be a list of glob patterns"
if any(m.strip().startswith("/") for m in markers):
return "markers are repo-relative — no leading slash"
return None
async def create_platform(
user_id: int, name: str, *, description: str | None = None,
markers: list[str] | None = None,
) -> Platform | dict | None:
"""Add a platform to the global catalog. Admin only.
Duplicate-gated on the slug, so "Android App" cannot be added beside
"Android app": the existing entry comes back instead of a second spelling
of it. None means not permitted, or no usable name.
"""
if not await access.is_instance_admin(user_id):
return None
slug = canonical_slug(name)
if not slug:
return None
error = validate_markers(markers)
if error:
raise ValueError(error)
async with async_session() as session:
existing = await session.scalar(
select(Platform).where(Platform.slug == slug, Platform.deleted_at.is_(None))
)
if existing is not None:
return {
"duplicate": True,
"existing_id": existing.id,
"message": (
f"'{existing.name}' (#{existing.id}) is already this platform — "
f"both names reduce to '{slug}'."
),
}
highest = await session.scalar(
select(Platform.order_index).order_by(Platform.order_index.desc()).limit(1)
)
entry = Platform(
name=" ".join(name.split()),
slug=slug,
description=description,
markers=_clean_markers(markers),
order_index=(highest or 0) + 1,
)
session.add(entry)
await session.commit()
await session.refresh(entry)
return entry
async def update_platform(user_id: int, platform_id: int, **fields: object) -> Platform | None:
"""Rename, re-describe, re-order or re-mark a catalog entry. Admin only.
A rename recomputes the slug — the display name and the match key must not
disagree. Changing the slug of a platform projects already belong to is
safe: membership is by id; only a backup carries the slug.
"""
if not await access.is_instance_admin(user_id):
return None
if "markers" in fields:
error = validate_markers(fields["markers"])
if error:
raise ValueError(error)
async with async_session() as session:
entry = await session.get(Platform, platform_id)
if entry is None or entry.deleted_at is not None:
return None
if fields.get("name"):
entry.name = " ".join(str(fields["name"]).split())
entry.slug = canonical_slug(entry.name)
if fields.get("description") is not None:
entry.description = fields["description"] or None
if fields.get("order_index") is not None:
entry.order_index = int(fields["order_index"])
if fields.get("markers") is not None:
entry.markers = _clean_markers(fields["markers"])
entry.updated_at = datetime.now(timezone.utc)
await session.commit()
await session.refresh(entry)
return entry
async def resolve_slugs(slugs: list[str]) -> tuple[dict[str, int], list[str]]:
"""{slug: id} for the slugs the live catalog knows, and the ones it does
not. Doors take slugs — readable, stable across installs, and the form an
inception record and a backup both carry."""
wanted = [s for s in dict.fromkeys(slugs or [])]
if not wanted:
return {}, []
async with async_session() as session:
rows = (await session.execute(
select(Platform.slug, Platform.id).where(
Platform.slug.in_(wanted), Platform.deleted_at.is_(None),
)
)).all()
known = {slug: pid for slug, pid in rows}
return known, [s for s in wanted if s not in known]
# --- detection (pure) --------------------------------------------------------
def marker_matches(marker: str, path: str) -> bool:
"""Whether one marker matches one repo-relative path.
A marker with no slash matches a file's BASENAME anywhere in the tree
(`go.mod`, `AndroidManifest.xml`, `vite.config.*`). A marker with a slash
matches the whole repo-relative path (`.github/workflows/*`), so a
directory-shaped marker cannot be satisfied by a same-named file
somewhere else.
"""
marker = marker.strip()
if not marker:
return False
if "/" in marker:
return fnmatch.fnmatchcase(path, marker)
return fnmatch.fnmatchcase(path.rsplit("/", 1)[-1], marker)
def detect(catalog: list, paths: list[str]) -> list[int]:
"""The ids of every catalog platform at least one of whose markers matches
at least one path. Pure, so it is testable against a fixture tree with no
forge and no database. A platform with no markers is never detected —
that is what declare-only means."""
hits: list[int] = []
for platform in catalog:
markers = list(getattr(platform, "markers", None) or [])
if markers and any(marker_matches(m, p) for m in markers for p in paths):
hits.append(platform.id)
return hits
# --- membership ----------------------------------------------------------------
async def project_platforms(user_id: int, project_id: int) -> list[dict] | None:
"""Every platform the project has an answer for, joined to the catalog.
None when the caller cannot read the project.
Rejected rows are included — a settings screen has to show "no" as well
as "yes", or it cannot let anyone change their mind.
"""
if not await access.can_read_project(user_id, project_id):
return None
async with async_session() as session:
rows = (await session.execute(
select(ProjectPlatform, Platform)
.join(Platform, Platform.id == ProjectPlatform.platform_id)
.where(ProjectPlatform.project_id == project_id, Platform.deleted_at.is_(None))
.order_by(Platform.order_index.asc(), Platform.name.asc())
)).all()
return [
{"id": p.id, "slug": p.slug, "name": p.name, "state": m.state}
for m, p in rows
]
def members(platforms: list[dict]) -> list[dict]:
"""The rows of `project_platforms` that are membership — the brief form
enter_project and get_project carry."""
return [
{"slug": p["slug"], "name": p["name"], "state": p["state"]}
for p in platforms if p["state"] in MEMBER_STATES
]
def validate_updates(updates) -> str | None:
"""The error a set of membership answers would earn, or None. Pure.
`updates` is {slug: "declared" | "rejected" | None}. None withdraws the
answer — the row goes, and detection may add the platform again later.
`detected` is not settable: it is what the refresh writes, and a person
claiming it would erase the difference between the two."""
if not isinstance(updates, dict):
return "platforms must be an object of {slug: state}"
for slug, state in updates.items():
if not isinstance(slug, str) or not slug:
return "each platform is named by its slug"
if state is not None and state not in SETTABLE_STATES:
return (
f"'{state}' is not a state a person sets — use one of "
f"{', '.join(SETTABLE_STATES)}, or null to withdraw the answer"
)
return None
async def set_project_platforms(
user_id: int, project_id: int, updates: dict[str, str | None],
) -> list[dict]:
"""Apply a person's answers about a project's platforms. Write-gated on
the project (rule 78). Platforms not named are left exactly as they are.
Raises ValueError, naming the problem, on a malformed update, an unknown
slug, or no write access — before anything is written, so the update
applies whole or not at all.
"""
error = validate_updates(updates)
if error:
raise ValueError(error)
if not await access.can_write_project(user_id, project_id):
raise ValueError(f"project {project_id} not found or no write access")
known, unknown = await resolve_slugs(list(updates))
if unknown:
raise ValueError(f"unknown platform(s): {', '.join(unknown)} (list_platforms)")
async with async_session() as session:
existing = {
row.platform_id: row for row in (await session.execute(
select(ProjectPlatform).where(ProjectPlatform.project_id == project_id)
)).scalars().all()
}
for slug, state in updates.items():
pid = known[slug]
row = existing.get(pid)
if state is None:
if row is not None:
await session.delete(row)
elif row is None:
session.add(ProjectPlatform(project_id=project_id, platform_id=pid, state=state))
else:
row.state = state
await session.commit()
return await project_platforms(user_id, project_id) or []
async def record_detected(project_id: int, platform_ids: list[int]) -> list[int]:
"""Add `detected` membership for each platform the project has NO answer
for. Returns the ids actually added.
The refresh's writer, so it takes no user: it runs on the owner's behalf
inside a sync the owner's keyring authorised. It never updates or deletes
a row — see the module docstring for why that is the whole contract.
"""
if not platform_ids:
return []
async with async_session() as session:
answered = set((await session.execute(
select(ProjectPlatform.platform_id).where(
ProjectPlatform.project_id == project_id,
)
)).scalars().all())
added = [pid for pid in dict.fromkeys(platform_ids) if pid not in answered]
for pid in added:
session.add(ProjectPlatform(project_id=project_id, platform_id=pid, state="detected"))
await session.commit()
return added
async def detect_for_project(project_id: int, paths: list[str]) -> list[int]:
"""Run detection over a refresh's paths and record what is new. The
coverage refresh's single call — it must not be able to fail that
refresh, so the caller wraps it."""
hits = detect(await list_platforms(), paths)
added = await record_detected(project_id, hits)
if added:
logger.info("project %s: detected platform(s) %s", project_id, added)
return added
+130 -17
View File
@@ -514,7 +514,7 @@ def validate_classifications(items: list[dict]) -> str | None:
snippet_id = item.get("snippet_id") or 0
if status in _NEEDS_TARGET and not snippet_id:
return (
f"classifications[{i}]: status {status!r} needs snippet_id — "
f"classifications[{i}]: status {status!r} needs snippet_id (or idea_id) — "
"the snippet this shape is (or departs from)"
)
if status in ("variant", "exempt") and not (item.get("reason") or "").strip():
@@ -570,6 +570,7 @@ async def classify_shapes(
if via not in _CALLER_VIAS:
raise ValueError(f"via must be one of: {', '.join(_CALLER_VIAS)}")
classifications = await _resolve_ideas(user_id, classifications)
error = validate_classifications(classifications)
if error:
raise ValueError(error)
@@ -602,6 +603,7 @@ async def classify_shapes(
classified = 0
provisional = 0
unmatched: list[dict] = []
touched: set[int] = set()
async with async_session() as session:
rows = (
await session.execute(
@@ -639,6 +641,7 @@ async def classify_shapes(
continue
status = item["status"]
for row in matches:
touched.update(x for x in (row.snippet_id, item.get("snippet_id")) if x)
await _judge(
session, row, status=status,
snippet_id=int(item["snippet_id"]) if status in _NEEDS_TARGET else None,
@@ -653,9 +656,52 @@ async def classify_shapes(
out = {"classified": classified, "unmatched": unmatched}
if provisional:
out["provisional"] = provisional
moved = await _sync_family(user_id, project_id, touched)
if moved:
out["family"] = moved
return out
async def _resolve_ideas(user_id: int, items: list[dict]) -> list[dict]:
"""An item may name a family idea (`idea_id`) in place of a snippet
(milestone 463 step 5): it is judged against the idea's reference in the
shape's language, or its first when there is none in that language — an
idea is shared across languages. An explicit snippet_id wins."""
from scribe.services import family_adoption
out = []
for i, item in enumerate(items or []):
if (isinstance(item, dict) and item.get("idea_id") and not item.get("snippet_id")
and item.get("status") in _NEEDS_TARGET):
try:
sid = await family_adoption.reference_for(
user_id, int(item["idea_id"]), (item.get("path") or "").strip())
except (TypeError, ValueError) as exc:
raise ValueError(f"classifications[{i}]: {exc}") from None
item = {**item, "snippet_id": sid}
out.append(item)
return out
async def _sync_family(user_id: int, project_id: int, snippet_ids: set[int]) -> list[dict]:
"""The adoption ledger follows the shapes (milestone 463 step 5): after a
judgment commits, the canon ideas whose references it touched — as the
new target or the one it replaced — are answered from the shapes.
A failure is logged and the judgment stands; the next judgment touching
those references, or the next coverage refresh, brings the two back in
line."""
from scribe.services import family_adoption
if not snippet_ids:
return []
try:
return await family_adoption.sync_from_shapes(user_id, project_id, snippet_ids)
except Exception:
logger.warning("family adoption sync failed for project %s", project_id, exc_info=True)
return []
def rule_matches(row: CodeShape, *, path: str, pattern: str, kind: str) -> bool:
"""Does a ledger row fall under a rule-form classification (#2868)?
``path`` is a file or a directory (everything beneath it), ``pattern``
@@ -721,6 +767,7 @@ async def classify_shapes_where(
now = datetime.now(timezone.utc)
judged: list[str] = []
touched: set[int] = {int(snippet_id)} if snippet_id and status in _NEEDS_TARGET else set()
async with async_session() as session:
conds = [CodeShape.project_id == project_id, CodeShape.vanished_at.is_(None)]
if not include_judged:
@@ -729,6 +776,8 @@ async def classify_shapes_where(
for row in rows:
if not rule_matches(row, path=path, pattern=pattern, kind=kind):
continue
if row.snippet_id:
touched.add(row.snippet_id)
await _judge(
session, row, status=status,
snippet_id=int(snippet_id) if status in _NEEDS_TARGET else None,
@@ -738,7 +787,11 @@ async def classify_shapes_where(
await record_uses(session, row, uses, basis=via, evidence=reason)
judged.append(f"{row.path}::{row.symbol}")
await session.commit()
return {"classified": len(judged), "sample": sorted(judged)[:12]}
out = {"classified": len(judged), "sample": sorted(judged)[:12]}
moved = await _sync_family(user_id, project_id, touched)
if moved:
out["family"] = moved
return out
async def list_project_shapes(
@@ -1611,6 +1664,29 @@ _SEMANTIC_LIMIT = 8
# against a prose-forward snippet document measures the wrong field (#2518);
# no floor separates the two bands. The arm PROPOSES on a hit; its silence
# says nothing.
#
# THE FAMILY ARM (milestone 463 step 5). The arm above is held to the shape's
# own project and language family, because across projects it was pure noise
# (#2871). A family idea is the exception the hold was waiting for: an idea
# shared across projects ON PURPOSE, whose reference may be in another
# language (a Python throttle is an instance of a Go one). So the arm also
# proposes the reference of a canon idea that reaches the shape's project
# through a shared platform — any language, any project — under a stricter
# rule of its own, measured 2026-10-06 (#4991) on cross-project pairs:
#
# true pairs A py→go 0.728 (next 0.703) B py→go 0.715 (next 0.675)
# D kt→kt 0.816 (next 0.734) E go→go 0.610 (next 0.604)
# no reference N1 0.666 N2 0.623 N3 0.626 N4 0.715 N5 0.614 (best hit,
# none of them a family reference); unrelated hits to 0.774
#
# No floor separates those bands — the false band reaches 0.715–0.774, a
# true pair sits at 0.610. RANK does: every true pair was the single best
# snippet the user can read. So a family reference is proposed only when it
# is the TOP hit over everything readable (not merely the first allowed one,
# as above) and clears _FAMILY_FLOOR: A, B, D proposed; E missed; no
# negative proposed. A miss is still not evidence — the judge can classify
# against the idea by idea_id.
_FAMILY_FLOOR = 0.70
def _semantic_priority(row) -> tuple:
@@ -1640,7 +1716,9 @@ def _semantic_priority(row) -> tuple:
# v5: the semantic arm's floor dropped from 0.8 to the write-path floor (#4208),
# and the signature floor from 0.8 to 0.75 (#4306), so rows examined and
# found nothing for must be read once more.
_PROPOSER_VERSION = 5
# v6: the family arm (milestone 463 step 5) — a canon idea's reference on a
# shared platform, any language, as the top hit at _FAMILY_FLOOR.
_PROPOSER_VERSION = 6
# Signature resemblance floor, name blanked (difflib ratio) — and a length
# floor, because `def NAME():` resembles `def NAME(x):` at 0.95 while saying
# nothing; a family shape has parameters to resemble.
@@ -1883,28 +1961,47 @@ def _substance(text: str) -> int:
return len("".join((text or "").split()))
def pick_semantic(
hits: list[tuple[float, int]], allowed: set[int], family: set[int], *, floor: float,
) -> tuple[int, float, str] | None:
"""Which ranked hit the semantic arm proposes, as (snippet_id, score,
basis). Pure; ``hits`` are (score, snippet_id) over everything the user
can read, best first. The TOP hit may be a family reference (basis
"family", at _FAMILY_FLOOR); any hit at ``floor`` may be an own-project
canon (basis "semantic")."""
for rank, (score, sid) in enumerate(hits):
if rank == 0 and sid in family and score >= _FAMILY_FLOOR:
return sid, round(float(score), 3), "family"
if sid in allowed and score >= floor:
return sid, round(float(score), 3), "semantic"
return None
async def _semantic_canon(
user_id: int, body: str, allowed: set[int],
) -> tuple[int, float] | None:
"""The canon this body MEANS, or None. None is "no proposal", never "not
the canon" — see the note above `_SEMANTIC_LIMIT`."""
user_id: int, body: str, allowed: set[int], family: set[int] | None = None,
) -> tuple[int, float, str] | None:
"""The canon this body MEANS, as (snippet_id, score, basis), or None.
None is "no proposal", never "not the canon" — see the note above
`_SEMANTIC_LIMIT`; the family arm's rule is the note above
`_FAMILY_FLOOR`."""
from scribe.services.embeddings import semantic_search_notes
from scribe.services.plugin_context import (
WRITEPATH_DEFAULT_THRESHOLD, WRITEPATH_MIN_CODE_CHARS, concept_query,
)
if _substance(body) < WRITEPATH_MIN_CODE_CHARS or not allowed:
family = family or set()
if _substance(body) < WRITEPATH_MIN_CODE_CHARS or not (allowed or family):
return None
query = concept_query(body) or body
hits = await semantic_search_notes(
user_id, query, limit=_SEMANTIC_LIMIT,
threshold=WRITEPATH_DEFAULT_THRESHOLD,
threshold=min(WRITEPATH_DEFAULT_THRESHOLD, _FAMILY_FLOOR),
note_type="snippet", scope="browse",
)
for score, note in hits:
if int(note.id) in allowed:
return int(note.id), round(float(score), 3)
return None
return pick_semantic(
[(float(score), int(note.id)) for score, note in hits], allowed, family,
floor=WRITEPATH_DEFAULT_THRESHOLD,
)
async def propose_for_repo(
@@ -1933,6 +2030,16 @@ async def propose_for_repo(
def semantic_allowed(path: str) -> set[int]:
return {c.snippet_id for c in sym_canons if same_family(path, c.language)}
# The family arm: references of canon ideas on a platform this project
# shares, in any language (see _FAMILY_FLOOR). A project on no shared
# platform gets none, so nothing is proposed across to it.
try:
from scribe.services import family_adoption
family_refs = await family_adoption.family_reference_ids(project_id)
except Exception:
logger.warning("family references unreadable for project %s", project_id, exc_info=True)
family_refs = set()
now = datetime.now(timezone.utc)
examined = proposed = checked = 0
async with async_session() as session:
@@ -1998,13 +2105,13 @@ async def propose_for_repo(
continue
checked += 1
try:
found = await _semantic_canon(user_id, d[5], semantic_allowed(row.path))
found = await _semantic_canon(
user_id, d[5], semantic_allowed(row.path), family_refs)
except Exception:
logger.warning("semantic proposal failed", exc_info=True)
found = None
if found:
row.proposed_snippet_id, row.proposal_score = found
row.proposal_basis = "semantic"
row.proposed_snippet_id, row.proposal_score, row.proposal_basis = found
row.proposal_group = None
proposed += 1
await session.commit()
@@ -2210,11 +2317,13 @@ async def confirm_proposals(
conds.append(CodeShape.proposal_basis == basis.strip())
now = datetime.now(timezone.utc)
confirmed = 0
touched: set[int] = set()
async with async_session() as session:
rows = (await session.execute(select(CodeShape).where(*conds))).scalars().all()
for row in rows:
if (row.proposal_score or 0.0) < min_score:
continue
touched.add(row.proposed_snippet_id)
await _judge(
session, row, status="instance", snippet_id=row.proposed_snippet_id,
by="agent", at=now,
@@ -2225,7 +2334,11 @@ async def confirm_proposals(
)
confirmed += 1
await session.commit()
return {"confirmed": confirmed}
out = {"confirmed": confirmed}
moved = await _sync_family(user_id, project_id, touched)
if moved:
out["family"] = moved
return out
# --- the divergence readout (#2793): button B where button A is canon -------
+24
View File
@@ -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).
+15
View File
@@ -512,3 +512,18 @@ def skill_text(name: str) -> str:
folder = pathlib.Path(__file__).resolve().parents[1] / "plugin" / "skills" / name
refs = sorted(p for p in folder.glob("*.md") if p.name != "SKILL.md")
return "\n\n".join(p.read_text() for p in [folder / "SKILL.md", *refs])
def forge_tarball(files: dict[str, bytes], top: str = "widget") -> bytes:
"""A gzipped tar shaped like a forge archive: every file under one
top-level directory (repo-ref/), which the scan strips."""
import io
import tarfile
buf = io.BytesIO()
with tarfile.open(fileobj=buf, mode="w:gz") as tar:
for path, data in files.items():
info = tarfile.TarInfo(f"{top}/{path}")
info.size = len(data)
tar.addfile(info, io.BytesIO(data))
return buf.getvalue()
+2 -2
View File
@@ -64,14 +64,14 @@ def _patch(mock: AsyncMock):
async def test_a_hit_returns_the_canon() -> None:
with _patch(_hits((0.88, CANON))):
assert await _semantic_canon(1, BODY, {CANON}) == (CANON, 0.88)
assert await _semantic_canon(1, BODY, {CANON}) == (CANON, 0.88, "semantic")
async def test_an_allowed_canon_below_the_top_hit_still_wins() -> None:
"""The scan is over the whole result set, so a disallowed snippet ranking
first does not hide an allowed one behind it."""
with _patch(_hits((0.95, OTHER), (0.83, CANON))):
assert await _semantic_canon(1, BODY, {CANON}) == (CANON, 0.83)
assert await _semantic_canon(1, BODY, {CANON}) == (CANON, 0.83, "semantic")
async def test_a_miss_is_none_and_nothing_more() -> None:
+163
View File
@@ -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()
+186
View File
@@ -0,0 +1,186 @@
"""The adoption ledger without a database (milestone 463 step 4).
The parts that decide are pure and pinned here: what an assessment needs
before it can be recorded, what each ground of the conflict order needs —
including that every ground ABOVE the deciding one was said not to apply —
when an answer needs a recheck, how a losing side is folded into the note,
and which reference an owed task points at. Beside them: the outcomes and
the order the agent is told are the ones enforced. The state machine against
Postgres is in tests/test_integration_family_adoption.py.
"""
from __future__ import annotations
import pytest
from scribe.mcp.server import _READ_ONLY_TOOLS, _WRITE_TOOLS
from scribe.services.family_adoption import (
CONFLICT_KEYS, CONFLICT_ORDER, OUTCOME_KEYS, _fold_section, _reference_line,
assessment_problems, conflict_problems, needs_recheck,
)
from tests.helpers import tool_doc
# --- what an assessment needs ------------------------------------------------------
@pytest.mark.parametrize("outcome", OUTCOME_KEYS)
def test_every_outcome_needs_a_reason(outcome):
problems = assessment_problems(outcome=outcome, reason=" ", evidence=["src/x.py"])
assert len(problems) == 1 and outcome in problems[0]
def test_adopted_needs_evidence_naming_where():
assert assessment_problems(outcome="adopted", reason="done", evidence=[]) == [
"adopted needs evidence naming where the project does it"]
assert assessment_problems(outcome="adopted", reason="done", evidence=[" "]) != []
assert assessment_problems(outcome="adopted", reason="done", evidence=["ci run 12"]) == []
@pytest.mark.parametrize("outcome", ("exempt", "variant", "owed"))
def test_the_other_outcomes_need_no_evidence(outcome):
assert assessment_problems(outcome=outcome, reason="a fact", evidence=None) == []
def test_an_unknown_outcome_is_refused_by_name():
problems = assessment_problems(outcome="ignored", reason="x", evidence=[])
assert problems and all(k in problems[0] for k in OUTCOME_KEYS)
# --- the conflict order, branch by branch ----------------------------------------
def _checked(upto: str) -> dict:
return {k: f"{k} did not decide" for k in CONFLICT_KEYS[:CONFLICT_KEYS.index(upto)]}
GOOD = {
"operator_stance": dict(evidence=["rule: one keystore per app"]),
"covers_failure": dict(evidence=["incident #12: update refused, signature mismatch"]),
"split_by_condition": dict(conditions={"canon": "the app self-updates",
"other": "a store installs it"}),
"most_recent_complete": dict(evidence=["verified on a device 2026-10-01"]),
}
@pytest.mark.parametrize("ground", CONFLICT_KEYS)
def test_each_ground_holds_when_its_needs_are_met(ground):
kw = {"evidence": None, "conditions": None, **GOOD[ground]}
assert conflict_problems(ground=ground, grounds_checked=_checked(ground),
fold="their reasoning", **kw) == []
@pytest.mark.parametrize("ground", CONFLICT_KEYS[1:])
def test_a_ground_is_refused_while_any_ground_above_it_is_unanswered(ground):
above = CONFLICT_KEYS[:CONFLICT_KEYS.index(ground)]
for skipped in above:
checked = {k: v for k, v in _checked(ground).items() if k != skipped}
problems = conflict_problems(ground=ground, grounds_checked=checked,
fold="x", **{"evidence": None, "conditions": None,
**GOOD[ground]})
assert len(problems) == 1 and f"'{skipped}'" in problems[0]
def test_the_first_ground_needs_nothing_checked_above_it():
assert conflict_problems(ground="operator_stance", grounds_checked=None, fold="x",
evidence=["rule 4"], conditions=None) == []
@pytest.mark.parametrize("ground", ("operator_stance", "covers_failure", "most_recent_complete"))
def test_grounds_one_two_and_four_need_their_evidence(ground):
problems = conflict_problems(ground=ground, grounds_checked=_checked(ground), fold="x",
evidence=[" "], conditions=None)
assert len(problems) == 1 and "evidence" in problems[0]
def test_a_split_needs_both_conditions():
for conds in (None, {"canon": "self-updates"}, {"canon": "", "other": "store"}):
problems = conflict_problems(ground="split_by_condition",
grounds_checked=_checked("split_by_condition"),
fold="x", evidence=None, conditions=conds)
assert len(problems) == 1 and "conditions" in problems[0]
def test_the_losing_reasoning_is_required():
problems = conflict_problems(ground="operator_stance", grounds_checked=None, fold=" ",
evidence=["rule 4"], conditions=None)
assert len(problems) == 1 and "never dropped" in problems[0]
def test_an_unknown_ground_names_the_order():
problems = conflict_problems(ground="seniority", grounds_checked=None, fold="x",
evidence=[], conditions=None)
assert problems == [f"ground must be one of, in order: {', '.join(CONFLICT_KEYS)}"]
# --- recheck -------------------------------------------------------------------------
@pytest.mark.parametrize("row_status,row_version,idea_status,idea_version,expected", [
("adopted", 1, "canon", 2, True),
("owed", 1, "canon", 2, True),
("adopted", 2, "canon", 2, False),
# An undone revision leaves answers AHEAD of the idea: still not agreeing.
("adopted", 3, "canon", 2, True),
("unassessed", None, "canon", 2, False),
("adopted", 1, "retired", 2, False),
])
def test_needs_recheck(row_status, row_version, idea_status, idea_version, expected):
assert needs_recheck(row_status, row_version, idea_status, idea_version) is expected
# --- folding the losing side into the note ------------------------------------------
@pytest.mark.parametrize("ground,heading", [
("operator_stance", "### Alternative — two's approach"),
("covers_failure", "### Trap — two's approach"),
("most_recent_complete", "### Alternative — two's approach"),
("split_by_condition", "### When a store installs it — two's approach"),
])
def test_the_losing_side_is_folded_as_its_ground_says(ground, heading):
text = _fold_section(ground=ground, fold="Their reasoning.",
conditions=GOOD["split_by_condition"]["conditions"],
canon_title="one", other_title="two", when="2026-10-06")
assert text.startswith(heading)
assert "Their reasoning." in text and "one's approach" in text
# --- the reference an owed task points at -----------------------------------------
REFS = [
{"id": 10, "title": "Update installer", "language": "kotlin"},
{"id": 11, "title": "Update installer", "language": "dart"},
]
def test_the_reference_in_the_projects_language_is_named():
line = _reference_line(REFS, ["dart"], idea_id=5)
assert "#11" in line and "#10" not in line
def test_no_reference_in_the_language_names_the_ones_that_exist():
line = _reference_line(REFS, ["python"], idea_id=5)
assert "python" in line and "#10" in line and "#11" in line
def test_no_reference_at_all_points_at_the_idea():
assert "#5" in _reference_line([], ["python"], idea_id=5)
# --- the product text and the doors ---------------------------------------------------
def test_the_outcomes_the_agent_is_told_are_the_ones_enforced():
"""Rule 119: the outcomes and the order are product text. The tool
docstrings must name every outcome and every ground the service checks,
in the service's order."""
assess_doc = tool_doc("scribe.mcp.tools.family", "assess_family_adoption")
for n, key in enumerate(OUTCOME_KEYS, start=1):
assert f"{n}. {key} —" in assess_doc, f"outcome {n} is not taught as {key}"
resolve_doc = tool_doc("scribe.mcp.tools.family", "resolve_family_conflict")
for n, key in enumerate(CONFLICT_KEYS, start=1):
assert f"{n}. {key} —" in resolve_doc, f"ground {n} is not taught as {key}"
assert [c["fold_as"] for c in CONFLICT_ORDER] == ["alternative", "trap", "condition", "alternative"]
def test_every_adoption_tool_is_classified():
reads = {"get_family_adoption", "list_family_adoptions"}
writes = {"assess_family_adoption", "resolve_family_conflict", "revise_family_idea",
"set_family_references"}
assert reads <= _READ_ONLY_TOOLS
assert writes <= _WRITE_TOOLS
+99
View File
@@ -0,0 +1,99 @@
"""How family canon reaches a session (milestone 463 step 6), without a
database: the line enter_project carries, the skill and its triggers, and the
pointers every other surface gives to it. Retrieval reach, the readout's
counts and the owed answers against Postgres are in
tests/test_integration_family_reach.py.
"""
from __future__ import annotations
import pathlib
import re
from scribe.services import moment_actions
from scribe.services.family_adoption import family_line
from tests.helpers import skill_text, tool_doc
ROOT = pathlib.Path(__file__).resolve().parents[1]
def _cell(status="unassessed", *, in_scope=True, needs_recheck=False):
return {"status": status, "in_scope": in_scope, "needs_recheck": needs_recheck}
# --- the enter_project line ----------------------------------------------------------
def test_nothing_to_answer_is_no_line():
assert family_line(5, []) is None
assert family_line(5, [_cell("adopted"), _cell("exempt")]) is None
def test_the_line_counts_each_ask_and_names_the_call_that_lists_it():
line = family_line(5, [
_cell(), _cell(), _cell("owed"), _cell("adopted", needs_recheck=True),
])
assert (line["unassessed"], line["owed"], line["needs_recheck"]) == (2, 1, 1)
assert line["line"].endswith("2 unassessed · 1 owed · 1 to recheck")
assert line["calls"] == {
"unassessed": 'list_family_adoptions(project_id=5, status="unassessed")',
"owed": 'list_family_adoptions(project_id=5, status="owed")',
"needs_recheck": "list_family_adoptions(project_id=5, needs_recheck=true)",
}
assert "family-canon" in line["answer_with"]
def test_a_count_of_zero_names_no_call():
line = family_line(5, [_cell("owed")])
assert set(line["calls"]) == {"owed"} and "unassessed" not in line["line"]
def test_an_answer_kept_as_history_off_the_platform_is_not_asked_again():
"""A row whose project left the idea's platforms stays as history; an
unassessed one there is not something this project is asked for."""
assert family_line(5, [_cell(in_scope=False)]) is None
# --- the skill ----------------------------------------------------------------------
def _frontmatter(name: str) -> str:
text = (ROOT / "plugin" / "skills" / name / "SKILL.md").read_text()
return re.match(r"\A---\n(.*?)\n---\n", text, re.S).group(1)
def test_the_family_canon_skill_ships_and_triggers_on_what_the_server_sends():
"""Its description is what makes a session load it, so it names every
key the server hands back about family canon."""
front = _frontmatter("family-canon")
assert "name: family-canon" in front
for trigger in ("family_hint", "`family`", "family_owed", "enter_project"):
assert trigger in front, trigger
assert moment_actions.BUNDLED_SKILL_MOMENTS["family-canon"] == ("work.record",)
def test_the_skill_carries_the_reflexes_and_defers_the_lists_to_the_tools():
text = " ".join(skill_text("family-canon").split())
for phrase in ("one criterion with no support vetoes it",
"in the order `assess_family_adoption` lists them",
"every ground above it must be said not to apply",
"What counts as a reason", "The precedent reflex"):
assert phrase in text, phrase
def test_the_plugin_names_the_skill_it_ships():
manifest = (ROOT / "plugin" / ".claude-plugin" / "plugin.json").read_text()
static = (ROOT / "plugin" / "hooks" / "scribe_static_context.md").read_text()
assert "family-canon" in manifest and "family-canon" in " ".join(static.split())
# --- the pointers ---------------------------------------------------------------------
def test_every_surface_that_meets_family_canon_points_at_the_skill():
server = (ROOT / "src" / "scribe" / "mcp" / "server.py").read_text()
index = re.search(r'_INSTRUCTIONS = """(.*?)"""', server, re.S).group(1)
assert "family-canon" in index and "`family`" in index
for module, tool in (("notes", "create_note"), ("snippets", "create_snippet"),
("shapes", "classify_shapes")):
assert "family-canon" in tool_doc(f"scribe.mcp.tools.{module}", tool), tool
assert "family_hint" in tool_doc("scribe.mcp.tools.notes", "create_note")
assert "`family`" in tool_doc("scribe.mcp.tools.projects", "enter_project")
assert "family_owed" in tool_doc("scribe.mcp.tools.tasks", "update_task")
assert "family_owed" in skill_text("reporting-back")
+69
View File
@@ -0,0 +1,69 @@
"""Family canon's schema — the parts that need no database (milestone 463 step 1).
The constraints themselves, against real Postgres, are in
tests/test_integration_family_canon.py. These pin that the migration and the
model agree on every whitelist (rule 36: a value one side accepts and the
other refuses only fails at INSERT time, which CI's unit lane never reaches),
and that the seed ships nothing an install other than this one would find
foreign (rule 115).
"""
from __future__ import annotations
import importlib.util
from pathlib import Path
from scribe.models import family
ROOT = Path(__file__).resolve().parents[1]
def _migration():
path = ROOT / "alembic" / "versions" / "0120_family_canon.py"
spec = importlib.util.spec_from_file_location("m0120", path)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
def test_the_migration_checks_and_the_model_agree():
m = _migration()
assert tuple(m._MEMBERSHIP) == family.MEMBERSHIP_STATES
assert tuple(m._IDEA_STATUSES) == family.IDEA_STATUSES
assert tuple(m._ADOPTION_STATUSES) == family.ADOPTION_STATUSES
assert tuple(m._REASONED) == family.REASONED_STATUSES
assert tuple(m._ACTIONS) == family.DECISION_ACTIONS
assert tuple(m._DECIDERS) == family.DECIDERS
def test_the_departures_that_need_a_reason_are_adoption_states():
assert set(family.REASONED_STATUSES) <= set(family.ADOPTION_STATUSES)
def test_the_seed_is_generic_and_unique():
seed = _migration()._SEED
slugs = [slug for _, slug, _, _ in seed]
assert len(slugs) == len(set(slugs)), "a duplicate slug breaks the live-unique index"
for name, slug, description, markers in seed:
assert name and slug and description
assert slug == slug.lower() and " " not in slug
# A list, possibly empty — never None. Detection reads it directly.
assert isinstance(markers, list)
def test_the_column_defaults_match_the_first_state_of_each_lifecycle():
"""A row created with no state given starts where its lifecycle starts.
An idea is a candidate until promoted; an answer is unassessed until
someone assesses it — never silently `adopted`."""
assert family.FamilyIdea.__table__.c.status.server_default.arg == "candidate"
assert family.FamilyAdoption.__table__.c.status.server_default.arg == "unassessed"
assert family.ProjectPlatform.__table__.c.state.server_default.arg == "declared"
def test_a_canon_ideas_scope_lives_on_the_idea_and_nowhere_else():
"""One scope source. A rule topic is linked FROM the idea and carries no
platforms of its own, so the two cannot disagree about who a standard
reaches (#4221's two-sources-of-truth shape)."""
from scribe.models.rulebook import RulebookTopic
assert "topic_id" in family.FamilyIdea.__table__.c
assert not {c.name for c in RulebookTopic.__table__.c} & {"platform_id", "platform_ids"}
+142
View File
@@ -0,0 +1,142 @@
"""The shape ledger judging against family ideas, without a database
(milestone 463 step 5).
Pinned here: what a project's shapes say about an idea, when an answer
contradicts them, which reference a shape in a given language is judged
against, and the family arm's rule — measured on 2026-10-06 (#4991), and
replayed below as the table it was chosen from. The two ledgers moving
together against Postgres are in tests/test_integration_family_adoption.py.
"""
from __future__ import annotations
from types import SimpleNamespace
from unittest.mock import AsyncMock, patch
import pytest
from scribe.services.family_adoption import (
_contradiction, path_speaks, shape_outcome, shapes_say,
)
from scribe.services.shape_ledger import _FAMILY_FLOOR, _semantic_canon, pick_semantic
from tests.helpers import tool_doc
def _shape(status: str, symbol: str = "f", reason: str = "", snippet_id: int = 7):
return SimpleNamespace(id=1, path=f"src/{symbol}.py", symbol=symbol, status=status,
reason=reason, snippet_id=snippet_id)
# --- what the shapes say ---------------------------------------------------------
@pytest.mark.parametrize("statuses,expected", [
(["instance"], "adopted"),
(["canonical"], "adopted"),
(["variant", "instance"], "adopted"),
(["variant"], "variant"),
([], None),
])
def test_shape_outcome(statuses, expected):
assert shape_outcome(statuses) == expected
def test_no_shape_is_silence_not_owed():
assert shapes_say([]) is None
def test_an_adopted_reason_is_fixed_text_so_another_instance_logs_nothing():
one = shapes_say([_shape("instance", "a")])
two = shapes_say([_shape("instance", "a"), _shape("instance", "b")])
assert one["reason"] == two["reason"]
assert two["evidence"] == ["src/a.py::a", "src/b.py::b"]
def test_a_variant_carries_the_shapes_why_and_only_variants_are_its_evidence():
said = shapes_say([_shape("variant", "k", reason="a kiosk installs it")])
assert said["outcome"] == "variant" and "a kiosk installs it" in said["reason"]
mixed = shapes_say([_shape("variant", "k", reason="x"), _shape("instance", "i")])
assert mixed["outcome"] == "adopted" and mixed["evidence"] == ["src/i.py::i"]
# --- the two ledgers never disagree -----------------------------------------------
def test_an_answer_the_shapes_contradict_names_them_and_the_way_out():
said = shapes_say([_shape("instance", "a")])
refused = _contradiction("owed", said, idea_id=5)
assert "src/a.py::a" in refused and "classify_shapes" in refused and "#5" in refused
@pytest.mark.parametrize("outcome,said", [
("adopted", shapes_say([_shape("instance")])),
("owed", None),
("exempt", None),
])
def test_an_agreeing_answer_or_silent_shapes_refuse_nothing(outcome, said):
assert _contradiction(outcome, said, idea_id=5) is None
# --- which reference a shape is judged against --------------------------------------
@pytest.mark.parametrize("path,language,expected", [
("tools/release.py", "python", True),
("app/Updater.kt", "kotlin", True),
("cmd/main.go", "go", True),
("cmd/main.go", "kotlin", False),
("web/App.vue", "vue", True),
("README", "python", False),
("x.py", "", False),
])
def test_path_speaks(path, language, expected):
assert path_speaks(path, language) is expected
# --- the family arm, and the measurement it was chosen from -----------------------
OWN, REF, OTHER = 1, 2, 3
@pytest.mark.parametrize("name,hits,expected", [
# #4991's pairs, the reference as REF and the best other hit as OTHER.
("A py->go", [(0.728, REF), (0.703, OTHER)], "family"),
("B py->go", [(0.715, REF), (0.675, OTHER)], "family"),
("D kt->kt", [(0.816, REF), (0.734, OTHER)], "family"),
("E go->go", [(0.610, REF), (0.604, OTHER)], None),
# No reference exists: the best hit is unrelated, the reference trails.
("N4", [(0.715, OTHER), (0.706, REF)], None),
("N1", [(0.666, OTHER)], None),
])
def test_the_family_arm_replays_its_measurement(name, hits, expected):
found = pick_semantic(hits, allowed=set(), family={REF}, floor=0.68)
assert (found[2] if found else None) == expected, name
def test_a_family_reference_must_be_the_top_hit_not_merely_the_first_allowed():
assert pick_semantic([(0.80, OTHER), (0.79, REF)], set(), {REF}, floor=0.68) is None
def test_the_own_project_arm_still_looks_past_disallowed_hits():
assert pick_semantic([(0.80, OTHER), (0.70, OWN)], {OWN}, {REF}, floor=0.68) == (
OWN, 0.7, "semantic")
def test_the_family_floor_sits_between_the_bands_it_was_measured_on():
assert 0.610 < _FAMILY_FLOOR <= 0.715
async def test_a_project_on_a_shared_platform_is_searched_with_no_own_canon():
"""The own-project allowed set is often empty (the language gate); the
family references alone are reason to search."""
body = "def throttle(key: str) -> bool:\n return attempts[key] < LIMIT and not locked(key)\n"
hit = SimpleNamespace(id=REF)
with patch("scribe.services.embeddings.semantic_search_notes",
AsyncMock(return_value=[(0.72, hit)])):
assert await _semantic_canon(1, body, set(), {REF}) == (REF, 0.72, "family")
# --- the agent-facing contract -----------------------------------------------------
def test_the_tools_teach_idea_id_and_the_family_basis():
classify = tool_doc("scribe.mcp.tools.shapes", "classify_shapes")
assert "idea_id" in classify and "shapes contradict" in classify
assert "family" in tool_doc("scribe.mcp.tools.shapes", "list_shapes")
assert '"family"' in tool_doc("scribe.mcp.tools.shapes", "confirm_shape_proposals")
assert "classify_shapes" in tool_doc("scribe.mcp.tools.family", "assess_family_adoption")
+53
View File
@@ -0,0 +1,53 @@
"""A stylesheet shared by `<style src>` must sit at the same block index in
every component that loads it that way.
plugin-vue caches one SFC descriptor per `src` file and resolves a request for
`moments-shared.css?vue&type=style&index=N` against whichever component last
registered it. When two components carry the same src at different indexes,
the lookup can land on a component with fewer style blocks, and the production
build dies with "Cannot read properties of undefined (reading 'scoped')". It
depends on transform order, so an unrelated change, such as a new import in a
view, is enough to flip it. vue-tsc cannot see it; only `vite build` can, and
that runs at image-build time, after every test has passed.
"""
from __future__ import annotations
import pathlib
import re
from collections import defaultdict
ROOT = pathlib.Path(__file__).resolve().parents[1] / "frontend" / "src"
STYLE_TAG = re.compile(r"<style\b([^>]*)>")
SRC_ATTR = re.compile(r'\bsrc="([^"]+)"')
def src_indexes(sources: dict[str, str]) -> dict[str, dict[int, list[str]]]:
"""{src: {block index: [component, ...]}} over a set of SFC sources."""
seen: dict[str, dict[int, list[str]]] = defaultdict(lambda: defaultdict(list))
for name, text in sources.items():
for index, attrs in enumerate(STYLE_TAG.findall(text)):
match = SRC_ATTR.search(attrs)
if match:
seen[match.group(1)][index].append(name)
return seen
def disagreements(sources: dict[str, str]) -> dict[str, dict[int, list[str]]]:
return {src: dict(at) for src, at in src_indexes(sources).items() if len(at) > 1}
def test_every_shared_style_src_sits_at_one_block_index():
sources = {str(p.relative_to(ROOT)): p.read_text() for p in ROOT.rglob("*.vue")}
assert sources, f"no .vue files under {ROOT}"
assert disagreements(sources) == {}, (
"a <style src> sheet sits at different block indexes across components — "
"load it with @import in the odd one out"
)
def test_the_guard_can_fail():
sources = {
"A.vue": '<style scoped></style>\n<style src="@/x.css" />',
"B.vue": '<style scoped></style>\n<style src="@/y.css" />\n<style src="@/x.css" />',
}
assert disagreements(sources) == {"@/x.css": {1: ["A.vue"], 2: ["B.vue"]}}
+9
View File
@@ -270,6 +270,15 @@ TOPICS: tuple[Topic, ...] = (
Topic("a record you only cite still gets read",
"skill:reporting-back", ("next_step", "only mention"),
"a record you only mention is a record to read"),
# Milestone 463 step 6: family canon — when an idea is evaluated, how a
# project answers one, what a reason is, and the conflict order's reflex.
# The criteria, outcomes and grounds themselves are product text on the
# tools that enforce them; the skill owns when and how.
Topic("family canon: evaluate on a hint, answer in order, a reason is a fact",
"skill:family-canon",
("family_hint", "get_family_adoption", "resolve_family_conflict", "precedent"),
"write the reason so a session in another project can tell whether the same fact holds there",
index=("family-canon",)),
# ── per-tool contracts and in-band behaviour — owned by the server ──
Topic("closing a task cues the report", "docstrings", ("report_back",), "reporting this to the operator?"),
# The agent is the judge (#4208). Two topics, not one, because they fire
+21 -3
View File
@@ -27,7 +27,7 @@ def test_project_carries_an_inception_record_and_to_dict_shows_it():
def test_validate_inception_pins_the_choice_vocabulary():
assert CHOICE_KEYS == ("design_system_id", "seed_systems")
assert CHOICE_KEYS == ("design_system_id", "seed_systems", "platforms")
assert validate_inception({}) is None
assert validate_inception({"design_system_id": 3, "seed_systems": True}) is None
assert validate_inception({"design_system_id": None}) is None
@@ -46,10 +46,28 @@ def test_validate_inception_pins_the_choice_vocabulary():
assert "true or false" in validate_inception({"seed_systems": "yes"})
def test_validate_inception_takes_platforms_as_slugs_or_unstated():
"""Slugs, not ids: an inception record outlives a restore, and a slug is
what both sides of a restore agree on. None is "not answered", which is
different from [] — "none of them"."""
assert validate_inception({"platforms": ["android-app", "go"]}) is None
assert validate_inception({"platforms": []}) is None
assert validate_inception({"platforms": None}) is None
assert "platform slugs" in validate_inception({"platforms": "android-app"})
assert "platform slugs" in validate_inception({"platforms": [3]})
assert "platform slugs" in validate_inception({"platforms": [" "]})
def test_normalize_choices_is_canonical_and_complete():
out = normalize_choices({"design_system_id": 4})
assert out == {"design_system_id": 4, "seed_systems": False}
assert normalize_choices(None) == {"design_system_id": None, "seed_systems": False}
assert out == {"design_system_id": 4, "seed_systems": False, "platforms": None}
assert normalize_choices(None) == {
"design_system_id": None, "seed_systems": False, "platforms": None,
}
# Sorted and de-duplicated, so two equal answers record identically.
assert normalize_choices({"platforms": ["go", " android-app", "go"]})["platforms"] == [
"android-app", "go",
]
def test_standard_systems_vocabulary_reads_the_catalog_not_a_constant():
+577
View File
@@ -0,0 +1,577 @@
"""The adoption ledger against real Postgres (milestone 463 step 4).
What the unit lane can't show: an answer moves the row and logs exactly one
decision; an owed answer files its task in the OWING project, tagged to the
System matching the idea's, and the task follows the answer from there; a
version bump leaves every older answer reading as needing a recheck; each
branch of the conflict order folds the losing side into the note and settles
both rows; and the same assessment given twice records nothing the second
time.
Precedent search ranks by meaning and the lane has no embedding model, so
the search is stubbed to "nothing similar" (`_no_meaning`). The same-idea
precedents — another project's answer to this idea — need no embedder, and
are tested here.
"""
from unittest.mock import AsyncMock, patch
import pytest
import pytest_asyncio
from sqlalchemy import select
from scribe.models import async_session
from scribe.models.canonical_system import CanonicalSystem
from scribe.models.family import FamilyAdoption, FamilyDecision, FamilyIdea, Platform, ProjectPlatform
from scribe.models.note import Note
from scribe.models.project import Project
from scribe.models.system import RecordSystem, System
from scribe.models.task_log import TaskLog
from scribe.models.user import User
from scribe.services import family as family_svc
from scribe.services import family_adoption as adoption_svc
from tests.helpers import ensure_user
pytestmark = [pytest.mark.integration, pytest.mark.usefixtures("_dispose_engine")]
OWNER = "family_adoption_owner"
OUTSIDER = "family_adoption_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 promotion 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():
"""Two Android apps and a Go service. The idea is a note in the first app,
filed under a System mapped to a canonical area; the second app has its
own System mapped to the same area, and one that is not. Promoted, so
both apps hold an `unassessed` row."""
android, go = await _platform_id("android-app"), await _platform_id("go")
async with async_session() as s:
canonical = await s.scalar(select(CanonicalSystem.id).where(
CanonicalSystem.deleted_at.is_(None)).order_by(CanonicalSystem.id).limit(1))
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")
s.add_all([a, b, c])
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="declared"),
ProjectPlatform(project_id=c.id, platform_id=go, state="declared"),
])
a_sys = System(user_id=owner.id, project_id=a.id, name="Release", canonical_id=canonical)
b_sys = System(user_id=owner.id, project_id=b.id, name="Shipping", canonical_id=canonical)
b_other = System(user_id=owner.id, project_id=b.id, name="Unrelated")
pattern = Note(user_id=owner.id, project_id=a.id, title="Signed APK lane",
body="One keystore, two channels, in-place update.")
s.add_all([a_sys, b_sys, b_other, pattern])
await s.flush()
s.add(RecordSystem(note_id=pattern.id, system_id=a_sys.id))
await s.commit()
ids = {"owner": owner.id, "outsider": outsider.id, "a": a.id, "b": b.id, "c": c.id,
"note": pattern.id, "b_sys": b_sys.id, "b_other": b_other.id}
out = await family_svc.promote(
ids["owner"], ids["note"], applies_when="any Android app that ships its own APK",
platforms=["android-app"], criteria=CRITERIA,
evidence=["CI green on the release lane"], reason="proven, stated for the platform",
)
assert out["promoted"] is True
return ids
async def _assess(f, project: str, outcome: str, reason: str = "the gap", **kw):
if outcome == "adopted":
kw.setdefault("evidence", ["app/build.gradle: the release lane"])
return await adoption_svc.assess(f["owner"], f[project], f["note"],
outcome=outcome, reason=reason, **kw)
async def _row(f, project: str) -> FamilyAdoption:
async with async_session() as s:
return (await s.execute(select(FamilyAdoption).where(
FamilyAdoption.project_id == f[project], FamilyAdoption.idea_id == f["note"]))).scalars().one()
async def _task(task_id: int) -> Note:
async with async_session() as s:
return await s.get(Note, task_id)
async def _decisions(f, project: str) -> list[FamilyDecision]:
async with async_session() as s:
return list((await s.execute(select(FamilyDecision).where(
FamilyDecision.idea_id == f["note"], FamilyDecision.project_id == f[project])
.order_by(FamilyDecision.id))).scalars().all())
# --- each state change ----------------------------------------------------------------
@pytest.mark.parametrize("outcome", ("adopted", "variant", "exempt"))
async def test_an_answer_moves_the_row_and_logs_one_decision(family, outcome):
out = await _assess(family, "b", outcome, reason="a fact about android two")
row = await _row(family, "b")
assert (row.status, row.reason, row.canon_version, row.decided_via) == (
outcome, "a fact about android two", 1, "agent")
assert row.assessed_at is not None and row.owed_task_id is None
[decision] = await _decisions(family, "b")
assert decision.action == "assess"
assert decision.before == {"status": "unassessed", "reason": "", "canon_version": None}
assert decision.after["status"] == outcome
assert out["changed"] is True and out["owed_task"] is None
async def test_owed_files_a_task_in_the_owing_project_tagged_to_the_matching_system(family):
out = await _assess(family, "b", "owed", reason="no in-place update yet")
row = await _row(family, "b")
assert row.status == "owed" and row.owed_task_id == out["owed_task"]["id"]
task = await _task(row.owed_task_id)
# Filed where the work is owed — never in the idea's own project.
assert task.project_id == family["b"] and task.status == "todo"
assert f"#{family['note']}" in task.body and "no in-place update yet" in task.body
assert "any Android app that ships its own APK" in task.body
async with async_session() as s:
tagged = set((await s.execute(select(RecordSystem.system_id).where(
RecordSystem.note_id == task.id))).scalars().all())
assert tagged == {family["b_sys"]}
async def test_the_owed_task_follows_the_answer(family):
await _assess(family, "b", "owed", reason="not built")
task_id = (await _row(family, "b")).owed_task_id
await _assess(family, "b", "variant", reason="installs through a managed store")
assert (await _task(task_id)).status == "cancelled"
out = await _assess(family, "b", "owed", reason="the store plan fell through")
assert out["owed_task"] == {"id": task_id, "title": (await _task(task_id)).title,
"status": "todo", "action": "reopened"}
await _assess(family, "b", "adopted", reason="built")
assert (await _task(task_id)).status == "done"
async with async_session() as s:
logs = (await s.execute(select(TaskLog.content).where(TaskLog.task_id == task_id)
.order_by(TaskLog.id))).scalars().all()
assert [("variant" in logs[0]), ("owed again" in logs[1]), ("adopted" in logs[2])] == [True] * 3
# One task across the whole life of the answer.
async with async_session() as s:
filed = (await s.execute(select(Note.id).where(
Note.project_id == family["b"], Note.title.like("Adopt the family idea%")))).scalars().all()
assert filed == [task_id]
async def test_the_same_assessment_twice_records_nothing_the_second_time(family):
first = await _assess(family, "b", "owed", reason="not built")
second = await _assess(family, "b", "owed", reason="not built")
assert first["changed"] is True and second["changed"] is False
assert second["decision"] is None
assert second["owed_task"]["id"] == first["owed_task"]["id"]
assert len(await _decisions(family, "b")) == 1
async def test_a_departure_without_a_reason_writes_nothing(family):
with pytest.raises(ValueError, match="exempt needs a reason"):
await _assess(family, "b", "exempt", reason=" ")
with pytest.raises(ValueError, match="adopted needs evidence"):
await _assess(family, "b", "adopted", evidence=[])
assert (await _row(family, "b")).status == "unassessed"
assert await _decisions(family, "b") == []
async def test_an_idea_that_does_not_reach_the_project_is_refused(family):
with pytest.raises(ValueError, match="not on any of"):
await _assess(family, "c", "exempt", reason="it is a Go service")
async def test_only_canon_is_assessed(family):
await family_svc.retire(family["owner"], family["note"], reason="superseded")
with pytest.raises(ValueError, match="not family canon"):
await _assess(family, "a", "adopted")
async def test_someone_who_cannot_write_the_project_cannot_answer_for_it(family):
with pytest.raises(ValueError, match="no write access"):
await adoption_svc.assess(family["outsider"], family["b"], family["note"],
outcome="exempt", reason="not mine to say")
async def test_another_projects_answer_is_recorded_as_precedent(family):
first = await _assess(family, "a", "adopted", reason="the source app")
second = await _assess(family, "b", "owed", reason="not built")
assert first["decision"]["id"] in second["decision"]["precedent_ids"]
assert second["precedents"][0]["relation"] == "same idea, another project"
assert second["precedents"][0]["project_title"] == "android one"
# --- recheck --------------------------------------------------------------------------
async def test_a_revision_leaves_every_older_answer_needing_a_recheck(family):
await _assess(family, "a", "adopted")
await _assess(family, "b", "exempt", reason="ships through a store")
out = await family_svc.revise(family["owner"], family["note"],
reason="the keystore now rotates", evidence=["incident"])
assert out["idea"]["canon_version"] == 2
rows = await adoption_svc.list_adoptions(family["owner"], idea_id=family["note"])
assert {r["project_title"]: r["needs_recheck"] for r in rows} == {
"android one": True, "android two": True}
assert [r["project_title"] for r in await adoption_svc.list_adoptions(
family["owner"], idea_id=family["note"], recheck_only=True)] == ["android one", "android two"]
await _assess(family, "a", "adopted", reason="rotation added")
rows = {r["project_title"]: r for r in await adoption_svc.list_adoptions(
family["owner"], idea_id=family["note"])}
assert rows["android one"]["needs_recheck"] is False
assert rows["android one"]["canon_version"] == 2
assert rows["android two"]["needs_recheck"] is True
async def test_a_revision_needs_canon_and_a_reason(family):
with pytest.raises(ValueError, match="needs a reason"):
await family_svc.revise(family["owner"], family["note"], reason="")
with pytest.raises(ValueError, match="at least one platform"):
await family_svc.revise(family["owner"], family["note"], reason="x", platforms=[])
# --- undo ------------------------------------------------------------------------------
async def test_undoing_an_owed_answer_restores_the_row_and_closes_its_task(family):
out = await _assess(family, "b", "owed", reason="not built")
task_id = out["owed_task"]["id"]
decisions = await family_svc.list_decisions(family["owner"], project_id=family["b"])
assert decisions[0]["undoable"] is True and decisions[0]["project_title"] == "android two"
await family_svc.undo(family["owner"], out["decision"]["id"], reason="assessed too early")
row = await _row(family, "b")
assert (row.status, row.reason, row.canon_version, row.assessed_at) == (
"unassessed", None, None, None)
assert (await _task(task_id)).status == "cancelled"
undo_row = (await _decisions(family, "b"))[-1]
assert undo_row.action == "undo" and undo_row.precedent_ids == [out["decision"]["id"]]
async def test_only_the_latest_answer_is_undoable(family):
first = await _assess(family, "b", "owed", reason="not built")
await _assess(family, "b", "adopted", reason="built")
with pytest.raises(ValueError, match="came after it"):
await family_svc.undo(family["owner"], first["decision"]["id"], reason="x")
# --- the conflict order -------------------------------------------------------------
def _checked(ground: str) -> dict:
keys = adoption_svc.CONFLICT_KEYS
return {k: f"{k} does not apply here" for k in keys[:keys.index(ground)]}
async def _resolve(f, ground: str, **kw):
kw.setdefault("evidence", ["named in the record"])
return await adoption_svc.resolve_conflict(
f["owner"], f["note"], canon_project_id=f["a"], other_project_id=f["b"],
ground=ground, grounds_checked=_checked(ground),
fold="Android two signs in CI with a throwaway key.", reason="settled", **kw)
@pytest.mark.parametrize("ground,heading,other_outcome", [
("operator_stance", "### Alternative — android two's approach", "owed"),
("covers_failure", "### Trap — android two's approach", "owed"),
("most_recent_complete", "### Alternative — android two's approach", "owed"),
])
async def test_a_side_that_loses_is_folded_in_and_owes_the_canon(family, ground, heading, other_outcome):
out = await _resolve(family, ground)
async with async_session() as s:
body = (await s.get(Note, family["note"])).body
idea = await s.get(FamilyIdea, family["note"])
assert heading in body and "throwaway key" in body
assert idea.canon_version == 2
assert out["decision"]["action"] == "revise"
assert out["decision"]["evidence"]["conflict"]["ground"] == ground
a, b = await _row(family, "a"), await _row(family, "b")
assert (a.status, a.canon_version) == ("adopted", 2)
assert (b.status, b.canon_version) == (other_outcome, 2)
assert (await _task(b.owed_task_id)).project_id == family["b"]
# Each answer names the revision as the decision it followed.
assert (await _decisions(family, "b"))[-1].precedent_ids == [out["decision"]["id"]]
async def test_a_split_makes_each_side_canon_under_its_condition(family):
out = await _resolve(family, "split_by_condition", evidence=None,
conditions={"canon": "the app updates itself",
"other": "a managed store installs it"})
async with async_session() as s:
body = (await s.get(Note, family["note"])).body
assert "### When a managed store installs it — android two's approach" in body
assert "When the app updates itself, android one's approach above is the canon" in body
a, b = await _row(family, "a"), await _row(family, "b")
assert (a.status, b.status) == ("adopted", "adopted")
assert b.owed_task_id is None and out["owed_task"] is None
async def test_a_ground_cannot_skip_the_order_and_nothing_is_written(family):
async with async_session() as s:
body_before = (await s.get(Note, family["note"])).body
with pytest.raises(ValueError, match="'operator_stance' comes before 'covers_failure'"):
await adoption_svc.resolve_conflict(
family["owner"], family["note"], canon_project_id=family["a"],
other_project_id=family["b"], ground="covers_failure", grounds_checked={},
fold="x", evidence=["incident"], reason="settled")
async with async_session() as s:
assert (await s.get(Note, family["note"])).body == body_before
assert (await s.get(FamilyIdea, family["note"])).canon_version == 1
# --- the matrix -----------------------------------------------------------------------
async def test_the_matrix_shows_every_member_and_who_has_not_been_asked(family):
android = await _platform_id("android-app")
async with async_session() as s:
late = Project(user_id=family["owner"], title="android three")
s.add(late)
await s.flush()
s.add(ProjectPlatform(project_id=late.id, platform_id=android, state="detected"))
await s.commit()
late_id = late.id
await _assess(family, "b", "owed", reason="not built")
matrix = await adoption_svc.adoption_matrix(family["owner"])
cells = {(c["project_title"]): c for c in matrix["cells"] if c["idea_id"] == family["note"]}
assert set(cells) == {"android one", "android two", "android three"}
assert cells["android two"]["owed_task"]["status"] == "todo"
assert cells["android three"]["reached"] is False and cells["android three"]["status"] == "unassessed"
assert [o["key"] for o in matrix["outcomes"]] == list(adoption_svc.OUTCOME_KEYS)
# A late member's first answer creates its row.
await adoption_svc.assess(family["owner"], late_id, family["note"],
outcome="exempt", reason="ships through a store")
one = await adoption_svc.adoption_matrix(family["owner"], project_id=late_id)
assert [c["status"] for c in one["cells"]] == ["exempt"]
# Filtered to a platform no canon idea is for, there is nothing to show.
assert (await adoption_svc.adoption_matrix(family["owner"], platform="go"))["ideas"] == []
async def test_the_matrix_hides_what_the_caller_cannot_read(family):
matrix = await adoption_svc.adoption_matrix(family["outsider"])
assert all(i["note_id"] != family["note"] for i in matrix["ideas"])
# --- the shape ledger as evidence (step 5) -------------------------------------------
REPO = "git.example/android-two"
KOTLIN_BODY = (
"fun installUpdate(context: Context, apk: File) {\n"
" val installer = context.packageManager.packageInstaller\n"
" val session = installer.openSession(installer.createSession(params()))\n"
" apk.inputStream().use { src -> session.openWrite(\"apk\", 0, apk.length()).use(src::copyTo) }\n"
"}\n"
)
B_SHAPES = [
("app/src/main/kotlin/Updater.kt", "sym", "selfUpdate"),
("tools/release.py", "sym", "push_update"),
]
async def _reference(f, *, language: str = "kotlin", name: str = "installUpdate") -> int:
from scribe.services import snippets as snippets_svc
snippet = await snippets_svc.create_snippet(
f["owner"], name=f"{name} ({language})", code=KOTLIN_BODY, language=language,
project_id=f["a"],
)
return int(snippet.id)
@pytest_asyncio.fixture
async def shapes(family):
"""Android two's ledger has two live shapes, and the idea has a Kotlin
reference in android one."""
from scribe.services.shape_ledger import sync_repo_shapes
await sync_repo_shapes(family["b"], REPO, B_SHAPES, seen_marker="main")
family["kotlin"] = await _reference(family)
await adoption_svc.set_references(family["owner"], family["note"], [family["kotlin"]])
return family
async def _classify(f, project: str, symbol: str, status: str, **item):
from scribe.services import shape_ledger
path = next(p for p, _, s in B_SHAPES if s == symbol)
return await shape_ledger.classify_shapes(
f["owner"], f[project], [{"path": path, "symbol": symbol, "status": status, **item}])
async def test_a_shape_classified_against_the_idea_answers_it_adopted(shapes):
"""Across languages: a Python shape is an instance of an idea whose only
reference is Kotlin, and the project's answer moves with it."""
out = await _classify(shapes, "b", "push_update", "instance", idea_id=shapes["note"])
assert out["classified"] == 1
assert [m["status"] for m in out["family"]] == ["adopted"]
row = await _row(shapes, "b")
assert (row.status, row.decided_via, row.canon_version) == ("adopted", "system", 1)
[decision] = await _decisions(shapes, "b")
assert decision.evidence["source"] == adoption_svc.SHAPE_SOURCE
assert decision.evidence["evidence"] == ["tools/release.py::push_update"]
assert decision.evidence["shapes"][0]["snippet_id"] == shapes["kotlin"]
# The matrix shows the code that answers the cell.
matrix = await adoption_svc.adoption_matrix(shapes["owner"], project_id=shapes["b"])
[cell] = [c for c in matrix["cells"] if c["idea_id"] == shapes["note"]]
assert cell["shapes"] == [
{"path": "tools/release.py", "symbol": "push_update", "status": "instance"}]
# A second instance agrees with the standing answer: nothing more is logged.
await _classify(shapes, "b", "selfUpdate", "instance", idea_id=shapes["note"])
assert len(await _decisions(shapes, "b")) == 1
async def test_idea_id_prefers_the_reference_in_the_shapes_language(shapes):
from scribe.services.shape_ledger import live_rows
python_ref = await _reference(shapes, language="python", name="install_update")
await adoption_svc.set_references(shapes["owner"], shapes["note"],
[shapes["kotlin"], python_ref])
await _classify(shapes, "b", "push_update", "instance", idea_id=shapes["note"])
await _classify(shapes, "b", "selfUpdate", "instance", idea_id=shapes["note"])
by_symbol = {r.symbol: r.snippet_id for r in await live_rows(shapes["b"])}
assert by_symbol == {"push_update": python_ref, "selfUpdate": shapes["kotlin"]}
async def test_an_idea_with_no_reference_is_refused_and_nothing_applies(family):
from scribe.services.shape_ledger import live_rows, sync_repo_shapes
await sync_repo_shapes(family["b"], REPO, B_SHAPES, seen_marker="main")
with pytest.raises(ValueError, match="no reference implementation"):
await _classify(family, "b", "push_update", "instance", idea_id=family["note"])
assert {r.status for r in await live_rows(family["b"])} == {"unclassified"}
async def test_a_variant_shape_answers_variant_and_withdrawing_it_unassesses(shapes):
await _classify(shapes, "b", "selfUpdate", "variant", idea_id=shapes["note"],
reason="installs through the device-owner API: it is a kiosk")
row = await _row(shapes, "b")
assert row.status == "variant" and "device-owner API" in row.reason
out = await _classify(shapes, "b", "selfUpdate", "unclassified")
assert [(m["idea_id"], m["status"]) for m in out["family"]] == [
(shapes["note"], "unassessed")]
row = await _row(shapes, "b")
assert (row.status, row.canon_version, row.decided_via) == ("unassessed", None, None)
assert (await _decisions(shapes, "b"))[-1].evidence["source"] == adoption_svc.SHAPE_SOURCE
async def test_the_shapes_close_an_owed_task_as_done(shapes):
owed = await _assess(shapes, "b", "owed", reason="no in-place update yet")
await _classify(shapes, "b", "push_update", "instance", idea_id=shapes["note"])
assert (await _task(owed["owed_task"]["id"])).status == "done"
async def test_an_answer_the_shapes_contradict_is_refused(shapes):
await _classify(shapes, "b", "push_update", "instance", idea_id=shapes["note"])
with pytest.raises(ValueError, match="never disagree"):
await _assess(shapes, "b", "owed", reason="the agent thinks otherwise")
# The agreeing answer is not refused.
await _assess(shapes, "b", "adopted", reason="release.py does it",
evidence=["tools/release.py"])
async def test_an_undo_that_would_contradict_the_shapes_is_refused(shapes):
await _assess(shapes, "b", "owed", reason="not yet")
await _classify(shapes, "b", "push_update", "instance", idea_id=shapes["note"])
latest = (await _decisions(shapes, "b"))[-1]
with pytest.raises(ValueError, match="never disagree"):
await family_svc.undo(shapes["owner"], latest.id, reason="go back")
async def test_an_answer_given_on_other_evidence_survives_the_shapes_leaving(shapes):
"""No shape is not the same as owed: withdrawing a shape only withdraws
an answer the shape ledger gave."""
await _assess(shapes, "b", "adopted", reason="CI proves it",
evidence=["ci run 12"])
await _classify(shapes, "b", "push_update", "instance", idea_id=shapes["note"])
out = await _classify(shapes, "b", "push_update", "unclassified")
assert "family" not in out
assert (await _row(shapes, "b")).status == "adopted"
async def test_nothing_is_answered_or_proposed_across_projects_sharing_no_platform(shapes):
"""The Go service shares no platform with the idea: its shape judged
against the reference is plain canon use, and the proposer never offers
the reference to it."""
from scribe.services import shape_ledger
await shape_ledger.sync_repo_shapes(shapes["c"], "git.example/go", [
("cmd/update.go", "sym", "PushUpdate")], seen_marker="main")
out = await shape_ledger.classify_shapes(shapes["owner"], shapes["c"], [
{"path": "cmd/update.go", "symbol": "PushUpdate", "status": "instance",
"snippet_id": shapes["kotlin"]}])
assert out["classified"] == 1 and "family" not in out
async with async_session() as s:
assert (await s.execute(select(FamilyAdoption).where(
FamilyAdoption.project_id == shapes["c"]))).first() is None
assert await adoption_svc.family_reference_ids(shapes["c"]) == set()
assert await adoption_svc.family_reference_ids(shapes["b"]) == {shapes["kotlin"]}
async def test_the_proposer_offers_the_family_reference_as_the_top_hit(shapes):
from scribe.services import shape_ledger
body = ("def push_update(apk_path: str) -> None:\n"
" session = installer.open_session(installer.create_session())\n"
" session.write_stream('apk', open(apk_path, 'rb'))\n"
" session.commit()\n")
defs = [("tools/release.py", "sym", "push_update", "def push_update(apk_path: str) -> None:",
"sha-push", body)]
ref = type("Hit", (), {"id": shapes["kotlin"]})()
with patch("scribe.services.embeddings.semantic_search_notes",
AsyncMock(return_value=[(0.73, ref)])):
await shape_ledger.propose_for_repo(shapes["owner"], shapes["b"], REPO, defs)
row = next(r for r in await shape_ledger.live_rows(shapes["b"]) if r.symbol == "push_update")
assert (row.proposed_snippet_id, row.proposal_basis) == (shapes["kotlin"], "family")
# Confirming it answers the idea.
out = await shape_ledger.confirm_proposals(shapes["owner"], shapes["b"], basis="family")
assert out["confirmed"] == 1 and out["family"][0]["status"] == "adopted"
async def test_a_conflict_brings_the_losing_sides_variant_shapes_back_to_the_todo(shapes):
from scribe.services.shape_ledger import live_rows
await _classify(shapes, "b", "selfUpdate", "variant", idea_id=shapes["note"],
reason="signs in CI with a throwaway key")
out = await _resolve(shapes, "covers_failure")
assert out["shapes_rejudged"] == {str(shapes["b"]): 1}
b = await _row(shapes, "b")
assert b.status == "owed"
row = next(r for r in await live_rows(shapes["b"]) if r.symbol == "selfUpdate")
assert row.status == "unclassified"
+304
View File
@@ -0,0 +1,304 @@
"""Real-Postgres checks for family canon's schema (milestone 463 step 1).
Two kinds of claim live here, and both need a database:
1. THE CONSTRAINTS. The agent assesses and promotes with no approval step, so
the few things that must always hold are held by the schema rather than by
prose a session might skip: a canon idea states when it applies, a variant
or an exemption says why, a decision has a reason. Each is shown refusing
the bad row, and each beside a good row so the refusal is not a fixture
that fails for some other reason.
2. THE RESTORE REMAPS. Two seams can come back plausible and wrong:
- platforms travel by SLUG and must land on the destination's own seeded
rows, not create duplicates of them;
- `precedent_ids` is a list of ids INSIDE JSON. A restore that copied it
raw would leave each decision pointing at whatever took the old number —
populated, plausible, and about the wrong decision.
"""
import pytest
import pytest_asyncio
from sqlalchemy import select
from sqlalchemy.exc import IntegrityError
from scribe.models import async_session
from scribe.models.family import (
FamilyAdoption, FamilyDecision, FamilyIdea, FamilyIdeaPlatform,
FamilyIdeaReference, Platform, ProjectPlatform,
)
from scribe.models.note import Note
from scribe.models.project import Project
from scribe.models.user import User
from scribe.services import backup
from tests.helpers import ensure_user
pytestmark = [pytest.mark.integration, pytest.mark.usefixtures("_dispose_engine")]
OWNER_USERNAME = "family_canon_owner"
RESTORED_USERNAME = "family_canon_restored"
async def _purge(username: str) -> None:
"""user -> project / note is ON DELETE CASCADE, and every family table
cascades from a project or a note, so dropping the user's rows clears
everything this file made. Platforms are global and never created here
beyond the migration's seed, so there is nothing of theirs to clear."""
async with async_session() as s:
for user in (await s.execute(
select(User).where(User.username == username)
)).scalars().all():
for note in (await s.execute(
select(Note).where(Note.user_id == user.id)
)).scalars().all():
await s.delete(note)
for project in (await s.execute(
select(Project).where(Project.user_id == user.id)
)).scalars().all():
await s.delete(project)
if username == RESTORED_USERNAME:
await s.delete(user)
await s.commit()
@pytest_asyncio.fixture(autouse=True)
async def _no_leftovers():
"""SETUP ONLY — a database call after a `yield` in an autouse fixture
orphans a pooled connection (see the backup round-trip siblings)."""
await _purge(RESTORED_USERNAME)
await _purge(OWNER_USERNAME)
async def _platform(slug: str) -> Platform:
async with async_session() as s:
return (await s.execute(
select(Platform).where(Platform.slug == slug, Platform.deleted_at.is_(None))
)).scalars().one()
async def _owner_project_and_note() -> tuple[int, int, int]:
async with async_session() as s:
owner = await ensure_user(s, OWNER_USERNAME)
project = Project(user_id=owner.id, title="an app on a platform")
note = Note(user_id=owner.id, title="an idea", body="the idea")
s.add_all([project, note])
await s.commit()
return owner.id, project.id, note.id
async def _refused(*rows) -> bool:
async with async_session() as s:
s.add_all(list(rows))
try:
await s.commit()
except IntegrityError:
await s.rollback()
return True
return False
# --- the seed ---------------------------------------------------------------
async def test_the_migration_seeds_generic_platforms_with_their_markers():
android = await _platform("android-app")
assert "AndroidManifest.xml" in android.markers
# Declare-only platforms carry an empty list, never NULL: detection reads
# the list and must not have to special-case a missing one.
assert (await _platform("postgresql")).markers == []
# --- the constraints ----------------------------------------------------------
async def test_a_canon_idea_must_say_when_it_applies():
_, _, note_id = await _owner_project_and_note()
assert await _refused(FamilyIdea(note_id=note_id, status="canon", applies_when=" "))
# A candidate may be vague — that is what a candidate is.
assert not await _refused(FamilyIdea(note_id=note_id, status="candidate"))
async def test_a_variant_or_an_exemption_must_say_why():
_, project_id, note_id = await _owner_project_and_note()
assert not await _refused(FamilyIdea(
note_id=note_id, status="canon",
applies_when="any app that installs its own updates",
))
for status in ("variant", "exempt"):
assert await _refused(FamilyAdoption(
project_id=project_id, idea_id=note_id, status=status, reason=" ",
)), f"{status} with a blank reason was accepted"
# Owed and adopted need no reason: neither is a departure.
assert not await _refused(FamilyAdoption(
project_id=project_id, idea_id=note_id, status="owed", decided_via="agent",
))
async def test_an_unknown_state_is_refused():
_, project_id, note_id = await _owner_project_and_note()
assert not await _refused(FamilyIdea(note_id=note_id))
android = await _platform("android-app")
assert await _refused(ProjectPlatform(
project_id=project_id, platform_id=android.id, state="maybe",
))
assert await _refused(FamilyAdoption(
project_id=project_id, idea_id=note_id, status="declined",
))
async def test_a_decision_must_carry_a_reason():
_, _, note_id = await _owner_project_and_note()
assert not await _refused(FamilyIdea(note_id=note_id))
assert await _refused(FamilyDecision(idea_id=note_id, action="propose", reason=" "))
assert not await _refused(FamilyDecision(
idea_id=note_id, action="propose", reason="built twice, in two projects",
))
# --- the restore ------------------------------------------------------------
@pytest_asyncio.fixture
async def source():
"""An idea with everything family canon can hang off it: a platform scope,
a reference snippet, a project that declares one platform and rejects
another, a variant answer, and two decisions where the second cites the
first as its precedent.
`decoy` exists so the target database has an ADDITIONAL decision whose id
could collide with a source precedent id — without it, a raw-copied
precedent list could happen to point at nothing and read as dropped,
rather than as pointing at the wrong decision.
"""
owner_id, project_id, idea_id = await _owner_project_and_note()
android = await _platform("android-app")
container = await _platform("container-image")
async with async_session() as s:
snippet = Note(user_id=owner_id, title="the reference", body="code",
note_type="snippet")
s.add(snippet)
s.add(FamilyIdea(note_id=idea_id, status="canon", canon_version=2,
applies_when="any app that installs its own updates"))
await s.flush()
s.add_all([
FamilyIdeaPlatform(note_id=idea_id, platform_id=android.id),
FamilyIdeaReference(idea_id=idea_id, snippet_id=snippet.id),
ProjectPlatform(project_id=project_id, platform_id=android.id, state="declared"),
ProjectPlatform(project_id=project_id, platform_id=container.id, state="rejected"),
FamilyAdoption(project_id=project_id, idea_id=idea_id, status="variant",
reason="updates arrive through the store, not in-app",
canon_version=1, decided_via="agent"),
])
first = FamilyDecision(idea_id=idea_id, action="promote",
reason="proven in CI, stated in platform terms",
after={"status": "canon"})
s.add(first)
await s.flush()
second = FamilyDecision(idea_id=idea_id, project_id=project_id, action="assess",
reason="store-distributed, as in the promotion's terms",
precedent_ids=[first.id], decided_via="agent")
s.add(second)
await s.commit()
snippet_id = snippet.id
async with async_session() as s:
notes = (await s.execute(
select(Note).where(Note.id.in_([idea_id, snippet_id])).order_by(Note.id)
)).scalars().all()
payload = {
"version": backup.BACKUP_VERSION,
"users": backup._user_rows([await s.get(User, owner_id)]),
"projects": backup._project_rows([await s.get(Project, project_id)]),
"notes": backup._note_rows(notes),
**backup._family_sections(
(await s.execute(select(Platform))).scalars().all(),
(await s.execute(select(ProjectPlatform).where(
ProjectPlatform.project_id == project_id))).scalars().all(),
[await s.get(FamilyIdea, idea_id)],
(await s.execute(select(FamilyIdeaPlatform).where(
FamilyIdeaPlatform.note_id == idea_id))).scalars().all(),
(await s.execute(select(FamilyIdeaReference).where(
FamilyIdeaReference.idea_id == idea_id))).scalars().all(),
(await s.execute(select(FamilyAdoption).where(
FamilyAdoption.idea_id == idea_id))).scalars().all(),
(await s.execute(select(FamilyDecision).where(
FamilyDecision.idea_id == idea_id).order_by(FamilyDecision.id)
)).scalars().all(),
),
}
payload["users"][0]["username"] = RESTORED_USERNAME
return {"payload": payload}
@pytest_asyncio.fixture
async def restored(source):
async with async_session() as s:
before = len((await s.execute(select(Platform))).scalars().all())
stats = await backup.restore_full_backup(source["payload"])
async with async_session() as s:
user = (await s.execute(
select(User).where(User.username == RESTORED_USERNAME)
)).scalars().one()
project = (await s.execute(
select(Project).where(Project.user_id == user.id)
)).scalars().one()
notes = (await s.execute(
select(Note).where(Note.user_id == user.id)
)).scalars().all()
note_ids = [n.id for n in notes]
return {
"stats": stats,
"platforms_before": before,
"platforms_after": len((await s.execute(select(Platform))).scalars().all()),
"project": project,
"notes": {n.note_type: n for n in notes},
"idea": (await s.execute(
select(FamilyIdea).where(FamilyIdea.note_id.in_(note_ids))
)).scalars().one(),
"memberships": (await s.execute(
select(ProjectPlatform).where(ProjectPlatform.project_id == project.id)
)).scalars().all(),
"adoption": (await s.execute(
select(FamilyAdoption).where(FamilyAdoption.project_id == project.id)
)).scalars().one(),
"decisions": (await s.execute(
select(FamilyDecision).where(FamilyDecision.idea_id.in_(note_ids))
.order_by(FamilyDecision.id)
)).scalars().all(),
"references": (await s.execute(
select(FamilyIdeaReference).where(FamilyIdeaReference.idea_id.in_(note_ids))
)).scalars().all(),
}
async def test_platforms_match_by_slug_and_create_nothing(restored):
assert restored["stats"]["platforms"] == 0
assert restored["platforms_after"] == restored["platforms_before"]
async def test_a_rejected_platform_survives_so_detection_cannot_re_add_it(restored):
states = {m.state for m in restored["memberships"]}
assert states == {"declared", "rejected"}
async def test_the_idea_and_its_answer_come_back_whole(restored):
idea = restored["idea"]
assert idea.status == "canon"
assert idea.canon_version == 2
adoption = restored["adoption"]
assert adoption.status == "variant"
assert adoption.reason == "updates arrive through the store, not in-app"
# Assessed against version 1 of a version-2 idea: the derived recheck
# signal must survive the trip, which needs both numbers intact.
assert adoption.canon_version < idea.canon_version
async def test_the_reference_points_at_the_RESTORED_snippet(restored):
[ref] = restored["references"]
assert ref.snippet_id == restored["notes"]["snippet"].id
async def test_a_precedent_is_remapped_to_the_RESTORED_decision(restored):
"""The trap: precedent_ids is a list of ids inside JSON. Copied raw, it
would name the SOURCE decision's id — which in a shared database is a
real row, the wrong one."""
first, second = restored["decisions"]
assert first.action == "promote" and second.action == "assess"
assert second.precedent_ids == [first.id]
assert second.project_id == restored["project"].id
+293
View File
@@ -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
+178
View File
@@ -0,0 +1,178 @@
"""A family idea reaches every project on its platform, and no other
(milestone 463 step 6).
WHY THIS IS AN INTEGRATION TEST — the lesson-reach reasoning (#3730) holds
here too: the reach is one `OR` inside the search's project filter, and only
the ROWS that come back prove it. Every note embeds identically to the query,
so scoping is the only thing that can separate them. The embedder is stubbed;
no similarity is asserted, only membership.
The corpus: an idea written in android one, canon for the Android platform,
with a Kotlin and a Python reference. Android two writes Python, so it should
reach the idea and the Python reference only. The Go service is on no shared
platform and should reach none of it.
"""
import uuid
from unittest.mock import AsyncMock, patch
import pytest
import pytest_asyncio
from sqlalchemy import select
from scribe.models import async_session
from scribe.models.embedding import EMBEDDING_DIM, NoteEmbedding
from scribe.models.family import (
FamilyAdoption, FamilyDecision, FamilyIdea, FamilyIdeaPlatform, FamilyIdeaReference,
Platform, ProjectPlatform,
)
from scribe.models.note import Note
from scribe.models.project import Project
from scribe.services import family_adoption as adoption_svc
from scribe.services.embeddings import CHUNKER_VERSION, EMBEDDING_MODEL, semantic_search_notes
from tests.helpers import ensure_user
pytestmark = [pytest.mark.integration, pytest.mark.usefixtures("_dispose_engine")]
QUERY_VEC = [1.0] + [0.0] * (EMBEDDING_DIM - 1)
async def _platform(s, slug: str) -> int:
return await s.scalar(select(Platform.id).where(
Platform.slug == slug, Platform.deleted_at.is_(None)))
@pytest_asyncio.fixture
async def corpus():
tag = uuid.uuid4().hex[:8]
async with async_session() as s:
owner = await ensure_user(s, f"family_reach_owner_{tag}")
await s.flush()
android, go = await _platform(s, "android-app"), await _platform(s, "go")
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")
s.add_all([a, b, c])
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="declared"),
ProjectPlatform(project_id=c.id, platform_id=go, state="declared"),
])
def snippet(project, title, language):
return Note(user_id=owner.id, project_id=project.id, note_type="snippet",
title=title, body=title, data={"language": language})
rows = {
"idea": Note(user_id=owner.id, project_id=a.id, note_type="note",
title="Signed APK lane", body="one keystore, in-place update"),
"kotlin_ref": snippet(a, "installUpdate (kotlin)", "kotlin"),
"python_ref": snippet(a, "install_update (python)", "python"),
"note_on_a": Note(user_id=owner.id, project_id=a.id, note_type="note",
title="An ordinary note", body="ordinary"),
"b_own": snippet(b, "b's own python helper", "python"),
}
s.add_all(rows.values())
await s.flush()
s.add(FamilyIdea(note_id=rows["idea"].id, status="canon",
applies_when="any Android app that ships its own APK"))
await s.flush()
s.add_all([
FamilyIdeaPlatform(note_id=rows["idea"].id, platform_id=android),
FamilyIdeaReference(idea_id=rows["idea"].id, snippet_id=rows["kotlin_ref"].id),
FamilyIdeaReference(idea_id=rows["idea"].id, snippet_id=rows["python_ref"].id),
])
for note in rows.values():
s.add(NoteEmbedding(
note_id=note.id, chunk_index=0, user_id=owner.id,
embedding=QUERY_VEC, chunk_text=note.title,
chunker_version=CHUNKER_VERSION, embedding_model=EMBEDDING_MODEL,
))
ids = {k: n.id for k, n in rows.items()}
ids.update(owner=owner.id, a=a.id, b=b.id, c=c.id)
await s.commit()
return ids
def _family(corpus) -> set[int]:
"""This corpus's family records. The reach is computed across every
user's canon (the search applies readability afterwards), and the
integration database holds other tests' Android ideas, so assertions
about it are made on this corpus's ids only."""
return {corpus["idea"], corpus["kotlin_ref"], corpus["python_ref"]}
async def _search(uid, **kw):
with patch("scribe.services.embeddings.get_embedding", AsyncMock(return_value=QUERY_VEC)):
hits = await semantic_search_notes(uid, "how does the app update itself", limit=20, **kw)
return {note.id for _score, note in hits}
async def test_an_idea_reaches_a_project_on_its_platform_with_the_reference_in_its_language(corpus):
found = await _search(corpus["owner"], project_id=corpus["b"], include_global_kinds=True)
assert {corpus["idea"], corpus["python_ref"], corpus["b_own"]} <= found
# Android two writes Python: the Kotlin reference stays with the idea.
assert corpus["kotlin_ref"] not in found
assert corpus["note_on_a"] not in found
async def test_an_idea_does_not_reach_a_project_on_no_shared_platform(corpus):
found = await _search(corpus["owner"], project_id=corpus["c"], include_global_kinds=True)
assert not found & {corpus["idea"], corpus["kotlin_ref"], corpus["python_ref"]}
async def test_the_reach_is_off_unless_the_search_widens_the_project(corpus):
"""The near-duplicate gate searches a project without widening it; a
family idea there would block a create on a project it was never
written in."""
found = await _search(corpus["owner"], project_id=corpus["b"])
assert found == {corpus["b_own"]}
async def test_with_no_reference_in_the_projects_language_every_reference_comes(corpus):
async with async_session() as s:
b_own = await s.get(Note, corpus["b_own"])
b_own.data = {"language": "dart"}
await s.commit()
reach = await adoption_svc.family_reach_ids(corpus["b"])
assert reach & _family(corpus) == _family(corpus)
async def test_a_retired_idea_reaches_nobody(corpus):
async with async_session() as s:
(await s.get(FamilyIdea, corpus["idea"])).status = "retired"
await s.commit()
assert not await adoption_svc.family_reach_ids(corpus["b"]) & _family(corpus)
# --- the entry readout and the owed answers a close hands back -------------------
async def test_the_entry_readout_counts_what_the_project_has_to_answer(corpus):
line = await adoption_svc.family_readout(corpus["owner"], corpus["b"])
assert (line["unassessed"], line["owed"], line["needs_recheck"]) == (1, 0, 0)
assert line["calls"]["unassessed"] == (
f'list_family_adoptions(project_id={corpus["b"]}, status="unassessed")')
# Nothing reaches the Go service, so it carries no line at all.
assert await adoption_svc.family_readout(corpus["owner"], corpus["c"]) is None
async def test_owed_since_lists_what_is_still_owed_and_only_since_then(corpus):
from datetime import datetime, timedelta, timezone
async with async_session() as s:
s.add(FamilyAdoption(project_id=corpus["b"], idea_id=corpus["idea"], status="owed",
reason="no in-place update yet", canon_version=1,
decided_via="agent"))
s.add(FamilyDecision(idea_id=corpus["idea"], project_id=corpus["b"], action="assess",
reason="no in-place update yet",
before={"status": "unassessed", "reason": "", "canon_version": None},
after={"status": "owed", "reason": "no in-place update yet",
"canon_version": 1},
evidence={}, precedent_ids=[], decided_via="agent",
user_id=corpus["owner"]))
await s.commit()
now = datetime.now(timezone.utc)
[owed] = await adoption_svc.owed_since(corpus["owner"], now - timedelta(minutes=5))
assert (owed["idea_id"], owed["project_id"], owed["project_title"]) == (
corpus["idea"], corpus["b"], "android two")
assert await adoption_svc.owed_since(corpus["owner"], now + timedelta(minutes=5)) == []
+7 -3
View File
@@ -37,14 +37,16 @@ async def seeded():
async def test_decide_applies_every_effect_and_records_last(seeded):
owner, pid = seeded["owner"], seeded["pid"]
defaults = await inception_svc.current_defaults(owner, pid)
assert set(defaults) == {"design_system_id", "design_systems", "systems"}
assert set(defaults) == {
"design_system_id", "design_systems", "systems", "platforms", "project_platforms",
}
assert defaults["systems"] == 0 and defaults["design_system_id"] is None
out = await inception_svc.decide(owner, pid, via="mcp", choices={
"design_system_id": None,
"seed_systems": True,
})
assert set(out["effects"]) == {"design_system_id", "systems_seeded"}
assert set(out["effects"]) == {"design_system_id", "systems_seeded", "platforms"}
catalog = await canonical_svc.list_canonical_systems()
assert len(out["effects"]["systems_seeded"]) == len(catalog)
# Seeded Systems come out mapped, not needing a later reconciliation.
@@ -56,7 +58,9 @@ async def test_decide_applies_every_effect_and_records_last(seeded):
project = await s.get(Project, pid)
assert inception_svc.is_decided(project)
assert project.inception["via"] == "mcp" and project.inception["decided_by"] == owner
assert project.inception["choices"] == {"design_system_id": None, "seed_systems": True}
assert project.inception["choices"] == {
"design_system_id": None, "seed_systems": True, "platforms": None,
}
# Re-deciding with seed again mints nothing twice.
again = await inception_svc.decide(owner, pid, via="ui", choices={"seed_systems": True})
assert again["effects"]["systems_seeded"] == []
+134
View File
@@ -0,0 +1,134 @@
"""Real-Postgres checks for platform membership (milestone 463 step 2).
The one invariant: detection only ever ADDS, and only where nobody has
answered. A refresh that overwrote a "no" would put a project back into a
family it was taken out of; one that overwrote a "yes" would erase who said
it. Each is shown here against the real writer, beside the person-facing
writes it must defer to.
"""
import pytest
import pytest_asyncio
from sqlalchemy import select
from scribe.models import async_session
from scribe.models.family import Platform
from scribe.models.project import Project
from scribe.services import inception as inception_svc
from scribe.services import platforms as platforms_svc
from tests.helpers import ensure_user
pytestmark = [pytest.mark.integration, pytest.mark.usefixtures("_dispose_engine")]
OWNER = "platforms_owner"
async def _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 seeded():
"""An owner, an outsider and a fresh project. Each test gets its own
project, so answers never leak between tests through a shared row."""
async with async_session() as s:
owner = await ensure_user(s, OWNER)
outsider = await ensure_user(s, "platforms_outsider")
project = Project(user_id=owner.id, title="an app with a server")
s.add(project)
await s.flush()
ids = {"owner": owner.id, "outsider": outsider.id, "pid": project.id}
await s.commit()
return ids
def _states(rows: list[dict]) -> dict[str, str]:
return {r["slug"]: r["state"] for r in rows}
async def test_detection_adds_only_where_nobody_has_answered(seeded):
owner, pid = seeded["owner"], seeded["pid"]
await platforms_svc.set_project_platforms(owner, pid, {
"go": "declared", "rust": "rejected",
})
added = await platforms_svc.detect_for_project(pid, [
"server/go.mod", "core/Cargo.toml", "app/src/main/AndroidManifest.xml",
])
# Only android was unanswered; go and rust keep the person's answers.
assert added == [await _id("android-app")]
states = _states(await platforms_svc.project_platforms(owner, pid))
assert states == {"go": "declared", "rust": "rejected", "android-app": "detected"}
async def test_detection_never_removes_a_platform_whose_marker_is_gone(seeded):
owner, pid = seeded["owner"], seeded["pid"]
await platforms_svc.detect_for_project(pid, ["server/go.mod"])
await platforms_svc.detect_for_project(pid, ["README.md"])
assert _states(await platforms_svc.project_platforms(owner, pid)) == {"go": "detected"}
async def test_a_withdrawn_answer_lets_detection_decide_again(seeded):
owner, pid = seeded["owner"], seeded["pid"]
await platforms_svc.set_project_platforms(owner, pid, {"go": "rejected"})
assert await platforms_svc.detect_for_project(pid, ["go.mod"]) == []
await platforms_svc.set_project_platforms(owner, pid, {"go": None})
assert await platforms_svc.detect_for_project(pid, ["go.mod"]) == [await _id("go")]
async def test_a_bad_update_writes_nothing(seeded):
owner, pid = seeded["owner"], seeded["pid"]
with pytest.raises(ValueError, match="unknown platform"):
await platforms_svc.set_project_platforms(owner, pid, {
"go": "declared", "not-a-platform": "declared",
})
assert await platforms_svc.project_platforms(owner, pid) == []
with pytest.raises(ValueError, match="no write access"):
await platforms_svc.set_project_platforms(seeded["outsider"], pid, {"go": "declared"})
assert await platforms_svc.project_platforms(seeded["outsider"], pid) is None
async def test_inception_records_slugs_and_rejects_what_it_leaves_out(seeded):
"""The inception list is the WHOLE answer. Detection found Android and
Go; the person says the project is Go and Python — so Android becomes a
recorded "no", and the next refresh cannot bring it back."""
owner, pid = seeded["owner"], seeded["pid"]
await platforms_svc.detect_for_project(pid, ["app/AndroidManifest.xml", "go.mod"])
defaults = await inception_svc.current_defaults(owner, pid)
assert {"android-app", "go"} <= {p["slug"] for p in defaults["project_platforms"]}
out = await inception_svc.decide(owner, pid, via="ui", choices={
"seed_systems": False, "platforms": ["python", "go"],
})
assert _states(out["effects"]["platforms"]) == {
"android-app": "rejected", "go": "declared", "python": "declared",
}
async with async_session() as s:
project = await s.get(Project, pid)
assert project.inception["choices"]["platforms"] == ["go", "python"]
assert await platforms_svc.detect_for_project(pid, ["app/AndroidManifest.xml"]) == []
async def test_an_unknown_inception_platform_applies_nothing(seeded):
owner, pid = seeded["owner"], seeded["pid"]
with pytest.raises(ValueError, match="unknown platform"):
await inception_svc.decide(owner, pid, via="mcp", choices={
"seed_systems": True, "platforms": ["go", "cobol-mainframe"],
})
assert await platforms_svc.project_platforms(owner, pid) == []
async def test_the_catalog_is_admin_written_and_duplicate_gated():
async with async_session() as s:
admin = await ensure_user(s, "platforms_admin", role="admin")
user = await ensure_user(s, OWNER)
await s.commit()
admin_id, user_id = admin.id, user.id
assert await platforms_svc.create_platform(user_id, "Kotlin Multiplatform") is None
# "Android App" reduces to the seeded android-app: the existing one comes
# back rather than a second spelling of it.
dup = await platforms_svc.create_platform(admin_id, "Android App")
assert dup["duplicate"] and dup["existing_id"] == await _id("android-app")
with pytest.raises(ValueError, match="repo-relative"):
await platforms_svc.create_platform(admin_id, "Odd", markers=["/abs"])
+53
View File
@@ -1,4 +1,5 @@
"""Tests for fable_*_project tools."""
import contextlib
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
@@ -25,6 +26,18 @@ def _no_systems():
yield
@pytest.fixture(autouse=True)
def _no_platforms():
"""enter_project and get_project carry the project's platforms (milestone
463). No database in this lane, so the lookup is stubbed to the common
case — a project with none yet. The populated shape is asserted in its
own test.
"""
with patch("scribe.mcp.tools.projects.platforms_svc.project_platforms",
AsyncMock(return_value=[])):
yield
@pytest.fixture(autouse=True)
def _no_coverage():
"""enter_project also reads the pattern-coverage cache (#2692) — same
@@ -36,6 +49,16 @@ def _no_coverage():
yield
@pytest.fixture(autouse=True)
def _no_family():
"""enter_project reads the family-canon readout (milestone 463 step 6) —
no database here, so the common case is stubbed: nothing to answer. The
populated key is asserted in its own test below."""
with patch("scribe.mcp.tools.projects.family_adoption_svc.family_readout",
AsyncMock(return_value=None)) as mock:
yield mock
@pytest.fixture(autouse=True)
def _no_background_seed():
"""enter_project now fire-and-forgets a coverage self-seed (#2802). These
@@ -471,3 +494,33 @@ def test_inception_routes_and_tool_are_registered():
# Rules left inception with subscriptions (milestone 414).
assert "subscribe_rulebooks" not in tool.parameters.get("properties", {})
@pytest.mark.asyncio
async def test_enter_project_carries_the_family_line_only_when_it_asks_something(_no_family):
"""The `family` key is attached when the project has family canon to
answer, and absent otherwise — a key that usually says null trains
readers to skip it (#2483)."""
line = {"line": "family canon on this project's platforms: 2 unassessed",
"unassessed": 2, "owed": 0, "needs_recheck": 0,
"calls": {"unassessed": 'list_family_adoptions(project_id=5, status="unassessed")'}}
async def enter():
with contextlib.ExitStack() as stack:
for cm in _enter_project_stubs(fake_project(id=5)):
stack.enter_context(cm)
return await enter_project(project_id=5)
assert "family" not in await enter()
_no_family.return_value = line
assert (await enter())["family"] == line
@pytest.mark.asyncio
async def test_a_failing_family_readout_never_fails_the_handshake(_no_family):
_no_family.side_effect = RuntimeError("database down")
with contextlib.ExitStack() as stack:
for cm in _enter_project_stubs(fake_project(id=5)):
stack.enter_context(cm)
out = await enter_project(project_id=5)
assert "family" not in out and out["project"]["id"] == 5
+23 -2
View File
@@ -16,15 +16,17 @@ pytestmark = pytest.mark.usefixtures("_bind_user")
_PREF = {"id": 41, "title": "Say how it was checked", "statement": "…", "kind": "preference"}
async def _update(prefs=None, **kwargs):
async def _update(prefs=None, owed=None, **kwargs):
from scribe.mcp.tools.tasks import update_task
note = MagicMock(id=5, user_id=7, project_id=3)
note = MagicMock(id=5, user_id=7, project_id=3, started_at="2026-10-06T09:00:00+00:00")
note.to_dict.return_value = {"id": 5}
lookup = AsyncMock(return_value=list(prefs or []))
with patch("scribe.mcp.tools.tasks.notes_svc.update_note", AsyncMock(return_value=note)), \
patch("scribe.mcp.tools.tasks.systems_tools.attach_systems", AsyncMock()), \
patch("scribe.mcp.tools.tasks.placement_svc.attach_placement", AsyncMock()), \
patch("scribe.mcp.tools.tasks.family_adoption_svc.owed_since",
AsyncMock(return_value=list(owed or []))), \
patch("scribe.mcp.tools.tasks.reply_prefs_svc.completion_preferences", lookup):
return await update_task(task_id=5, **kwargs), lookup
@@ -56,3 +58,22 @@ async def test_no_preferences_means_no_key_and_the_plain_cue():
out, _ = await _update(prefs=[], status="done")
assert "reply_preferences" not in out
assert out["report_back"] == REPORT_BACK_CUE
_OWED = {"idea_id": 9, "idea_title": "Signed APK lane", "project_id": 4,
"project_title": "android two", "owed_task_id": 77}
async def test_closing_names_the_owed_adoptions_filed_while_the_task_was_open():
"""The finishing moment of milestone 463 step 6: owed family work filed
into a project during this task comes back for the report to name."""
from scribe.services.family_adoption import OWED_CUE
out, _ = await _update(owed=[_OWED], status="done")
assert out["family_owed"] == [_OWED]
assert out["report_back"].endswith(OWED_CUE) and "family_owed" in out["report_back"]
async def test_no_owed_adoptions_means_no_key():
out, _ = await _update(owed=[], status="done")
assert "family_owed" not in out
+15
View File
@@ -24,6 +24,19 @@ PLAN = "A plan paragraph long enough to matter. " * 125 # ~5k chars
GOAL = "What the project is for. " * 50 # ~1.2k chars
@pytest.fixture(autouse=True)
def _no_platforms():
"""enter_project and get_project carry the project's platforms (milestone
463). No database in this lane, so the lookup is stubbed to the common
case — a project with none yet. The populated shape is asserted in its
own test.
"""
with patch("scribe.mcp.tools.projects.platforms_svc.project_platforms",
AsyncMock(return_value=[])):
yield
def _milestone(mid: int, status: str, touched_day: int) -> dict:
"""A summary row as get_project_milestone_summary returns it."""
touched = f"2026-08-{touched_day:02d}T00:00:00+00:00"
@@ -104,6 +117,8 @@ def _enter_stubs(project, milestones: list[dict], tasks: list, *, rules=None, sy
AsyncMock(return_value=design)),
patch("scribe.mcp.tools.projects.coverage_svc.cached_coverage",
AsyncMock(return_value=None)),
patch("scribe.mcp.tools.projects.family_adoption_svc.family_readout",
AsyncMock(return_value=None)),
patch("scribe.mcp.tools.projects.spawn"),
]
+2 -11
View File
@@ -8,11 +8,9 @@ deliberately reuse the definitions test_write_path_trigger stages for the
hook; extending one detector means extending both, and this comment is the
tripwire.
"""
import io
import json
import shutil
import subprocess
import tarfile
from pathlib import Path
import pytest
@@ -29,7 +27,7 @@ from scribe.services.coverage import (
shapes_from_archive,
)
from scribe.services.shape_ledger import location_covers
from tests.helpers import ensure_user
from tests.helpers import ensure_user, forge_tarball
PLUGIN = Path(__file__).resolve().parents[1] / "plugin"
@@ -173,14 +171,7 @@ def test_scannable_gates_prose_vendored_and_sourcemaps():
# --- unit: reading shapes out of a forge tarball -----------------------------
def _tarball(files: dict[str, bytes], top: str = "widget") -> bytes:
buf = io.BytesIO()
with tarfile.open(fileobj=buf, mode="w:gz") as tar:
for path, data in files.items():
info = tarfile.TarInfo(f"{top}/{path}")
info.size = len(data)
tar.addfile(info, io.BytesIO(data))
return buf.getvalue()
_tarball = forge_tarball # shared with tests/test_platforms.py
TREE = {
+145
View File
@@ -0,0 +1,145 @@
"""Platforms without a database (milestone 463 step 2).
Detection is pure — a catalog and a list of repo paths in, platform ids out —
so it is pinned here against fixture trees. What it writes, and what it must
never overwrite, is in tests/test_integration_platforms.py.
"""
from __future__ import annotations
from types import SimpleNamespace
from scribe.mcp.server import _READ_ONLY_TOOLS, _WRITE_TOOLS
from scribe.mcp.tools.projects import _inception_choices
from scribe.services.coverage import scan_archive
from tests.helpers import forge_tarball
from scribe.services.platforms import (
MEMBER_STATES, SETTABLE_STATES, detect, marker_matches, members,
validate_markers, validate_updates,
)
def _platform(pid: int, *markers: str) -> SimpleNamespace:
return SimpleNamespace(id=pid, markers=list(markers))
# --- a single marker ----------------------------------------------------------
def test_a_bare_marker_matches_the_basename_anywhere_in_the_tree():
assert marker_matches("go.mod", "go.mod")
assert marker_matches("go.mod", "services/api/go.mod")
assert marker_matches("AndroidManifest.xml", "app/src/main/AndroidManifest.xml")
assert marker_matches("vite.config.*", "frontend/vite.config.ts")
assert not marker_matches("go.mod", "go.mod.bak")
assert not marker_matches("go.mod", "docs/go.mod.md")
def test_a_marker_with_a_slash_matches_the_whole_path_only():
"""A directory-shaped marker cannot be satisfied by a same-named file
somewhere else — `.github/workflows/*` is about the repo root's CI, not
a vendored copy of some other project's."""
assert marker_matches(".github/workflows/*", ".github/workflows/ci.yml")
assert not marker_matches(".github/workflows/*", "vendor/x/.github/workflows/ci.yml")
assert not marker_matches(".github/workflows/*", "ci.yml")
def test_a_blank_marker_matches_nothing():
assert not marker_matches("", "anything")
assert not marker_matches(" ", "anything")
# --- detection over a tree -------------------------------------------------------
ANDROID_AND_GO = [
"app/build.gradle.kts",
"app/src/main/AndroidManifest.xml",
"server/go.mod",
"server/main.go",
"README.md",
]
def test_detect_finds_every_platform_a_tree_carries_and_nothing_else():
catalog = [
_platform(1, "AndroidManifest.xml"),
_platform(2, "go.mod"),
_platform(3, "Cargo.toml"),
_platform(4, ".github/workflows/*"),
]
assert detect(catalog, ANDROID_AND_GO) == [1, 2]
def test_a_platform_with_no_markers_is_declare_only():
"""An empty marker list is how a platform says "a person must tell you" —
it must not match everything, and it must not match nothing by error."""
catalog = [_platform(1), _platform(2, "go.mod")]
assert detect(catalog, ANDROID_AND_GO) == [2]
assert detect([SimpleNamespace(id=9, markers=None)], ANDROID_AND_GO) == []
def test_detect_on_an_empty_tree_finds_nothing():
assert detect([_platform(1, "go.mod")], []) == []
def test_the_archive_scan_carries_every_path_not_only_the_scannable_ones():
"""The markers that say what a project IS are mostly files the shape scan
skips — a manifest, a go.mod, a gradle script. Detection reads the scan's
paths, so a scan that listed only code files would detect nothing."""
scan = scan_archive(forge_tarball({
"app/src/main/AndroidManifest.xml": b"<manifest/>",
"server/go.mod": b"module x\n",
"server/main.py": b"def main():\n pass\n",
}))
assert set(scan.paths) == {
"app/src/main/AndroidManifest.xml", "server/go.mod", "server/main.py",
}
# The forge's wrapping directory is stripped, as it is for shapes.
assert not any(p.startswith("widget/") for p in scan.paths)
# --- the answers a person may give -------------------------------------------------
def test_detected_is_the_refreshs_to_write_never_a_persons():
assert "detected" in MEMBER_STATES
assert "detected" not in SETTABLE_STATES
assert "rejected" not in MEMBER_STATES
def test_validate_updates():
assert validate_updates({"go": "declared", "rust": "rejected", "python": None}) is None
assert validate_updates({}) is None
assert "slug: state" in validate_updates(["go"])
assert "not a state a person sets" in validate_updates({"go": "detected"})
assert "not a state a person sets" in validate_updates({"go": "maybe"})
assert "named by its slug" in validate_updates({"": "declared"})
def test_validate_markers():
assert validate_markers(None) is None
assert validate_markers(["go.mod", ".github/workflows/*"]) is None
assert "list of glob patterns" in validate_markers("go.mod")
assert "list of glob patterns" in validate_markers([1])
assert "repo-relative" in validate_markers(["/etc/passwd"])
def test_members_drops_the_rejected_answers():
rows = [
{"id": 1, "slug": "go", "name": "Go", "state": "declared"},
{"id": 2, "slug": "rust", "name": "Rust", "state": "rejected"},
{"id": 3, "slug": "python", "name": "Python", "state": "detected"},
]
assert [m["slug"] for m in members(rows)] == ["go", "python"]
# --- the doors ------------------------------------------------------------------------
def test_the_platform_tools_are_classified():
assert "list_platforms" in _READ_ONLY_TOOLS
assert "set_project_platforms" in _WRITE_TOOLS
def test_inception_choices_carry_platforms_only_when_given():
"""Left out, platforms stays UNSTATED (None after normalising) — which is
not the same answer as an empty list, "none of them"."""
assert "platforms" not in _inception_choices(0, True)
assert _inception_choices(0, True, ["go"])["platforms"] == ["go"]
assert _inception_choices(0, True, [])["platforms"] == []
+37
View File
@@ -0,0 +1,37 @@
"""Structural tests for the family blueprint (milestone 463 steps 3 and 4) — 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"),
("/api/family/matrix", "GET"),
):
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", "revise"):
params = inspect.signature(getattr(svc, name)).parameters
assert "user_id" in params and "decided_via" in params, name
def test_every_ledger_write_takes_the_caller_and_who_decided():
from scribe.services import family_adoption as svc
for name in ("assess", "resolve_conflict", "undo_assessment"):
params = inspect.signature(getattr(svc, name)).parameters
assert "user_id" in params and "decided_via" in params, name
+28
View File
@@ -0,0 +1,28 @@
"""Structural tests for the platforms blueprint (milestone 463 step 2) —
registration and the route-to-service contract. What the writes do is in
tests/test_integration_platforms.py."""
import inspect
def test_platforms_blueprint_registered_in_app():
from scribe.app import create_app
app = create_app()
assert "platforms" 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/platforms", "GET"),
("/api/platforms", "POST"),
("/api/platforms/<int:platform_id>", "PATCH"),
("/api/projects/<int:project_id>/platforms", "GET"),
("/api/projects/<int:project_id>/platforms", "PUT"),
):
assert (rule, method) in rules, f"{method} {rule} is not routed"
def test_writes_to_the_catalog_and_to_a_project_take_the_caller():
"""The catalog's admin gate and the project's write gate both live in the
service, so every write must be handed the caller to check."""
from scribe.services import platforms as svc
for name in ("create_platform", "update_platform", "set_project_platforms",
"project_platforms"):
assert "user_id" in inspect.signature(getattr(svc, name)).parameters
+46 -3
View File
@@ -27,7 +27,7 @@ def test_backup_version_is_current():
(Named for the number it asserted until v10, which is exactly the drift a
name-carrying-a-value invites; it now says what it checks.)"""
assert backup.BACKUP_VERSION == 24
assert backup.BACKUP_VERSION == 25
def _exportable_note(**over):
@@ -125,11 +125,25 @@ def test_a_repo_binding_carries_the_branch_its_ledger_follows():
assert row["ref"] == "dev"
class _AnySlug(dict):
"""A slug map in which every platform id resolves. The stand-in's
platform_id is an arbitrary integer, and the guard is about which columns
travel, not about what a missing platform does — that would make the
import builder skip a row the guard needs it to build."""
def get(self, key, default=None):
return "platform-x"
# The table -> (model, row helper) registry the column guard walks. Kept here
# rather than in the service because it exists only to be introspected: the
# product code already knows these pairings by calling them.
def _column_guard_targets():
from scribe.models.canonical_system import CanonicalSystem
from scribe.models.family import (
FamilyAdoption, FamilyDecision, FamilyIdea, FamilyIdeaPlatform,
FamilyIdeaReference, Platform, ProjectPlatform,
)
from scribe.models.code_shape import CodeShape, CodeShapeEvent, CodeShapeUse
from scribe.models.lesson_rule_link import LessonNoRule, LessonRuleLink
from scribe.models.rule_moment_judgment import RuleMomentJudgment
@@ -187,6 +201,18 @@ def _column_guard_targets():
"code_shapes": (CodeShape, backup._code_shape_rows),
"code_shape_events": (CodeShapeEvent, backup._code_shape_event_rows),
"code_shape_uses": (CodeShapeUse, backup._code_shape_use_rows),
"platforms": (Platform, backup._platform_rows),
"project_platforms": (
ProjectPlatform, lambda rows: backup._project_platform_rows(rows, _AnySlug()),
),
"family_ideas": (FamilyIdea, backup._family_idea_rows),
"family_idea_platforms": (
FamilyIdeaPlatform,
lambda rows: backup._family_idea_platform_rows(rows, _AnySlug()),
),
"family_idea_references": (FamilyIdeaReference, backup._family_idea_reference_rows),
"family_adoptions": (FamilyAdoption, backup._family_adoption_rows),
"family_decisions": (FamilyDecision, backup._family_decision_rows),
}
@@ -306,6 +332,13 @@ def _import_guard_targets():
"code_shapes": backup._build_code_shape,
"code_shape_events": backup._build_code_shape_event,
"code_shape_uses": backup._build_code_shape_use,
"platforms": backup._build_platform,
"project_platforms": backup._build_project_platform,
"family_ideas": backup._build_family_idea,
"family_idea_platforms": backup._build_family_idea_platform,
"family_idea_references": backup._build_family_idea_reference,
"family_adoptions": backup._build_family_adoption,
"family_decisions": backup._build_family_decision,
}
return {
table: (model, helper, builders[table])
@@ -324,7 +357,8 @@ def _everything_maps(row: dict) -> "backup._Maps":
maps = backup._Maps()
ids = {v for v in row.values() if isinstance(v, int)} | {0, 1}
for name in ("users", "projects", "milestones", "notes", "rulebooks",
"topics", "rules", "systems", "design_systems", "shapes"):
"topics", "rules", "systems", "design_systems", "shapes",
"decisions"):
getattr(maps, name).update({i: i + 1000 for i in ids})
# `canonical_slug` only, never `slug`: a canonical_systems row is built
# exactly when its slug is NOT already known to the destination, so
@@ -332,6 +366,11 @@ def _everything_maps(row: dict) -> "backup._Maps":
slug = row.get("canonical_slug")
if slug:
maps.canonical_by_slug[slug] = 7
# `platform_slug` only, never `slug`, for the same reason: a platforms row
# is built exactly when its slug is unknown to the destination.
platform_slug = row.get("platform_slug")
if platform_slug:
maps.platform_by_slug[platform_slug] = 8
return maps
@@ -556,7 +595,11 @@ async def test_export_full_backup_contains_every_declared_section():
# v20: whether an area's rulings were read once shown.
"system_usage_events",
# v23: proposals and judgments about a rule's moments.
"rule_moment_judgments"):
"rule_moment_judgments",
# v25: family canon (milestone 463).
"platforms", "project_platforms", "family_ideas",
"family_idea_platforms", "family_idea_references",
"family_adoptions", "family_decisions"):
assert key in out, f"missing export section: {key}"
assert out[key] == []