diff --git a/packages/cli/drizzle/0004_silly_firebird.sql b/packages/cli/drizzle/0004_silly_firebird.sql new file mode 100644 index 0000000..b2b9d8a --- /dev/null +++ b/packages/cli/drizzle/0004_silly_firebird.sql @@ -0,0 +1 @@ +ALTER TABLE `chapter_run` ADD `prologue` text; \ No newline at end of file diff --git a/packages/cli/drizzle/meta/0004_snapshot.json b/packages/cli/drizzle/meta/0004_snapshot.json new file mode 100644 index 0000000..32ce80d --- /dev/null +++ b/packages/cli/drizzle/meta/0004_snapshot.json @@ -0,0 +1,612 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "df482c89-af64-45e8-9e36-5fe43b4e209f", + "prevId": "536350ff-7be0-4c61-829c-54a834aaba84", + "tables": { + "chapter": { + "name": "chapter", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updatedAt": { + "name": "updatedAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "runId": { + "name": "runId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "externalId": { + "name": "externalId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "chapterIndex": { + "name": "chapterIndex", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "hunkRefs": { + "name": "hunkRefs", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "keyChanges": { + "name": "keyChanges", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + } + }, + "indexes": { + "chapter_run_idx_unique": { + "name": "chapter_run_idx_unique", + "columns": [ + "runId", + "chapterIndex" + ], + "isUnique": true + } + }, + "foreignKeys": { + "chapter_runId_chapter_run_id_fk": { + "name": "chapter_runId_chapter_run_id_fk", + "tableFrom": "chapter", + "tableTo": "chapter_run", + "columnsFrom": [ + "runId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "chapter_file_view": { + "name": "chapter_file_view", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updatedAt": { + "name": "updatedAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'local'" + }, + "chapterId": { + "name": "chapterId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "filePath": { + "name": "filePath", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "chapter_file_view_chapter_id_idx": { + "name": "chapter_file_view_chapter_id_idx", + "columns": [ + "chapterId" + ], + "isUnique": false + }, + "chapter_file_view_user_chapter_path_unique": { + "name": "chapter_file_view_user_chapter_path_unique", + "columns": [ + "userId", + "chapterId", + "filePath" + ], + "isUnique": true + } + }, + "foreignKeys": { + "chapter_file_view_chapterId_chapter_id_fk": { + "name": "chapter_file_view_chapterId_chapter_id_fk", + "tableFrom": "chapter_file_view", + "tableTo": "chapter", + "columnsFrom": [ + "chapterId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "chapter_run": { + "name": "chapter_run", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updatedAt": { + "name": "updatedAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "repoRoot": { + "name": "repoRoot", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "originUrl": { + "name": "originUrl", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scopeKind": { + "name": "scopeKind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "workingTreeRef": { + "name": "workingTreeRef", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "baseSha": { + "name": "baseSha", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "headSha": { + "name": "headSha", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "mergeBaseSha": { + "name": "mergeBaseSha", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "generatedAt": { + "name": "generatedAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "prologue": { + "name": "prologue", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "chapter_run_created_at_idx": { + "name": "chapter_run_created_at_idx", + "columns": [ + "createdAt" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "chapter_view": { + "name": "chapter_view", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updatedAt": { + "name": "updatedAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'local'" + }, + "chapterId": { + "name": "chapterId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "chapter_view_user_chapter_unique": { + "name": "chapter_view_user_chapter_unique", + "columns": [ + "userId", + "chapterId" + ], + "isUnique": true + } + }, + "foreignKeys": { + "chapter_view_chapterId_chapter_id_fk": { + "name": "chapter_view_chapterId_chapter_id_fk", + "tableFrom": "chapter_view", + "tableTo": "chapter", + "columnsFrom": [ + "chapterId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "file_view": { + "name": "file_view", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updatedAt": { + "name": "updatedAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'local'" + }, + "runId": { + "name": "runId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "filePath": { + "name": "filePath", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "file_view_user_run_path_unique": { + "name": "file_view_user_run_path_unique", + "columns": [ + "userId", + "runId", + "filePath" + ], + "isUnique": true + } + }, + "foreignKeys": { + "file_view_runId_chapter_run_id_fk": { + "name": "file_view_runId_chapter_run_id_fk", + "tableFrom": "file_view", + "tableTo": "chapter_run", + "columnsFrom": [ + "runId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "key_change": { + "name": "key_change", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updatedAt": { + "name": "updatedAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "chapterId": { + "name": "chapterId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "externalId": { + "name": "externalId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "lineRefs": { + "name": "lineRefs", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + } + }, + "indexes": { + "key_change_chapter_id_idx": { + "name": "key_change_chapter_id_idx", + "columns": [ + "chapterId" + ], + "isUnique": false + } + }, + "foreignKeys": { + "key_change_chapterId_chapter_id_fk": { + "name": "key_change_chapterId_chapter_id_fk", + "tableFrom": "key_change", + "tableTo": "chapter", + "columnsFrom": [ + "chapterId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "key_change_view": { + "name": "key_change_view", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updatedAt": { + "name": "updatedAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'local'" + }, + "keyChangeId": { + "name": "keyChangeId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "key_change_view_key_change_id_idx": { + "name": "key_change_view_key_change_id_idx", + "columns": [ + "keyChangeId" + ], + "isUnique": false + }, + "key_change_view_user_key_change_unique": { + "name": "key_change_view_user_key_change_unique", + "columns": [ + "userId", + "keyChangeId" + ], + "isUnique": true + } + }, + "foreignKeys": { + "key_change_view_keyChangeId_key_change_id_fk": { + "name": "key_change_view_keyChangeId_key_change_id_fk", + "tableFrom": "key_change_view", + "tableTo": "key_change", + "columnsFrom": [ + "keyChangeId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/packages/cli/drizzle/meta/_journal.json b/packages/cli/drizzle/meta/_journal.json index b8e5421..ff6c18c 100644 --- a/packages/cli/drizzle/meta/_journal.json +++ b/packages/cli/drizzle/meta/_journal.json @@ -29,6 +29,13 @@ "when": 1777829566446, "tag": "0003_file_view", "breakpoints": true + }, + { + "idx": 4, + "version": "6", + "when": 1777914813035, + "tag": "0004_silly_firebird", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/cli/src/__tests__/import-chapters.test.ts b/packages/cli/src/__tests__/import-chapters.test.ts index fae70b0..a079097 100644 --- a/packages/cli/src/__tests__/import-chapters.test.ts +++ b/packages/cli/src/__tests__/import-chapters.test.ts @@ -185,6 +185,43 @@ describe("chapter import", () => { expect(db2.select().from(chapterRun).all()).toHaveLength(2); }); + it("stores the prologue on chapter_run when present", () => { + const db = getDb({ dbPath }); + const prologue = { + motivation: "Dashboards would break during deploys.", + outcome: "Dashboards stay up during deploys now.", + keyChanges: [ + { + summary: "Deploy-safe dashboard rendering", + description: "Uses cached data during deploys", + }, + ], + focusAreas: [ + { + type: "architecture" as const, + severity: "info" as const, + title: "New caching layer", + description: "Confirm cache invalidation on deploy completion", + locations: ["src/dashboard.ts"], + }, + ], + complexity: { level: "medium" as const, reasoning: "Touches caching and rendering" }, + }; + + insertChaptersFile(db, makeFixture({ prologue }), makeRepoContext()); + + const [row] = db.select().from(chapterRun).all(); + expect(row?.prologue).toEqual(prologue); + }); + + it("stores null prologue when omitted from the fixture", () => { + const db = getDb({ dbPath }); + insertChaptersFile(db, makeFixture(), makeRepoContext()); + + const [row] = db.select().from(chapterRun).all(); + expect(row?.prologue).toBeNull(); + }); + it("uses isolated databases for distinct dbPaths", async () => { const dbPathA = path.join(tmpDir, "a.sqlite"); const dbPathB = path.join(tmpDir, "b.sqlite"); diff --git a/packages/cli/src/__tests__/runs.routes.test.ts b/packages/cli/src/__tests__/runs.routes.test.ts index aedb5e4..76d3daf 100644 --- a/packages/cli/src/__tests__/runs.routes.test.ts +++ b/packages/cli/src/__tests__/runs.routes.test.ts @@ -170,6 +170,47 @@ describe("runs API", () => { expect(body.chapters[0]?.keyChanges.every((k) => typeof k === "object")).toBe(true); }); + it("GET /api/runs/:runId/chapters returns prologue: null when no prologue was imported", async () => { + const db = getDb({ dbPath }); + const { runId } = insertChaptersFile(db, makeFixture(), makeRepoContext()); + + const { port } = await startWithRoutes(); + const res = await getJson(port, `/api/runs/${runId}/chapters`); + + expect(res.status).toBe(200); + const body = res.body as { prologue: unknown }; + expect(body.prologue).toBeNull(); + }); + + it("GET /api/runs/:runId/chapters includes the prologue when imported", async () => { + const db = getDb({ dbPath }); + const prologue = { + motivation: "Slow page loads on large repos.", + outcome: "Pages load fast now.", + keyChanges: [ + { summary: "Pagination added to repo list", description: "Limits to 50 repos per page" }, + ], + focusAreas: [ + { + type: "performance" as const, + severity: "info" as const, + title: "Pagination boundary", + description: "Verify off-by-one at page boundaries", + locations: ["src/repos.ts"], + }, + ], + complexity: { level: "low" as const, reasoning: "Simple pagination" }, + }; + const { runId } = insertChaptersFile(db, makeFixture({ prologue }), makeRepoContext()); + + const { port } = await startWithRoutes(); + const res = await getJson(port, `/api/runs/${runId}/chapters`); + + expect(res.status).toBe(200); + const body = res.body as { prologue: typeof prologue }; + expect(body.prologue).toEqual(prologue); + }); + it("GET /api/runs/:runId/chapters returns 404 for unknown runs", async () => { const { port } = await startWithRoutes(); const res = await getJson(port, "/api/runs/00000000-0000-0000-0000-000000000000/chapters"); diff --git a/packages/cli/src/__tests__/schema.test.ts b/packages/cli/src/__tests__/schema.test.ts index d753eb1..fe8d262 100644 --- a/packages/cli/src/__tests__/schema.test.ts +++ b/packages/cli/src/__tests__/schema.test.ts @@ -147,6 +147,42 @@ describe("ChaptersFileSchema", () => { expectInvalidAt(makeFixture({ generatedAt: "yesterday" }), "generatedAt"); }); + it("accepts a file with a valid prologue", () => { + const prologue = { + motivation: "Users couldn't reset their password without getting logged out.", + outcome: "Password reset now preserves the session.", + keyChanges: [ + { + summary: "Session token survives password reset", + description: "Token refresh logic moved earlier in the reset flow", + }, + ], + focusAreas: [ + { + type: "security", + severity: "high", + title: "Session token handling", + description: + "Session persists across password change — confirm token rotation still occurs", + locations: ["src/auth/reset.ts"], + }, + ], + complexity: { level: "medium", reasoning: "Touches auth and session layers" }, + }; + const result = ChaptersFileSchema.parse(makeFixture({ prologue })); + expect(result.prologue).toBeDefined(); + expect(result.prologue?.motivation).toBe(prologue.motivation); + }); + + it("accepts a file without a prologue (backward compatibility)", () => { + const result = ChaptersFileSchema.parse(makeFixture()); + expect(result.prologue).toBeUndefined(); + }); + + it("rejects a file with a malformed prologue", () => { + expectInvalidAt(makeFixture({ prologue: { motivation: "test" } }), "prologue.keyChanges"); + }); + it("rejects line references the UI cannot anchor safely", () => { expectInvalidAt( makeFixture({ diff --git a/packages/cli/src/db/schema/chapter-run.ts b/packages/cli/src/db/schema/chapter-run.ts index 99db82d..e7af734 100644 --- a/packages/cli/src/db/schema/chapter-run.ts +++ b/packages/cli/src/db/schema/chapter-run.ts @@ -1,3 +1,4 @@ +import type { Prologue } from "@stage-cli/types/prologue"; import { index, integer, sqliteTable, text } from "drizzle-orm/sqlite-core"; import { SCOPE_KIND, WORKING_TREE_REF } from "../../schema.js"; import { baseColumns } from "./columns.js"; @@ -16,6 +17,7 @@ export const chapterRun = sqliteTable( headSha: text().notNull(), mergeBaseSha: text().notNull(), generatedAt: integer({ mode: "timestamp_ms" }).notNull(), + prologue: text({ mode: "json" }).$type(), }, (table) => [index("chapter_run_created_at_idx").on(table.createdAt)], ); diff --git a/packages/cli/src/git.ts b/packages/cli/src/git.ts index 04a8586..e46ef6e 100644 --- a/packages/cli/src/git.ts +++ b/packages/cli/src/git.ts @@ -1,5 +1,7 @@ import { execFileSync } from "node:child_process"; import path from "node:path"; +import type { ChapterRunRow } from "./db/schema/chapter-run.js"; +import { SCOPE_KIND, WORKING_TREE_REF } from "./schema.js"; export class NotInGitRepoError extends Error { constructor() { @@ -48,6 +50,23 @@ function readOriginUrl(repoRoot: string): string | null { } } +export function buildDiffArgs(run: ChapterRunRow): string[] { + if (run.scopeKind === SCOPE_KIND.COMMITTED) { + return ["diff", "--no-color", `${run.baseSha}..${run.headSha}`]; + } + if (run.workingTreeRef === null) { + throw new Error("workingTree run is missing workingTreeRef"); + } + switch (run.workingTreeRef) { + case WORKING_TREE_REF.UNSTAGED: + return ["diff", "--no-color"]; + case WORKING_TREE_REF.STAGED: + return ["diff", "--no-color", "--cached"]; + case WORKING_TREE_REF.WORK: + return ["diff", "--no-color", "HEAD"]; + } +} + /** * Derive the repo's display name from its origin URL, falling back to the * worktree directory's basename when the URL is missing or unparseable. diff --git a/packages/cli/src/routes/diff.ts b/packages/cli/src/routes/diff.ts index 43dfd08..4faf906 100644 --- a/packages/cli/src/routes/diff.ts +++ b/packages/cli/src/routes/diff.ts @@ -4,12 +4,11 @@ import path from "node:path"; import { eq } from "drizzle-orm"; import type { StageDb } from "../db/client.js"; import { chapterRun } from "../db/schema/index.js"; -import { SCOPE_KIND, WORKING_TREE_REF } from "../schema.js"; +import { buildDiffArgs } from "../git.js"; +import { SCOPE_KIND } from "../schema.js"; import type { Route } from "../server.js"; import { writeJson } from "./json.js"; -type ChapterRunRow = typeof chapterRun.$inferSelect; - export function diffRoutes(db: StageDb): Route[] { return [ { @@ -49,25 +48,6 @@ export function diffRoutes(db: StageDb): Route[] { ]; } -function buildDiffArgs(run: ChapterRunRow): string[] { - if (run.scopeKind === SCOPE_KIND.COMMITTED) { - // `..` (not `...`) — we want the literal diff between the two SHAs, not the - // merge-base diff that `...` would produce. - return ["diff", "--no-color", `${run.baseSha}..${run.headSha}`]; - } - if (run.workingTreeRef === null) { - throw new Error("workingTree run is missing workingTreeRef"); - } - switch (run.workingTreeRef) { - case WORKING_TREE_REF.UNSTAGED: - return ["diff", "--no-color"]; - case WORKING_TREE_REF.STAGED: - return ["diff", "--no-color", "--cached"]; - case WORKING_TREE_REF.WORK: - return ["diff", "--no-color", "HEAD"]; - } -} - function streamGitDiff( res: ServerResponse, cwd: string, diff --git a/packages/cli/src/routes/runs.ts b/packages/cli/src/routes/runs.ts index 26b9348..4243511 100644 --- a/packages/cli/src/routes/runs.ts +++ b/packages/cli/src/routes/runs.ts @@ -80,6 +80,7 @@ export function runRoutes(db: StageDb): Route[] { writeJson(res, 200, { run: mapRun(run), chapters: chapters.map((ch) => mapChapter(ch, byChapter.get(ch.id) ?? [])), + prologue: run.prologue ?? null, }); }, }, diff --git a/packages/cli/src/runs/import-chapters.ts b/packages/cli/src/runs/import-chapters.ts index 184bee7..39ae64c 100644 --- a/packages/cli/src/runs/import-chapters.ts +++ b/packages/cli/src/runs/import-chapters.ts @@ -37,6 +37,7 @@ export function insertChaptersFile( headSha: file.scope.headSha, mergeBaseSha: file.scope.mergeBaseSha, generatedAt: new Date(file.generatedAt), + prologue: file.prologue ?? null, }) .returning({ id: chapterRun.id }) .all(); diff --git a/packages/cli/src/schema.ts b/packages/cli/src/schema.ts index 0de1b0f..70b5910 100644 --- a/packages/cli/src/schema.ts +++ b/packages/cli/src/schema.ts @@ -1,4 +1,5 @@ import { hunkReferenceSchema, lineRefSchema } from "@stage-cli/types/chapters"; +import { PrologueSchema } from "@stage-cli/types/prologue"; import { z } from "zod"; export type { DiffSide, HunkReference, LineRef } from "@stage-cli/types/chapters"; @@ -62,6 +63,7 @@ export type Scope = z.infer; export const ChaptersFileSchema = z.strictObject({ scope: scopeSchema, chapters: z.array(chapterSchema), + prologue: PrologueSchema.optional(), generatedAt: z.iso.datetime(), }); export type ChaptersFile = z.infer; diff --git a/packages/types/package.json b/packages/types/package.json index 89d0fe1..73091be 100644 --- a/packages/types/package.json +++ b/packages/types/package.json @@ -7,6 +7,7 @@ "exports": { ".": "./src/index.ts", "./chapters": "./src/chapters.ts", + "./prologue": "./src/prologue.ts", "./view-state": "./src/view-state.ts" }, "files": [ diff --git a/packages/types/src/chapters.ts b/packages/types/src/chapters.ts index 91cf457..b375120 100644 --- a/packages/types/src/chapters.ts +++ b/packages/types/src/chapters.ts @@ -1,4 +1,5 @@ import { z } from "zod"; +import { PrologueSchema } from "./prologue.ts"; export const DIFF_SIDE = { ADDITIONS: "additions", @@ -55,5 +56,6 @@ export type ChapterRun = z.infer; export const ChaptersResponseSchema = z.object({ run: ChapterRunSchema, chapters: z.array(ChapterSchema), + prologue: PrologueSchema.nullable().optional(), }); export type ChaptersResponse = z.infer; diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 6bcb6ca..2cc9b4c 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -1,2 +1,3 @@ export * from "./chapters.ts"; +export * from "./prologue.ts"; export * from "./view-state.ts"; diff --git a/packages/types/src/prologue.ts b/packages/types/src/prologue.ts new file mode 100644 index 0000000..a5626c4 --- /dev/null +++ b/packages/types/src/prologue.ts @@ -0,0 +1,59 @@ +import { z } from "zod"; + +export const FOCUS_AREA_TYPE = { + SECURITY: "security", + BREAKING_CHANGE: "breaking-change", + HIGH_COMPLEXITY: "high-complexity", + DATA_INTEGRITY: "data-integrity", + NEW_PATTERN: "new-pattern", + ARCHITECTURE: "architecture", + PERFORMANCE: "performance", + TESTING_GAP: "testing-gap", +} as const; +export type FocusAreaType = (typeof FOCUS_AREA_TYPE)[keyof typeof FOCUS_AREA_TYPE]; + +export const FOCUS_AREA_SEVERITY = { + CRITICAL: "critical", + HIGH: "high", + MEDIUM: "medium", + INFO: "info", +} as const; +export type FocusAreaSeverity = (typeof FOCUS_AREA_SEVERITY)[keyof typeof FOCUS_AREA_SEVERITY]; + +export const COMPLEXITY_LEVEL = { + LOW: "low", + MEDIUM: "medium", + HIGH: "high", + VERY_HIGH: "very-high", +} as const; +export type ComplexityLevel = (typeof COMPLEXITY_LEVEL)[keyof typeof COMPLEXITY_LEVEL]; + +export const PrologueKeyChangeSchema = z.object({ + summary: z.string(), + description: z.string(), +}); +export type PrologueKeyChange = z.infer; + +export const FocusAreaSchema = z.object({ + type: z.enum(FOCUS_AREA_TYPE), + severity: z.enum(FOCUS_AREA_SEVERITY), + title: z.string(), + description: z.string(), + locations: z.array(z.string()), +}); +export type FocusArea = z.infer; + +export const ComplexitySchema = z.object({ + level: z.enum(COMPLEXITY_LEVEL), + reasoning: z.string(), +}); +export type Complexity = z.infer; + +export const PrologueSchema = z.object({ + motivation: z.string().nullable(), + outcome: z.string().nullable(), + keyChanges: z.array(PrologueKeyChangeSchema), + focusAreas: z.array(FocusAreaSchema), + complexity: ComplexitySchema, +}); +export type Prologue = z.infer; diff --git a/packages/web/src/app/runs.$runId.files.tsx b/packages/web/src/app/runs.$runId.files.tsx index cb99880..fc46a87 100644 --- a/packages/web/src/app/runs.$runId.files.tsx +++ b/packages/web/src/app/runs.$runId.files.tsx @@ -1,11 +1,18 @@ import { createFileRoute } from "@tanstack/react-router"; +import { z } from "zod"; import { FilesPage } from "@/routes/files-page"; +const filesSearchSchema = z.object({ + scrollTo: z.string().optional(), +}); + export const Route = createFileRoute("/runs/$runId/files")({ + validateSearch: (search) => filesSearchSchema.parse(search), component: FilesRoute, }); function FilesRoute() { const { runId } = Route.useParams(); - return ; + const { scrollTo } = Route.useSearch(); + return ; } diff --git a/packages/web/src/app/runs.$runId.index.tsx b/packages/web/src/app/runs.$runId.index.tsx index c66e1be..2e799c9 100644 --- a/packages/web/src/app/runs.$runId.index.tsx +++ b/packages/web/src/app/runs.$runId.index.tsx @@ -1,5 +1,6 @@ import { createFileRoute } from "@tanstack/react-router"; import { useMemo } from "react"; +import { PrologueSection } from "@/components/prologue/prologue-section"; import { useChapters } from "@/lib/use-chapters"; import { countViewedChapters, useViewStateData } from "@/lib/use-view-state"; import { ChaptersIndexPage } from "@/routes/chapters-index-page"; @@ -18,12 +19,34 @@ function ChaptersRoute() { [chapters, chapterIdSet], ); + const prologue = data?.prologue; + + if (!prologue) { + return ( + + ); + } + return ( - +
+
+
+ +
+
+ +
+
+
); } diff --git a/packages/web/src/components/prologue/prologue-section.tsx b/packages/web/src/components/prologue/prologue-section.tsx new file mode 100644 index 0000000..8fc53ce --- /dev/null +++ b/packages/web/src/components/prologue/prologue-section.tsx @@ -0,0 +1,112 @@ +import type { FocusArea, FocusAreaSeverity, Prologue } from "@stage-cli/types/prologue"; +import { FOCUS_AREA_SEVERITY } from "@stage-cli/types/prologue"; +import { Link } from "@tanstack/react-router"; +import { AlertTriangle } from "lucide-react"; +import { cn } from "@/lib/utils"; + +const SEVERITY_COLORS: Record = { + [FOCUS_AREA_SEVERITY.CRITICAL]: "text-red-500", + [FOCUS_AREA_SEVERITY.HIGH]: "text-orange-500", + [FOCUS_AREA_SEVERITY.MEDIUM]: "text-yellow-500", +}; + +function getConcerns(focusAreas: FocusArea[]): FocusArea[] { + return focusAreas.filter((f) => f.severity !== FOCUS_AREA_SEVERITY.INFO); +} + +function getFileName(filePath: string): string { + return filePath.split("/").pop() ?? filePath; +} + +function PrologueDisplay({ prologue, runId }: { prologue: Prologue; runId: string }) { + const concerns = getConcerns(prologue.focusAreas); + + return ( +
+ {(prologue.motivation || prologue.outcome) && ( +
+ {prologue.motivation && ( +
+

+ Why this change? +

+

{prologue.motivation}

+
+ )} + {prologue.outcome && ( +
+

+ What it does +

+

{prologue.outcome}

+
+ )} +
+ )} + +
+

+ Key Changes +

+
    + {prologue.keyChanges.map((change) => ( +
  • + + + {change.summary} + {change.description && ( + {change.description} + )} + +
  • + ))} +
+
+ + {concerns.length > 0 && ( +
+

+ Review Focus +

+
    + {concerns.map((area) => ( +
  • + + ], + )} + aria-hidden="true" + /> + {area.title} + {area.locations[0] && ( + + {getFileName(area.locations[0])} → + + )} + +

    {area.description}

    +
  • + ))} +
+
+ )} +
+ ); +} + +interface PrologueSectionProps { + prologue: Prologue | null | undefined; + runId: string; +} + +export function PrologueSection({ prologue, runId }: PrologueSectionProps) { + if (!prologue) return null; + return ; +} diff --git a/packages/web/src/routes/files-page.tsx b/packages/web/src/routes/files-page.tsx index 5ecce7a..65c2af5 100644 --- a/packages/web/src/routes/files-page.tsx +++ b/packages/web/src/routes/files-page.tsx @@ -1,4 +1,4 @@ -import { useCallback, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useHotkeys } from "react-hotkeys-hook"; import { FileDiffList, @@ -19,9 +19,10 @@ import { useViewState } from "@/lib/use-view-state"; interface FilesPageProps { runId: string; + scrollTo?: string; } -export function FilesPage({ runId }: FilesPageProps) { +export function FilesPage({ runId, scrollTo }: FilesPageProps) { const { data, isLoading, error } = useDiffPatch(runId); const rawEntries = useFileDiffEntries(data); @@ -62,6 +63,12 @@ export function FilesPage({ runId }: FilesPageProps) { [setActiveFileManually], ); + useEffect(() => { + if (scrollTo && !isLoading) { + diffListRef.current?.scrollToFile(scrollTo); + } + }, [scrollTo, isLoading]); + const [isPickerCollapsed, setIsPickerCollapsed] = useState(false); useHotkeys(KEYBOARD_SHORTCUTS.TOGGLE_FILES.hotkey, () => setIsPickerCollapsed((c) => !c), { preventDefault: true, diff --git a/skills/stage-chapters/SKILL.md b/skills/stage-chapters/SKILL.md index 550bb88..e414a09 100644 --- a/skills/stage-chapters/SKILL.md +++ b/skills/stage-chapters/SKILL.md @@ -177,7 +177,66 @@ Produce an array of chapter objects. Each chapter: - Do **not** invent `hunkRefs` — only use `(filePath, oldStart)` tuples that actually appear in the diff's `@@` headers. - `keyChanges[].lineRefs` must have at least one entry per key change. -## Step 4 — Write JSON file +## Step 4 — Generate prologue + +After building the chapters, generate a **prologue** — a high-level overview of the entire change. The prologue helps reviewers orient themselves before diving into individual chapters. + +Read the commit messages for context: + +```bash +git log --oneline "$MERGE_BASE..HEAD" +``` + +Using the diff, chapters, and commit messages, produce a `prologue` object with the following fields: + +### motivation (string or null) + +One sentence a non-engineer would understand. What was broken, annoying, or missing — from a person's perspective. If the commit messages are generic and the diff doesn't make the motivation obvious, use `null`. + +**Good:** "Dashboards would break during deploys, so people had to keep refreshing until things came back." +**Bad:** "The API client had no retry logic for 503 errors." (too technical — no one outside the team knows what that means) + +### outcome (string or null) + +One sentence a non-engineer would understand. What's better now. Same null rule as motivation. + +**Good:** "Dashboards stay up during deploys now." +**Bad:** "Added exponential backoff with a base delay of 100ms." (implementation detail) + +### keyChanges (array of 2–5 objects) + +Each object has: +- `summary`: 6–10 words describing what's different now. **Outcome-focused**, not action-focused. +- `description`: Capitalized sentence, 10–15 words of additional context. + +**Good:** `summary: "Audit runs are now tracked in a database"`, `description: "Uses new Drizzle ORM schema with full history retention"` +**Bad:** `summary: "Adds Drizzle ORM layer"` (action-focused — describe what changed, not what you did) + +### focusAreas (array of 1–5 objects) + +Always provide at least 1 focus area. Even clean changes have spots worth a reviewer's attention. + +Each object has: +- `type`: one of `security`, `breaking-change`, `high-complexity`, `data-integrity`, `new-pattern`, `architecture`, `performance`, `testing-gap` +- `severity`: one of `critical`, `high`, `medium` (for problems) or `info` (for points of interest) +- `title`: 3–5 word noun phrase (e.g., "Unvalidated user input") +- `description`: WHY this was flagged + a declarative action for the reviewer. Use "confirm", "verify", or "check" to give the reviewer a specific task. +- `locations`: array of file paths where this applies + +**Good:** `type: "security", severity: "high", title: "Unvalidated user input", description: "User-provided ID passed directly to database query — confirm input is validated and parameterized"` +**Bad:** `description: "Worth understanding"` (no action, vague) + +### complexity + +Object with: +- `level`: one of `low`, `medium`, `high`, `very-high` +- `reasoning`: brief explanation (e.g., "New DB schema plus multiple service changes") + +### Style + +Talk like a coworker, not a changelog. No jargon, no filler phrases, no "this change introduces/implements/adds". Just say what happened and why it matters. + +## Step 5 — Write JSON file Compute a unique temp path. The trailing `XXXXXX` (with no suffix after) is required by macOS BSD `mktemp` — placing characters after the X's causes BSD `mktemp` to return the template verbatim instead of substituting random characters: @@ -194,7 +253,7 @@ Write a JSON file at `"$TMPFILE"` matching the shape below. The file must valida High-level shape: ``` -{ scope: {...}, chapters: [...], generatedAt: "..." } +{ scope: {...}, chapters: [...], prologue: {...}, generatedAt: "..." } ``` Full example: @@ -233,6 +292,29 @@ Full example: ] } ], + "prologue": { + "motivation": "What was broken or annoying, in plain English (or null).", + "outcome": "What's better now, in plain English (or null).", + "keyChanges": [ + { + "summary": "Outcome-focused summary, 6-10 words", + "description": "Capitalized sentence, 10-15 words of context" + } + ], + "focusAreas": [ + { + "type": "architecture", + "severity": "info", + "title": "3-5 word noun phrase", + "description": "WHY flagged + action for the reviewer", + "locations": ["path/to/file.ts"] + } + ], + "complexity": { + "level": "medium", + "reasoning": "Brief explanation" + } + }, "generatedAt": "2026-05-04T12:34:56.000Z" } ``` @@ -250,9 +332,17 @@ Field rules: | `chapters[].keyChanges[].lineRefs` | Array with at least one entry | | `lineRefs[].side` | `"additions"` (right side) or `"deletions"` (left side) | | `lineRefs[].startLine` / `endLine` | Positive integers; `endLine >= startLine` | +| `prologue` | Optional object; omit entirely if not desired | +| `prologue.motivation` | String or `null` | +| `prologue.outcome` | String or `null` | +| `prologue.keyChanges` | Array of 2–5 objects with `summary` and `description` | +| `prologue.focusAreas` | Array of 1–5 objects | +| `prologue.focusAreas[].type` | One of: `security`, `breaking-change`, `high-complexity`, `data-integrity`, `new-pattern`, `architecture`, `performance`, `testing-gap` | +| `prologue.focusAreas[].severity` | One of: `critical`, `high`, `medium`, `info` | +| `prologue.complexity.level` | One of: `low`, `medium`, `high`, `very-high` | | `generatedAt` | ISO 8601 datetime string | -## Step 5 — Display generated chapters +## Step 6 — Display generated chapters Hand the file to `stage-cli`: