#!/usr/bin/env node
import { createRequire } from "node:module";
import { Command, Option } from "commander";
import { z } from "zod";
import { createReadStream, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { homedir, tmpdir } from "node:os";
import path from "node:path";
import parseDiff from "parse-diff";
import { execFile, execFileSync } from "node:child_process";
import open from "open";
import { fileURLToPath } from "node:url";
import Database from "better-sqlite3";
import { drizzle } from "drizzle-orm/better-sqlite3";
import { migrate } from "drizzle-orm/better-sqlite3/migrator";
import { createHash, randomUUID } from "node:crypto";
import { index, integer, sqliteTable, text, unique } from "drizzle-orm/sqlite-core";
import fs from "node:fs/promises";
import { promisify } from "node:util";
import { and, asc, eq, inArray } from "drizzle-orm";
import http from "node:http";
import { pipeline } from "node:stream/promises";
//#region \0rolldown/runtime.js
var __defProp = Object.defineProperty;
var __exportAll = (all, no_symbols) => {
	let target = {};
	for (var name in all) __defProp(target, name, {
		get: all[name],
		enumerable: true
	});
	if (!no_symbols) __defProp(target, Symbol.toStringTag, { value: "Module" });
	return target;
};
//#endregion
//#region ../types/src/parsed-diff.ts
const FILE_STATUS = {
	ADDED: "added",
	MODIFIED: "modified",
	DELETED: "deleted",
	RENAMED: "renamed",
	MOVED: "moved"
};
const LINE_TYPE = {
	CONTEXT: "context",
	ADDITION: "addition",
	DELETION: "deletion",
	HEADER: "header"
};
const diffLineSchema = z.object({
	type: z.enum(LINE_TYPE),
	content: z.string(),
	oldLineNumber: z.number().optional(),
	newLineNumber: z.number().optional()
});
const hunkSchema = z.object({
	header: z.string(),
	oldStart: z.number(),
	newStart: z.number(),
	oldLines: z.number(),
	newLines: z.number(),
	lines: z.array(diffLineSchema)
});
z.object({
	path: z.string(),
	oldPath: z.string().optional(),
	filename: z.string(),
	status: z.enum(FILE_STATUS),
	additions: z.number(),
	deletions: z.number(),
	hunks: z.array(hunkSchema),
	patch: z.string().optional(),
	isSymlink: z.boolean().optional(),
	symlinkTarget: z.string().optional(),
	oldSymlinkTarget: z.string().optional()
});
//#endregion
//#region src/diff-parser.ts
function determineFileStatus(file) {
	if (file.new) return FILE_STATUS.ADDED;
	if (file.deleted) return FILE_STATUS.DELETED;
	if (file.from !== file.to && file.from && file.to) return file.additions > 0 || file.deletions > 0 ? FILE_STATUS.RENAMED : FILE_STATUS.MOVED;
	return FILE_STATUS.MODIFIED;
}
function getFilePath(file) {
	if (file.deleted) {
		if (!file.from) throw new Error("Deleted file is missing 'from' path");
		return file.from.replace(/^a\//, "");
	}
	const filePath = file.to ?? file.from;
	if (!filePath) throw new Error("File is missing both 'to' and 'from' paths");
	return filePath.replace(/^[ab]\//, "");
}
function getFilename(filePath) {
	return filePath.split("/").at(-1) ?? filePath;
}
function transformChange(change) {
	let type;
	let oldLineNumber;
	let newLineNumber;
	switch (change.type) {
		case "add":
			type = LINE_TYPE.ADDITION;
			newLineNumber = change.ln;
			break;
		case "del":
			type = LINE_TYPE.DELETION;
			oldLineNumber = change.ln;
			break;
		default:
			type = LINE_TYPE.CONTEXT;
			oldLineNumber = change.ln1;
			newLineNumber = change.ln2;
	}
	let content = change.content;
	if (content.startsWith("+") || content.startsWith("-") || content.startsWith(" ")) content = content.slice(1);
	return {
		type,
		content,
		oldLineNumber,
		newLineNumber
	};
}
function transformChunk(chunk) {
	return {
		header: chunk.content,
		oldStart: chunk.oldStart,
		newStart: chunk.newStart,
		oldLines: chunk.oldLines,
		newLines: chunk.newLines,
		lines: chunk.changes.map(transformChange)
	};
}
function getOldPath(file) {
	if (!file.from) return void 0;
	return file.from.replace(/^a\//, "");
}
function isSymlinkPatch(patch) {
	const headerEnd = patch.indexOf("@@");
	const header = headerEnd === -1 ? patch : patch.slice(0, headerEnd);
	return /(?:new file mode|deleted file mode|old mode|new mode|index [0-9a-f]+\.\.[0-9a-f]+) 120000\b/m.test(header);
}
function extractSymlinkTargets(file) {
	let symlinkTarget;
	let oldSymlinkTarget;
	for (const chunk of file.chunks) for (const change of chunk.changes) {
		const content = change.content.replace(/^[+-]/, "").trim();
		if (content === "\\ No newline at end of file") continue;
		if (change.type === "add" && file.additions === 1) symlinkTarget = content;
		else if (change.type === "del" && file.deletions === 1) oldSymlinkTarget = content;
	}
	return {
		symlinkTarget,
		oldSymlinkTarget
	};
}
function transformFile(file, patch) {
	const filePath = getFilePath(file);
	const status = determineFileStatus(file);
	const isPathChange = status === FILE_STATUS.RENAMED || status === FILE_STATUS.MOVED;
	const isSymlink = isSymlinkPatch(patch);
	return {
		path: filePath,
		oldPath: isPathChange ? getOldPath(file) : void 0,
		filename: getFilename(filePath),
		status,
		additions: file.additions,
		deletions: file.deletions,
		hunks: file.chunks.map(transformChunk),
		patch,
		...isSymlink && {
			isSymlink: true,
			...extractSymlinkTargets(file)
		}
	};
}
function findNextDiffMarker(text, startIndex) {
	const marker = "diff --git ";
	let pos = startIndex;
	while (pos < text.length) {
		const idx = text.indexOf(marker, pos);
		if (idx === -1) return -1;
		if (idx === 0 || text[idx - 1] === "\n") return idx;
		pos = idx + 11;
	}
	return -1;
}
function splitRawPatches(diffOutput) {
	const patches = [];
	let start = findNextDiffMarker(diffOutput, 0);
	while (start !== -1) {
		const next = findNextDiffMarker(diffOutput, start + 1);
		const segment = next === -1 ? diffOutput.slice(start) : diffOutput.slice(start, next);
		patches.push(segment.trimEnd());
		start = next;
	}
	return patches;
}
function parseGitDiff(diffOutput) {
	if (!diffOutput || diffOutput.trim() === "") return [];
	return splitRawPatches(diffOutput).map((patch, index) => {
		const file = parseDiff(patch)[0];
		if (!file) throw new Error(`Failed to parse patch at index ${index}`);
		return transformFile(file, patch);
	});
}
//#endregion
//#region src/filter-files.ts
const IGNORED_FILENAMES = new Set([
	"package-lock.json",
	"yarn.lock",
	"pnpm-lock.yaml",
	"bun.lockb",
	"bun.lock",
	"composer.lock",
	"gemfile.lock",
	"cargo.lock",
	"poetry.lock",
	"pipfile.lock",
	"go.sum",
	"flake.lock",
	".ds_store",
	"thumbs.db"
]);
const IGNORED_EXTENSIONS = [
	".min.js",
	".min.css",
	".map",
	".snap",
	".svg",
	".png",
	".jpg",
	".jpeg",
	".gif",
	".ico",
	".woff",
	".woff2",
	".ttf",
	".eot",
	".mp4",
	".webm",
	".pdf"
];
function shouldIncludeFile(filePath) {
	const basename = (filePath.split("/").at(-1) ?? filePath).toLowerCase();
	if (IGNORED_FILENAMES.has(basename)) return false;
	const lowerPath = filePath.toLowerCase();
	return !IGNORED_EXTENSIONS.some((ext) => lowerPath.endsWith(ext));
}
function filterFilesForLlm(files) {
	const excludedByPath = [];
	const reviewable = [];
	for (const file of files) {
		if (!shouldIncludeFile(file.path)) {
			excludedByPath.push(file.path);
			continue;
		}
		reviewable.push(file);
	}
	return {
		files: reviewable,
		excludedByPath
	};
}
//#endregion
//#region src/format-diff.ts
const LINE_PREFIX = {
	[LINE_TYPE.ADDITION]: "+",
	[LINE_TYPE.DELETION]: "-"
};
function formatHunkDiffWithLineNumbers(hunk) {
	const maxOld = hunk.oldStart + Math.max(hunk.oldLines - 1, 0);
	const maxNew = hunk.newStart + Math.max(hunk.newLines - 1, 0);
	const colWidth = Math.max(String(maxOld).length, String(maxNew).length);
	return hunk.lines.map((line) => {
		const prefix = LINE_PREFIX[line.type] ?? " ";
		return `${line.oldLineNumber != null ? String(line.oldLineNumber).padStart(colWidth) : " ".repeat(colWidth)} ${line.newLineNumber != null ? String(line.newLineNumber).padStart(colWidth) : " ".repeat(colWidth)} |${prefix}${line.content}`;
	}).join("\n");
}
//#endregion
//#region ../types/src/prologue.ts
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"
};
const FOCUS_AREA_SEVERITY = {
	CRITICAL: "critical",
	HIGH: "high",
	MEDIUM: "medium",
	INFO: "info"
};
const COMPLEXITY_LEVEL = {
	LOW: "low",
	MEDIUM: "medium",
	HIGH: "high",
	VERY_HIGH: "very-high"
};
const PrologueKeyChangeSchema = z.object({
	summary: z.string(),
	description: z.string()
});
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())
});
const ComplexitySchema = z.object({
	level: z.enum(COMPLEXITY_LEVEL),
	reasoning: z.string()
});
const PrologueSchema = z.object({
	motivation: z.string().nullable(),
	outcome: z.string().nullable(),
	keyChanges: z.array(PrologueKeyChangeSchema),
	focusAreas: z.array(FocusAreaSchema),
	complexity: ComplexitySchema
});
//#endregion
//#region ../types/src/chapters.ts
const DIFF_SIDE = {
	ADDITIONS: "additions",
	DELETIONS: "deletions"
};
const hunkReferenceSchema = z.strictObject({
	filePath: z.string().min(1),
	oldStart: z.number().int().nonnegative()
});
const lineRefSchema = z.strictObject({
	filePath: z.string().min(1),
	side: z.enum(DIFF_SIDE),
	startLine: z.number().int().positive(),
	endLine: z.number().int().positive()
}).refine((v) => v.startLine <= v.endLine, {
	message: "endLine must be greater than or equal to startLine",
	path: ["endLine"]
});
const KeyChangeSchema = z.object({
	id: z.string(),
	externalId: z.string(),
	content: z.string(),
	lineRefs: z.array(lineRefSchema)
});
const ChapterSchema = z.object({
	id: z.string(),
	externalId: z.string(),
	order: z.number().int(),
	title: z.string(),
	summary: z.string(),
	hunkRefs: z.array(hunkReferenceSchema),
	keyChanges: z.array(KeyChangeSchema)
});
const ChapterRunSchema = z.object({
	id: z.string(),
	repoName: z.string()
});
z.object({
	run: ChapterRunSchema,
	chapters: z.array(ChapterSchema),
	prologue: PrologueSchema.nullable().optional()
});
//#endregion
//#region src/schema.ts
const SCOPE_KIND = {
	COMMITTED: "committed",
	WORKING_TREE: "workingTree"
};
const WORKING_TREE_REF = {
	WORK: "work",
	STAGED: "staged",
	UNSTAGED: "unstaged"
};
const fullShaSchema = z.string().regex(/^[0-9a-f]{40}$/, "Expected a full commit SHA");
const keyChangeSchema = z.strictObject({
	/** A judgment-call question for a human reviewer, not source code. */
	content: z.string().min(1),
	lineRefs: z.array(lineRefSchema).min(1)
});
const chapterSchema = z.strictObject({
	id: z.string().min(1),
	order: z.number().int().positive(),
	title: z.string().min(1),
	summary: z.string().min(1),
	hunkRefs: z.array(hunkReferenceSchema),
	keyChanges: z.array(keyChangeSchema)
});
const committedScopeSchema = z.strictObject({
	kind: z.literal(SCOPE_KIND.COMMITTED),
	baseSha: fullShaSchema,
	headSha: fullShaSchema,
	mergeBaseSha: fullShaSchema
});
const workingTreeScopeSchema = z.strictObject({
	kind: z.literal(SCOPE_KIND.WORKING_TREE),
	ref: z.enum(WORKING_TREE_REF),
	baseSha: fullShaSchema,
	headSha: fullShaSchema,
	mergeBaseSha: fullShaSchema
});
const scopeSchema = z.discriminatedUnion("kind", [committedScopeSchema, workingTreeScopeSchema]);
const ChaptersFileSchema = z.strictObject({
	scope: scopeSchema,
	chapters: z.array(chapterSchema),
	prologue: PrologueSchema.optional(),
	generatedAt: z.iso.datetime()
});
const AgentOutputSchema = z.strictObject({
	chapters: z.array(chapterSchema),
	prologue: PrologueSchema.optional()
});
//#endregion
//#region src/git.ts
var NotInGitRepoError = class extends Error {
	constructor() {
		super("stage-cli must be run inside a git repository");
		this.name = "NotInGitRepoError";
	}
};
function readRepoContext() {
	const root = readRepoRoot();
	return {
		root,
		originUrl: readOriginUrl(root)
	};
}
function readRepoRoot() {
	try {
		return execFileSync("git", ["rev-parse", "--show-toplevel"], {
			encoding: "utf8",
			stdio: [
				"ignore",
				"pipe",
				"ignore"
			]
		}).trim();
	} catch {
		throw new NotInGitRepoError();
	}
}
function readOriginUrl(repoRoot) {
	try {
		return execFileSync("git", [
			"-C",
			repoRoot,
			"remote",
			"get-url",
			"origin"
		], {
			encoding: "utf8",
			stdio: [
				"ignore",
				"pipe",
				"ignore"
			]
		}).trim() || null;
	} catch {
		return null;
	}
}
function buildDiffArgs(run) {
	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",
			run.baseSha
		];
	}
}
/**
* 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.
*
* Handles the URL shapes git emits in practice:
*   git@github.com:owner/repo(.git)
*   https://github.com/owner/repo(.git)
*   ssh://git@github.com/owner/repo(.git)
*/
function parseRepoName(originUrl, repoRoot) {
	if (originUrl) {
		const trimmed = originUrl.replace(/\.git$/, "");
		const lastSeparator = Math.max(trimmed.lastIndexOf("/"), trimmed.lastIndexOf(":"));
		const segment = trimmed.slice(lastSeparator + 1);
		if (segment) return segment;
	}
	return path.basename(repoRoot);
}
function detectBaseRef() {
	for (const args of [
		[
			"rev-parse",
			"--abbrev-ref",
			"origin/HEAD"
		],
		[
			"rev-parse",
			"--verify",
			"main"
		],
		[
			"rev-parse",
			"--verify",
			"master"
		],
		[
			"rev-parse",
			"--verify",
			"origin/main"
		],
		[
			"rev-parse",
			"--verify",
			"origin/master"
		]
	]) try {
		const out = execFileSync("git", args, {
			encoding: "utf8",
			stdio: [
				"ignore",
				"pipe",
				"ignore"
			]
		}).trim();
		if (out) return out;
	} catch {}
	throw new Error("No default branch detected. Tried origin/HEAD, main, master, origin/main, and origin/master.");
}
function resolveMergeBase(base) {
	return execFileSync("git", [
		"merge-base",
		base,
		"HEAD"
	], {
		encoding: "utf8",
		stdio: [
			"ignore",
			"pipe",
			"ignore"
		]
	}).trim();
}
function resolveHead() {
	return execFileSync("git", ["rev-parse", "HEAD"], {
		encoding: "utf8",
		stdio: [
			"ignore",
			"pipe",
			"ignore"
		]
	}).trim();
}
function getRawDiff(args) {
	return execFileSync("git", [
		"diff",
		"--no-color",
		"--src-prefix=a/",
		"--dst-prefix=b/",
		...args
	], {
		encoding: "utf8",
		stdio: [
			"ignore",
			"pipe",
			"ignore"
		],
		maxBuffer: 50 * 1024 * 1024
	});
}
function getUntrackedFiles() {
	const out = execFileSync("git", [
		"ls-files",
		"--others",
		"--exclude-standard"
	], {
		encoding: "utf8",
		stdio: [
			"ignore",
			"pipe",
			"ignore"
		]
	}).trim();
	return out ? out.split("\n") : [];
}
function hasStringStdout(err) {
	return typeof err === "object" && err !== null && "stdout" in err && typeof err.stdout === "string";
}
function getUntrackedDiff(files) {
	const patches = [];
	for (const file of files) try {
		execFileSync("git", [
			"diff",
			"--no-index",
			"--no-color",
			"--src-prefix=a/",
			"--dst-prefix=b/",
			"--",
			"/dev/null",
			file
		], {
			encoding: "utf8",
			stdio: [
				"ignore",
				"pipe",
				"ignore"
			],
			maxBuffer: 50 * 1024 * 1024
		});
	} catch (err) {
		if (hasStringStdout(err)) patches.push(err.stdout);
	}
	return patches.join("\n");
}
function getCommitMessages(mergeBase) {
	return execFileSync("git", [
		"log",
		"--oneline",
		`${mergeBase}..HEAD`
	], {
		encoding: "utf8",
		stdio: [
			"ignore",
			"pipe",
			"ignore"
		]
	}).trim();
}
function hasUncommittedChanges() {
	return execFileSync("git", ["status", "--porcelain"], {
		encoding: "utf8",
		stdio: [
			"ignore",
			"pipe",
			"ignore"
		]
	}).trim().length > 0;
}
function workingTreeDiffArgs(ref, mergeBaseSha) {
	switch (ref) {
		case WORKING_TREE_REF.UNSTAGED: return [];
		case WORKING_TREE_REF.STAGED: return ["--cached"];
		case WORKING_TREE_REF.WORK: return [mergeBaseSha];
	}
}
function includesUntrackedFiles(ref) {
	return ref === WORKING_TREE_REF.WORK;
}
function buildWorkingTreeDiff(ref, mergeBaseSha) {
	let rawDiff = getRawDiff(workingTreeDiffArgs(ref, mergeBaseSha));
	if (includesUntrackedFiles(ref)) {
		const untrackedFiles = getUntrackedFiles();
		if (untrackedFiles.length > 0) {
			const untrackedDiff = getUntrackedDiff(untrackedFiles);
			if (untrackedDiff) rawDiff = rawDiff ? `${rawDiff}\n${untrackedDiff}` : untrackedDiff;
		}
	}
	return rawDiff;
}
function resolveScope(baseOverride, ref) {
	const mergeBaseSha = resolveMergeBase(baseOverride ?? detectBaseRef());
	const headSha = resolveHead();
	const effectiveRef = ref ?? (hasUncommittedChanges() ? WORKING_TREE_REF.WORK : null);
	if (effectiveRef) return {
		scope: {
			kind: SCOPE_KIND.WORKING_TREE,
			ref: effectiveRef,
			baseSha: mergeBaseSha,
			headSha,
			mergeBaseSha
		},
		mergeBaseSha,
		rawDiff: buildWorkingTreeDiff(effectiveRef, mergeBaseSha)
	};
	return {
		scope: {
			kind: SCOPE_KIND.COMMITTED,
			baseSha: mergeBaseSha,
			headSha,
			mergeBaseSha
		},
		mergeBaseSha,
		rawDiff: getRawDiff([`${mergeBaseSha}..${headSha}`])
	};
}
//#endregion
//#region src/prep.ts
function formatHunkForPrompt(file, hunk) {
	return `=== File: ${file.path} (${file.status}) | filePath: "${file.path}", oldStart: ${hunk.oldStart} ===
=== Hunk @${hunk.oldStart}: ${hunk.header} ===
${formatHunkDiffWithLineNumbers(hunk)}`;
}
function runPrep(base, ref) {
	const { rawDiff, mergeBaseSha } = resolveScope(base, ref);
	const { files } = filterFilesForLlm(parseGitDiff(rawDiff));
	const formattedHunks = files.flatMap((file) => file.hunks.map((hunk) => formatHunkForPrompt(file, hunk))).join("\n\n");
	const sections = [
		"=== COMMIT MESSAGES ===",
		getCommitMessages(mergeBaseSha),
		"",
		"=== HUNKS ===",
		formattedHunks
	];
	const filePath = path.join(tmpdir(), `stage-prep-${Date.now()}.txt`);
	writeFileSync(filePath, sections.join("\n"), "utf8");
	return filePath;
}
//#endregion
//#region src/build-other-changes.ts
const OTHER_CHANGES_CHAPTER_ID = "chapter-other-changes";
const OTHER_CHANGES_TITLE = "Other changes";
const OTHER_CHANGES_SUMMARY = "Lockfiles, generated files, and binary assets excluded from the chapters.";
function buildOtherChangesChapter(allFiles, excludedByPath) {
	if (excludedByPath.length === 0) return null;
	const excluded = new Set(excludedByPath);
	const hunkRefs = [];
	for (const file of allFiles) {
		if (!excluded.has(file.path)) continue;
		for (const hunk of file.hunks) hunkRefs.push({
			filePath: file.path,
			oldStart: hunk.oldStart
		});
	}
	return {
		id: OTHER_CHANGES_CHAPTER_ID,
		title: OTHER_CHANGES_TITLE,
		summary: OTHER_CHANGES_SUMMARY,
		hunkRefs,
		keyChanges: []
	};
}
//#endregion
//#region src/db/path.ts
const STAGE_HOME = ".stage";
const DB_FILE = "db.sqlite";
const REPO_HASH_LEN = 12;
function getDbPath() {
	const dir = ensureRepoDir(readRepoRoot());
	return path.join(dir, DB_FILE);
}
function ensureRepoDir(repoRoot) {
	const hash = createHash("sha256").update(repoRoot.trim()).digest("hex").slice(0, REPO_HASH_LEN);
	const dir = path.join(homedir(), STAGE_HOME, hash);
	mkdirSync(dir, { recursive: true });
	return dir;
}
//#endregion
//#region src/db/schema/columns.ts
function baseColumns() {
	return {
		id: text().primaryKey().$defaultFn(() => randomUUID()),
		createdAt: integer({ mode: "timestamp_ms" }).$defaultFn(() => /* @__PURE__ */ new Date()).notNull(),
		updatedAt: integer({ mode: "timestamp_ms" }).$defaultFn(() => /* @__PURE__ */ new Date()).$onUpdateFn(() => /* @__PURE__ */ new Date()).notNull()
	};
}
//#endregion
//#region src/db/schema/chapter-run.ts
const chapterRun = sqliteTable("chapter_run", {
	...baseColumns(),
	repoRoot: text().notNull(),
	originUrl: text(),
	scopeKind: text({ enum: [SCOPE_KIND.COMMITTED, SCOPE_KIND.WORKING_TREE] }).notNull(),
	workingTreeRef: text({ enum: [
		WORKING_TREE_REF.WORK,
		WORKING_TREE_REF.STAGED,
		WORKING_TREE_REF.UNSTAGED
	] }),
	baseSha: text().notNull(),
	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)]);
