Project inception — decide what a new project inherits (milestone 297) #124

Merged
bvandeusen merged 8 commits from dev into main 2026-08-21 22:16:33 -04:00
32 changed files with 1515 additions and 45 deletions
+1 -1
View File
@@ -4,7 +4,7 @@ A self-hosted work system-of-record for software projects, built to be driven by
## Features
Notes and tasks with a Markdown editor, sub-tasks, milestones, issues, and kanban project workspaces. Stored processes, an engineering rulebook system, and semantic search with proactive knowledge-injection into Claude's context. A knowledge graph, per-user/group sharing, and a built-in MCP server (`/mcp`) plus a bundled Claude Code plugin so Claude can record and recall your work directly.
Notes and tasks with a Markdown editor, sub-tasks, milestones, issues, and kanban project workspaces. Stored processes, an engineering rulebook system (with an inception step that decides what each project inherits), and semantic search with proactive knowledge-injection into Claude's context. A knowledge graph, per-user/group sharing, and a built-in MCP server (`/mcp`) plus a bundled Claude Code plugin so Claude can record and recall your work directly.
## Quick Start
@@ -0,0 +1,74 @@
"""Project inception: the decision record + always-on rulebook exclusions (milestone 297)
Revision ID: 0085
Revises: 0084
Create Date: 2026-08-22
`projects.inception` is the WHY a project inherits what it does — NULL until
someone decides, at which point enter_project stops asking. The new
association `project_rulebook_exclusions` is the opt-out of a whole always-on
rulebook for one project (the sibling of the rule/topic suppressions).
Backfill: every project that exists when this runs is stamped
via="legacy" with its CURRENT standing (no exclusions, its subscriptions,
its design_system_id, no seed) — so the ask fires only for projects created
after the step shipped, and nothing a running install relies on changes.
"""
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects import postgresql
revision = "0085"
down_revision = "0084"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"projects",
sa.Column("inception", postgresql.JSONB(), nullable=True),
)
op.create_table(
"project_rulebook_exclusions",
sa.Column(
"project_id", sa.BigInteger(),
sa.ForeignKey("projects.id", ondelete="CASCADE"),
primary_key=True, nullable=False,
),
sa.Column(
"rulebook_id", sa.BigInteger(),
sa.ForeignKey("rulebooks.id", ondelete="CASCADE"),
primary_key=True, nullable=False,
),
sa.Column(
"created_at", sa.DateTime(timezone=True),
server_default=sa.text("now()"), nullable=False,
),
)
# Legacy stamp: what each existing project inherits today, recorded as a
# decision so the inception ask does not fire on a project that has been
# running for months.
op.execute(sa.text("""
UPDATE projects p SET inception = jsonb_build_object(
'via', 'legacy',
'decided_at', to_jsonb(now()),
'decided_by', NULL,
'choices', jsonb_build_object(
'exclude_always_on_rulebooks', '[]'::jsonb,
'subscribe_rulebooks', COALESCE(
(SELECT jsonb_agg(s.rulebook_id ORDER BY s.rulebook_id)
FROM project_rulebook_subscriptions s
WHERE s.project_id = p.id),
'[]'::jsonb),
'design_system_id', to_jsonb(p.design_system_id),
'seed_systems', false
)
)
WHERE p.inception IS NULL
"""))
def downgrade() -> None:
op.drop_table("project_rulebook_exclusions")
op.drop_column("projects", "inception")
+4 -1
View File
@@ -76,7 +76,9 @@ endpoint at `/mcp`, not these REST routes.
| Method | Path | Description |
|--------|------|-------------|
| GET / POST | `/api/projects` | List (owned + shared) / create |
| GET / PATCH / DELETE | `/api/projects/:id` | Read (with `milestone_summary`) / update / delete |
| GET / PATCH / DELETE | `/api/projects/:id` | Read (with `milestone_summary`, `inception`) / update / delete |
| POST | `/api/projects/:id/inception` | Record what the project inherits `{choices: {exclude_always_on_rulebooks, subscribe_rulebooks, design_system_id, seed_systems}}` (owner-only; `POST /api/projects` accepts the same under `inception`) |
| GET | `/api/projects/:id/inception/defaults` | What binds if nobody decides — the inception card's payload |
| GET | `/api/projects/:id/notes` | Notes + tasks in this project |
| GET / POST | `/api/projects/:id/milestones` | List / create milestones |
| GET / PATCH / DELETE | `/api/projects/:id/milestones/:mid` | Read / update / delete |
@@ -118,6 +120,7 @@ endpoint at `/mcp`, not these REST routes.
| POST | `/api/projects/:id/rules` | Create a project-scoped rule |
| POST / DELETE | `/api/projects/:id/suppressions/rules/:rid` | Suppress / unsuppress a rule |
| POST / DELETE | `/api/projects/:id/suppressions/topics/:tid` | Suppress / unsuppress a topic |
| POST / DELETE | `/api/projects/:id/exclusions/rulebooks/:rid` | Exclude / include an always-on rulebook for this project (inception) |
## Sharing
+42
View File
@@ -0,0 +1,42 @@
/** Project inception (milestone 297): what a project was decided to inherit. */
import { apiGet, apiPost } from "@/api/client";
export interface InceptionChoices {
exclude_always_on_rulebooks: number[];
subscribe_rulebooks: number[];
design_system_id: number | null;
seed_systems: boolean;
}
export interface InceptionRecord {
decided_at: string;
decided_by: number | null;
via: "mcp" | "ui" | "legacy";
choices: InceptionChoices;
}
export interface InceptionDefaults {
always_on_rulebooks: { id: number; title: string }[];
other_rulebooks: { id: number; title: string }[];
excluded_always_on: { id: number; title: string }[];
subscribed_rulebooks: { id: number; title: string }[];
design_system_id: number | null;
design_systems: { id: number; title: string }[];
systems: number;
}
export interface InceptionDecision {
project_id: number;
inception: InceptionRecord;
effects: { excluded: number[]; subscribed: number[]; design_system_id: number | null; systems_seeded: string[] };
}
export const emptyChoices = (): InceptionChoices => ({
exclude_always_on_rulebooks: [], subscribe_rulebooks: [], design_system_id: null, seed_systems: false,
});
export const fetchInceptionDefaults = (projectId: number) =>
apiGet<InceptionDefaults>(`/api/projects/${projectId}/inception/defaults`);
export const decideInception = (projectId: number, choices: InceptionChoices) =>
apiPost<InceptionDecision>(`/api/projects/${projectId}/inception`, { choices });
+13
View File
@@ -71,6 +71,8 @@ export interface ApplicableRules {
}[];
truncated: boolean;
subscribed_rulebooks: { id: number; title: string }[];
/** Always-on rulebooks this project opted out of at inception (milestone 297). */
excluded_always_on: { id: number; title: string }[];
}
// ── Rulebooks ───────────────────────────────────────────────────────
@@ -181,3 +183,14 @@ export async function suppressTopicForProject(projectId: number, topicId: number
export async function unsuppressTopicForProject(projectId: number, topicId: number): Promise<void> {
return apiDelete(`/api/projects/${projectId}/suppressions/topics/${topicId}`);
}
// ── Always-on exclusions (milestone 297) ────────────────────────────────────
export async function excludeAlwaysOnRulebook(projectId: number, rulebookId: number): Promise<void> {
await apiPost(`/api/projects/${projectId}/exclusions/rulebooks/${rulebookId}`, {});
}
export async function includeAlwaysOnRulebook(projectId: number, rulebookId: number): Promise<void> {
await apiDelete(`/api/projects/${projectId}/exclusions/rulebooks/${rulebookId}`);
}
+189
View File
@@ -0,0 +1,189 @@
<script setup lang="ts">
/**
* The inception form (milestone 297): "what does this project inherit?"
*
* Two homes, one component. mode="create" rides the New-project modal's
* 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.
*/
import { computed, onMounted, ref, watch } from "vue";
import { apiErrorMessage } from "@/api/client";
import { fetchDesignSystems } from "@/api/designSystems";
import {
decideInception, emptyChoices, fetchInceptionDefaults,
type InceptionChoices, type InceptionDecision, type InceptionDefaults,
} from "@/api/inception";
import { listRulebooks } from "@/api/rulebooks";
const props = withDefaults(defineProps<{
mode: "create" | "decide";
projectId?: number;
choices?: InceptionChoices;
}>(), { projectId: 0, choices: undefined });
const emit = defineEmits<{
"update:choices": [value: InceptionChoices];
decided: [decision: InceptionDecision];
}>();
const local = ref<InceptionChoices>(props.choices ? { ...props.choices } : emptyChoices());
const alwaysOn = ref<{ id: number; title: string }[]>([]);
const others = ref<{ id: number; title: string }[]>([]);
const designSystems = ref<{ id: number; title: string }[]>([]);
const systemsCount = ref(0);
const loading = ref(true);
const saving = ref(false);
const error = ref("");
function emitChoices() {
emit("update:choices", { ...local.value });
}
watch(local, emitChoices, { deep: true });
async function load() {
loading.value = true;
error.value = "";
try {
if (props.mode === "decide" && props.projectId) {
const d: InceptionDefaults = await fetchInceptionDefaults(props.projectId);
alwaysOn.value = d.always_on_rulebooks;
others.value = d.other_rulebooks;
designSystems.value = d.design_systems;
systemsCount.value = d.systems;
// Start from what stands today so "record" without changes is a true inherit-all.
local.value = {
exclude_always_on_rulebooks: d.excluded_always_on.map((r) => r.id),
subscribe_rulebooks: d.subscribed_rulebooks.map((r) => r.id),
design_system_id: d.design_system_id,
seed_systems: false,
};
} else {
const [rulebooks, ds] = await Promise.all([listRulebooks(), fetchDesignSystems()]);
alwaysOn.value = rulebooks.filter((r) => r.always_on).map((r) => ({ id: r.id, title: r.title }));
others.value = rulebooks.filter((r) => !r.always_on).map((r) => ({ id: r.id, title: r.title }));
designSystems.value = ds.design_systems.map((d) => ({ id: d.id, title: d.title }));
}
} catch (e: unknown) {
error.value = apiErrorMessage(e, "Could not load what this project could inherit");
} finally {
loading.value = false;
}
}
function inherits(id: number): boolean {
return !local.value.exclude_always_on_rulebooks.includes(id);
}
function toggleInherit(id: number) {
const list = local.value.exclude_always_on_rulebooks;
local.value.exclude_always_on_rulebooks = list.includes(id) ? list.filter((x) => x !== id) : [...list, id];
}
function subscribed(id: number): boolean {
return local.value.subscribe_rulebooks.includes(id);
}
function toggleSubscribe(id: number) {
const list = local.value.subscribe_rulebooks;
local.value.subscribe_rulebooks = list.includes(id) ? list.filter((x) => x !== id) : [...list, id];
}
const nothingToDecide = computed(
() => !alwaysOn.value.length && !others.value.length && !designSystems.value.length,
);
async function record() {
if (!props.projectId) return;
saving.value = true;
error.value = "";
try {
const decision = await decideInception(props.projectId, local.value);
emit("decided", decision);
} catch (e: unknown) {
error.value = apiErrorMessage(e, "Could not record the decision");
} finally {
saving.value = false;
}
}
onMounted(load);
</script>
<template>
<section class="inception" aria-labelledby="inception-title">
<h3 id="inception-title" class="inception-title">What does this project inherit?</h3>
<p class="inception-lede">
A project's inheritance is a decision, not a default. Until it is recorded,
every always-on rulebook binds, nothing is subscribed, and there is no design
system or Systems.
</p>
<p v-if="loading" class="inception-muted">Loading…</p>
<p v-else-if="error" class="error-msg">{{ error }}</p>
<template v-else>
<div v-if="alwaysOn.length" class="inception-group">
<h4>Always-on rulebooks</h4>
<p class="inception-muted">Checked = inherits. Uncheck to exclude a rulebook for this project only.</p>
<label v-for="rb in alwaysOn" :key="rb.id" class="inception-choice">
<input type="checkbox" :checked="inherits(rb.id)" @change="toggleInherit(rb.id)" />
<span>{{ rb.title }}</span>
</label>
</div>
<div v-if="others.length" class="inception-group">
<h4>Subscribe to rulebooks</h4>
<label v-for="rb in others" :key="rb.id" class="inception-choice">
<input type="checkbox" :checked="subscribed(rb.id)" @change="toggleSubscribe(rb.id)" />
<span>{{ rb.title }}</span>
</label>
</div>
<div class="inception-group">
<h4>Design system</h4>
<select v-model="local.design_system_id" class="inception-select" aria-label="Design system">
<option :value="null">None</option>
<option v-for="ds in designSystems" :key="ds.id" :value="ds.id">{{ ds.title }}</option>
</select>
</div>
<div class="inception-group">
<label class="inception-choice">
<input type="checkbox" v-model="local.seed_systems" :disabled="systemsCount > 0" />
<span>
Seed the standard starter Systems (CI &amp; Release, Auth &amp; Access, Data Model &amp; Storage, …)
<em v-if="systemsCount > 0" class="inception-muted"> — this project already has {{ systemsCount }}</em>
</span>
</label>
</div>
<p v-if="nothingToDecide" class="inception-muted">
Nothing to inherit yet on this install — recording still settles the question.
</p>
<div v-if="mode === 'decide'" class="inception-actions">
<button class="btn-primary" :disabled="saving" @click="record">
{{ saving ? "Recording" : "Record decision" }}
</button>
</div>
</template>
</section>
</template>
<style scoped>
.inception {
background: var(--fs-surface-raised);
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-lg);
padding: 1.25rem 1.5rem;
margin-bottom: 1.5rem;
}
.inception-title { margin: 0 0 0.35rem; font-size: 1.05rem; }
.inception-lede { margin: 0 0 1rem; color: var(--fs-text-secondary); font-size: 0.9rem; }
.inception-muted { color: var(--fs-text-tertiary); font-size: 0.85rem; margin: 0 0 0.35rem; }
.inception-group { margin-bottom: 1rem; }
.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-select {
padding: 0.45rem 0.7rem;
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.9rem;
}
.inception-actions { display: flex; justify-content: flex-end; margin-top: 0.5rem; }
.error-msg { color: var(--fs-error); font-size: 0.9rem; }
</style>
@@ -2,10 +2,18 @@
import { ref, onMounted, watch } from "vue";
import { useRouter } from "vue-router";
import {
getProjectApplicableRules, subscribeProject, unsubscribeProject,
listRulebooks, getRule, createProjectRule, deleteRule,
suppressRuleForProject, unsuppressRuleForProject,
suppressTopicForProject, unsuppressTopicForProject,
getProjectApplicableRules,
subscribeProject,
unsubscribeProject,
listRulebooks,
getRule,
createProjectRule,
deleteRule,
suppressRuleForProject,
unsuppressRuleForProject,
suppressTopicForProject,
unsuppressTopicForProject,
includeAlwaysOnRulebook,
} from "@/api/rulebooks";
import type { ApplicableRules, Rulebook } from "@/api/rulebooks";
@@ -35,6 +43,11 @@ async function subscribe(rulebookId: number) {
await load();
}
async function includeBack(rulebookId: number) {
await includeAlwaysOnRulebook(props.projectId, rulebookId);
await load();
}
async function unsubscribe(rulebookId: number) {
if (!confirm("Unsubscribe from this rulebook for this project?")) return;
await unsubscribeProject(props.projectId, rulebookId);
@@ -172,6 +185,17 @@ watch(() => props.projectId, load);
</div>
</section>
<section v-if="applicable.excluded_always_on?.length" class="excluded">
<h3>Excluded always-on rulebooks</h3>
<p class="excluded-note">Opted out at inception these do not bind this project.</p>
<div class="chips">
<span v-for="rb in applicable.excluded_always_on" :key="rb.id" class="chip chip-excluded">
<a @click="openInRulesView(rb.id)">{{ rb.title }}</a>
<button class="chip-remove" @click="includeBack(rb.id)" aria-label="Include again" title="Include again"></button>
</span>
</div>
</section>
<section class="project-rules">
<div class="section-head">
<h3>Project rules</h3>
@@ -321,6 +345,9 @@ watch(() => props.projectId, load);
</template>
<style scoped>
.excluded-note { margin: 0 0 0.5rem; color: var(--fs-text-tertiary); font-size: 0.85rem; }
.chip-excluded { opacity: 0.8; text-decoration: line-through; }
.chip-excluded .chip-remove { text-decoration: none; }
.rules-tab { padding: 1rem; }
h3 {
font-size: 0.9em; opacity: 0.7; text-transform: uppercase; letter-spacing: 0.05em;
+28 -8
View File
@@ -1,7 +1,9 @@
<script setup lang="ts">
import { ref, computed, onMounted } from "vue";
import { useRouter } from "vue-router";
import { apiGet, apiPost } from "@/api/client";
import { apiGet, apiPost, apiErrorMessage } from "@/api/client";
import { emptyChoices, type InceptionChoices } from "@/api/inception";
import InceptionCard from "@/components/InceptionCard.vue";
import { useToastStore } from "@/stores/toast";
import { milestoneColor } from "@/utils/palette";
@@ -47,6 +49,9 @@ const newTitle = ref("");
const newDescription = ref("");
const newGoal = ref("");
const creating = ref(false);
// Step 2 of the modal (milestone 297): what the new project inherits.
const modalStep = ref<1 | 2>(1);
const newInception = ref<InceptionChoices>(emptyChoices());
const filteredProjects = computed(() => {
if (activeTab.value === "all") return projects.value;
@@ -73,6 +78,8 @@ function openNewProjectModal() {
newTitle.value = "";
newDescription.value = "";
newGoal.value = "";
modalStep.value = 1;
newInception.value = emptyChoices();
showNewProjectModal.value = true;
}
@@ -88,13 +95,15 @@ async function createProject() {
title: newTitle.value.trim(),
description: newDescription.value.trim() || undefined,
goal: newGoal.value.trim() || undefined,
// The decision rides the create: a project made here is never undecided.
inception: newInception.value,
});
projects.value.unshift(project);
showNewProjectModal.value = false;
toast.show("Project created");
router.push(`/projects/${project.id}`);
} catch {
toast.show("Failed to create project", "error");
} catch (e: unknown) {
toast.show(apiErrorMessage(e, "Failed to create project"), "error");
} finally {
creating.value = false;
}
@@ -266,8 +275,9 @@ function overallPct(project: Project): { total: number; pct: number } {
<teleport to="body">
<div v-if="showNewProjectModal" class="modal-overlay" @click.self="closeModal">
<div class="modal-card">
<h3 class="modal-title">New Project</h3>
<div class="modal-field">
<h3 class="modal-title">{{ modalStep === 1 ? "New Project" : "New Project — what it inherits" }}</h3>
<InceptionCard v-if="modalStep === 2" mode="create" v-model:choices="newInception" />
<div v-if="modalStep === 1" class="modal-field">
<label>Title <span class="required">*</span></label>
<input
v-model="newTitle"
@@ -275,11 +285,11 @@ function overallPct(project: Project): { total: number; pct: number } {
class="modal-input"
placeholder="Project title"
autofocus
@keydown.enter="createProject"
@keydown.enter="modalStep = 2"
@keydown.escape="closeModal"
/>
</div>
<div class="modal-field">
<div v-if="modalStep === 1" class="modal-field">
<label>Goal</label>
<input
v-model="newGoal"
@@ -289,7 +299,7 @@ function overallPct(project: Project): { total: number; pct: number } {
@keydown.escape="closeModal"
/>
</div>
<div class="modal-field">
<div v-if="modalStep === 1" class="modal-field">
<label>Description</label>
<textarea
v-model="newDescription"
@@ -301,7 +311,17 @@ function overallPct(project: Project): { total: number; pct: number } {
</div>
<div class="modal-actions">
<button class="modal-btn" @click="closeModal">Cancel</button>
<button v-if="modalStep === 2" class="modal-btn" @click="modalStep = 1">Back</button>
<button
v-if="modalStep === 1"
class="modal-btn modal-btn-primary"
@click="modalStep = 2"
:disabled="!newTitle.trim()"
>
Next
</button>
<button
v-else
class="modal-btn modal-btn-primary"
@click="createProject"
:disabled="!newTitle.trim() || creating"
+31
View File
@@ -11,6 +11,9 @@ import ShareDialog from "@/components/ShareDialog.vue";
import ProjectDesignTab from "@/components/ProjectDesignTab.vue";
import ProjectRulesTab from "@/components/rules/ProjectRulesTab.vue";
import SystemsSection from "@/components/SystemsSection.vue";
import InceptionCard from "@/components/InceptionCard.vue";
import { fmtDate } from "@/utils/dateFormat";
import type { InceptionDecision, InceptionRecord } from "@/api/inception";
import {
fetchDesignSystems,
setProjectDesignSystem,
@@ -50,6 +53,7 @@ interface Project {
color: string | null;
design_system_id: number | null;
forge_connection_id: number | null;
inception?: InceptionRecord | null;
permission?: string;
created_at: string;
updated_at: string;
@@ -75,6 +79,12 @@ interface NoteItem {
const route = useRoute();
const router = useRouter();
const toast = useToastStore();
function onInceptionDecided(decision: InceptionDecision) {
if (project.value) project.value.inception = decision.inception;
toast.show("Inheritance recorded");
void loadProject();
}
const tasksStore = useTasksStore();
const project = ref<Project | null>(null);
@@ -695,6 +705,26 @@ async function confirmDelete() {
</p>
</div>
<!-- Inception (milestone 297): the owner of an undecided project is asked
what it inherits; once recorded, one line says what was decided. -->
<InceptionCard
v-if="project.inception == null && isProjectOwner"
mode="decide"
:project-id="projectId"
@decided="onInceptionDecided"
/>
<p v-else-if="project.inception" class="inception-line">
Inheritance decided {{ fmtDate(project.inception.decided_at) }} via {{ project.inception.via }}
<template v-if="project.inception.choices.exclude_always_on_rulebooks.length">
· excludes {{ project.inception.choices.exclude_always_on_rulebooks.length }} always-on rulebook(s)
</template>
<template v-if="project.inception.choices.subscribe_rulebooks.length">
· subscribes {{ project.inception.choices.subscribe_rulebooks.length }}
</template>
· 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>
</p>
<!-- Summary stat chips -->
<div v-if="project.summary" class="summary-stats">
<div class="stat-chip stat-todo">
@@ -1197,6 +1227,7 @@ async function confirmDelete() {
min-width: 200px;
}
.inception-line { margin: 0 0 1rem; color: var(--fs-text-secondary); font-size: 0.85rem; }
.project-title-input {
flex: 1;
font-size: 1.75rem;
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "scribe",
"description": "Scribe system-of-record for Claude Code: MCP tools over your notes/tasks/projects/rules, a session-start push channel that surfaces your always-on rules + active-project context, process-skills (writing-plans, systematic-debugging, verification, brainstorming, reusing-code), and your saved Scribe Processes auto-surfaced as skills (/scribe:sync). Replaces superpowers + file-memory with one app-backed plugin.",
"version": "0.1.37",
"version": "0.1.38",
"author": { "name": "Bryan Van Deusen" },
"mcpServers": {
"scribe": {
+22
View File
@@ -120,6 +120,28 @@ bound — confine the session to it:
- If something clearly belongs to a *different* project, say so and **ask before
switching** — never silently operate cross-project.
## Starting a project: decide what it inherits
A project's inheritance is a **decision, not a default**. Before
`create_project`, ask the operator the four inception questions and pass the
answers — never create a project bare by default:
- which **always-on rulebooks** it should NOT inherit (`list_rulebooks` shows
which are always_on; default: inherit them all) →
`exclude_always_on_rulebooks=[...]`
- which other rulebooks to **subscribe**`subscribe_rulebooks=[...]`
- which **design system** its UI is built from (`list_design_systems`; or
none) → `design_system_id=<id | -1>`
- whether to **seed the standard starter Systems** so records can be tagged
from day one → `seed_systems=true|false`
If `enter_project` returns an `inception` key, the project was never decided
(it inherits its defaults silently): raise that ask once, with the defaults it
carries, then `decide_project_inception(project_id, …)`. Existing projects
were stamped "legacy" (inherit-all) and do not ask; any project can be
re-decided. The rules/design-system/Systems tools still work one at a time —
inception is the moment they are decided together, and the record of why.
## Where a new rule goes
When codifying a rule, pick its home by **who it should bind** — and keep
+5 -6
View File
@@ -37,24 +37,23 @@ in local files (CLAUDE.md, auto-memory); Scribe holds the single copy.
Hierarchy: Project -> Milestone -> Task/Note. The map, by purpose:
- ORIENT: enter_project(id) at session start — rules, open tasks, recent
notes, Systems and design system in one call.
notes, Systems, design system. `inception` key: ask what the project
inherits, decide_project_inception (create_project takes the same).
- DO: create_task. Fixed a problem? kind="issue" (symptom -> root cause ->
fix), never a work-log line on an unrelated task. Log with add_task_log;
keep status honest — in_progress on start, done on finish.
- PLAN work with an arc: start_planning. The plan IS a milestone; each step is
a child task, not a checkbox. No local plan .md files.
- CAPTURE: create_note. RECALL: search first, before answering about the
operator's work or opening a task — assume prior art exists, and pass the
- CAPTURE: create_note. RECALL: search first — prior art exists; pass the
active project_id to stay in scope.
- WHERE work happens: Systems. Tag records with system_ids as you write;
create_system when the area is unmodelled.
- HOW to work: rules are pull-only and binding — call list_always_on_rules()
yourself at session start.
- HOW: rules are binding — list_always_on_rules() at session start.
- UI: the project's design system is binding — resolve_design_system /
get_design_system_stylesheet before hand-writing a value.
- REUSE: search snippets before writing a helper; record what you build with
create_snippet; classify shapes against canon (classify_shapes) — a
consumer map is rows, never prose. Saved procedures are Processes (follow
consumer map is rows, never prose. Processes are saved procedures (follow
verbatim). Deletes are trash-recoverable.
A task is a note with status (*_note vs *_task tools).
+102 -3
View File
@@ -20,6 +20,7 @@ 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 inception as inception_svc
from scribe.services import milestones as milestones_svc
from scribe.services import notes as notes_svc
from scribe.services import projects as projects_svc
@@ -80,6 +81,12 @@ 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.
`inception` (milestone 297) appears ONLY when the project is yours and
nobody has decided what it inherits: it carries the current defaults
(which always-on rulebooks bind, design system, Systems), 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.
`systems_bootstrap` appears ONLY when the project has many records and no
Systems at all — act on it before starting other work: create_system a
starter vocabulary from the areas the project's records name, directly
@@ -141,6 +148,14 @@ async def enter_project(project_id: int) -> dict:
uid, project_id
)
# The inception ask (milestone 297): a project nobody has decided on
# inherits its defaults silently — always-on rulebooks, no design system,
# no Systems. Owner-only (deciding is the owner's), and only until a
# decision is recorded; the key is ABSENT otherwise (#2483).
inception_ask = None
if project.user_id == uid and not inception_svc.is_decided(project):
inception_ask = await inception_svc.inception_ask(uid, project_id)
# Probably the largest surfacing by volume, and it emitted nothing — so
# the pulls it caused floated unattributed and the surfaced:pulled ratio
# ran against a denominator missing its biggest contributor (#2477). An
@@ -213,6 +228,8 @@ async def enter_project(project_id: int) -> dict:
# readers to skip it (#2483), and this one exists to be acted on.
if systems_bootstrap:
out["systems_bootstrap"] = systems_bootstrap
if inception_ask:
out["inception"] = inception_ask
return out
@@ -238,14 +255,43 @@ async def get_project(project_id: int) -> dict:
return data
def _inception_choices(
exclude_always_on_rulebooks, subscribe_rulebooks, design_system_id, seed_systems,
) -> 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 (exclude_always_on_rulebooks is None and subscribe_rulebooks is None
and not design_system_id and seed_systems is None):
return None
return {
"exclude_always_on_rulebooks": list(exclude_always_on_rulebooks or []),
"subscribe_rulebooks": list(subscribe_rulebooks or []),
"design_system_id": None if design_system_id in (0, -1) else design_system_id,
"seed_systems": bool(seed_systems),
}
async def create_project(
title: str,
description: str = "",
goal: str = "",
status: str = "active",
color: str = "",
exclude_always_on_rulebooks: list[int] | None = None,
subscribe_rulebooks: list[int] | None = None,
design_system_id: int = 0,
seed_systems: bool | None = None,
) -> dict:
"""Create a new project in Scribe.
"""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 four inception questions and pass
the answers; a project created without any of them is UNDECIDED and
enter_project will ask until decide_project_inception records it.
Defaults if nobody decides: every always-on rulebook binds, nothing is
subscribed, no design system, no Systems.
Args:
title: Project name (required).
@@ -253,6 +299,14 @@ async def create_project(
goal: The desired outcome or definition of done for the project.
status: one of active (default), paused, completed, archived.
color: Optional hex colour for the project card (e.g. "#6366f1").
exclude_always_on_rulebooks: always-on rulebook ids this project does
NOT inherit ([] = inherit them all). list_rulebooks shows which are
always_on.
subscribe_rulebooks: rulebook ids to subscribe (the non-always-on ones).
design_system_id: the design system this project's UI is built from
(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.
"""
uid = current_user_id()
project = await projects_svc.create_project(
@@ -263,7 +317,52 @@ async def create_project(
status=status,
color=color or None,
)
return project.to_dict()
data = project.to_dict()
choices = _inception_choices(
exclude_always_on_rulebooks, subscribe_rulebooks, design_system_id, seed_systems,
)
if choices is not None:
decided = await inception_svc.decide(uid, project.id, choices=choices, via="mcp")
data["inception"] = decided["inception"]
data["inception_effects"] = decided["effects"]
else:
data["inception_hint"] = (
"Undecided: this project inherits its defaults until "
"decide_project_inception records what it should inherit "
"(enter_project will ask)."
)
return data
async def decide_project_inception(
project_id: int,
exclude_always_on_rulebooks: list[int] | None = None,
subscribe_rulebooks: list[int] | None = None,
design_system_id: int = 0,
seed_systems: bool | None = None,
) -> dict:
"""Record what a project inherits — answer enter_project's `inception` ask,
or re-decide later (milestone 297).
Owner-only. Applies the effects through the ordinary tools' paths —
exclude_always_on_rulebook, subscribe_project_to_rulebook,
set_project_design_system, the standard Systems seed — and writes the
decision on the project last, so get_project/enter_project can say why
the project has the rules, design and Systems it has. Re-deciding is
additive for exclusions/subscriptions (use include_always_on_rulebook /
unsubscribe_project_from_rulebook to undo one), replaces the design
system, and never re-seeds Systems a project already has.
Args: as create_project's inception args. Passing nothing records an
inherit-all decision (every always-on rulebook binds, no subscriptions,
no design system, no seed) — a valid answer, stated.
"""
uid = current_user_id()
choices = _inception_choices(
exclude_always_on_rulebooks, subscribe_rulebooks, design_system_id, seed_systems,
) or {}
decided = await inception_svc.decide(uid, project_id, choices=choices, via="mcp")
return {"project_id": project_id, **decided}
async def update_project(
@@ -320,6 +419,6 @@ def register(mcp) -> None:
get_project,
create_project,
update_project,
delete_project,
delete_project, decide_project_inception,
):
mcp.tool(name=fn.__name__)(fn)
+38 -2
View File
@@ -222,16 +222,22 @@ async def list_rules(
return {"rules": [_rule_summary(r) for r in rows], "total": len(rows)}
async def list_always_on_rules() -> dict:
async def list_always_on_rules(project_id: int = 0) -> dict:
"""Return all rules from rulebooks flagged always_on for the current user.
Call this at session start. Treat the returned rules as binding for the
session — they apply regardless of which project (if any) is in scope.
Pair with get_project(id).applicable_rules when working on a specific
project to also load that project's subscription-derived rules.
Args:
project_id: 0 (default) = the user-wide set. Inside a project, pass
its id: an always-on rulebook the project EXCLUDED at inception
(see enter_project's `excluded_always_on`) is left out — the
project decided not to inherit it.
"""
uid = current_user_id()
rules = await rulebooks_svc.list_always_on_rules(uid)
rules = await rulebooks_svc.list_always_on_rules(uid, project_id=project_id)
return {"rules": [_rule_summary(r) for r in rules], "total": len(rules)}
@@ -407,6 +413,35 @@ async def unsubscribe_project_from_rulebook(
# ── Suppressions — project-level mute of rulebook rules / topics ────────
async def exclude_always_on_rulebook(project_id: int, rulebook_id: int) -> dict:
"""Opt a project OUT of a whole always-on rulebook (milestone 297).
Always-on rulebooks bind every project implicitly; an inception decision
can say "not this one, not here". The exclusion is total for that project
— list_always_on_rules(project_id), enter_project/get_project rules and
the session-start context all leave it out and name it under
`excluded_always_on`. Owner-only; the rulebook must be always_on (a
subscribed rulebook is left with unsubscribe_project_from_rulebook).
Idempotent; include_always_on_rulebook reverses it. Normally reached via
decide_project_inception, not by hand.
"""
uid = current_user_id()
await rulebooks_svc.exclude_always_on_rulebook_for_project(
project_id=project_id, rulebook_id=rulebook_id, user_id=uid,
)
return {"project_id": project_id, "rulebook_id": rulebook_id, "excluded": True}
async def include_always_on_rulebook(project_id: int, rulebook_id: int) -> dict:
"""Reverse exclude_always_on_rulebook: the always-on rulebook binds this
project again. Idempotent."""
uid = current_user_id()
await rulebooks_svc.include_always_on_rulebook_for_project(
project_id=project_id, rulebook_id=rulebook_id, user_id=uid,
)
return {"project_id": project_id, "rulebook_id": rulebook_id, "excluded": False}
async def suppress_rule_for_project(
project_id: int, rule_id: int,
) -> dict:
@@ -470,5 +505,6 @@ def register(mcp) -> None:
subscribe_project_to_rulebook, unsubscribe_project_from_rulebook,
suppress_rule_for_project, unsuppress_rule_for_project,
suppress_topic_for_project, unsuppress_topic_for_project,
exclude_always_on_rulebook, include_always_on_rulebook,
):
mcp.tool(name=fn.__name__)(fn)
+4 -4
View File
@@ -30,10 +30,10 @@ _BOOTSTRAP_TITLES = 6
# design (rule #115): archetypes any codebase could have, never one
# install's subsystems. Mint freely beyond the list; the duplicate gate
# guards sprawl.
_STANDARD_SYSTEMS = (
"CI & Release", "Auth & Access", "Data Model & Storage", "API Surface",
"UI & Design", "Import & Export", "Background Jobs", "Observability",
)
# The standard vocabulary lives with the service (services/systems.
# STANDARD_SYSTEMS) since milestone 297 — the inception seed mints it and this
# ask names it, one list for both.
_STANDARD_SYSTEMS = tuple(name for name, _charter in systems_svc.STANDARD_SYSTEMS)
async def bootstrap_systems_ask(user_id: int, project_id: int) -> str | None:
+10
View File
@@ -1,5 +1,6 @@
import enum
from sqlalchemy import BigInteger, ForeignKey, Integer, Text
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column
from scribe.models import Base
from scribe.models.base import SoftDeleteMixin, TimestampMixin, iso
@@ -36,6 +37,14 @@ class Project(Base, TimestampMixin, SoftDeleteMixin):
BigInteger, ForeignKey("forge_connections.id", ondelete="SET NULL"),
nullable=True,
)
# The inception record (milestone 297): what this project was decided to
# inherit, when, and through which door — {decided_at, decided_by, via,
# choices: {exclude_always_on_rulebooks, subscribe_rulebooks,
# design_system_id, seed_systems}}. NULL means nobody has decided yet,
# and enter_project asks; the effects themselves live in the subscription
# / exclusion tables, design_system_id and the project's Systems — this is
# the WHY, kept so later surfaces can say it. See services/inception.py.
inception: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
def to_dict(self) -> dict:
return {
@@ -48,6 +57,7 @@ class Project(Base, TimestampMixin, SoftDeleteMixin):
"color": self.color,
"design_system_id": self.design_system_id,
"forge_connection_id": self.forge_connection_id,
"inception": self.inception,
"created_at": iso(self.created_at),
"updated_at": iso(self.updated_at),
}
+13
View File
@@ -129,6 +129,19 @@ project_rule_suppressions = Table(
Column("created_at", DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)),
)
# A project's opt-out of a whole ALWAYS-ON rulebook (milestone 297): the
# sibling of the two suppression tables below, one level up. Always-on
# rulebooks bind every project implicitly; an inception decision can exclude
# specific ones for this project, and get_applicable_rules /
# list_always_on_rules(project_id) skip them. FKs CASCADE like the others.
project_rulebook_exclusions = Table(
"project_rulebook_exclusions",
Base.metadata,
Column("project_id", BigInteger, ForeignKey("projects.id", ondelete="CASCADE"), primary_key=True),
Column("rulebook_id", BigInteger, ForeignKey("rulebooks.id", ondelete="CASCADE"), primary_key=True),
Column("created_at", DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)),
)
project_topic_suppressions = Table(
"project_topic_suppressions",
Base.metadata,
+48 -1
View File
@@ -5,6 +5,7 @@ from quart import Blueprint, g, jsonify, request
from scribe.auth import login_required, get_current_user_id
from scribe.routes.utils import not_found, parse_pagination
from scribe.services import inception as inception_svc
from scribe.services.milestones import list_milestones
from scribe.services.notes import list_notes
from scribe.services.projects import (
@@ -66,6 +67,15 @@ async def create_project_route():
status = data.get("status", "active")
if status not in ("active", "paused", "completed", "archived"):
return jsonify({"error": "status must be 'active', 'paused', 'completed', or 'archived'"}), 400
# The inception decision rides the create (milestone 297): the UI's
# second step sends `inception: {choices}`; absent = undecided, and the
# project page shows the card until it is. Validated before the create
# so a bad decision never leaves a half-made project behind.
inception = data.get("inception")
if inception is not None:
error = inception_svc.validate_inception(inception)
if error:
return jsonify({"error": error}), 400
project = await create_project(
uid,
title=data["title"],
@@ -74,7 +84,44 @@ async def create_project_route():
color=data.get("color"),
status=status,
)
return jsonify(project.to_dict()), 201
out = project.to_dict()
if inception is not None:
try:
decided = await inception_svc.decide(uid, project.id, choices=inception, via="ui")
except ValueError as exc:
return jsonify({"error": str(exc), "project": out}), 400
out["inception"] = decided["inception"]
out["inception_effects"] = decided["effects"]
return jsonify(out), 201
@projects_bp.route("/<int:project_id>/inception", methods=["POST"])
@login_required
async def decide_inception_route(project_id: int):
"""Record (or re-record) what a project inherits — milestone 297.
Body: the choices object {exclude_always_on_rulebooks, subscribe_rulebooks,
design_system_id, seed_systems}; owner-only."""
uid = get_current_user_id()
data = await request.get_json() or {}
choices = data.get("choices", data)
try:
decided = await inception_svc.decide(uid, project_id, choices=choices, via="ui")
except ValueError as exc:
msg = str(exc)
status = 404 if "not found" in msg else 400
return jsonify({"error": msg}), status
return jsonify({"project_id": project_id, **decided})
@projects_bp.route("/<int:project_id>/inception/defaults", methods=["GET"])
@login_required
async def inception_defaults_route(project_id: int):
"""What the project inherits if nobody decides — the card's payload."""
uid = get_current_user_id()
try:
return jsonify(await inception_svc.current_defaults(uid, project_id))
except ValueError:
return not_found("Project")
@projects_bp.route("/<int:project_id>", methods=["GET"])
+26
View File
@@ -288,6 +288,32 @@ async def unsuppress_project_topic(project_id: int, topic_id: int):
return "", 204
@rulebooks_bp.post("/projects/<int:project_id>/exclusions/rulebooks/<int:rulebook_id>")
@login_required
async def exclude_project_rulebook(project_id: int, rulebook_id: int):
"""Opt the project out of a whole always-on rulebook (milestone 297)."""
try:
await rulebooks_svc.exclude_always_on_rulebook_for_project(
project_id=project_id, rulebook_id=rulebook_id, user_id=get_current_user_id(),
)
except ValueError as exc:
msg = str(exc)
return jsonify({"error": msg}), (400 if "not always-on" in msg else 404)
return "", 204
@rulebooks_bp.delete("/projects/<int:project_id>/exclusions/rulebooks/<int:rulebook_id>")
@login_required
async def include_project_rulebook(project_id: int, rulebook_id: int):
try:
await rulebooks_svc.include_always_on_rulebook_for_project(
project_id=project_id, rulebook_id=rulebook_id, user_id=get_current_user_id(),
)
except ValueError as exc:
return jsonify({"error": str(exc)}), 404
return "", 204
@rulebooks_bp.post("/projects/<int:project_id>/rules")
@login_required
async def create_project_rule(project_id: int):
+63 -4
View File
@@ -19,6 +19,7 @@ from scribe.models.rulebook import (
Rulebook,
RulebookTopic,
project_rule_suppressions,
project_rulebook_exclusions,
project_rulebook_subscriptions,
project_topic_suppressions,
)
@@ -45,8 +46,10 @@ logger = logging.getLogger(__name__)
# v9 (2026-08) added code_shape_uses — the ledger's consumption edges (#2870):
# judgment-grade edges (agent/audit/import) are operator records; mechanical
# ones (reference/hook) travel too, cheaply, and the next refresh refreshes them.
# v10 (2026-08) added projects.inception + project_rulebook_exclusions
# (milestone 297): the WHY a project inherits what it does, and its opt-outs.
# Bump when the serialized schema changes.
BACKUP_VERSION = 9
BACKUP_VERSION = 10
# 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
@@ -60,7 +63,7 @@ _BACKED_UP = [
"users", "projects", "milestones", "notes", "task_logs", "note_drafts",
"note_versions", "settings", "rulebooks", "rulebook_topics", "rules",
"project_rulebook_subscriptions", "project_rule_suppressions",
"project_topic_suppressions",
"project_topic_suppressions", "project_rulebook_exclusions",
# v5 (2026-08): the five-year gap this list was written to stop.
"systems", "record_systems", "design_systems", "design_tokens",
"note_usage_events", "repo_bindings", "note_supersessions",
@@ -112,6 +115,10 @@ def _topic_suppression_rows(rows) -> list[dict]:
return [{"project_id": r.project_id, "topic_id": r.topic_id} for r in rows]
def _rulebook_exclusion_rows(rows) -> list[dict]:
return [{"project_id": r.project_id, "rulebook_id": r.rulebook_id} for r in rows]
# The v5 sections. Pure row-builders like the join-table helpers above, for the
# same reason: CI has no database, so a serialiser that is a plain function is
# one that can actually be tested.
@@ -219,6 +226,8 @@ def _project_rows(rows) -> list[dict]:
"id": p.id, "user_id": p.user_id, "title": p.title,
"description": p.description, "goal": p.goal, "status": p.status,
"color": p.color,
"design_system_id": p.design_system_id,
"inception": p.inception,
"created_at": p.created_at.isoformat(),
"updated_at": p.updated_at.isoformat(),
}
@@ -383,6 +392,9 @@ async def export_full_backup() -> dict:
topic_suppressions = (await session.execute(
select(project_topic_suppressions)
)).all()
rulebook_exclusions = (await session.execute(
select(project_rulebook_exclusions)
)).all()
return {
"version": BACKUP_VERSION,
@@ -407,6 +419,7 @@ async def export_full_backup() -> dict:
"rulebook_subscriptions": _subscription_rows(subscriptions),
"rule_suppressions": _rule_suppression_rows(rule_suppressions),
"topic_suppressions": _topic_suppression_rows(topic_suppressions),
"rulebook_exclusions": _rulebook_exclusion_rows(rulebook_exclusions),
"systems": _system_rows(systems),
"record_systems": _record_system_rows(record_systems),
"design_systems": _design_system_rows(design_systems),
@@ -532,8 +545,13 @@ async def export_user_backup(user_id: int) -> dict:
project_topic_suppressions.c.project_id.in_(project_ids)
)
)).all()
rulebook_exclusions = (await session.execute(
select(project_rulebook_exclusions).where(
project_rulebook_exclusions.c.project_id.in_(project_ids)
)
)).all()
else:
subscriptions = rule_suppressions = topic_suppressions = []
subscriptions = rule_suppressions = topic_suppressions = rulebook_exclusions = []
return {
"version": BACKUP_VERSION,
@@ -560,6 +578,7 @@ async def export_user_backup(user_id: int) -> dict:
"rulebook_subscriptions": _subscription_rows(subscriptions),
"rule_suppressions": _rule_suppression_rows(rule_suppressions),
"topic_suppressions": _topic_suppression_rows(topic_suppressions),
"rulebook_exclusions": _rulebook_exclusion_rows(rulebook_exclusions),
"systems": _system_rows(systems),
"record_systems": _record_system_rows(record_systems),
"design_systems": _design_system_rows(design_systems),
@@ -670,7 +689,7 @@ async def _restore_v2(data: dict) -> dict:
"task_logs": 0, "note_drafts": 0, "note_versions": 0,
"settings": 0, "rulebooks": 0, "rulebook_topics": 0, "rules": 0,
"rulebook_subscriptions": 0, "rule_suppressions": 0,
"topic_suppressions": 0,
"topic_suppressions": 0, "rulebook_exclusions": 0,
"systems": 0, "record_systems": 0, "design_systems": 0,
"design_tokens": 0, "note_usage_events": 0, "repo_bindings": 0,
"note_supersessions": 0, "code_shapes": 0, "code_shape_events": 0,
@@ -933,6 +952,17 @@ async def _restore_v2(data: dict) -> dict:
))
stats["topic_suppressions"] += 1
# 14b. Always-on rulebook exclusions (v10, milestone 297)
for exc in data.get("rulebook_exclusions", []):
mapped_pid = project_id_map.get(exc.get("project_id", 0))
mapped_rbid = rulebook_id_map.get(exc.get("rulebook_id", 0))
if mapped_pid is None or mapped_rbid is None:
continue
await session.execute(project_rulebook_exclusions.insert().values(
project_id=mapped_pid, rulebook_id=mapped_rbid,
))
stats["rulebook_exclusions"] += 1
# --- v5 sections. Every one is data.get()-guarded, so a v2/v3/v4
# payload restores without them rather than failing on an absent key.
@@ -1137,6 +1167,35 @@ async def _restore_v2(data: dict) -> dict:
))
stats["code_shape_uses"] += 1
# v10: a project's design-system pointer and its inception record ride
# the project but point at design systems and rulebooks restored AFTER
# it — so they are written last, with ids re-mapped. An id that did
# not survive drops out of the record rather than dangling.
for p_data in data.get("projects", []):
new_pid = project_id_map.get(p_data.get("id") or 0)
if new_pid is None:
continue
proj = await session.get(Project, new_pid)
if proj is None:
continue
old_ds = p_data.get("design_system_id")
if old_ds:
proj.design_system_id = design_system_id_map.get(old_ds)
inception = p_data.get("inception")
if isinstance(inception, dict):
choices = dict(inception.get("choices") or {})
choices["exclude_always_on_rulebooks"] = [
rulebook_id_map[i] for i in choices.get("exclude_always_on_rulebooks") or []
if i in rulebook_id_map
]
choices["subscribe_rulebooks"] = [
rulebook_id_map[i] for i in choices.get("subscribe_rulebooks") or []
if i in rulebook_id_map
]
ds = choices.get("design_system_id")
choices["design_system_id"] = design_system_id_map.get(ds) if ds else None
proj.inception = {**inception, "choices": choices}
await session.commit()
logger.info("Restored v2/v3 backup: %s", stats)
+272
View File
@@ -0,0 +1,272 @@
"""Project inception — what a project was decided to inherit (milestone 297).
A project's inheritance is a decision, not a default. The record lives on
``projects.inception``::
{
"decided_at": "<iso>", "decided_by": <user id> | null,
"via": "mcp" | "ui" | "legacy",
"choices": {
"exclude_always_on_rulebooks": [rulebook ids],
"subscribe_rulebooks": [rulebook ids],
"design_system_id": <id> | null,
"seed_systems": bool
}
}
NULL = undecided → enter_project asks. ``legacy`` is the migration's stamp on
projects that existed before the step did (inherit-all / no design system /
no seed), so the ask fires only for projects created after this shipped.
The shape and its validator are pure; ``decide`` composes the existing
services — always-on exclusions, subscriptions, set_project_design_system,
the standard Systems seed — checks every target BEFORE touching anything,
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.
"""
from __future__ import annotations
from datetime import datetime, timezone
from sqlalchemy import select
from scribe.models import async_session
from scribe.models.project import Project
from scribe.models.rulebook import Rulebook
INCEPTION_VIAS = ("mcp", "ui", "legacy")
CHOICE_KEYS = ("exclude_always_on_rulebooks", "subscribe_rulebooks", "design_system_id", "seed_systems")
def _is_id_list(value) -> bool:
return isinstance(value, list) and all(
isinstance(v, int) and not isinstance(v, bool) and v > 0 for v in value
)
def validate_inception(choices) -> str | None:
"""The structural error an inception ``choices`` object would earn, or
None. Pure and checked BEFORE any effect is applied: a decision either
applies whole or errors whole (the StrictArgs lesson, #2709).
Accepts the four keys, each optional: two id lists (positive ints, no
duplicates between exclude and subscribe), ``design_system_id`` an int
or None, ``seed_systems`` a bool. Unknown keys are an error — a typo
must not become a silently ignored choice."""
if not isinstance(choices, dict):
return "choices must be an object"
unknown = sorted(set(choices) - set(CHOICE_KEYS))
if unknown:
return f"unknown inception choice(s): {', '.join(unknown)} (one of: {', '.join(CHOICE_KEYS)})"
excl = choices.get("exclude_always_on_rulebooks") or []
subs = choices.get("subscribe_rulebooks") or []
if not _is_id_list(excl):
return "exclude_always_on_rulebooks must be a list of rulebook ids"
if not _is_id_list(subs):
return "subscribe_rulebooks must be a list of rulebook ids"
both = sorted(set(excl) & set(subs))
if both:
return f"rulebook(s) {both} cannot be both excluded and subscribed"
ds = choices.get("design_system_id")
if ds is not None and (isinstance(ds, bool) or not isinstance(ds, int) or ds <= 0):
return "design_system_id must be a positive id or null"
seed = choices.get("seed_systems", False)
if not isinstance(seed, bool):
return "seed_systems must be true or false"
return None
def normalize_choices(choices: dict | None) -> dict:
"""The four keys, always present, in canonical form — what gets stored
and what the UI/agent reads back. Call after validate_inception."""
choices = choices or {}
return {
"exclude_always_on_rulebooks": sorted(set(choices.get("exclude_always_on_rulebooks") or [])),
"subscribe_rulebooks": sorted(set(choices.get("subscribe_rulebooks") or [])),
"design_system_id": choices.get("design_system_id"),
"seed_systems": bool(choices.get("seed_systems", False)),
}
def is_decided(project) -> bool:
"""A project is decided once its inception record exists (any via)."""
return bool(getattr(project, "inception", None))
async def current_defaults(user_id: int, project_id: int) -> dict:
"""What the project inherits if nobody decides — the ask's payload.
{always_on_rulebooks: [{id,title}], other_rulebooks: [{id,title}],
excluded_always_on: [...], subscribed_rulebooks: [...],
design_system_id, design_systems: [{id,title}], systems: <count>}.
Instance-agnostic: an install with no rulebooks / design systems shows
empty lists, and the ask says so rather than inventing a default.
"""
from scribe.services import design_systems as design_systems_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
project = await projects_svc.get_project(user_id, project_id)
if project is None:
raise ValueError(f"project {project_id} not found")
async with async_session() as session:
rows = (
await session.execute(
select(Rulebook.id, Rulebook.title, Rulebook.always_on)
.where(Rulebook.owner_user_id == user_id, Rulebook.deleted_at.is_(None))
.order_by(Rulebook.title)
)
).all()
applicable = await rulebooks_svc.get_applicable_rules(project_id, user_id, limit=1)
designs = await design_systems_svc.list_design_systems(user_id)
systems = await systems_svc.list_systems(user_id, project_id, include_archived=True)
return {
"always_on_rulebooks": [{"id": i, "title": t} for i, t, on in rows if on],
"other_rulebooks": [{"id": i, "title": t} for i, t, on in rows if not on],
"excluded_always_on": applicable.get("excluded_always_on", []),
"subscribed_rulebooks": applicable.get("subscribed_rulebooks", []),
"design_system_id": project.design_system_id,
"design_systems": [{"id": d.id, "title": d.title} for d in designs],
"systems": len(systems),
}
async def _check_targets(user_id: int, choices: dict) -> None:
"""Every id a decision names must be the caller's (or readable) BEFORE any
effect lands — a decision applies whole or errors whole."""
from scribe.services import access
wanted = set(choices["exclude_always_on_rulebooks"]) | set(choices["subscribe_rulebooks"])
if wanted:
async with async_session() as session:
rows = (
await session.execute(
select(Rulebook.id, Rulebook.always_on).where(
Rulebook.id.in_(wanted),
Rulebook.owner_user_id == user_id,
Rulebook.deleted_at.is_(None),
)
)
).all()
found = {rid: on for rid, on in rows}
missing = sorted(wanted - set(found))
if missing:
raise ValueError(f"rulebook(s) {missing} not found (or not yours)")
not_always = sorted(r for r in choices["exclude_always_on_rulebooks"] if not found[r])
if not_always:
raise ValueError(
f"rulebook(s) {not_always} are not always-on — only always-on rulebooks "
"can be excluded; a subscribed rulebook is simply not subscribed"
)
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)")
async def decide(
user_id: int,
project_id: int,
*,
choices: dict | None,
via: str,
) -> dict:
"""Record a project's inception decision and apply it (milestone 297).
Owner-only. Validates the choices (pure) and every target (owned /
readable) first; then, each idempotent: exclude the named always-on
rulebooks, subscribe the named rulebooks, point the project at the design
system (None = explicitly none), seed the standard Systems if asked and
the project has none; then write ``projects.inception`` LAST. Re-deciding
is additive for exclusions/subscriptions (nothing is silently dropped —
include/unsubscribe are explicit calls), replaces the design system, and
re-seeds nothing a project already has.
Returns {"inception": <record>, "effects": {excluded, subscribed,
design_system_id, systems_seeded}}.
"""
from scribe.services import design_systems as design_systems_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
if via not in INCEPTION_VIAS or via == "legacy":
raise ValueError("via must be 'mcp' or 'ui' ('legacy' is the migration's stamp)")
error = validate_inception(choices or {})
if error:
raise ValueError(error)
choices = normalize_choices(choices)
project = await projects_svc.get_project(user_id, project_id) # owner-scoped
if project is None:
raise ValueError(f"project {project_id} not found (or not yours)")
await _check_targets(user_id, choices)
for rb in choices["exclude_always_on_rulebooks"]:
await rulebooks_svc.exclude_always_on_rulebook_for_project(project_id, rb, user_id)
for rb in choices["subscribe_rulebooks"]:
await rulebooks_svc.subscribe_project(project_id, rb, user_id)
if not await design_systems_svc.set_project_design_system(
user_id, project_id, choices["design_system_id"]
):
raise ValueError("could not set the design system (no write on the project?)")
seeded = (
await systems_svc.seed_standard_systems(user_id, project_id)
if choices["seed_systems"] else []
)
record = {
"decided_at": datetime.now(timezone.utc).isoformat(),
"decided_by": user_id,
"via": via,
"choices": choices,
}
async with async_session() as session:
row = await session.get(Project, project_id)
row.inception = record
row.updated_at = datetime.now(timezone.utc)
await session.commit()
return {
"inception": record,
"effects": {
"excluded": choices["exclude_always_on_rulebooks"],
"subscribed": choices["subscribe_rulebooks"],
"design_system_id": choices["design_system_id"],
"systems_seeded": [sy.name for sy in seeded],
},
}
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
defaults, what to ask the operator, and the exact call that answers it.
Fail-open: a hint must never break the call it rides on."""
try:
defaults = await current_defaults(user_id, project_id)
except Exception:
return {}
always = ", ".join(f"{r['title']} (#{r['id']})" for r in defaults["always_on_rulebooks"]) or "none"
others = ", ".join(f"{r['title']} (#{r['id']})" for r in defaults["other_rulebooks"]) or "none"
designs = ", ".join(f"{d['title']} (#{d['id']})" for d in defaults["design_systems"]) or "none"
return {
"defaults": defaults,
"ask": (
"This project has no inception decision: nobody has said what it "
f"inherits. Today, by default: always-on rulebooks binding it — {always}; "
f"rulebooks it could subscribe to — {others}; 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 always-on rulebooks to EXCLUDE here (default: none), which "
"rulebooks to subscribe, 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."
),
"call": (
f"decide_project_inception(project_id={project_id}, "
"exclude_always_on_rulebooks=[...], subscribe_rulebooks=[...], "
"design_system_id=<id | -1 for none>, seed_systems=<true|false>)"
),
}
+14 -1
View File
@@ -1097,7 +1097,14 @@ async def build_session_context(
at _MAX_CHARS with an explicit truncation note so the hook can pass it
through verbatim.
"""
rules = await rulebooks_svc.list_always_on_rules(user_id)
# Inside a project, the always-on set is the project's: an inception
# exclusion (milestone 297) takes a rulebook out of this block, and is
# named below so the departure is visible rather than silent.
rules = await rulebooks_svc.list_always_on_rules(user_id, project_id=project_id)
excluded = (
await rulebooks_svc.excluded_always_on_rulebooks(user_id, project_id)
if project_id else []
)
topic_map = await _topic_titles({r.topic_id for r in rules if r.topic_id})
lines: list[str] = [
@@ -1119,6 +1126,12 @@ async def build_session_context(
heading = topic_map.get(r.topic_id, "ungrouped") if r.topic_id else "ungrouped"
lines.append(f"### {heading}")
lines.append(f"- [{r.id}] {r.title}")
if excluded:
names = ", ".join(f"{e['title']} (#{e['id']})" for e in excluded)
lines += [
"",
f"Excluded for this project by its inception decision (not binding here): {names}.",
]
project_dict: dict | None = None
if project_id:
+105 -6
View File
@@ -394,15 +394,57 @@ async def list_rules(
return rulebook_rules + list(proj_result.scalars().all())
async def list_always_on_rules(user_id: int, limit: int = 100) -> list[Rule]:
def _excluded_rulebook_ids_q(project_id: int):
"""Subquery: the always-on rulebooks this project opted out of at
inception (milestone 297) — used by every rule-resolution path so an
exclusion is total, not just cosmetic."""
from scribe.models.rulebook import project_rulebook_exclusions
return select(project_rulebook_exclusions.c.rulebook_id).where(
project_rulebook_exclusions.c.project_id == project_id
)
async def excluded_always_on_rulebooks(user_id: int, project_id: int) -> list[dict]:
"""[{id, title}] of the always-on rulebooks excluded for ``project_id``
(owner-scoped). Empty for an undecided or inherit-all project."""
from scribe.models.rulebook import project_rulebook_exclusions
if not project_id:
return []
async with async_session() as session:
rows = (
await session.execute(
select(Rulebook.id, Rulebook.title)
.join(project_rulebook_exclusions,
project_rulebook_exclusions.c.rulebook_id == Rulebook.id)
.where(
project_rulebook_exclusions.c.project_id == project_id,
Rulebook.owner_user_id == user_id,
Rulebook.deleted_at.is_(None),
)
.order_by(Rulebook.title)
)
).all()
return [{"id": rid, "title": title} for rid, title in rows]
async def list_always_on_rules(
user_id: int, limit: int = 100, project_id: int = 0,
) -> list[Rule]:
"""Return all rules from rulebooks flagged always_on for the user.
Called by the MCP tool of the same name at session start to load the
standing rules that apply regardless of which project (if any) is in
scope. Ordering matches list_rules so results are stable across calls.
``project_id`` (milestone 297): inside a project that excluded specific
always-on rulebooks at inception, those rulebooks' rules are NOT
returned — the project decided not to inherit them. 0 = the user-wide
set, which is what a session sees before a project is in scope.
"""
async with async_session() as session:
result = await session.execute(
q = (
select(Rule)
.join(RulebookTopic, Rule.topic_id == RulebookTopic.id)
.join(Rulebook, RulebookTopic.rulebook_id == Rulebook.id)
@@ -413,10 +455,13 @@ async def list_always_on_rules(user_id: int, limit: int = 100) -> list[Rule]:
RulebookTopic.deleted_at.is_(None),
Rulebook.deleted_at.is_(None),
)
.order_by(
)
if project_id:
q = q.where(Rulebook.id.notin_(_excluded_rulebook_ids_q(project_id)))
result = await session.execute(
q.order_by(
Rulebook.id, RulebookTopic.order_index, Rule.order_index, Rule.title,
)
.limit(limit)
).limit(limit)
)
return list(result.scalars().all())
@@ -489,6 +534,7 @@ async def delete_rule(rule_id: int, user_id: int) -> None:
# ── Subscriptions + get_applicable_rules ───────────────────────────────
from sqlalchemy import insert, delete as sql_delete
from sqlalchemy.exc import IntegrityError
async def subscribe_project(
@@ -568,6 +614,51 @@ async def unsuppress_rule_for_project(
await session.commit()
async def exclude_always_on_rulebook_for_project(
project_id: int, rulebook_id: int, user_id: int,
) -> None:
"""Opt one project out of a whole ALWAYS-ON rulebook (milestone 297).
Owner-only on both sides; the rulebook must be always_on — a subscribed
rulebook is left by unsubscribing, not excluding. Idempotent."""
from scribe.models.rulebook import project_rulebook_exclusions
async with async_session() as session:
await _assert_project_owned(session, project_id, user_id)
await _assert_rulebook_owned(session, rulebook_id, user_id)
rb = await session.get(Rulebook, rulebook_id)
if rb is None or not rb.always_on:
raise ValueError(
f"rulebook {rulebook_id} is not always-on — it binds only by "
"subscription; unsubscribe_project_from_rulebook instead"
)
try:
await session.execute(
insert(project_rulebook_exclusions).values(
project_id=project_id, rulebook_id=rulebook_id,
)
)
await session.commit()
except IntegrityError:
await session.rollback() # already excluded — idempotent
async def include_always_on_rulebook_for_project(
project_id: int, rulebook_id: int, user_id: int,
) -> None:
"""Undo exclude_always_on_rulebook_for_project. Idempotent."""
from scribe.models.rulebook import project_rulebook_exclusions
async with async_session() as session:
await _assert_project_owned(session, project_id, user_id)
await session.execute(
sql_delete(project_rulebook_exclusions).where(
project_rulebook_exclusions.c.project_id == project_id,
project_rulebook_exclusions.c.rulebook_id == rulebook_id,
)
)
await session.commit()
async def suppress_topic_for_project(
project_id: int, topic_id: int, user_id: int,
) -> None:
@@ -731,6 +822,9 @@ async def get_applicable_rules(
Rule.deleted_at.is_(None),
RulebookTopic.deleted_at.is_(None),
Rulebook.deleted_at.is_(None),
# An inception exclusion is total (milestone 297): a rulebook the
# project opted out of contributes nothing, subscribed or not.
Rulebook.id.notin_(_excluded_rulebook_ids_q(project_id)),
)
.order_by(
Rulebook.id, RulebookTopic.order_index, Rule.order_index, Rule.title,
@@ -778,6 +872,7 @@ async def get_applicable_rules(
"suppressed_topics": suppressed_topics,
"truncated": truncated,
"subscribed_rulebooks": subscribed_rulebooks,
"excluded_always_on": await excluded_always_on_rulebooks(user_id, project_id),
}
@@ -786,9 +881,12 @@ def rules_payload(applicable: dict) -> dict:
Every surface that hands rules to an agent (enter_project, get_project,
get_milestone, get_task for legacy plans, start_planning) carries the
same six keys under the same names — so a reader learns them once. One
same seven keys under the same names — so a reader learns them once. One
place renames `rules` → `applicable_rules` and `truncated` →
`applicable_rules_truncated`; the tools merge this into their payloads.
`excluded_always_on` (milestone 297) names the always-on rulebooks this
project decided NOT to inherit, so the departure is visible wherever the
rules are.
"""
return {
"applicable_rules": applicable["rules"],
@@ -797,4 +895,5 @@ def rules_payload(applicable: dict) -> dict:
"project_rules": applicable.get("project_rules", []),
"suppressed_rules": applicable.get("suppressed_rules", []),
"suppressed_topics": applicable.get("suppressed_topics", []),
"excluded_always_on": applicable.get("excluded_always_on", []),
}
+35
View File
@@ -18,6 +18,41 @@ from scribe.services import access
logger = logging.getLogger(__name__)
# The standard cross-project vocabulary (#2798): names that mean the same
# thing in every project, so a starter set reads the same everywhere. The
# bootstrap ask (mcp/tools/systems) names them; the inception seed
# (services/inception, milestone 297) mints them. Charters are deliberately
# generic — a project refines them as its own records accrue.
STANDARD_SYSTEMS: tuple[tuple[str, str], ...] = (
("CI & Release", "How the project is verified and shipped: pipelines, runners, image/artifact builds, release tagging and rollback."),
("Auth & Access", "Who may do what: identity, sessions/tokens, permissions and the scoping of every read and write to the right users."),
("Data Model & Storage", "What is stored and how it is shaped: the schema, migrations, serialisation and the services that own a table's lifecycle."),
("API Surface", "The doors into the capability: HTTP routes, tool/RPC surfaces, request parsing, error envelopes and their contracts."),
("UI & Design", "What people see and touch: views, components, client state, and the design tokens/recipes they are built from."),
("Import & Export", "Data crossing the boundary: backups, exports, imports, sync with other systems, file formats."),
("Background Jobs", "Work that runs without a request: schedulers, queues, periodic ticks, retention and maintenance."),
("Observability", "How the system reports on itself: logging, metrics, audit trails, health and diagnostics."),
)
async def seed_standard_systems(user_id: int, project_id: int) -> list[System]:
"""Mint the standard starter set for a project that has NO Systems yet
(milestone 297). Idempotent: a project with any System — the vocabulary
already started, standard or not — gets nothing; the duplicate gate and
the project's own judgment take it from there. [] without write access."""
if await list_systems(user_id, project_id, include_archived=True):
return []
out: list[System] = []
for index, (name, charter) in enumerate(STANDARD_SYSTEMS):
system = await create_system(
user_id, project_id, name, description=charter, order_index=index,
)
if system is None:
break
out.append(system)
return out
async def create_system(
user_id: int,
project_id: int,
+65
View File
@@ -0,0 +1,65 @@
"""Project inception (milestone 297) — step 1: the record's shape.
The WHY a project inherits what it does lives on projects.inception; the
opt-out of an always-on rulebook is its own association table. Pure
validation is pinned here; the effects are step 3's integration tests.
"""
from scribe.models import Base
from scribe.models.project import Project
from scribe.models.rulebook import project_rulebook_exclusions
from scribe.services.inception import (
CHOICE_KEYS, INCEPTION_VIAS, is_decided, normalize_choices, validate_inception,
)
def test_project_carries_an_inception_record_and_to_dict_shows_it():
assert "inception" in Project.__table__.c
assert Project.__table__.c.inception.nullable # NULL = undecided
p = Project(user_id=1, title="x", inception=None)
assert p.to_dict()["inception"] is None and not is_decided(p)
p.inception = {"via": "mcp", "decided_at": "2026-08-22T00:00:00+00:00", "decided_by": 1,
"choices": normalize_choices({"seed_systems": True})}
assert is_decided(p) and p.to_dict()["inception"]["via"] == "mcp"
assert INCEPTION_VIAS == ("mcp", "ui", "legacy")
def test_exclusions_table_is_the_suppressions_sibling():
t = Base.metadata.tables["project_rulebook_exclusions"]
assert project_rulebook_exclusions is t
assert {c.name for c in t.primary_key.columns} == {"project_id", "rulebook_id"}
fks = {fk.column.table.name: fk.ondelete for c in t.columns for fk in c.foreign_keys}
assert fks == {"projects": "CASCADE", "rulebooks": "CASCADE"}
def test_validate_inception_pins_the_choice_vocabulary():
assert CHOICE_KEYS == ("exclude_always_on_rulebooks", "subscribe_rulebooks", "design_system_id", "seed_systems")
assert validate_inception({}) is None
assert validate_inception({"exclude_always_on_rulebooks": [1], "subscribe_rulebooks": [2],
"design_system_id": 3, "seed_systems": True}) is None
assert validate_inception({"design_system_id": None}) is None
assert "must be an object" in validate_inception([])
assert "unknown inception choice" in validate_inception({"repo": "x"})
assert "list of rulebook ids" in validate_inception({"exclude_always_on_rulebooks": "1"})
assert "list of rulebook ids" in validate_inception({"subscribe_rulebooks": [0]})
assert "list of rulebook ids" in validate_inception({"subscribe_rulebooks": [True]})
assert "both excluded and subscribed" in validate_inception(
{"exclude_always_on_rulebooks": [1, 2], "subscribe_rulebooks": [2]})
assert "positive id or null" in validate_inception({"design_system_id": 0})
assert "positive id or null" in validate_inception({"design_system_id": True})
assert "true or false" in validate_inception({"seed_systems": "yes"})
def test_normalize_choices_is_canonical_and_complete():
out = normalize_choices({"subscribe_rulebooks": [3, 1, 3], "exclude_always_on_rulebooks": [2]})
assert out == {"exclude_always_on_rulebooks": [2], "subscribe_rulebooks": [1, 3],
"design_system_id": None, "seed_systems": False}
assert normalize_choices(None) == {"exclude_always_on_rulebooks": [], "subscribe_rulebooks": [],
"design_system_id": None, "seed_systems": False}
def test_standard_systems_vocabulary_is_one_list_for_ask_and_seed():
from scribe.mcp.tools.systems import _STANDARD_SYSTEMS
from scribe.services.systems import STANDARD_SYSTEMS
assert _STANDARD_SYSTEMS == tuple(n for n, _ in STANDARD_SYSTEMS)
assert len(STANDARD_SYSTEMS) == 8 and all(charter for _, charter in STANDARD_SYSTEMS)
+66
View File
@@ -0,0 +1,66 @@
"""Milestone 297 step 2 — always-on exclusions reach every rule surface.
The SQL is the integration lane's; here the contracts: rules_payload carries
the seventh key, list_always_on_rules takes project_id, the session-start
block names the excluded rulebooks, and the MCP tools mount.
"""
from unittest.mock import AsyncMock, patch
import pytest
from scribe.services.rulebooks import rules_payload
def test_rules_payload_carries_excluded_always_on_as_the_seventh_key():
out = rules_payload({
"rules": [], "truncated": False, "subscribed_rulebooks": [],
"excluded_always_on": [{"id": 1, "title": "Family"}],
})
assert set(out) == {
"applicable_rules", "applicable_rules_truncated", "subscribed_rulebooks",
"project_rules", "suppressed_rules", "suppressed_topics", "excluded_always_on",
}
assert out["excluded_always_on"] == [{"id": 1, "title": "Family"}]
# An older applicable dict without the key still renders (empty list).
assert rules_payload({"rules": [], "truncated": False, "subscribed_rulebooks": []})["excluded_always_on"] == []
def test_list_always_on_rules_service_and_tool_take_a_project_id():
import inspect
from scribe.mcp.tools import rulebooks as tools
from scribe.services import rulebooks as svc
assert "project_id" in inspect.signature(svc.list_always_on_rules).parameters
assert "project_id" in inspect.signature(tools.list_always_on_rules).parameters
@pytest.mark.asyncio
async def test_session_context_names_the_excluded_always_on_rulebooks():
from types import SimpleNamespace as NS
from scribe.services.plugin_context import build_session_context
rules = [NS(id=1, title="`dev` is home", topic_id=1, statement="x")]
project = NS(id=9, title="Widget", goal="", design_system_id=None)
with patch("scribe.services.plugin_context.rulebooks_svc.list_always_on_rules",
AsyncMock(return_value=rules)) as lao, \
patch("scribe.services.plugin_context.rulebooks_svc.excluded_always_on_rulebooks",
AsyncMock(return_value=[{"id": 5, "title": "Design standards"}])), \
patch("scribe.services.plugin_context._topic_titles", AsyncMock(return_value={1: "git"})), \
patch("scribe.services.plugin_context.projects_svc.get_project", AsyncMock(return_value=project)), \
patch("scribe.services.plugin_context.notes_svc.list_notes", AsyncMock(return_value=([], 0))), \
patch("scribe.services.plugin_context.rulebooks_svc.get_applicable_rules",
AsyncMock(return_value={"rules": [], "truncated": False, "subscribed_rulebooks": [],
"project_rules": [], "suppressed_rules": [],
"suppressed_topics": [], "excluded_always_on": []})):
out = await build_session_context(user_id=7, project_id=9)
# The always-on set was asked FOR THIS PROJECT, and the departure is named.
assert lao.await_args.kwargs.get("project_id") == 9
assert "Excluded for this project by its inception decision" in out["context"]
assert "Design standards (#5)" in out["context"]
def test_exclusion_routes_are_registered():
from scribe.app import create_app
rules = {r.rule for r in create_app().url_map.iter_rules()}
assert "/api/projects/<int:project_id>/exclusions/rulebooks/<int:rulebook_id>" in rules
+111
View File
@@ -0,0 +1,111 @@
"""Real-Postgres integration tests for project inception (milestone 297).
What mocks can't prove: a decision's effects land through the real services
(exclusions filter the always-on set, subscriptions bind, the design system
points, the standard Systems seed once), the record is written last, a bad
target applies nothing.
"""
import pytest
import pytest_asyncio
from scribe.models import async_session
from scribe.models.project import Project
from scribe.models.rulebook import Rulebook
from scribe.services import inception as inception_svc
from scribe.services import rulebooks as rulebooks_svc
from scribe.services import systems as systems_svc
from tests.helpers import ensure_user
pytestmark = [pytest.mark.integration, pytest.mark.usefixtures("_dispose_engine")]
@pytest_asyncio.fixture
async def seeded():
"""Owner, a fresh project, one always-on rulebook (with a rule) and one
ordinary rulebook (with a rule)."""
async with async_session() as s:
owner = await ensure_user(s, "inception_owner")
project = Project(user_id=owner.id, title="Inception target")
s.add(project)
await s.flush()
ids = {"owner": owner.id, "pid": project.id}
await s.commit()
always = await rulebooks_svc.create_rulebook(ids["owner"], "Family standards")
other = await rulebooks_svc.create_rulebook(ids["owner"], "Optional practices")
async with async_session() as s:
rb = await s.get(Rulebook, always.id)
rb.always_on = True
await s.commit()
t1 = await rulebooks_svc.create_topic(always.id, ids["owner"], "git")
await rulebooks_svc.create_rule(t1.id, ids["owner"], "dev is home", "Work on dev.")
t2 = await rulebooks_svc.create_topic(other.id, ids["owner"], "docs")
await rulebooks_svc.create_rule(t2.id, ids["owner"], "Write the why", "Record reasons.")
ids.update({"always": always.id, "other": other.id})
return ids
@pytest.mark.integration
async def test_decide_applies_every_effect_and_records_last(seeded):
owner, pid = seeded["owner"], seeded["pid"]
# Undecided: the always-on rulebook binds, nothing subscribed, no Systems.
assert [r.title for r in await rulebooks_svc.list_always_on_rules(owner, project_id=pid)] == ["dev is home"]
defaults = await inception_svc.current_defaults(owner, pid)
assert [r["id"] for r in defaults["always_on_rulebooks"]] == [seeded["always"]]
assert [r["id"] for r in defaults["other_rulebooks"]] == [seeded["other"]]
assert defaults["systems"] == 0 and defaults["design_system_id"] is None
out = await inception_svc.decide(owner, pid, via="mcp", choices={
"exclude_always_on_rulebooks": [seeded["always"]],
"subscribe_rulebooks": [seeded["other"]],
"design_system_id": None,
"seed_systems": True,
})
assert out["effects"]["excluded"] == [seeded["always"]]
assert out["effects"]["subscribed"] == [seeded["other"]]
assert len(out["effects"]["systems_seeded"]) == len(systems_svc.STANDARD_SYSTEMS)
# The exclusion is total: the project's always-on set is empty, the
# departure is named, the subscription binds.
assert await rulebooks_svc.list_always_on_rules(owner, project_id=pid) == []
assert len(await rulebooks_svc.list_always_on_rules(owner)) == 1 # user-wide unchanged
applicable = await rulebooks_svc.get_applicable_rules(pid, owner)
assert [r["title"] for r in applicable["rules"]] == ["Write the why"]
assert [e["id"] for e in applicable["excluded_always_on"]] == [seeded["always"]]
assert [s["id"] for s in applicable["subscribed_rulebooks"]] == [seeded["other"]]
# The record, written last, says why.
async with async_session() as s:
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"]["exclude_always_on_rulebooks"] == [seeded["always"]]
# Re-deciding with seed again mints nothing twice; include reverses the exclusion.
again = await inception_svc.decide(owner, pid, via="ui", choices={"seed_systems": True})
assert again["effects"]["systems_seeded"] == []
assert len(await systems_svc.list_systems(owner, pid)) == len(systems_svc.STANDARD_SYSTEMS)
await rulebooks_svc.include_always_on_rulebook_for_project(pid, seeded["always"], owner)
assert [r.title for r in await rulebooks_svc.list_always_on_rules(owner, project_id=pid)] == ["dev is home"]
@pytest.mark.integration
async def test_a_bad_decision_applies_nothing(seeded):
owner, pid = seeded["owner"], seeded["pid"]
# Excluding a rulebook that is not always-on is refused BEFORE any effect.
with pytest.raises(ValueError, match="not always-on"):
await inception_svc.decide(owner, pid, via="mcp", choices={
"exclude_always_on_rulebooks": [seeded["other"]], "seed_systems": True,
})
assert await systems_svc.list_systems(owner, pid) == []
with pytest.raises(ValueError, match="not found"):
await inception_svc.decide(owner, pid, via="mcp", choices={"subscribe_rulebooks": [999999]})
with pytest.raises(ValueError, match="legacy"):
await inception_svc.decide(owner, pid, via="legacy", choices={})
async with async_session() as s:
project = await s.get(Project, pid)
assert not inception_svc.is_decided(project)
# An outsider cannot decide someone else's project.
async with async_session() as s:
other = await ensure_user(s, "inception_other")
other_id = other.id
await s.commit()
with pytest.raises(ValueError, match="not found"):
await inception_svc.decide(other_id, pid, via="mcp", choices={})
+78
View File
@@ -391,3 +391,81 @@ def test_enter_project_registered_in_register():
register(mcp)
assert "enter_project" in mcp.names
# --- milestone 297: the inception doors ---------------------------------------
@pytest.mark.asyncio
async def test_create_project_without_inception_args_stays_undecided():
p = fake_project(id=5, title="P", inception=None)
with patch("scribe.mcp.tools.projects.projects_svc.create_project", AsyncMock(return_value=p)), \
patch("scribe.mcp.tools.projects.inception_svc.decide", AsyncMock()) as decide:
out = await create_project(title="P")
decide.assert_not_awaited()
assert "inception_hint" in out and "inception_effects" not in out
@pytest.mark.asyncio
async def test_create_project_with_inception_args_decides_via_mcp():
p = fake_project(id=5, title="P", inception=None)
decided = {"inception": {"via": "mcp", "choices": {}}, "effects": {"systems_seeded": []}}
with patch("scribe.mcp.tools.projects.projects_svc.create_project", AsyncMock(return_value=p)), \
patch("scribe.mcp.tools.projects.inception_svc.decide", AsyncMock(return_value=decided)) as decide:
out = await create_project(title="P", exclude_always_on_rulebooks=[1], design_system_id=-1, seed_systems=True)
kw = decide.await_args.kwargs
assert decide.await_args.args[1] == 5 and kw["via"] == "mcp"
assert kw["choices"] == {"exclude_always_on_rulebooks": [1], "subscribe_rulebooks": [],
"design_system_id": None, "seed_systems": True}
assert out["inception"]["via"] == "mcp" and "inception_effects" in out
@pytest.mark.asyncio
async def test_decide_project_inception_tool_records_an_inherit_all_decision_when_given_nothing():
from scribe.mcp.tools.projects import decide_project_inception
decided = {"inception": {"via": "mcp"}, "effects": {}}
with patch("scribe.mcp.tools.projects.inception_svc.decide", AsyncMock(return_value=decided)) as decide:
out = await decide_project_inception(project_id=5)
assert decide.await_args.kwargs["choices"] == {}
assert out["project_id"] == 5 and out["inception"]["via"] == "mcp"
@pytest.mark.asyncio
async def test_enter_project_carries_the_inception_ask_only_for_an_undecided_own_project():
applicable = {"rules": [], "project_rules": [], "truncated": False,
"subscribed_rulebooks": [], "excluded_always_on": []}
ask = {"defaults": {}, "ask": "decide", "call": "decide_project_inception(...)"}
async def run(project):
with patch("scribe.mcp.tools.projects.projects_svc.get_project", AsyncMock(return_value=project)), \
patch("scribe.mcp.tools.projects.rulebooks_svc.get_applicable_rules", AsyncMock(return_value=applicable)), \
patch("scribe.mcp.tools.projects.milestones_svc.get_project_milestone_summary", AsyncMock(return_value=[])), \
patch("scribe.mcp.tools.projects.notes_svc.list_notes", AsyncMock(side_effect=[([], 0), ([], 0)])), \
patch("scribe.mcp.tools.projects.systems_svc.list_systems", AsyncMock(return_value=[])), \
patch("scribe.mcp.tools.projects.systems_tools.bootstrap_systems_ask", AsyncMock(return_value=None)), \
patch("scribe.mcp.tools.projects.inception_svc.inception_ask", AsyncMock(return_value=ask)) as asked:
return await enter_project(project_id=5), asked
# Own + undecided → the ask rides along.
out, asked = await run(fake_project(id=5, title="P", user_id=7, inception=None))
assert out["inception"] == ask and asked.await_count == 1
# Decided → absent, and the ask is not even built.
out, asked = await run(fake_project(id=5, title="P", user_id=7, inception={"via": "legacy"}))
assert "inception" not in out and asked.await_count == 0
# Someone else's (shared) project, undecided → not this caller's to decide.
out, asked = await run(fake_project(id=5, title="P", user_id=8, inception=None))
assert "inception" not in out and asked.await_count == 0
def test_inception_routes_and_tool_are_registered():
from scribe.app import create_app
from scribe.mcp.server import build_mcp_server
rules = {r.rule for r in create_app().url_map.iter_rules()}
assert "/api/projects/<int:project_id>/inception" in rules
assert "/api/projects/<int:project_id>/inception/defaults" in rules
mcp = build_mcp_server()
assert mcp._tool_manager.get_tool("decide_project_inception") is not None
tool = mcp._tool_manager.get_tool("create_project")
for name in ("exclude_always_on_rulebooks", "subscribe_rulebooks", "design_system_id", "seed_systems"):
assert name in tool.parameters.get("properties", {}), name
+4 -1
View File
@@ -169,12 +169,15 @@ def test_register_attaches_all_sixteen_tools():
mcp = FakeMCP()
register(mcp)
assert len(mcp.names) == 22
assert len(mcp.names) == 24 # +exclude/include_always_on_rulebook (milestone 297)
# spot-check a few names
assert "list_rulebooks" in mcp.names
assert "create_rule" in mcp.names
assert "subscribe_project_to_rulebook" in mcp.names
assert "list_always_on_rules" in mcp.names
# milestone 297: a project's opt-out of a whole always-on rulebook
assert "exclude_always_on_rulebook" in mcp.names
assert "include_always_on_rulebook" in mcp.names
assert "create_project_rule" in mcp.names
assert "suppress_rule_for_project" in mcp.names
assert "unsuppress_rule_for_project" in mcp.names
+2 -2
View File
@@ -18,7 +18,7 @@ def test_backup_version_is_v8():
point of the test — a payload section added without moving the version
produces backups that are structurally different and indistinguishable
by inspection."""
assert backup.BACKUP_VERSION == 9
assert backup.BACKUP_VERSION == 10
def test_not_included_lists_the_known_gaps():
@@ -116,7 +116,7 @@ async def test_export_full_backup_contains_every_declared_section():
"systems", "record_systems", "design_systems",
"design_tokens", "note_usage_events", "repo_bindings",
"note_supersessions", "code_shapes", "code_shape_events",
"code_shape_uses"):
"code_shape_uses", "rulebook_exclusions"):
assert key in out, f"missing export section: {key}"
assert out[key] == []
+9
View File
@@ -4,6 +4,15 @@ import pytest
from tests.helpers import fake_note
@pytest.fixture(autouse=True)
def _no_exclusions():
"""build_session_context asks for the bound project's always-on
exclusions (milestone 297); these tests script the rules only."""
with patch("scribe.services.plugin_context.rulebooks_svc.excluded_always_on_rulebooks",
AsyncMock(return_value=[])):
yield
pytestmark = pytest.mark.usefixtures("_no_supersession")
+9
View File
@@ -8,6 +8,15 @@ import pytest
from tests.helpers import fake_rule, fake_rulebook, fake_topic, make_mock_session
@pytest.fixture(autouse=True)
def _no_exclusions():
"""get_applicable_rules asks for the project's always-on exclusions
(milestone 297) through its own session; these mocked-session tests
script the rule queries only, so the exclusions lookup is stubbed empty."""
with patch("scribe.services.rulebooks.excluded_always_on_rulebooks", AsyncMock(return_value=[])):
yield
@pytest.mark.asyncio
async def test_create_rulebook_stores_to_db():
mock_session = make_mock_session()