diff --git a/README.md b/README.md index df29bc6..efa1621 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/alembic/versions/0085_project_inception.py b/alembic/versions/0085_project_inception.py new file mode 100644 index 0000000..40e776b --- /dev/null +++ b/alembic/versions/0085_project_inception.py @@ -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") diff --git a/docs/api-reference.md b/docs/api-reference.md index 8fe527b..bc79aae 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -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 diff --git a/frontend/src/api/inception.ts b/frontend/src/api/inception.ts new file mode 100644 index 0000000..05773e1 --- /dev/null +++ b/frontend/src/api/inception.ts @@ -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(`/api/projects/${projectId}/inception/defaults`); + +export const decideInception = (projectId: number, choices: InceptionChoices) => + apiPost(`/api/projects/${projectId}/inception`, { choices }); diff --git a/frontend/src/api/rulebooks.ts b/frontend/src/api/rulebooks.ts index 858d854..1c52447 100644 --- a/frontend/src/api/rulebooks.ts +++ b/frontend/src/api/rulebooks.ts @@ -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 { return apiDelete(`/api/projects/${projectId}/suppressions/topics/${topicId}`); } + +// ── Always-on exclusions (milestone 297) ──────────────────────────────────── + +export async function excludeAlwaysOnRulebook(projectId: number, rulebookId: number): Promise { + await apiPost(`/api/projects/${projectId}/exclusions/rulebooks/${rulebookId}`, {}); +} + +export async function includeAlwaysOnRulebook(projectId: number, rulebookId: number): Promise { + await apiDelete(`/api/projects/${projectId}/exclusions/rulebooks/${rulebookId}`); +} + diff --git a/frontend/src/components/InceptionCard.vue b/frontend/src/components/InceptionCard.vue new file mode 100644 index 0000000..b37699b --- /dev/null +++ b/frontend/src/components/InceptionCard.vue @@ -0,0 +1,189 @@ + + + + + diff --git a/frontend/src/components/rules/ProjectRulesTab.vue b/frontend/src/components/rules/ProjectRulesTab.vue index 4749279..cd989b0 100644 --- a/frontend/src/components/rules/ProjectRulesTab.vue +++ b/frontend/src/components/rules/ProjectRulesTab.vue @@ -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); +
+

Excluded always-on rulebooks

+

Opted out at inception — these do not bind this project.

+
+ + {{ rb.title }} + + +
+
+

Project rules

@@ -321,6 +345,9 @@ watch(() => props.projectId, load);