Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,9 @@ jobs:
- name: Verify runtime config contracts
run: pnpm test:runtime-config

- name: Verify query contracts
run: pnpm test:query-contracts

- name: Verify CI workflow contracts
run: pnpm test:ci-workflows

Expand Down
45 changes: 45 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,4 +15,49 @@

- Preserve core app behavior and generic self-host behavior when removing hosted/VPS surfaces.
- Prefer small, test-backed changes.
- Do not put test stubs in production code. Production route handlers, services, and app code must never branch on test-only env vars or fake provider responses. Keep stubs, fixtures, and provider fakes in tests, local harnesses, or pure contract verifiers only.
- Run targeted scans before committing OSS changes.

<!-- gitnexus:start -->
# GitNexus - Code Intelligence

This project is indexed by GitNexus as **minutia** (4111 symbols, 7801 relationships, 300 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.

> If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first.

## Always Do

- **MUST run impact analysis before editing any symbol.** Before modifying a function, class, or method, run `gitnexus_impact({target: "symbolName", direction: "upstream"})` and report the blast radius (direct callers, affected processes, risk level) to the user.
- **MUST run `gitnexus_detect_changes()` before committing** to verify your changes only affect expected symbols and execution flows.
- **MUST warn the user** if impact analysis returns HIGH or CRITICAL risk before proceeding with edits.
- When exploring unfamiliar code, use `gitnexus_query({query: "concept"})` to find execution flows instead of grepping. It returns process-grouped results ranked by relevance.
- When you need full context on a specific symbol - callers, callees, which execution flows it participates in - use `gitnexus_context({name: "symbolName"})`.

## Never Do

- NEVER edit a function, class, or method without first running `gitnexus_impact` on it.
- NEVER ignore HIGH or CRITICAL risk warnings from impact analysis.
- NEVER rename symbols with find-and-replace - use `gitnexus_rename` which understands the call graph.
- NEVER commit changes without running `gitnexus_detect_changes()` to check affected scope.

## Resources

| Resource | Use for |
|----------|---------|
| `gitnexus://repo/minutia/context` | Codebase overview, check index freshness |
| `gitnexus://repo/minutia/clusters` | All functional areas |
| `gitnexus://repo/minutia/processes` | All execution flows |
| `gitnexus://repo/minutia/process/{name}` | Step-by-step execution trace |

## CLI

| Task | Read this skill file |
|------|---------------------|
| Understand architecture / "How does X work?" | `.claude/skills/gitnexus/gitnexus-exploring/SKILL.md` |
| Blast radius / "What breaks if I change X?" | `.claude/skills/gitnexus/gitnexus-impact-analysis/SKILL.md` |
| Trace bugs / "Why is X failing?" | `.claude/skills/gitnexus/gitnexus-debugging/SKILL.md` |
| Rename / extract / split / refactor | `.claude/skills/gitnexus/gitnexus-refactoring/SKILL.md` |
| Tools, resources, schema reference | `.claude/skills/gitnexus/gitnexus-guide/SKILL.md` |
| Index, status, clean, wiki CLI commands | `.claude/skills/gitnexus/gitnexus-cli/SKILL.md` |

<!-- gitnexus:end -->
156 changes: 1 addition & 155 deletions e2e/regression/ai-notes.spec.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,10 @@
import { randomUUID } from "node:crypto";
import { test, expect, type APIRequestContext } from "@playwright/test";
import { MEETINGS, SERIES, waitForApp } from "./seed-data";
import { SERIES, waitForApp } from "./seed-data";

const SUPABASE_URL = process.env.SUPABASE_URL ?? "http://127.0.0.1:54321";
const HAS_SERVICE_ROLE = !!process.env.SUPABASE_SERVICE_ROLE_KEY;
const HAS_OPENROUTER_KEY = !!process.env.OPENROUTER_API_KEY;
const HAS_TEST_OPENROUTER_RESPONSE = !!process.env.MINUTIA_TEST_OPENROUTER_RESPONSE;
const HAS_TEST_AI_SUGGESTIONS_RESPONSE = !!process.env.MINUTIA_TEST_AI_SUGGESTIONS_RESPONSE;
const HAS_TEST_SERIES_ASK_RESPONSE = !!process.env.MINUTIA_TEST_SERIES_ASK_RESPONSE;
const TEST_USER_ID = "00000000-0000-0000-0000-000000000001";

function serviceHeaders(prefer = "return=representation") {
Expand Down Expand Up @@ -114,54 +111,6 @@ test.describe("AI notes", () => {
}
});

test("real endpoint stores structured OpenRouter output", async ({
page,
request,
}) => {
test.skip(!HAS_SERVICE_ROLE, "Requires service role setup for isolated AI notes data");
test.skip(
!HAS_OPENROUTER_KEY || !HAS_TEST_OPENROUTER_RESPONSE,
"Requires test OpenRouter fixture"
);

const fixture = await createAiNotesFixture(request);

try {
await page.goto(`/series/${fixture.seriesId}/meetings/${fixture.meetingId}`);
await waitForApp(page);

const response = await page.request.post(
`/api/meetings/${fixture.meetingId}/enhance-notes`,
{ data: { mode: "preview" }, timeout: 20_000 }
);
expect(response.status()).toBe(200);
await expect(response.json()).resolves.toMatchObject({
ai_notes: {
summary: ["Alice owns onboarding and launch scope stays small."],
action_items: ["Alice owns onboarding by Friday."],
decisions: ["Keep launch scope small."],
risks: ["Support queue may spike."],
},
model: "minimax/minimax-m3",
prompt_version: "ai-notes-v1",
});

const rows = await rest(
request,
`meetings?id=eq.${fixture.meetingId}&select=raw_notes_markdown,ai_notes_markdown,ai_notes_model,ai_notes_prompt_version`
);
expect(rows[0]).toMatchObject({
raw_notes_markdown: fixture.rawNotes,
ai_notes_model: "minimax/minimax-m3",
ai_notes_prompt_version: "ai-notes-v1",
});
expect(rows[0].ai_notes_markdown).toContain("## Summary");
expect(rows[0].ai_notes_markdown).toContain("Alice owns onboarding");
} finally {
await deleteSeries(request, fixture.seriesId);
}
});

test("preserves raw notes and applies generated notes after preview", async ({
page,
request,
Expand Down Expand Up @@ -278,78 +227,6 @@ test.describe("AI notes", () => {
}
});

test("generates reviewable suggestions, accepts an edited issue, and rejects a decision", async ({
page,
request,
}) => {
test.skip(!HAS_SERVICE_ROLE, "Requires service role setup for isolated AI notes data");
test.skip(
!HAS_OPENROUTER_KEY || !HAS_TEST_AI_SUGGESTIONS_RESPONSE,
"Requires test AI suggestions fixture"
);

const fixture = await createAiNotesFixture(request);

try {
await page.goto(`/series/${fixture.seriesId}/meetings/${fixture.meetingId}`);
await waitForApp(page);
await expect(page.getByRole("heading", { name: /AI notes session/ })).toBeVisible({ timeout: 20_000 });

await page.getByRole("button", { name: "Review AI suggestions" }).click();
await expect(page.getByRole("region", { name: "AI suggestions" })).toBeVisible();
await expect(page.getByLabel("Suggestion title").first()).toHaveValue("Alice owns onboarding by Friday");
await expect(page.getByText("Decision: Keep launch scope small")).toBeVisible();

await page.getByLabel("Suggestion title").first().fill("Alice owns the onboarding checklist");
await page.getByLabel("Suggestion owner").first().fill("Alice");
await page.getByRole("button", { name: "Accept suggestion" }).first().click();
await expect(page.getByText("Accepted into tracked work.")).toBeVisible();

await page.getByRole("button", { name: "Reject suggestion" }).first().click();
await expect(page.getByText("Rejected.")).toBeVisible();

const issues = await rest(
request,
`issues?series_id=eq.${fixture.seriesId}&select=title,owner_name,source,ai_confidence,category,due_date`
);
expect(issues).toEqual([
expect.objectContaining({
title: "Alice owns the onboarding checklist",
owner_name: "Alice",
source: "ai_suggested",
category: "action",
due_date: "2026-06-05",
}),
]);
expect(issues[0].ai_confidence).toBeGreaterThanOrEqual(0.8);

const decisions = await rest(
request,
`decisions?series_id=eq.${fixture.seriesId}&select=title,source`
);
expect(decisions).toEqual([]);

const suggestions = await rest(
request,
`meeting_ai_suggestions?meeting_id=eq.${fixture.meetingId}&select=title,status,created_issue_id,created_decision_id&order=created_at.asc`
);
expect(suggestions).toEqual([
expect.objectContaining({
title: "Alice owns the onboarding checklist",
status: "accepted",
}),
expect.objectContaining({
title: "Keep launch scope small",
status: "rejected",
created_decision_id: null,
}),
]);
expect(suggestions[0].created_issue_id).toBeTruthy();
} finally {
await deleteSeries(request, fixture.seriesId);
}
});

test("shows an empty suggestions state", async ({ page, request }) => {
test.skip(!HAS_SERVICE_ROLE, "Requires service role setup for isolated AI notes data");

Expand Down Expand Up @@ -396,37 +273,6 @@ test.describe("Ask this series", () => {
});
});

test("answers a series question with citations", async ({ page }) => {
test.skip(
!HAS_OPENROUTER_KEY || !HAS_TEST_SERIES_ASK_RESPONSE,
"Requires test Ask this series fixture"
);

await page.goto(`/series/${SERIES.platformStandup}`);
await waitForApp(page);

await page.getByLabel("Ask this series question").fill("What did we decide about CI/CD?");
const askResponse = page.waitForResponse(
(response) =>
response.url().includes(`/api/series/${SERIES.platformStandup}/ask`) &&
response.request().method() === "POST",
{ timeout: 30_000 }
);
await page.getByRole("button", { name: "Ask series" }).click();
await expect((await askResponse).ok()).toBeTruthy();

const answer = page.getByRole("region", { name: "Series answer" });
await expect(answer).toBeVisible({ timeout: 20_000 });
await expect(answer.getByText("Use GitHub Actions for CI/CD")).toBeVisible();
await expect(answer.getByText("Sources")).toBeVisible();
await expect(
answer.getByRole("link", { name: /Platform Standup #2/ })
).toHaveAttribute(
"href",
`/series/${SERIES.platformStandup}/meetings/${MEETINGS.standup2}`
);
});

test("shows unsupported answers without citations", async ({ page }) => {
await page.route("**/api/series/*/ask", async (route) => {
await route.fulfill({
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@
"deploy:env": "node scripts/generate-self-host-env.mjs",
"test:oss-boundaries": "node scripts/verify-oss-boundaries.mjs",
"test:runtime-config": "node --test scripts/verify-runtime-config.test.mjs",
"test:query-contracts": "node scripts/verify-query-contracts.mjs",
"test:ci-workflows": "node scripts/verify-ci-playwright-shards.mjs",
"test:e2e": "playwright test",
"test:e2e:headed": "playwright test --headed",
Expand Down
55 changes: 55 additions & 0 deletions scripts/verify-ai-notes-contract.mjs
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { pathToFileURL } from "node:url";
import * as esbuild from "esbuild";

const root = process.cwd();

Expand Down Expand Up @@ -99,6 +102,58 @@ assert(askSeriesRoute.includes("AI_API_KEY"), "Ask series route must support AI_
assert(askSeriesRoute.includes("https://openrouter.ai/api/v1/chat/completions"), "Ask series route must call OpenRouter chat completions");
assert(askSeriesRoute.includes("The source context does not prove the answer."), "Ask series route must include unsupported-answer guard copy");

const askSeriesParserPath = "src/lib/ai/ask-series-answer.ts";
assert(exists(askSeriesParserPath), "Missing Ask this series provider parser module");

const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "minutia-ai-contract-"));
const bundledParser = path.join(tempDir, "ask-series-answer.mjs");
await esbuild.build({
entryPoints: [askSeriesParserPath],
outfile: bundledParser,
bundle: true,
platform: "node",
format: "esm",
logLevel: "silent",
});

const { parseAskSeriesAnswer } = await import(pathToFileURL(bundledParser).href);
const sparseAnswer = parseAskSeriesAnswer({
providerData: {
choices: [
{
message: {
content: JSON.stringify({
answer: "Use GitHub Actions for CI/CD.",
citations: [
{
source_id: "20000000-0000-0000-0000-000000000002",
quote: "Use GitHub Actions for CI/CD",
},
],
unsupported: false,
}),
},
},
],
},
seriesId: "10000000-0000-0000-0000-000000000001",
meetings: [
{
id: "20000000-0000-0000-0000-000000000002",
title: "Platform Standup #2",
},
],
issues: [],
decisions: [],
});
assert(sparseAnswer.unsupported === false, "Sparse provider citations should stay supported");
assert(sparseAnswer.citations[0]?.type === "notes", "Sparse meeting citations should resolve to notes");
assert(
sparseAnswer.citations[0]?.href ===
"/series/10000000-0000-0000-0000-000000000001/meetings/20000000-0000-0000-0000-000000000002",
"Sparse meeting citations should link to the source meeting"
);

const meetingDetail = read("src/app/(app)/series/[id]/meetings/[meetingId]/meeting-detail-content.tsx");
for (const copy of [
"Enhance notes",
Expand Down
18 changes: 18 additions & 0 deletions scripts/verify-query-contracts.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import fs from "node:fs";

function assert(condition, message) {
if (!condition) {
throw new Error(message);
}
}

const useSeries = fs.readFileSync("src/lib/hooks/use-series.ts", "utf8");

assert(
!useSeries.includes("setInterval(refresh, 3000)") &&
!useSeries.includes("setInterval(refresh, 2_000)") &&
!useSeries.includes("setInterval(refresh, 2000)"),
"Series detail realtime must not poll every few seconds"
);

console.log("Query contracts verified");
12 changes: 0 additions & 12 deletions src/app/api/meetings/[meetingId]/enhance-notes/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,18 +82,6 @@ function getTextFromOpenRouter(data: unknown) {
}

async function getOpenRouterData(prompt: string, apiKey: string) {
if (process.env.MINUTIA_TEST_OPENROUTER_RESPONSE) {
return {
choices: [
{
message: {
content: process.env.MINUTIA_TEST_OPENROUTER_RESPONSE,
},
},
],
};
}

const providerResponse = await fetch(OPENROUTER_URL, {
method: "POST",
headers: {
Expand Down
12 changes: 0 additions & 12 deletions src/app/api/meetings/[meetingId]/suggestions/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,18 +75,6 @@ function buildPrompt(input: {
}

async function getOpenRouterData(prompt: string, apiKey: string) {
if (process.env.MINUTIA_TEST_AI_SUGGESTIONS_RESPONSE) {
return {
choices: [
{
message: {
content: process.env.MINUTIA_TEST_AI_SUGGESTIONS_RESPONSE,
},
},
],
};
}

const providerResponse = await fetch(OPENROUTER_URL, {
method: "POST",
headers: {
Expand Down
Loading
Loading