//#endregion
//#region src/db/schema/chapter.ts
const chapter = sqliteTable("chapter", {
	...baseColumns(),
	runId: text().notNull().references(() => chapterRun.id, { onDelete: "cascade" }),
	externalId: text().notNull(),
	chapterIndex: integer().notNull(),
	title: text().notNull(),
	summary: text().notNull(),
	hunkRefs: text({ mode: "json" }).$type().notNull(),
	keyChanges: text({ mode: "json" }).$type().notNull().default([])
}, (table) => [unique("chapter_run_idx_unique").on(table.runId, table.chapterIndex)]);
//#endregion
//#region src/db/local-user.ts
const LOCAL_USER_ID = "local";
//#endregion
//#region src/db/schema/chapter-file-view.ts
/**
* Per-(chapter, file) viewed mark. The global `file_view` row for a path is
* only set once every chapter in the run touching that path has a row here,
* so a file shared across chapters stays unviewed until each one marks it.
*/
const chapterFileView = sqliteTable("chapter_file_view", {
	...baseColumns(),
	userId: text().notNull().default(LOCAL_USER_ID),
	chapterId: text().notNull().references(() => chapter.id, { onDelete: "cascade" }),
	filePath: text().notNull()
}, (table) => [unique("chapter_file_view_user_chapter_path_unique").on(table.userId, table.chapterId, table.filePath), index("chapter_file_view_chapter_id_idx").on(table.chapterId)]);
//#endregion
//#region src/db/schema/chapter-view.ts
const chapterView = sqliteTable("chapter_view", {
	...baseColumns(),
	userId: text().notNull().default(LOCAL_USER_ID),
	chapterId: text().notNull().references(() => chapter.id, { onDelete: "cascade" })
}, (table) => [unique("chapter_view_user_chapter_unique").on(table.userId, table.chapterId)]);
//#endregion
//#region src/db/schema/file-view.ts
const fileView = sqliteTable("file_view", {
	...baseColumns(),
	userId: text().notNull().default(LOCAL_USER_ID),
	runId: text().notNull().references(() => chapterRun.id, { onDelete: "cascade" }),
	filePath: text().notNull()
}, (table) => [unique("file_view_user_run_path_unique").on(table.userId, table.runId, table.filePath)]);
//#endregion
//#region src/db/schema/key-change.ts
const keyChange = sqliteTable("key_change", {
	...baseColumns(),
	chapterId: text().notNull().references(() => chapter.id, { onDelete: "cascade" }),
	externalId: text().notNull(),
	content: text().notNull(),
	lineRefs: text({ mode: "json" }).$type().notNull().default([])
}, (table) => [index("key_change_chapter_id_idx").on(table.chapterId)]);
//#endregion
//#region src/db/schema/key-change-view.ts
const keyChangeView = sqliteTable("key_change_view", {
	...baseColumns(),
	userId: text().notNull().default(LOCAL_USER_ID),
	keyChangeId: text().notNull().references(() => keyChange.id, { onDelete: "cascade" })
}, (table) => [unique("key_change_view_user_key_change_unique").on(table.userId, table.keyChangeId), index("key_change_view_key_change_id_idx").on(table.keyChangeId)]);
//#endregion
//#region src/db/schema/index.ts
var schema_exports = /* @__PURE__ */ __exportAll({
	chapter: () => chapter,
	chapterFileView: () => chapterFileView,
	chapterRun: () => chapterRun,
	chapterView: () => chapterView,
	fileView: () => fileView,
	keyChange: () => keyChange,
	keyChangeView: () => keyChangeView
});
//#endregion
//#region src/db/client.ts
let cached = null;
function getDb(opts = {}) {
	const dbPath = opts.dbPath ?? getDbPath();
	if (cached && cached.path === dbPath) return cached.drizzle;
	if (cached) closeDb();
	const sqlite = new Database(dbPath);
	sqlite.pragma("journal_mode = WAL");
	sqlite.pragma("foreign_keys = ON");
	const db = drizzle(sqlite, { schema: schema_exports });
	migrate(db, { migrationsFolder: findMigrationsFolder() });
	cached = {
		sqlite,
		drizzle: db,
		path: dbPath
	};
	return db;
}
function closeDb() {
	if (!cached) return;
	cached.sqlite.close();
	cached = null;
}
function findMigrationsFolder() {
	let dir = path.dirname(fileURLToPath(import.meta.url));
	for (let i = 0; i < 10; i++) {
		const candidate = path.join(dir, "drizzle");
		if (existsSync(path.join(candidate, "meta", "_journal.json"))) return candidate;
		const parent = path.dirname(dir);
		if (parent === dir) break;
		dir = parent;
	}
	throw new Error("Could not locate drizzle migrations folder");
}
//#endregion
//#region src/routes/json.ts
function writeJson(res, status, body) {
	res.writeHead(status, { "Content-Type": "application/json; charset=utf-8" });
	res.end(JSON.stringify(body));
}
const MAX_JSON_BODY_BYTES = 1024 * 1024;
async function readJsonBody(req) {
	let total = 0;
	const chunks = [];
	for await (const chunk of req) {
		const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
		total += buf.length;
		if (total > MAX_JSON_BODY_BYTES) throw new Error(`Request body exceeds ${MAX_JSON_BODY_BYTES} bytes`);
		chunks.push(buf);
	}
	const text = Buffer.concat(chunks).toString("utf8");
	if (text.length === 0) return {};
	return JSON.parse(text);
}
//#endregion
//#region src/routes/diff.ts
const execFileAsync = promisify(execFile);
const MAX_FILE_BYTES = 5 * 1024 * 1024;
const MAX_DIFF_BYTES = 50 * 1024 * 1024;
async function buildUntrackedPatch(cwd) {
	const { stdout } = await execFileAsync("git", [
		"ls-files",
		"--others",
		"--exclude-standard"
	], {
		cwd,
		encoding: "utf8"
	});
	const files = stdout.trim() ? stdout.trim().split("\n") : [];
	if (files.length === 0) return "";
	return (await Promise.all(files.map(async (file) => {
		try {
			await execFileAsync("git", [
				"diff",
				"--no-index",
				"--no-color",
				"--src-prefix=a/",
				"--dst-prefix=b/",
				"--",
				"/dev/null",
				file
			], {
				cwd,
				encoding: "utf8",
				maxBuffer: MAX_DIFF_BYTES
			});
			return "";
		} catch (err) {
			return hasStringStdout(err) ? err.stdout : "";
		}
	}))).filter(Boolean).join("\n");
}
function diffRoutes(db) {
	return [{
		method: "GET",
		pattern: "/api/runs/:runId/diff.patch",
		handler: async (_req, res, params) => {
			const runId = params.runId;
			if (!runId) {
				writeJson(res, 400, { error: "Missing runId" });
				return;
			}
			const [run] = db.select().from(chapterRun).where(eq(chapterRun.id, runId)).limit(1).all();
			if (!run) {
				writeJson(res, 404, { error: `Run ${runId} not found` });
				return;
			}
			const repoRoot = run.repoRoot;
			if (!path.isAbsolute(repoRoot) || repoRoot.split(path.sep).includes("..")) {
				writeJson(res, 500, { error: "Run repoRoot is not an absolute path or contains traversal segments" });
				return;
			}
			const args = buildDiffArgs(run);
			const cacheControl = run.scopeKind === SCOPE_KIND.COMMITTED ? "private, max-age=300" : "no-store";
			try {
				const { stdout: trackedPatch } = await execFileAsync("git", args, {
					cwd: repoRoot,
					encoding: "utf8",
					maxBuffer: MAX_DIFF_BYTES
				});
				let patch = trackedPatch;
				if (run.scopeKind === SCOPE_KIND.WORKING_TREE && run.workingTreeRef === WORKING_TREE_REF.WORK) {
					const untrackedPatch = await buildUntrackedPatch(repoRoot);
					if (untrackedPatch) patch = patch ? `${patch}\n${untrackedPatch}` : untrackedPatch;
				}
				const fileContents = await buildFileContents(run, repoRoot, patch);
				const body = {
					patch,
					fileContents
				};
				res.writeHead(200, {
					"Content-Type": "application/json; charset=utf-8",
					"Cache-Control": cacheControl
				});
				res.end(JSON.stringify(body));
			} catch (err) {
				writeJson(res, 500, { error: err instanceof Error ? err.message : String(err) });
			}
		}
	}];
}
const MINUS_RE = /^--- (?:a\/)?(.+)$/m;
const PLUS_RE = /^\+\+\+ (?:b\/)?(.+)$/m;
const BINARY_RE = /^Binary files/m;
function parseFilePathsFromPatch(patch) {
	if (!patch.trim()) return [];
	const segments = patch.split(/\ndiff --git /);
	const results = [];
	for (let i = 0; i < segments.length; i++) {
		const segment = segments[i];
		if (segment === void 0) continue;
		const text = i === 0 ? segment : `diff --git ${segment}`;
		if (!text.startsWith("diff --git ")) continue;
		const isBinary = BINARY_RE.test(text);
		const minus = text.match(MINUS_RE);
		const plus = text.match(PLUS_RE);
		const oldPath = minus?.[1] && minus[1] !== "/dev/null" ? minus[1] : null;
		const newPath = plus?.[1] && plus[1] !== "/dev/null" ? plus[1] : null;
		results.push({
			oldPath,
			newPath,
			isBinary
		});
	}
	return results;
}
async function getGitFileContent(cwd, ref, filePath) {
	try {
		const { stdout } = await execFileAsync("git", ["show", `${ref}:${filePath}`], {
			cwd,
			encoding: "utf8",
			maxBuffer: MAX_FILE_BYTES
		});
		return stdout;
	} catch {
		return null;
	}
}
async function readFileContent(repoRoot, filePath) {
	const resolved = path.resolve(repoRoot, filePath);
	const rel = path.relative(repoRoot, resolved);
	if (rel.startsWith("..") || path.isAbsolute(rel)) return null;
	try {
		return await fs.readFile(resolved, "utf8");
	} catch {
		return null;
	}
}
function getContentRefs(run) {
	if (run.scopeKind === SCOPE_KIND.COMMITTED) return {
		oldRef: run.baseSha,
		newRef: run.headSha
	};
	switch (run.workingTreeRef) {
		case WORKING_TREE_REF.UNSTAGED: return {
			oldRef: "",
			newRef: "DISK"
		};
		case WORKING_TREE_REF.STAGED: return {
			oldRef: "HEAD",
			newRef: ""
		};
		case WORKING_TREE_REF.WORK: return {
			oldRef: run.baseSha,
			newRef: "DISK"
		};
		default: return {
			oldRef: "HEAD",
			newRef: "HEAD"
		};
	}
}
function fetchContent(repoRoot, ref, filePath) {
	if (ref === "DISK") return readFileContent(repoRoot, filePath);
	return getGitFileContent(repoRoot, ref, filePath);
}
async function buildFileContents(run, repoRoot, patch) {
	const files = parseFilePathsFromPatch(patch);
	const { oldRef, newRef } = getContentRefs(run);
	const entries = await Promise.all(files.map(async ({ oldPath, newPath, isBinary }) => {
		const key = newPath ?? oldPath;
		if (!key || isBinary) return null;
		const [oldContent, newContent] = await Promise.all([oldPath ? fetchContent(repoRoot, oldRef, oldPath) : Promise.resolve(null), newPath ? fetchContent(repoRoot, newRef, newPath) : Promise.resolve(null)]);
		return [key, {
			oldContent,
			newContent
		}];
	}));
	const map = {};
	for (const entry of entries) if (entry) map[entry[0]] = entry[1];
	return map;
}
//#endregion
//#region src/routes/runs.ts
function mapKeyChange(kc) {
	return {
		id: kc.id,
		externalId: kc.externalId,
		content: kc.content,
		lineRefs: kc.lineRefs
	};
}
function mapChapter(ch, kcs) {
	return {
		id: ch.id,
		externalId: ch.externalId,
		order: ch.chapterIndex,
		title: ch.title,
		summary: ch.summary,
		hunkRefs: ch.hunkRefs,
		keyChanges: kcs.map(mapKeyChange)
	};
}
function mapRun(run) {
	return {
		id: run.id,
		repoName: parseRepoName(run.originUrl, run.repoRoot)
	};
}
function runRoutes(db) {
	return [{
		method: "GET",
		pattern: "/api/runs/:runId/chapters",
		handler: (_req, res, params) => {
			const runId = params.runId;
			if (!runId) {
				writeJson(res, 400, { error: "Missing runId" });
				return;
			}
			const [run] = db.select().from(chapterRun).where(eq(chapterRun.id, runId)).limit(1).all();
			if (!run) {
				writeJson(res, 404, { error: `Run ${runId} not found` });
				return;
			}
			const chapters = db.select().from(chapter).where(eq(chapter.runId, runId)).orderBy(asc(chapter.chapterIndex)).all();
			const chapterIds = chapters.map((c) => c.id);
			const keyChanges = chapterIds.length > 0 ? db.select().from(keyChange).where(inArray(keyChange.chapterId, chapterIds)).all() : [];
			const byChapter = /* @__PURE__ */ new Map();
			for (const kc of keyChanges) {
				const list = byChapter.get(kc.chapterId);
				if (list) list.push(kc);
				else byChapter.set(kc.chapterId, [kc]);
			}
			writeJson(res, 200, {
				run: mapRun(run),
				chapters: chapters.map((ch) => mapChapter(ch, byChapter.get(ch.id) ?? [])),
				prologue: run.prologue ?? null
			});
		}
	}];
}
z.object({
	chapterIds: z.array(z.string()),
	keyChangeIds: z.array(z.string()),
	filePaths: z.array(z.string())
});
const FileViewBodySchema = z.object({ path: z.string().min(1) });
//#endregion
//#region src/routes/view-state.ts
function viewStateRoutes(db) {
	return [
		{
			method: "POST",
			pattern: "/api/chapter-view/:chapterId",
			handler: (_req, res, params) => {
				const rows = resolveChapterRows(db, params.chapterId);
				if (rows.length === 0) {
					writeJson(res, 404, { error: `Chapter ${params.chapterId} not found` });
					return;
				}
				db.transaction((tx) => {
					tx.insert(chapterView).values(rows.map((r) => ({
						userId: LOCAL_USER_ID,
						chapterId: r.id
					}))).onConflictDoNothing().run();
					const cfvInserts = chapterFileViewInserts(rows);
					if (cfvInserts.length === 0) {
						writeJson(res, 200, {});
						return;
					}
					tx.insert(chapterFileView).values(cfvInserts).onConflictDoNothing().run();
					promoteFullyCoveredFiles(tx, touchedRunPaths(rows));
				});
				writeJson(res, 200, {});
			}
		},
		{
			method: "DELETE",
			pattern: "/api/chapter-view/:chapterId",
			handler: (_req, res, params) => {
				const rows = resolveChapterRows(db, params.chapterId);
				if (rows.length === 0) {
					writeJson(res, 200, {});
					return;
				}
				db.transaction((tx) => {
					const chapterIds = rows.map((r) => r.id);
					tx.delete(chapterView).where(and(eq(chapterView.userId, LOCAL_USER_ID), inArray(chapterView.chapterId, chapterIds))).run();
					tx.delete(chapterFileView).where(and(eq(chapterFileView.userId, LOCAL_USER_ID), inArray(chapterFileView.chapterId, chapterIds))).run();
					const touched = touchedRunPaths(rows);
					if (touched.length === 0) return;
					const runIds = Array.from(new Set(touched.map((t) => t.runId)));
					const filePaths = Array.from(new Set(touched.map((t) => t.filePath)));
					tx.delete(fileView).where(and(eq(fileView.userId, LOCAL_USER_ID), inArray(fileView.runId, runIds), inArray(fileView.filePath, filePaths))).run();
				});
				writeJson(res, 200, {});
			}
		},
		{
			method: "POST",
			pattern: "/api/key-change-view/:keyChangeId",
			handler: (_req, res, params) => {
				const ids = resolveKeyChangeIds(db, params.keyChangeId);
				if (ids.length === 0) {
					writeJson(res, 404, { error: `Key change ${params.keyChangeId} not found` });
					return;
				}
				db.insert(keyChangeView).values(ids.map((id) => ({
					userId: LOCAL_USER_ID,
					keyChangeId: id
				}))).onConflictDoNothing().run();
				writeJson(res, 200, {});
			}
		},
		{
			method: "DELETE",
			pattern: "/api/key-change-view/:keyChangeId",
			handler: (_req, res, params) => {
				const ids = resolveKeyChangeIds(db, params.keyChangeId);
				if (ids.length === 0) {
					writeJson(res, 200, {});
					return;
				}
				db.delete(keyChangeView).where(and(eq(keyChangeView.userId, LOCAL_USER_ID), inArray(keyChangeView.keyChangeId, ids))).run();
				writeJson(res, 200, {});
			}
		},
		{
			method: "POST",
			pattern: "/api/runs/:runId/file-views",
			handler: async (req, res, params) => {
				const runId = params.runId;
				if (!runId) {
					writeJson(res, 400, { error: "Missing runId" });
					return;
				}
				if (!runExists(db, runId)) {
					writeJson(res, 404, { error: `Run ${runId} not found` });
					return;
				}
				const parsed = await parseFileViewBody(req, res);
				if (!parsed) return;
				db.insert(fileView).values({
					userId: LOCAL_USER_ID,
					runId,
					filePath: parsed.path
				}).onConflictDoNothing().run();
				writeJson(res, 200, {});
			}
		},
		{
			method: "DELETE",
			pattern: "/api/runs/:runId/file-views",
			handler: async (req, res, params) => {
				const runId = params.runId;
				if (!runId) {
					writeJson(res, 400, { error: "Missing runId" });
					return;
				}
				if (!runExists(db, runId)) {
					writeJson(res, 200, {});
					return;
				}
				const parsed = await parseFileViewBody(req, res);
				if (!parsed) return;
				db.transaction((tx) => {
					tx.delete(fileView).where(and(eq(fileView.userId, LOCAL_USER_ID), eq(fileView.runId, runId), eq(fileView.filePath, parsed.path))).run();
					const affectedChapterIds = chaptersContainingFile(tx, runId, parsed.path);
					if (affectedChapterIds.length === 0) return;
					tx.delete(chapterFileView).where(and(eq(chapterFileView.userId, LOCAL_USER_ID), eq(chapterFileView.filePath, parsed.path), inArray(chapterFileView.chapterId, affectedChapterIds))).run();
					tx.delete(chapterView).where(and(eq(chapterView.userId, LOCAL_USER_ID), inArray(chapterView.chapterId, affectedChapterIds))).run();
				});
				writeJson(res, 200, {});
			}
		},
		{
			method: "GET",
			pattern: "/api/runs/:runId/view-state",
			handler: (_req, res, params) => {
				const runId = params.runId;
				if (!runId) {
					writeJson(res, 400, { error: "Missing runId" });
					return;
				}
				const [run] = db.select().from(chapterRun).where(eq(chapterRun.id, runId)).limit(1).all();
				if (!run) {
					writeJson(res, 404, { error: `Run ${runId} not found` });
					return;
				}
				const viewedChapters = db.select({ externalId: chapter.externalId }).from(chapterView).innerJoin(chapter, eq(chapter.id, chapterView.chapterId)).where(and(eq(chapterView.userId, LOCAL_USER_ID), eq(chapter.runId, runId))).all();
				const checkedKeyChanges = db.select({ externalId: keyChange.externalId }).from(keyChangeView).innerJoin(keyChange, eq(keyChange.id, keyChangeView.keyChangeId)).innerJoin(chapter, eq(chapter.id, keyChange.chapterId)).where(and(eq(keyChangeView.userId, LOCAL_USER_ID), eq(chapter.runId, runId))).all();
				const viewedFiles = db.select({ filePath: fileView.filePath }).from(fileView).where(and(eq(fileView.userId, LOCAL_USER_ID), eq(fileView.runId, runId))).all();
				writeJson(res, 200, {
					chapterIds: viewedChapters.map((r) => r.externalId),
					keyChangeIds: checkedKeyChanges.map((r) => r.externalId),
					filePaths: viewedFiles.map((r) => r.filePath)
				});
			}
		}
	];
}
function resolveChapterRows(db, idOrExternalId) {
	if (!idOrExternalId) return [];
	const cols = {
		id: chapter.id,
		runId: chapter.runId,
		hunkRefs: chapter.hunkRefs
	};
	const byPk = db.select(cols).from(chapter).where(eq(chapter.id, idOrExternalId)).all();
	if (byPk.length > 0) return byPk;
	return db.select(cols).from(chapter).where(eq(chapter.externalId, idOrExternalId)).all();
}
function chapterFileViewInserts(rows) {
	const out = [];
	for (const row of rows) {
		const seen = /* @__PURE__ */ new Set();
		for (const ref of row.hunkRefs) {
			if (seen.has(ref.filePath)) continue;
			seen.add(ref.filePath);
			out.push({
				userId: LOCAL_USER_ID,
				chapterId: row.id,
				filePath: ref.filePath
			});
		}
	}
	return out;
}
function touchedRunPaths(rows) {
	const seen = /* @__PURE__ */ new Set();
	const out = [];
	for (const row of rows) for (const ref of row.hunkRefs) {
		const key = `${row.runId} ${ref.filePath}`;
		if (seen.has(key)) continue;
		seen.add(key);
		out.push({
			runId: row.runId,
			filePath: ref.filePath
		});
	}
	return out;
}
/**
* Promotes file_view for each touched (runId, filePath) iff every chapter in
* the run whose hunkRefs contain that path has a chapter_file_view row for it.
*/
function promoteFullyCoveredFiles(tx, touched) {
	if (touched.length === 0) return;
	const runIds = Array.from(new Set(touched.map((t) => t.runId)));
	const paths = Array.from(new Set(touched.map((t) => t.filePath)));
	const containing = countChaptersPerPath(tx.select({
		id: chapter.id,
		runId: chapter.runId,
		hunkRefs: chapter.hunkRefs
	}).from(chapter).where(inArray(chapter.runId, runIds)).all(), runIds, paths);
	const marked = countChaptersFromRows(tx.select({
		chapterId: chapterFileView.chapterId,
		runId: chapter.runId,
		filePath: chapterFileView.filePath
	}).from(chapterFileView).innerJoin(chapter, eq(chapter.id, chapterFileView.chapterId)).where(and(eq(chapterFileView.userId, LOCAL_USER_ID), inArray(chapter.runId, runIds), inArray(chapterFileView.filePath, paths))).all());
	const inserts = [];
	for (const t of touched) {
		const have = marked.get(t.runId)?.get(t.filePath) ?? 0;
		const need = containing.get(t.runId)?.get(t.filePath) ?? 0;
		if (need > 0 && have === need) inserts.push({
			userId: LOCAL_USER_ID,
			runId: t.runId,
			filePath: t.filePath
		});
	}
	if (inserts.length > 0) tx.insert(fileView).values(inserts).onConflictDoNothing().run();
}
function bumpCount(map, runId, filePath) {
	let inner = map.get(runId);
	if (!inner) {
		inner = /* @__PURE__ */ new Map();
		map.set(runId, inner);
	}
	inner.set(filePath, (inner.get(filePath) ?? 0) + 1);
}
function countChaptersPerPath(rows, runIds, paths) {
	const runIdSet = new Set(runIds);
	const pathSet = new Set(paths);
	const out = /* @__PURE__ */ new Map();
	for (const row of rows) {
		if (!runIdSet.has(row.runId)) continue;
		const seen = /* @__PURE__ */ new Set();
		for (const ref of row.hunkRefs) {
			if (!pathSet.has(ref.filePath) || seen.has(ref.filePath)) continue;
			seen.add(ref.filePath);
			bumpCount(out, row.runId, ref.filePath);
		}
	}
	return out;
}
function countChaptersFromRows(rows) {
	const out = /* @__PURE__ */ new Map();
	for (const row of rows) bumpCount(out, row.runId, row.filePath);
	return out;
}
function chaptersContainingFile(tx, runId, filePath) {
	return tx.select({
		id: chapter.id,
		hunkRefs: chapter.hunkRefs
	}).from(chapter).where(eq(chapter.runId, runId)).all().filter((row) => row.hunkRefs.some((ref) => ref.filePath === filePath)).map((row) => row.id);
}
function resolveKeyChangeIds(db, idOrExternalId) {
	if (!idOrExternalId) return [];
	const byPk = db.select({ id: keyChange.id }).from(keyChange).where(eq(keyChange.id, idOrExternalId)).all();
	if (byPk.length > 0) return byPk.map((r) => r.id);
	return db.select({ id: keyChange.id }).from(keyChange).where(eq(keyChange.externalId, idOrExternalId)).all().map((r) => r.id);
}
function runExists(db, runId) {
	return db.select({ id: chapterRun.id }).from(chapterRun).where(eq(chapterRun.id, runId)).limit(1).all().length > 0;
}
async function parseFileViewBody(req, res) {
	let raw;
	try {
		raw = await readJsonBody(req);
	} catch (err) {
		writeJson(res, 400, { error: err instanceof Error ? err.message : "Invalid JSON body" });
		return null;
	}
	const parsed = FileViewBodySchema.safeParse(raw);
	if (!parsed.success) {
		writeJson(res, 400, { error: "Invalid file-view body: missing or empty `path`" });
		return null;
	}
	return parsed.data;
}
//#endregion
//#region src/runs/import-chapters.ts
function insertChaptersFile(db, file, repo) {
	return db.transaction((tx) => {
		const [runRow] = tx.insert(chapterRun).values({
			repoRoot: repo.root,
			originUrl: repo.originUrl,
			scopeKind: file.scope.kind,
			workingTreeRef: file.scope.kind === SCOPE_KIND.WORKING_TREE ? file.scope.ref : null,
			baseSha: file.scope.baseSha,
			headSha: file.scope.headSha,
			mergeBaseSha: file.scope.mergeBaseSha,
			generatedAt: new Date(file.generatedAt),
			prologue: file.prologue ?? null
		}).returning({ id: chapterRun.id }).all();
		if (!runRow) throw new Error("chapter_run insert returned no row");
		const runId = runRow.id;
		const scopeKey = deriveScopeKey(file.scope);
		let keyChangeCount = 0;
		for (const c of file.chapters) {
			const [chapterRow] = tx.insert(chapter).values({
				runId,
				externalId: deriveChapterExternalId(scopeKey, c.id),
				chapterIndex: c.order,
				title: c.title,
				summary: c.summary,
				hunkRefs: c.hunkRefs,
				keyChanges: c.keyChanges.map((kc) => kc.content)
			}).returning({ id: chapter.id }).all();
			if (!chapterRow) throw new Error("chapter insert returned no row");
			const chapterId = chapterRow.id;
			for (const kc of c.keyChanges) {
				tx.insert(keyChange).values({
					chapterId,
					externalId: deriveKeyChangeExternalId(scopeKey, c.id, kc.content, kc.lineRefs),
					content: kc.content,
					lineRefs: kc.lineRefs
				}).run();
				keyChangeCount++;
			}
		}
		return {
			runId,
			chapterCount: file.chapters.length,
			keyChangeCount
		};
	});
}
function deriveScopeKey(scope) {
	if (scope.kind === SCOPE_KIND.COMMITTED) return `committed:${scope.baseSha}:${scope.headSha}:${scope.mergeBaseSha}`;
	return `workingTree:${scope.ref}:${scope.baseSha}:${scope.headSha}:${scope.mergeBaseSha}`;
}
function deriveChapterExternalId(scopeKey, agentId) {
	const hash = createHash("sha256");
	hash.update(scopeKey);
	hash.update(" ");
	hash.update(agentId);
	return hash.digest("hex").slice(0, 24);
}
function deriveKeyChangeExternalId(scopeKey, chapterAgentId, content, lineRefs) {
	const hash = createHash("sha256");
	hash.update(scopeKey);
	hash.update(" ");
	hash.update(chapterAgentId);
	hash.update(" ");
	hash.update(content);
	hash.update(" ");
	hash.update(JSON.stringify(lineRefs));
	return hash.digest("hex").slice(0, 24);
}
//#endregion
//#region src/server.ts
const MIME_TYPES = {
	".html": "text/html; charset=utf-8",
	".js": "application/javascript; charset=utf-8",
	".mjs": "application/javascript; charset=utf-8",
	".css": "text/css; charset=utf-8",
	".json": "application/json; charset=utf-8",
	".svg": "image/svg+xml",
	".png": "image/png",
	".jpg": "image/jpeg",
	".jpeg": "image/jpeg",
	".gif": "image/gif",
	".ico": "image/x-icon",
	".woff": "font/woff",
	".woff2": "font/woff2",
	".map": "application/json; charset=utf-8"
};
const CLI_DIR = path.dirname(fileURLToPath(import.meta.url));
const DEFAULT_WEB_DIST = path.resolve(CLI_DIR, "..", "web-dist");
const LOOPBACK_HOST = "127.0.0.1";
const DEFAULT_START_PORT = 5391;
const DEFAULT_MAX_PORT_ATTEMPTS = 100;
async function startServer(opts) {
	const webDist = path.resolve(opts.webDistPath ?? DEFAULT_WEB_DIST);
	const compiled = (opts.routes ?? []).map(compileRoute);
	const startPort = opts.port ?? DEFAULT_START_PORT;
	const maxPortAttempts = opts.maxPortAttempts ?? DEFAULT_MAX_PORT_ATTEMPTS;
	for (let i = 0; i < maxPortAttempts; i++) {
		const port = startPort + i;
		const server = http.createServer((req, res) => {
			handleRequest(req, res, webDist, compiled).catch((err) => {
				const msg = err instanceof Error ? err.message : String(err);
				process.stderr.write(`request handler error: ${msg}\n`);
				if (!res.headersSent) res.writeHead(500, { "Content-Type": "text/plain" });
				res.end("Internal Server Error");
			});
		});
		try {
			await listen(server, port);
			return {
				port,
				close: () => new Promise((resolve, reject) => {
					server.close((err) => err ? reject(err) : resolve());
				})
			};
		} catch (err) {
			if (!isPortUnavailable(err)) throw err;
		}
	}
	throw new Error(`Could not find a free port in range ${startPort}-${startPort + maxPortAttempts - 1}`);
}
function listen(server, port) {
	return new Promise((resolve, reject) => {
		const onError = (err) => reject(err);
		server.once("error", onError);
		server.once("listening", () => {
			server.removeListener("error", onError);
			resolve();
		});
		server.listen(port, LOOPBACK_HOST);
	});
}
function isPortUnavailable(err) {
	const code = err.code;
	return code === "EADDRINUSE" || code === "EACCES";
}
function compileRoute(route) {
	const paramNames = [];
	const body = route.pattern.replace(/[.*+?^${}()|[\]\\]/g, "\\$&").replace(/:([a-zA-Z_][a-zA-Z0-9_]*)/g, (_match, name) => {
		paramNames.push(name);
		return "([^/]+)";
	});
	return {
		method: route.method.toUpperCase(),
		regex: new RegExp(`^${body}$`),
		paramNames,
		handler: route.handler
	};
}
async function handleRequest(req, res, webDist, routes) {
	const fullUrl = req.url ?? "/";
	const queryIdx = fullUrl.indexOf("?");
	const pathname = (queryIdx >= 0 ? fullUrl.slice(0, queryIdx) : fullUrl) || "/";
	const method = (req.method ?? "GET").toUpperCase();
	if (pathname.startsWith("/api/")) {
		for (const route of routes) {
			if (route.method !== method) continue;
			const match = route.regex.exec(pathname);
			if (!match) continue;
			const params = {};
			try {
				route.paramNames.forEach((name, i) => {
					params[name] = decodeURIComponent(match[i + 1] ?? "");
				});
			} catch {
				res.writeHead(400, { "Content-Type": "text/plain" });
				res.end("Bad Request");
				return;
			}
			await route.handler(req, res, params);
			return;
		}
		res.writeHead(404, { "Content-Type": "text/plain" });
		res.end("Not Found");
		return;
	}
	if (method !== "GET") {
		res.writeHead(405, {
			"Content-Type": "text/plain",
			Allow: "GET"
		});
		res.end("Method Not Allowed");
		return;
	}
	let decoded;
	try {
		decoded = decodeURIComponent(pathname);
	} catch {
		res.writeHead(400, { "Content-Type": "text/plain" });
		res.end("Bad Request");
		return;
	}
	const rel = path.relative(webDist, path.resolve(webDist, `.${decoded}`));
	if (rel.startsWith("..") || path.isAbsolute(rel)) {
		res.writeHead(403, { "Content-Type": "text/plain" });
		res.end("Forbidden");
		return;
	}
	if (await sendFile(path.join(webDist, rel), res)) return;
	await sendIndexFallback(webDist, res);
}
async function sendFile(filePath, res) {
	let stat;
	try {
		stat = await fs.stat(filePath);
	} catch (err) {
		const code = err.code;
		if (code === "ENOENT" || code === "ENOTDIR") return false;
		throw err;
	}
	if (!stat.isFile()) return false;
	const ext = path.extname(filePath).toLowerCase();
	res.writeHead(200, {
		"Content-Type": MIME_TYPES[ext] ?? "application/octet-stream",
		"Content-Length": String(stat.size)
	});
	await pipeline(createReadStream(filePath), res);
	return true;
}
async function sendIndexFallback(webDist, res) {
	const indexPath = path.join(webDist, "index.html");
	let stat;
	try {
		stat = await fs.stat(indexPath);
	} catch {
		res.writeHead(404, { "Content-Type": "text/plain" });
		res.end("Not Found");
		return;
	}
	res.writeHead(200, {
		"Content-Type": "text/html; charset=utf-8",
		"Content-Length": String(stat.size)
	});
	await pipeline(createReadStream(indexPath), res);
}
//#endregion
//#region src/show.ts
async function show(jsonPath, base, ref) {
	const db = getDb();
	const { runId } = insertChaptersFile(db, loadChaptersFile(jsonPath, base, ref), readRepoContext());
	const handle = await startServer({ routes: [
		...runRoutes(db),
		...viewStateRoutes(db),
		...diffRoutes(db)
	] });
	const { port } = handle;
	const url = `http://${LOOPBACK_HOST}:${port}/runs/${encodeURIComponent(runId)}`;
	process.stdout.write(`Listening on ${url}\n`);
	process.stdout.write("Press Ctrl+C to exit.\n");
	try {
		await open(url);
	} catch {}
	await waitForShutdownSignal();
	await handle.close();
	closeDb();
}
function loadChaptersFile(jsonPath, base, ref) {
	const raw = readFileSync(path.resolve(jsonPath), "utf8");
	const parsed = JSON.parse(raw);
	const fullResult = ChaptersFileSchema.safeParse(parsed);
	if (fullResult.success) return fullResult.data;
	const agentResult = AgentOutputSchema.safeParse(parsed);
	if (agentResult.success) return assembleChaptersFile(agentResult.data, base, ref);
	throw fullResult.error;
}
function assembleChaptersFile(agentOutput, base, ref) {
	const { scope, rawDiff } = resolveScope(base, ref);
	const allFiles = parseGitDiff(rawDiff);
	const { files: filteredFiles, excludedByPath } = filterFilesForLlm(allFiles);
	validateHunkCoverage(filteredFiles, agentOutput.chapters);
	const chapters = [...sanitizeLineRefs(agentOutput.chapters, filteredFiles)];
	const otherChanges = buildOtherChangesChapter(allFiles, excludedByPath);
	if (otherChanges) chapters.push({
		...otherChanges,
		order: chapters.length + 1
	});
	return {
		scope,
		chapters,
		prologue: agentOutput.prologue,
		generatedAt: (/* @__PURE__ */ new Date()).toISOString()
	};
}
function validateHunkCoverage(filteredFiles, chapters) {
	const expected = /* @__PURE__ */ new Map();
	for (const file of filteredFiles) {
		const starts = /* @__PURE__ */ new Set();
		for (const hunk of file.hunks) starts.add(hunk.oldStart);
		if (starts.size > 0) expected.set(file.path, starts);
	}
	const actual = /* @__PURE__ */ new Map();
	const duplicates = [];
	for (const chapter of chapters) for (const ref of chapter.hunkRefs) {
		let starts = actual.get(ref.filePath);
		if (!starts) {
			starts = /* @__PURE__ */ new Map();
			actual.set(ref.filePath, starts);
		}
		const count = starts.get(ref.oldStart) ?? 0;
		if (count > 0) duplicates.push(`  filePath: "${ref.filePath}", oldStart: ${ref.oldStart}`);
		starts.set(ref.oldStart, count + 1);
	}
	const missing = [];
	for (const [filePath, starts] of expected) {
		const actualStarts = actual.get(filePath);
		for (const oldStart of starts) if (!actualStarts?.has(oldStart)) missing.push(`  filePath: "${filePath}", oldStart: ${oldStart}`);
	}
	const extra = [];
	for (const [filePath, starts] of actual) {
		const expectedStarts = expected.get(filePath);
		for (const oldStart of starts.keys()) if (!expectedStarts?.has(oldStart)) extra.push(`  filePath: "${filePath}", oldStart: ${oldStart}`);
	}
	if (missing.length === 0 && extra.length === 0 && duplicates.length === 0) return;
	const lines = ["Hunk coverage validation failed."];
	if (missing.length > 0) {
		lines.push(`Missing hunks (${missing.length}) — not assigned to any chapter:`);
		lines.push(...missing);
	}
	if (extra.length > 0) {
		lines.push(`Extra hunks (${extra.length}) — not found in the diff:`);
		lines.push(...extra);
	}
	if (duplicates.length > 0) {
		lines.push(`Duplicate hunks (${duplicates.length}) — assigned to multiple chapters:`);
		lines.push(...duplicates);
	}
	throw new Error(lines.join("\n"));
}
function sanitizeLineRefs(chapters, filteredFiles) {
	const hunkSpanIndex = /* @__PURE__ */ new Map();
	for (const file of filteredFiles) {
		const spans = /* @__PURE__ */ new Map();
		for (const hunk of file.hunks) spans.set(hunk.oldStart, {
			oldStart: hunk.oldStart,
			oldEnd: hunk.oldStart + hunk.oldLines - 1,
			newStart: hunk.newStart,
			newEnd: hunk.newStart + hunk.newLines - 1
		});
		if (spans.size > 0) hunkSpanIndex.set(file.path, spans);
	}
	return chapters.map((chapter) => {
		const chapterSpans = /* @__PURE__ */ new Map();
		for (const ref of chapter.hunkRefs) {
			const fileSpans = hunkSpanIndex.get(ref.filePath);
			if (!fileSpans) continue;
			const span = fileSpans.get(ref.oldStart);
			if (!span) continue;
			let spans = chapterSpans.get(ref.filePath);
			if (!spans) {
				spans = [];
				chapterSpans.set(ref.filePath, spans);
			}
			spans.push(span);
		}
		const keyChanges = chapter.keyChanges.flatMap((kc) => {
			const validRefs = kc.lineRefs.filter((ref) => {
				if (ref.startLine < 1 || ref.endLine < 1) return false;
				if (ref.startLine > ref.endLine) return false;
				const spans = chapterSpans.get(ref.filePath);
				if (!spans) return false;
				return spans.some((span) => {
					const [rangeStart, rangeEnd] = ref.side === DIFF_SIDE.ADDITIONS ? [span.newStart, span.newEnd] : [span.oldStart, span.oldEnd];
					if (rangeStart > rangeEnd) return false;
					return ref.startLine >= rangeStart && ref.endLine <= rangeEnd;
				});
			});
			const uniqueRefs = [];
			for (const ref of validRefs) if (!uniqueRefs.some((existing) => existing.filePath === ref.filePath && existing.side === ref.side && existing.startLine === ref.startLine && existing.endLine === ref.endLine)) uniqueRefs.push(ref);
			if (uniqueRefs.length === 0) return [];
			return [{
				content: kc.content,
				lineRefs: uniqueRefs
			}];
		});
		return {
			...chapter,
			keyChanges
		};
	});
}
function waitForShutdownSignal() {
	return new Promise((resolve) => {
		const cleanup = () => {
			process.removeListener("SIGINT", cleanup);
			process.removeListener("SIGTERM", cleanup);
			resolve();
		};
		process.once("SIGINT", cleanup);
		process.once("SIGTERM", cleanup);
	});
}
//#endregion
//#region src/index.ts
const { version } = createRequire(import.meta.url)("../package.json");
const program = new Command();
program.name("stagereview").description("Chapter-style code review against your local git branch.").version(version);
const refOption = new Option("--ref <mode>", "Diff scope: work (staged + unstaged + untracked), staged, or unstaged (default: auto-detect)").choices(Object.values(WORKING_TREE_REF));
program.command("prep").description("Parse the current branch diff and prepare input for chapter generation").option("--base <ref>", "Base ref to diff against (default: auto-detect main/master)").addOption(refOption).action((opts) => {
	const ref = opts.ref !== void 0 ? z.enum(WORKING_TREE_REF).parse(opts.ref) : void 0;
	const filePath = runPrep(opts.base, ref);
	process.stdout.write(filePath);
});
program.command("show").description("Load a chapters.json file and open it in a local browser").argument("<path>", "Path to a chapters.json file").option("--base <ref>", "Base ref to diff against (default: auto-detect main/master)").addOption(refOption).action(async (jsonPath, opts) => {
	const ref = opts.ref !== void 0 ? z.enum(WORKING_TREE_REF).parse(opts.ref) : void 0;
	await show(jsonPath, opts.base, ref);
});
program.parseAsync(process.argv).catch((err) => {
	process.stderr.write(`${err instanceof Error ? err.message : String(err)}\n`);
	process.exit(1);
});
//#endregion
export {};
