Merge pull request 'Merge dev: family canon, milestone 463 steps 1-6' (#206) from dev into main
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 12s
CI & Build / TypeScript typecheck (push) Successful in 53s
CI & Build / integration (push) Successful in 1m13s
CI & Build / Python tests (push) Successful in 1m57s
CI & Build / Build & push image (push) Successful in 30s
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 12s
CI & Build / TypeScript typecheck (push) Successful in 53s
CI & Build / integration (push) Successful in 1m13s
CI & Build / Python tests (push) Successful in 1m57s
CI & Build / Build & push image (push) Successful in 30s
This commit was merged in pull request #206.
This commit is contained in:
@@ -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")
|
||||
@@ -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}` : ""}`);
|
||||
}
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -128,6 +128,13 @@ const router = createRouter({
|
||||
name: "rules",
|
||||
component: () => import("@/views/RulesView.vue"),
|
||||
},
|
||||
{
|
||||
// Family canon (milestone 463): ideas shared across every project on a
|
||||
// platform, and the log of every decision about them.
|
||||
path: "/family",
|
||||
name: "family",
|
||||
component: () => import("@/views/FamilyView.vue"),
|
||||
},
|
||||
{
|
||||
// The design systems this install RECORDS — for the projects it tracks,
|
||||
// not for the install itself. There was a sibling `/design` that read the
|
||||
|
||||
@@ -0,0 +1,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 };
|
||||
});
|
||||
@@ -0,0 +1,9 @@
|
||||
/** Where a record opens. A task, a snippet, a lesson and a note live at
|
||||
* different routes, and a link that guesses wrong is worse than one that is
|
||||
* plain. One copy, for every list that links a record of any kind. */
|
||||
export function recordHref(rec: { id: number; is_task?: boolean; note_type?: string }): string {
|
||||
if (rec.is_task) return `/tasks/${rec.id}`;
|
||||
if (rec.note_type === "snippet") return `/snippets/${rec.id}`;
|
||||
if (rec.note_type === "lesson") return `/lessons/${rec.id}`;
|
||||
return `/notes/${rec.id}`;
|
||||
}
|
||||
@@ -0,0 +1,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>
|
||||
@@ -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" />
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
@@ -13,6 +13,7 @@ from __future__ import annotations
|
||||
|
||||
from scribe.mcp._context import current_user_id
|
||||
from scribe.services import dedup as dedup_svc
|
||||
from scribe.services import family as family_svc
|
||||
from scribe.services import milestones as milestones_svc
|
||||
from scribe.services import notes as notes_svc
|
||||
from scribe.services import task_logs as task_logs_svc
|
||||
@@ -172,12 +173,20 @@ async def update_milestone(
|
||||
if order_index >= 0:
|
||||
fields["order_index"] = order_index
|
||||
await refuse_guessed_ids(title, description, body)
|
||||
# Asked BEFORE the write: only the transition into done is a closing.
|
||||
closing = status == "done" and await family_svc.milestone_is_open(uid, milestone_id)
|
||||
milestone = await milestones_svc.update_milestone(uid, milestone_id, **fields)
|
||||
if milestone is None:
|
||||
raise ValueError(f"milestone {milestone_id} not found")
|
||||
data = milestone.to_dict()
|
||||
if closing:
|
||||
# Family canon's milestone trigger (milestone 463): a plan closing on
|
||||
# a platform is the moment to ask whether what it built is shared.
|
||||
hint = await family_svc.milestone_trigger(uid, milestone)
|
||||
if hint:
|
||||
data["family_hint"] = hint
|
||||
return await moment_delivery.attach_moment_rules(
|
||||
uid, "update_milestone", {"status": status, "project_id": project_id},
|
||||
milestone.to_dict(),
|
||||
uid, "update_milestone", {"status": status, "project_id": project_id}, data,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ from scribe.mcp._context import current_user_id
|
||||
from scribe.mcp.tools import systems as systems_tools
|
||||
from scribe.services import access as access_svc
|
||||
from scribe.services import dedup as dedup_svc
|
||||
from scribe.services import family as family_svc
|
||||
from scribe.services import notes as notes_svc
|
||||
from scribe.services import supersession as supersession_svc
|
||||
from scribe.services import systems as systems_svc
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -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}
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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})
|
||||
@@ -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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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>, ...])"
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
@@ -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>`
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 -------
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
"""The promotion engine without a database (milestone 463 step 3).
|
||||
|
||||
The parts that decide — which citations claim lineage, which criteria veto —
|
||||
are pure and pinned here, beside the doors' wiring: the triggers ride the
|
||||
write tools, fail open, and the criteria the service enforces are the ones
|
||||
the agent is told. The state machine against Postgres is in
|
||||
tests/test_integration_family_promotion.py.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from scribe.mcp.server import _READ_ONLY_TOOLS, _WRITE_TOOLS
|
||||
from scribe.mcp.tools import family as family_tools
|
||||
from scribe.mcp.tools.milestones import update_milestone
|
||||
from scribe.services import family as family_svc
|
||||
from scribe.services.family import CRITERIA_KEYS, lineage_citations, vetoes
|
||||
from tests.helpers import fake_milestone
|
||||
|
||||
pytestmark = pytest.mark.usefixtures("_bind_user")
|
||||
|
||||
|
||||
# --- which citations claim lineage ---------------------------------------------
|
||||
|
||||
@pytest.mark.parametrize("text", [
|
||||
"Signed release lane, matching roundtable-android (task #1615).",
|
||||
"Ported from #1615, with the keystore step moved first.",
|
||||
"Same shape as #1615: the APK is baked into the image.",
|
||||
"This mirrors #1615 for the desktop client.",
|
||||
"Modelled on #1615.",
|
||||
])
|
||||
def test_a_citation_with_a_lineage_word_names_its_source(text):
|
||||
assert lineage_citations(text) == [1615]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("text", [
|
||||
"See #1615 for context.",
|
||||
"#1615 is related.",
|
||||
"Closes #1615.",
|
||||
# The lineage word is in the PREVIOUS sentence — a different claim.
|
||||
"This is matching the old flow. Unrelated: #1615.",
|
||||
"Matching the old flow\nsee #1615",
|
||||
"",
|
||||
])
|
||||
def test_a_citation_without_lineage_is_silent(text):
|
||||
assert lineage_citations(text) == []
|
||||
|
||||
|
||||
def test_lineage_citations_are_in_order_and_unique():
|
||||
text = "Based on #20; ported from #10. Same pattern as #20."
|
||||
assert lineage_citations(text) == [20, 10]
|
||||
|
||||
|
||||
# --- each criterion vetoes on its own -----------------------------------------------
|
||||
|
||||
GOOD = dict(
|
||||
applies_when="any app that installs its own updates",
|
||||
platforms=["android-app"],
|
||||
criteria={
|
||||
"platform_terms": "stated as an Android distribution concern",
|
||||
"platform_problem": "Play Protect and signature checks are the platform's",
|
||||
"proven": "shipped in two apps",
|
||||
},
|
||||
evidence=["CI run 8274 green", "#4775"],
|
||||
)
|
||||
|
||||
|
||||
def test_all_three_criteria_held_means_no_veto():
|
||||
assert vetoes(**GOOD) == []
|
||||
|
||||
|
||||
@pytest.mark.parametrize("key", CRITERIA_KEYS)
|
||||
def test_each_criterion_left_unsupported_vetoes_on_its_own(key):
|
||||
case = {**GOOD, "criteria": {**GOOD["criteria"], key: " "}}
|
||||
assert vetoes(**case) == [key]
|
||||
|
||||
|
||||
def test_platform_terms_needs_an_applicability_test_and_a_scope():
|
||||
assert vetoes(**{**GOOD, "applies_when": ""}) == ["platform_terms"]
|
||||
assert vetoes(**{**GOOD, "platforms": []}) == ["platform_terms"]
|
||||
|
||||
|
||||
def test_proven_needs_named_evidence():
|
||||
assert vetoes(**{**GOOD, "evidence": []}) == ["proven"]
|
||||
assert vetoes(**{**GOOD, "evidence": [" "]}) == ["proven"]
|
||||
|
||||
|
||||
def test_the_criteria_the_agent_is_told_are_the_ones_enforced():
|
||||
"""Rule 119: the criteria are product text. The promote tool's docstring
|
||||
must name every criterion the service checks, by its parameter name."""
|
||||
doc = family_tools.promote_family_idea.__doc__
|
||||
for key in CRITERIA_KEYS:
|
||||
assert key in doc, f"promote_family_idea's docstring never names {key}"
|
||||
assert len(family_svc.CRITERIA) == 3
|
||||
|
||||
|
||||
# --- the doors ----------------------------------------------------------------------
|
||||
|
||||
def test_every_family_tool_is_classified():
|
||||
reads = {"list_family_ideas", "get_family_idea", "list_family_decisions"}
|
||||
writes = {"propose_family_idea", "promote_family_idea", "retire_family_idea",
|
||||
"undo_family_decision"}
|
||||
assert reads <= _READ_ONLY_TOOLS
|
||||
assert writes <= _WRITE_TOOLS
|
||||
|
||||
|
||||
def _note(**kw):
|
||||
base = dict(id=7, project_id=2, title="t", body="b", is_task=False)
|
||||
return SimpleNamespace(**{**base, **kw})
|
||||
|
||||
|
||||
async def test_a_citation_hint_wins_and_repeat_is_not_asked():
|
||||
data: dict = {}
|
||||
repeat = AsyncMock(return_value="repeat")
|
||||
with patch.object(family_svc, "citation_trigger", AsyncMock(return_value="cited")), \
|
||||
patch.object(family_svc, "repeat_trigger", repeat):
|
||||
await family_svc.attach_family_hint(1, data, _note(), created=True)
|
||||
assert data == {"family_hint": "cited"}
|
||||
repeat.assert_not_awaited()
|
||||
|
||||
|
||||
async def test_the_repeat_check_runs_only_on_a_create():
|
||||
repeat = AsyncMock(return_value="repeat")
|
||||
with patch.object(family_svc, "citation_trigger", AsyncMock(return_value=None)), \
|
||||
patch.object(family_svc, "repeat_trigger", repeat):
|
||||
edited: dict = {}
|
||||
await family_svc.attach_family_hint(1, edited, _note(), created=False)
|
||||
created: dict = {}
|
||||
await family_svc.attach_family_hint(1, created, _note(), created=True)
|
||||
assert edited == {}
|
||||
assert created == {"family_hint": "repeat"}
|
||||
|
||||
|
||||
async def test_a_failing_trigger_never_breaks_the_write():
|
||||
data: dict = {"id": 7}
|
||||
with patch.object(family_svc, "citation_trigger", AsyncMock(side_effect=RuntimeError("db down"))):
|
||||
await family_svc.attach_family_hint(1, data, _note(), created=True)
|
||||
assert data == {"id": 7}
|
||||
|
||||
|
||||
async def test_closing_a_milestone_on_a_platform_carries_the_hint():
|
||||
closed = fake_milestone(id=5, project_id=3, status="done")
|
||||
with patch("scribe.mcp.tools.milestones.milestones_svc.update_milestone",
|
||||
AsyncMock(return_value=closed)), \
|
||||
patch.object(family_svc, "milestone_is_open", AsyncMock(return_value=True)), \
|
||||
patch.object(family_svc, "milestone_trigger", AsyncMock(return_value="evaluate")):
|
||||
out = await update_milestone(project_id=3, milestone_id=5, status="done")
|
||||
assert out["family_hint"] == "evaluate"
|
||||
|
||||
|
||||
async def test_re_saving_a_closed_milestone_asks_nothing():
|
||||
closed = fake_milestone(id=5, project_id=3, status="done")
|
||||
trigger = AsyncMock(return_value="evaluate")
|
||||
with patch("scribe.mcp.tools.milestones.milestones_svc.update_milestone",
|
||||
AsyncMock(return_value=closed)), \
|
||||
patch.object(family_svc, "milestone_is_open", AsyncMock(return_value=False)), \
|
||||
patch.object(family_svc, "milestone_trigger", trigger):
|
||||
out = await update_milestone(project_id=3, milestone_id=5, status="done")
|
||||
assert "family_hint" not in out
|
||||
trigger.assert_not_awaited()
|
||||
@@ -0,0 +1,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
|
||||
@@ -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")
|
||||
@@ -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"}
|
||||
@@ -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")
|
||||
@@ -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"]}}
|
||||
@@ -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
@@ -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():
|
||||
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -0,0 +1,293 @@
|
||||
"""The promotion engine against real Postgres (milestone 463 step 3).
|
||||
|
||||
What the unit lane can't show: a promotion opens the right ledger rows and
|
||||
no others, a veto changes nothing but the log, an undo puts back exactly the
|
||||
recorded prior state, and each trigger fires on its fixture and stays silent
|
||||
on the near-miss beside it.
|
||||
|
||||
Precedent search and the repeat trigger both rank by meaning, and the lane
|
||||
has no embedding model. The search is stubbed to "nothing similar" for every
|
||||
test here (`_no_meaning`); the repeat tests stub it to one hit, to pin what
|
||||
the trigger does WITH a hit. The ranking itself is the shared semantic
|
||||
search, tested where that lives.
|
||||
"""
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from sqlalchemy import select
|
||||
|
||||
from scribe.models import async_session
|
||||
from scribe.models.family import (
|
||||
FamilyAdoption, FamilyDecision, FamilyIdea, Platform, ProjectPlatform,
|
||||
)
|
||||
from scribe.models.milestone import Milestone
|
||||
from scribe.models.note import Note
|
||||
from scribe.models.project import Project
|
||||
from scribe.models.user import User
|
||||
from scribe.services import family as family_svc
|
||||
from tests.helpers import ensure_user
|
||||
|
||||
pytestmark = [pytest.mark.integration, pytest.mark.usefixtures("_dispose_engine")]
|
||||
|
||||
OWNER = "family_promotion_owner"
|
||||
OUTSIDER = "family_promotion_outsider"
|
||||
|
||||
CRITERIA = {
|
||||
"platform_terms": "stated for any Android app that distributes its own APK",
|
||||
"platform_problem": "signature continuity is the platform's rule, not one app's",
|
||||
"proven": "shipped and updated in place on a device",
|
||||
}
|
||||
|
||||
|
||||
async def _purge(username: str) -> None:
|
||||
"""SETUP ONLY, as the backup round-trip siblings do: a database call
|
||||
after a `yield` in an autouse fixture orphans a pooled connection."""
|
||||
async with async_session() as s:
|
||||
for user in (await s.execute(select(User).where(User.username == username))).scalars():
|
||||
for note in (await s.execute(select(Note).where(Note.user_id == user.id))).scalars():
|
||||
await s.delete(note)
|
||||
for project in (await s.execute(select(Project).where(Project.user_id == user.id))).scalars():
|
||||
await s.delete(project)
|
||||
await s.commit()
|
||||
|
||||
|
||||
@pytest_asyncio.fixture(autouse=True)
|
||||
async def _clean():
|
||||
await _purge(OWNER)
|
||||
await _purge(OUTSIDER)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _no_meaning():
|
||||
with patch("scribe.services.embeddings.semantic_search_notes", AsyncMock(return_value=[])):
|
||||
yield
|
||||
|
||||
|
||||
async def _platform_id(slug: str) -> int:
|
||||
async with async_session() as s:
|
||||
return await s.scalar(select(Platform.id).where(
|
||||
Platform.slug == slug, Platform.deleted_at.is_(None)))
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def family():
|
||||
"""Three projects: two Android apps and a Go service, the pattern note
|
||||
in the first, and an outsider's Android app the owner cannot write."""
|
||||
android, go = await _platform_id("android-app"), await _platform_id("go")
|
||||
async with async_session() as s:
|
||||
owner = await ensure_user(s, OWNER)
|
||||
outsider = await ensure_user(s, OUTSIDER)
|
||||
a = Project(user_id=owner.id, title="android one")
|
||||
b = Project(user_id=owner.id, title="android two")
|
||||
c = Project(user_id=owner.id, title="go service")
|
||||
theirs = Project(user_id=outsider.id, title="someone else's android app")
|
||||
s.add_all([a, b, c, theirs])
|
||||
await s.flush()
|
||||
s.add_all([
|
||||
ProjectPlatform(project_id=a.id, platform_id=android, state="declared"),
|
||||
ProjectPlatform(project_id=b.id, platform_id=android, state="detected"),
|
||||
ProjectPlatform(project_id=c.id, platform_id=go, state="declared"),
|
||||
ProjectPlatform(project_id=theirs.id, platform_id=android, state="declared"),
|
||||
])
|
||||
pattern = Note(user_id=owner.id, project_id=a.id, title="Signed APK lane",
|
||||
body="One keystore, two channels, in-place update.")
|
||||
s.add(pattern)
|
||||
await s.commit()
|
||||
return {"owner": owner.id, "outsider": outsider.id, "a": a.id, "b": b.id,
|
||||
"c": c.id, "theirs": theirs.id, "note": pattern.id}
|
||||
|
||||
|
||||
async def _promote(f, **overrides):
|
||||
kw = dict(
|
||||
applies_when="any Android app that ships its own APK",
|
||||
platforms=["android-app"], criteria=CRITERIA,
|
||||
evidence=["CI green on the release lane", "verified on a device"],
|
||||
reason="built twice, proven, stated for the platform",
|
||||
)
|
||||
kw.update(overrides)
|
||||
return await family_svc.promote(f["owner"], f["note"], **kw)
|
||||
|
||||
|
||||
async def _ledger(note_id: int) -> dict[int, str]:
|
||||
async with async_session() as s:
|
||||
rows = (await s.execute(
|
||||
select(FamilyAdoption.project_id, FamilyAdoption.status)
|
||||
.where(FamilyAdoption.idea_id == note_id)
|
||||
)).all()
|
||||
return dict(rows)
|
||||
|
||||
|
||||
async def _idea(note_id: int) -> FamilyIdea:
|
||||
async with async_session() as s:
|
||||
return await s.get(FamilyIdea, note_id)
|
||||
|
||||
|
||||
# --- promotion ------------------------------------------------------------------
|
||||
|
||||
async def test_promotion_opens_a_row_for_every_member_project_the_promoter_can_write(family):
|
||||
out = await _promote(family)
|
||||
assert out["promoted"] is True
|
||||
# Declared and detected both count as membership; the Go service is not
|
||||
# on the platform; the outsider's app is not the promoter's to write.
|
||||
assert await _ledger(family["note"]) == {family["a"]: "unassessed",
|
||||
family["b"]: "unassessed"}
|
||||
idea = await _idea(family["note"])
|
||||
assert idea.status == "canon" and idea.canon_version == 1
|
||||
decision = out["decision"]
|
||||
assert decision["action"] == "promote"
|
||||
assert decision["after"]["platforms"] == ["android-app"]
|
||||
assert decision["evidence"]["criteria"]["proven"] == CRITERIA["proven"]
|
||||
assert decision["evidence"]["ledger_rows_opened"] == 2
|
||||
|
||||
|
||||
@pytest.mark.parametrize("key", list(family_svc.CRITERIA_KEYS))
|
||||
async def test_each_criterion_vetoes_on_its_own_and_the_veto_is_logged(family, key):
|
||||
out = await _promote(family, criteria={**CRITERIA, key: ""})
|
||||
assert out["promoted"] is False and out["vetoed_by"] == [key]
|
||||
assert (await _idea(family["note"])).status == "candidate"
|
||||
assert await _ledger(family["note"]) == {}
|
||||
assert out["decision"]["action"] == "propose"
|
||||
assert out["decision"]["evidence"]["vetoed_by"] == [key]
|
||||
|
||||
|
||||
async def test_an_unknown_platform_is_refused_before_anything_is_written(family):
|
||||
with pytest.raises(ValueError, match="unknown platform"):
|
||||
await _promote(family, platforms=["android-app", "no-such-platform"])
|
||||
assert await _idea(family["note"]) is None
|
||||
|
||||
|
||||
async def test_someone_who_cannot_write_the_note_cannot_promote_it(family):
|
||||
with pytest.raises(ValueError, match="no write access"):
|
||||
await family_svc.promote(
|
||||
family["outsider"], family["note"], applies_when="x", platforms=["android-app"],
|
||||
criteria=CRITERIA, evidence=["e"], reason="r",
|
||||
)
|
||||
|
||||
|
||||
# --- undo and retirement ------------------------------------------------------------
|
||||
|
||||
async def test_undoing_a_promotion_restores_the_prior_state_and_keeps_judged_rows(family):
|
||||
out = await _promote(family)
|
||||
async with async_session() as s:
|
||||
row = (await s.execute(select(FamilyAdoption).where(
|
||||
FamilyAdoption.idea_id == family["note"],
|
||||
FamilyAdoption.project_id == family["a"]))).scalars().one()
|
||||
row.status, row.canon_version, row.decided_via = "adopted", 1, "agent"
|
||||
await s.commit()
|
||||
|
||||
await family_svc.undo(family["owner"], out["decision"]["id"], reason="promoted too early")
|
||||
idea = await _idea(family["note"])
|
||||
# Promoted directly, with no proposal before it: the prior state was "not
|
||||
# an idea", which an undo records as retired rather than deleting the
|
||||
# idea and its log with it.
|
||||
assert idea.status == "retired" and idea.applies_when is None
|
||||
# The unjudged row goes; the judged one stays as history.
|
||||
assert await _ledger(family["note"]) == {family["a"]: "adopted"}
|
||||
|
||||
again = await _promote(family)
|
||||
# Re-promotion moves PAST every version this idea has held, so the kept
|
||||
# answer reads as needing a recheck rather than as still agreeing.
|
||||
assert again["idea"]["canon_version"] == 2
|
||||
assert await _ledger(family["note"]) == {family["a"]: "adopted", family["b"]: "unassessed"}
|
||||
|
||||
|
||||
async def test_retiring_closes_unjudged_rows_and_undoing_it_reopens_them(family):
|
||||
await _promote(family)
|
||||
out = await family_svc.retire(family["owner"], family["note"], reason="superseded")
|
||||
assert (await _idea(family["note"])).status == "retired"
|
||||
assert await _ledger(family["note"]) == {}
|
||||
await family_svc.undo(family["owner"], out["decision"]["id"], reason="not superseded after all")
|
||||
assert (await _idea(family["note"])).status == "canon"
|
||||
assert set((await _ledger(family["note"])).values()) == {"unassessed"}
|
||||
|
||||
|
||||
async def test_only_the_latest_idea_decision_can_be_undone(family):
|
||||
first = await _promote(family)
|
||||
await family_svc.retire(family["owner"], family["note"], reason="superseded")
|
||||
with pytest.raises(ValueError, match="came after it"):
|
||||
await family_svc.undo(family["owner"], first["decision"]["id"], reason="r")
|
||||
|
||||
|
||||
async def test_an_undo_names_what_it_reversed_as_its_precedent(family):
|
||||
out = await _promote(family)
|
||||
undo = await family_svc.undo(family["owner"], out["decision"]["id"], reason="r")
|
||||
assert undo["decision"]["action"] == "undo"
|
||||
assert undo["decision"]["precedent_ids"] == [out["decision"]["id"]]
|
||||
async with async_session() as s:
|
||||
actions = (await s.execute(select(FamilyDecision.action).where(
|
||||
FamilyDecision.idea_id == family["note"]).order_by(FamilyDecision.id))).scalars().all()
|
||||
assert actions == ["promote", "undo"]
|
||||
|
||||
|
||||
# --- triggers -------------------------------------------------------------------------
|
||||
|
||||
async def _note_in(project_id: int, owner_id: int, body: str) -> Note:
|
||||
async with async_session() as s:
|
||||
note = Note(user_id=owner_id, project_id=project_id, title="the second build", body=body)
|
||||
s.add(note)
|
||||
await s.commit()
|
||||
await s.refresh(note)
|
||||
return note
|
||||
|
||||
|
||||
async def test_a_cross_project_lineage_citation_opens_an_evaluation(family):
|
||||
citing = await _note_in(family["b"], family["owner"],
|
||||
f"Release lane, matching the first app (#{family['note']}).")
|
||||
hint = await family_svc.citation_trigger(family["owner"], citing)
|
||||
assert hint and f"#{family['note']}" in hint
|
||||
idea = await _idea(family["note"])
|
||||
assert idea is not None and idea.status == "candidate"
|
||||
async with async_session() as s:
|
||||
d = (await s.execute(select(FamilyDecision).where(
|
||||
FamilyDecision.idea_id == family["note"]))).scalars().one()
|
||||
assert d.decided_via == "system" and d.evidence["trigger"] == "citation"
|
||||
|
||||
|
||||
async def test_a_citation_without_lineage_or_within_one_project_is_silent(family):
|
||||
pointer = await _note_in(family["b"], family["owner"], f"See #{family['note']} for context.")
|
||||
same_project = await _note_in(family["a"], family["owner"], f"Matching #{family['note']}.")
|
||||
assert await family_svc.citation_trigger(family["owner"], pointer) is None
|
||||
assert await family_svc.citation_trigger(family["owner"], same_project) is None
|
||||
assert await _idea(family["note"]) is None
|
||||
|
||||
|
||||
async def test_a_repeat_on_a_shared_platform_opens_an_evaluation(family):
|
||||
"""The meaning match is stubbed — the lane has no model — so this pins
|
||||
what the trigger does with a hit: only a hit in ANOTHER project that
|
||||
shares a platform counts."""
|
||||
repeat = await _note_in(family["b"], family["owner"], "One keystore, two channels.")
|
||||
async with async_session() as s:
|
||||
original = await s.get(Note, family["note"])
|
||||
with patch("scribe.services.embeddings.semantic_search_notes",
|
||||
AsyncMock(return_value=[(0.86, original)])):
|
||||
hint = await family_svc.repeat_trigger(family["owner"], repeat)
|
||||
assert hint and f"#{family['note']}" in hint
|
||||
assert (await _idea(family["note"])).status == "candidate"
|
||||
|
||||
|
||||
async def test_a_repeat_with_no_shared_platform_is_silent(family):
|
||||
unrelated = await _note_in(family["c"], family["owner"], "One keystore, two channels.")
|
||||
async with async_session() as s:
|
||||
original = await s.get(Note, family["note"])
|
||||
with patch("scribe.services.embeddings.semantic_search_notes",
|
||||
AsyncMock(return_value=[(0.86, original)])):
|
||||
assert await family_svc.repeat_trigger(family["owner"], unrelated) is None
|
||||
assert await _idea(family["note"]) is None
|
||||
|
||||
|
||||
async def test_a_milestone_closing_on_a_platform_asks_and_one_off_platform_does_not(family):
|
||||
async with async_session() as s:
|
||||
on_platform = Milestone(user_id=family["owner"], project_id=family["a"], title="m1")
|
||||
bare = Project(user_id=family["owner"], title="no platforms")
|
||||
s.add_all([on_platform, bare])
|
||||
await s.flush()
|
||||
off_platform = Milestone(user_id=family["owner"], project_id=bare.id, title="m2")
|
||||
s.add(off_platform)
|
||||
await s.commit()
|
||||
await s.refresh(on_platform)
|
||||
await s.refresh(off_platform)
|
||||
hint = await family_svc.milestone_trigger(family["owner"], on_platform)
|
||||
assert hint and "Android app" in hint
|
||||
assert await family_svc.milestone_trigger(family["owner"], off_platform) is None
|
||||
assert await family_svc.milestone_is_open(family["owner"], on_platform.id) is True
|
||||
@@ -0,0 +1,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)) == []
|
||||
@@ -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"] == []
|
||||
|
||||
@@ -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"])
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"),
|
||||
]
|
||||
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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"] == []
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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] == []
|
||||
|
||||
|
||||
Reference in New Issue
Block a user