Initial curated projectbrain source from 8a7b1e0651dbdab97d744cbd2e653ab3970ac6c5

This commit is contained in:
Public Source Publisher committed 2026-10-01 01:43:26 +02:00
commit 5aa3e8d685
700 files changed
+132801

No files matched your search

+96
View File
@@ -0,0 +1,96 @@
#!/usr/bin/env node
import { readFile, readdir, stat } from "node:fs/promises";
import { createHash } from "node:crypto";
import { resolve, relative, extname, dirname } from "node:path";
import process from "node:process";
const root = resolve(import.meta.dirname, "..");
const config = JSON.parse(await readFile(resolve(root, "config/architecture-budgets.json"), "utf8"));
const normalized = (path) => relative(root, path).replaceAll("\\", "/");
const sourceRoots = [resolve(root, "src"), resolve(root, "backend/src")];
const walk = async (directory) => {
const entries = await readdir(directory, { withFileTypes: true });
const nested = await Promise.all(entries.map((entry) => entry.isDirectory() ? walk(resolve(directory, entry.name)) : [resolve(directory, entry.name)]));
return nested.flat();
};
const failures = [];
const sources = (await Promise.all(sourceRoots.map(walk))).flat().filter((path) => [".ts", ".tsx"].includes(extname(path)));
const sourceSet = new Set(sources);
const contentByPath = new Map();
for (const path of sources) {
const name = normalized(path);
const content = await readFile(path, "utf8");
contentByPath.set(path, content);
const lines = content.split(/\r?\n/).length;
const exception = config.temporaryFileExceptions[name];
const maximum = exception?.maxLines ?? config.maximumNewSourceLines;
if (lines > maximum) failures.push(`${name}: ${lines} lines exceeds ${maximum}${exception ? " temporary ceiling" : " general ceiling"}`);
}
const resolveImport = (from, specifier) => {
if (!specifier.startsWith(".") && !specifier.startsWith("@/")) return undefined;
const base = specifier.startsWith("@/") ? resolve(root, "src", specifier.slice(2)) : resolve(dirname(from), specifier);
return [base, `${base}.ts`, `${base}.tsx`, resolve(base, "index.ts"), resolve(base, "index.tsx")].find((candidate) => sourceSet.has(candidate));
};
const graph = new Map(sources.map((path) => {
const imports = [...contentByPath.get(path).matchAll(/(?:from\s+|import\s*\(\s*|import\s+)["']([^"']+)["']/g)]
.map((match) => resolveImport(path, match[1]))
.filter(Boolean);
return [path, [...new Set(imports)]];
}));
const visiting = new Set();
const visited = new Set();
const stack = [];
const cycles = new Set();
const visit = (path) => {
if (visiting.has(path)) {
const start = stack.indexOf(path);
cycles.add([...stack.slice(start), path].map(normalized).join(" -> "));
return;
}
if (visited.has(path)) return;
visiting.add(path); stack.push(path);
for (const dependency of graph.get(path) ?? []) visit(dependency);
stack.pop(); visiting.delete(path); visited.add(path);
};
for (const path of sources) visit(path);
for (const cycle of cycles) failures.push(`circular import: ${cycle}`);
const hashes = new Map();
for (const [path, content] of contentByPath) {
if (content.split(/\r?\n/).length < 20) continue;
const hash = createHash("sha256").update(content.replaceAll(/\s+/g, " ").trim()).digest("hex");
const duplicate = hashes.get(hash);
if (duplicate) failures.push(`exact duplicate modules: ${normalized(duplicate)} and ${normalized(path)}`);
else hashes.set(hash, path);
}
const forbiddenRoots = [resolve(root, "src/shared/components/ui"), resolve(root, "src/shared/components/layout/AppLayout.tsx")];
for (const path of forbiddenRoots) {
try { await stat(path); failures.push(`${normalized(path)}: deprecated duplicate UI boundary still exists`); } catch (error) { if (error.code !== "ENOENT") throw error; }
}
const distAssets = resolve(root, "dist/assets");
try {
const assets = (await readdir(distAssets)).filter((name) => name.endsWith(".js"));
for (const asset of assets) {
const bytes = (await stat(resolve(distAssets, asset))).size;
if (/^index-/.test(asset) && bytes > config.bundle.maxEntryBytes) failures.push(`${asset}: ${bytes} bytes exceeds entry budget ${config.bundle.maxEntryBytes}`);
if (!/^vendor-|^index-|^rolldown-runtime-/.test(asset)) {
const exception = Object.entries(config.bundle.routeExceptions ?? {}).find(([prefix]) => asset.startsWith(prefix))?.[1];
const maximum = exception?.maxBytes ?? config.bundle.maxRouteBytes;
if (bytes > maximum) failures.push(`${asset}: ${bytes} bytes exceeds route budget ${maximum}${exception ? " temporary ceiling" : ""}`);
}
}
} catch (error) {
if (error.code !== "ENOENT") throw error;
failures.push("dist/assets is missing; run pnpm build before the architecture audit");
}
if (failures.length) {
console.error("Architecture budget failed:\n- " + failures.join("\n- "));
process.exit(1);
}
console.log(`Architecture budget passed for ${sources.length} source files; ${Object.keys(config.temporaryFileExceptions).length} frozen exceptions, no import cycles/exact duplicate modules and production bundle ceilings verified.`);
@@ -0,0 +1,56 @@
import fs from "node:fs";
const read = (path) => fs.existsSync(path) ? fs.readFileSync(path, "utf8") : "";
const failures = [];
const expectIncludes = (file, needle, message) => {
const text = read(file);
if (!text.includes(needle)) failures.push(`${file}: ${message}`);
};
const expectExists = (file, message) => {
if (!fs.existsSync(file)) failures.push(`${file}: ${message}`);
};
expectIncludes("src/shared/lib/model.ts", "BrowserBridgeSession", "BrowserBridgeSession type must exist.");
expectIncludes("src/shared/lib/model.ts", "BrowserBridgePayload", "BrowserBridgePayload type must exist.");
expectIncludes("src/shared/lib/model.ts", "BrowserBridgeStatus", "BrowserBridgeStatus type must exist.");
expectIncludes("src/shared/lib/model.ts", "BrowserBridgeTarget", "BrowserBridgeTarget type must exist.");
expectIncludes("src/shared/lib/model.ts", "BrowserBridgeTransferMode", "BrowserBridgeTransferMode type must exist.");
expectIncludes("src/shared/lib/browserBridge.ts", "createBrowserBridgePayload", "Browser bridge payload helper must exist.");
expectIncludes("src/shared/lib/browserBridge.ts", "formatBrowserBridgePrompt", "Browser bridge prompt formatter must exist.");
expectIncludes("src/shared/lib/browserBridge.ts", "parseBrowserBridgeResponse", "Browser bridge response parser must exist.");
expectIncludes("src/shared/lib/browserBridge.ts", "No API cost", "No API cost copy must be present.");
expectIncludes("src/shared/lib/browserBridge.ts", "Uses your browser session", "Browser session control copy must be present.");
expectIncludes("src/shared/contexts/ProjectContext.tsx", "createBrowserBridgeSession", "ProjectContext must create bridge sessions.");
expectIncludes("src/shared/contexts/ProjectContext.tsx", "pasteBrowserBridgeResponse", "ProjectContext must paste bridge responses.");
expectIncludes("src/shared/contexts/ProjectContext.tsx", "createExecutionFeedbackFromBridge", "ProjectContext must create feedback from bridge.");
expectIncludes("src/app/routes.tsx", "/projects/:projectId/browser-bridge", "Browser Bridge route must be registered.");
expectExists("src/features/browser-bridge/pages/BrowserBridge.tsx", "Browser Bridge page must exist.");
expectIncludes("src/features/browser-bridge/pages/BrowserBridge.tsx", "No session pack is ready yet", "Browser Bridge must explain the empty session-pack state.");
expectIncludes("src/features/browser-bridge/pages/BrowserBridge.tsx", "createExecutionFeedbackFromBridge(project.id, selectedSession.id, responseText)", "Browser Bridge must support one-step response save and feedback creation.");
expectIncludes("src/features/browser-bridge/pages/BrowserBridge.tsx", "Clipboard is unavailable", "Browser Bridge must handle unavailable clipboard APIs.");
expectIncludes("src/features/browser-bridge/pages/BrowserBridge.tsx", "readBridgePayload", "Browser Bridge must validate stored payload JSON before instruction export.");
expectIncludes("src/features/browser-bridge/pages/BrowserBridge.tsx", "Feedback draft already exists", "Browser Bridge must route completed sessions back to feedback review.");
expectIncludes("src/features/session-packs/pages/SessionPacks.tsx", "Prepare browser bridge", "Session Packs must expose bridge preparation.");
expectIncludes("src/features/execution-feedback/pages/ExecutionFeedback.tsx", "Import from browser bridge", "Execution Feedback must import bridge responses.");
expectIncludes("src/features/build-loop/pages/BuildLoopDashboard.tsx", "Run manually in ChatGPT", "Build Loop must expose manual browser execution.");
expectIncludes("src/features/execution/pages/ExecutionCenter.tsx", "No API cost browser bridge", "Execution Center must expose bridge path.");
expectIncludes("src/features/handoffs/pages/CodexHandoffs.tsx", "Run via Browser Bridge", "Handoffs must expose browser bridge handoff path.");
expectIncludes("src/features/history/pages/ProjectHistory.tsx", "browserBridge", "History must include browser bridge entries.");
expectIncludes("src/features/audit/pages/ProjectAuditCenter.tsx", "Browser Bridge control", "Audit Center must include browser bridge checks.");
expectExists("tools/projectbrain-browser-bridge-extension/manifest.json", "Optional extension manifest must exist.");
expectIncludes("tools/projectbrain-browser-bridge-extension/content-script.js", "ProjectBrain Bridge", "Extension helper must render visible bridge UI.");
expectIncludes("tools/projectbrain-browser-bridge-extension/README.md", "does not read cookies", "Extension README must document safety boundary.");
const pkg = read("package.json");
expectIncludes("package.json", "audit:browser-bridge", "package.json must expose audit:browser-bridge.");
expectIncludes("package.json", "audit:session-pack-export && pnpm run audit:browser-bridge", "audit:all must include browser bridge after session pack export.");
if (failures.length) {
console.error("Browser Bridge readiness failed:");
for (const failure of failures) console.error(`- ${failure}`);
process.exit(1);
}
console.log("Browser Bridge readiness passed.");
+117
View File
@@ -0,0 +1,117 @@
#!/usr/bin/env node
import { existsSync, readFileSync } from "node:fs";
const requiredFiles = [
"src/shared/lib/buildLoop.ts",
"src/features/build-loop/pages/BuildLoopDashboard.tsx",
"src/app/routes.tsx",
"src/shared/lib/navigation.tsx",
"src/shared/components/ProjectFlowRail.tsx",
"src/features/projects/pages/Workspace.tsx",
"src/features/projects/pages/Projects.tsx",
"src/features/execution/pages/ExecutionCenter.tsx",
"src/features/repo-builder/pages/RepoBuilder.tsx",
"src/features/handoffs/pages/CodexHandoffs.tsx",
"src/features/execution-feedback/pages/ExecutionFeedback.tsx",
"src/features/audit/pages/ProjectAuditCenter.tsx",
"src/shared/lib/projectOperatingStep.ts",
"src/shared/lib/projectSearch.ts",
"src/shared/lib/routeRegistry.ts",
"package.json",
];
const failures = [];
for (const file of requiredFiles) {
if (!existsSync(file)) failures.push(`Missing required file: ${file}`);
}
const read = (file) => readFileSync(file, "utf8");
const text = Object.fromEntries(requiredFiles.filter(existsSync).map((file) => [file, read(file)]));
const helper = text["src/shared/lib/buildLoop.ts"] ?? "";
const page = text["src/features/build-loop/pages/BuildLoopDashboard.tsx"] ?? "";
const routes = text["src/app/routes.tsx"] ?? "";
const navigation = text["src/shared/lib/navigation.tsx"] ?? "";
const flowRail = text["src/shared/components/ProjectFlowRail.tsx"] ?? "";
const workspace = text["src/features/projects/pages/Workspace.tsx"] ?? "";
const projects = text["src/features/projects/pages/Projects.tsx"] ?? "";
const execution = text["src/features/execution/pages/ExecutionCenter.tsx"] ?? "";
const repoBuilder = text["src/features/repo-builder/pages/RepoBuilder.tsx"] ?? "";
const handoffs = text["src/features/handoffs/pages/CodexHandoffs.tsx"] ?? "";
const feedback = text["src/features/execution-feedback/pages/ExecutionFeedback.tsx"] ?? "";
const audit = text["src/features/audit/pages/ProjectAuditCenter.tsx"] ?? "";
const operatingStep = text["src/shared/lib/projectOperatingStep.ts"] ?? "";
const projectSearch = text["src/shared/lib/projectSearch.ts"] ?? "";
const routeRegistry = text["src/shared/lib/routeRegistry.ts"] ?? "";
const pkg = text["package.json"] ?? "";
const mustContain = [
[helper, "getBuildLoopStatus", "buildLoop helper must expose getBuildLoopStatus."],
[helper, "getCurrentBuildLoopPhase", "buildLoop helper must derive current phase."],
[helper, "getRecommendedBuildLoopAction", "buildLoop helper must recommend next action."],
[helper, "getBuildLoopRisks", "buildLoop helper must derive risks."],
[helper, "getBuildLoopValidationSummary", "buildLoop helper must summarize validation."],
[helper, "getBuildLoopSkillCoverage", "buildLoop helper must summarize skill coverage."],
[helper, "getBuildLoopProgress", "buildLoop helper must calculate progress."],
[helper, "formatBuildLoopMarkdown", "buildLoop helper must export markdown."],
[helper, "repositoryIntakes", "buildLoop helper must use repository intakes/gaps."],
[helper, "codexHandoffs", "buildLoop helper must use Codex handoffs."],
[helper, "handoffReviews", "buildLoop helper must use handoff reviews."],
[helper, "codexSessionPacks", "buildLoop helper must use session packs."],
[helper, "Create Codex session pack", "buildLoop helper must recommend creating session packs."],
[helper, "Run session and add feedback", "buildLoop helper must recommend feedback after session packs."],
[helper, "latestSessionPack", "buildLoop helper must expose latest session pack."],
[helper, "codexExecutionFeedback", "buildLoop helper must use execution feedback."],
[helper, "getActiveProjectSkills", "buildLoop helper must use Codex skills."],
[routes, "/projects/:projectId/build-loop", "Build Loop route missing."],
[routes, "BuildLoopDashboard", "Build Loop page must be imported in routes."],
[navigation, "getPrimaryPathNavigationItems", "Primary navigation must expose the workspace that owns the Build Loop entry."],
[routeRegistry, 'key: "workspace"', "Canonical route registry must expose the workspace that owns the Build Loop entry."],
[flowRail, 'id: "build-loop"', "Project Flow Rail must include Build Loop."],
[navigation, '`${base}/build-loop`', "Technical navigation must expose Build Loop."],
[workspace, "Technische details", "Workspace must progressively disclose technical execution."],
[page, "getBuildLoopStatus", "Technical Build Loop must use the shared helper."],
[projects, "resolveProjectListOperatingState", "Projects must combine daily guidance with live Auto state."],
[projects, "getProjectOperatingStep", "Projects cards must derive the central next safe action."],
[projects, "operatingState.label", "Projects must render the central operating-state label."],
[projects, "operatingState.description", "Projects must render the central operating-state explanation."],
[execution, "View build loop", "Execution Center must link to Build Loop."],
[execution, "getBuildLoopStatus", "Execution Center must use Build Loop recommended action."],
[repoBuilder, "Naar de buildloop", "Repo Builder must link back to the Build Loop from its task-first header."],
[handoffs, "View in Build Loop", "Codex Handoffs must link to Build Loop."],
[feedback, "Linked session pack", "Execution Feedback must link session packs."],
[feedback, "Return to Build Loop", "Execution Feedback must link back to Build Loop."],
[audit, "Build Loop cockpit", "Audit Center must show Build Loop coverage."],
[audit, "Codex session pack readiness", "Audit Center must show session pack coverage."],
[projectSearch, 'type: "buildLoop"', "Search index must include Build Loop result."],
[page, "Technische uitvoeringsstatus", "Build Loop page must show status summary."],
[page, "Interne fasen", "Build Loop page must show timeline."],
[page, "Geslaagde controles", "Build Loop page must show safety and validation."],
[page, "Repositorytoestand", "Build Loop page must show repo health."],
[page, "uitvoeringsprofielen", "Build Loop page must show skill coverage."],
[helper, "latestSessionPack", "Build Loop data must retain session pack readiness."],
[helper, "codexExecutionFeedback", "Build Loop data must retain execution feedback."],
[page, "Recente technische gebeurtenissen", "Build Loop page must show recent events."],
[helper, "formatBuildLoopMarkdown", "Build Loop must retain its export formatter."],
[pkg, "audit:build-loop", "package.json must expose audit:build-loop."],
[pkg, "check-build-loop-readiness.mjs", "package.json must wire Build Loop readiness script."],
];
for (const [haystack, needle, message] of mustContain) {
if (!haystack.includes(needle)) failures.push(message);
}
const scripts = JSON.parse(pkg || "{}").scripts ?? {};
const experienceAudit = scripts["audit:experience"] ?? "";
const allAudit = scripts["audit:all"] ?? "";
if (!experienceAudit.includes("audit:build-loop") || !allAudit.includes("audit:experience")) {
failures.push("audit:all must include the experience gate with Build Loop readiness.");
}
if (failures.length) {
console.error("ProjectBrain build loop readiness: FAIL");
for (const failure of failures) console.error(`- ${failure}`);
process.exit(1);
}
console.log("ProjectBrain build loop readiness: PASS");
@@ -0,0 +1,128 @@
#!/usr/bin/env node
import { existsSync, readFileSync } from "node:fs";
const requiredFiles = [
"src/app/routes.tsx",
"src/shared/lib/navigation.tsx",
"src/shared/components/ProjectFlowRail.tsx",
"src/shared/lib/projectStartFlow.ts",
"src/features/projects/pages/Projects.tsx",
"src/features/projects/pages/Workspace.tsx",
"src/features/build-loop/pages/BuildLoopDashboard.tsx",
"src/features/repo-builder/pages/RepoBuilder.tsx",
"src/features/handoffs/pages/CodexHandoffs.tsx",
"src/features/session-packs/pages/SessionPacks.tsx",
"src/features/execution-feedback/pages/ExecutionFeedback.tsx",
"src/shared/lib/safeData.ts",
"src/shared/lib/codexSessionPackExport.ts",
"src/shared/lib/downloads.ts",
"package.json",
];
const failures = [];
for (const file of requiredFiles) {
if (!existsSync(file)) failures.push(`Missing required file: ${file}`);
}
const read = (file) => readFileSync(file, "utf8");
const text = Object.fromEntries(requiredFiles.filter(existsSync).map((file) => [file, read(file)]));
const routes = text["src/app/routes.tsx"] ?? "";
const navigation = text["src/shared/lib/navigation.tsx"] ?? "";
const flowRail = text["src/shared/components/ProjectFlowRail.tsx"] ?? "";
const startFlow = text["src/shared/lib/projectStartFlow.ts"] ?? "";
const projects = text["src/features/projects/pages/Projects.tsx"] ?? "";
const workspace = text["src/features/projects/pages/Workspace.tsx"] ?? "";
const buildLoop = text["src/features/build-loop/pages/BuildLoopDashboard.tsx"] ?? "";
const repoBuilder = text["src/features/repo-builder/pages/RepoBuilder.tsx"] ?? "";
const handoffs = text["src/features/handoffs/pages/CodexHandoffs.tsx"] ?? "";
const sessionPacks = text["src/features/session-packs/pages/SessionPacks.tsx"] ?? "";
const feedback = text["src/features/execution-feedback/pages/ExecutionFeedback.tsx"] ?? "";
const safeData = text["src/shared/lib/safeData.ts"] ?? "";
const exportHelper = text["src/shared/lib/codexSessionPackExport.ts"] ?? "";
const downloads = text["src/shared/lib/downloads.ts"] ?? "";
const pkg = text["package.json"] ?? "";
const mustContain = [
[routes, "/start", "Unified Start route must be registered."],
[routes, "/projects/new", "Projects/new compatibility route must remain registered."],
[routes, "/projects/:projectId/workspace", "Workspace route missing."],
[routes, "/projects/:projectId/source-library", "Source Library route missing."],
[routes, "/projects/:projectId/skills", "Skill Library route missing."],
[routes, "/projects/:projectId/repo-builder", "Repo Builder route missing."],
[routes, "/projects/:projectId/handoffs", "Codex Handoffs route missing."],
[routes, "/projects/:projectId/session-packs", "Session Packs route missing."],
[routes, "/projects/:projectId/execution-feedback", "Execution Feedback route missing."],
[routes, "/projects/:projectId/build-loop", "Build Loop route missing."],
[routes, "/projects/:projectId/execution", "Execution Center route missing."],
[routes, "/projects/:projectId/history", "History route missing."],
[routes, "/projects/:projectId/audit", "Audit Center route missing."],
[routes, "/projects/:projectId/prompts", "Prompt Builder route missing."],
[navigation, "Build Loop", "Navigation must expose Build Loop."],
[navigation, "Repo Builder", "Navigation must expose Repo Builder."],
[navigation, "Codex Handoffs", "Navigation must expose Codex Handoffs."],
[navigation, "Session Packs", "Navigation must expose Session Packs."],
[navigation, "Execution Feedback", "Navigation must expose Execution Feedback."],
[flowRail, 'id: "source-library"', "Flow Rail must include Source Library."],
[flowRail, 'id: "skills"', "Flow Rail must include Skills."],
[flowRail, 'id: "repo-builder"', "Flow Rail must include Repo Builder."],
[flowRail, 'id: "handoffs"', "Flow Rail must include Handoffs."],
[flowRail, 'id: "session-packs"', "Flow Rail must include Session Packs."],
[flowRail, 'id: "execution-feedback"', "Flow Rail must include Execution Feedback."],
[flowRail, 'id: "build-loop"', "Flow Rail must include Build Loop."],
[startFlow, "/start?path=no-idea", "Start flow must support no-idea path."],
[startFlow, "/start?path=rough-idea", "Start flow must support rough-idea path."],
[startFlow, "/start?path=source-material", "Start flow must support source-material path."],
[startFlow, "/start?path=existing-repo", "Start flow must support existing-repo path."],
[startFlow, "/projects/new?source=unified-start", "Direct project setup must pass through unified project setup route."],
[projects, "/start", "Projects page must link into unified Start."],
[projects, "getProjectNextAction", "Projects page must use shared next-action helper."],
[workspace, "Build Loop", "Workspace must expose Build Loop."],
[workspace, "Session Packs", "Workspace must expose Session Packs."],
[buildLoop, "Open linked module", "Build Loop page must expose linked module navigation."],
[buildLoop, "Open Session Packs", "Build Loop page must link to Session Packs."],
[repoBuilder, "Imported repository evidence is not live filesystem truth", "Repo Builder must label imported evidence boundary."],
[handoffs, "ProjectBrain-derived impact simulation, not a live git diff", "Codex Handoffs must label simulated review boundary."],
[handoffs, "controlled input for a Codex build session", "Codex Handoffs must explain session pack boundary."],
[sessionPacks, "Download instructions/scripts ZIP", "Session Packs must expose clear instructions/scripts ZIP export."],
[sessionPacks, "Export session pack files", "Session Packs must keep individual-file export fallback."],
[sessionPacks, "ZIP export contains only", "Session Packs must explain ZIP contents."],
[feedback, "not live git truth or live filesystem truth", "Execution Feedback must label feedback truth boundary."],
[safeData, "repositoryIntakes: asArray(project.repositoryIntakes)", "safeData must normalize repository intakes."],
[safeData, "codexHandoffs: asArray(project.codexHandoffs)", "safeData must normalize Codex handoffs."],
[safeData, "handoffReviews: asArray(project.handoffReviews)", "safeData must normalize handoff reviews."],
[safeData, "codexExecutionFeedback: asArray(project.codexExecutionFeedback)", "safeData must normalize execution feedback."],
[safeData, "codexSessionPacks: asArray(project.codexSessionPacks)", "safeData must normalize session packs."],
[safeData, "repoStateSnapshots: asArray(project.repoStateSnapshots)", "safeData must normalize repo state snapshots."],
[safeData, "repoDeliveryPackages: asArray(project.repoDeliveryPackages)", "safeData must normalize delivery packages."],
[safeData, "repoWorkPlans: asArray(project.repoWorkPlans)", "safeData must normalize work plans."],
[safeData, "codexSkills: asArray(project.codexSkills)", "safeData must normalize project skill selections."],
[safeData, "hiddenHistoryEntryIds: asArray(project.hiddenHistoryEntryIds)", "safeData must normalize hidden history ids."],
[exportHelper, "zipExport: true", "Session pack manifest must state ZIP export availability."],
[exportHelper, "downloadCodexSessionPackZip", "Session pack helper must expose ZIP download."],
[exportHelper, "bundle-manifest.json", "Session pack bundle must include manifest."],
[downloads, "URL.revokeObjectURL", "Download helper must revoke Blob URLs."],
[pkg, "audit:release-consolidation", "package.json must expose release consolidation audit."],
[pkg, "check-build-loop-release-consolidation.mjs", "package.json must wire release consolidation audit."],
];
for (const [haystack, needle, message] of mustContain) {
if (!haystack.includes(needle)) failures.push(message);
}
if (sessionPacks.includes("Download Bundle / ZIP")) {
failures.push("Session Packs must not imply that a real ZIP is produced.");
}
const auditAll = JSON.parse(pkg || "{}")?.scripts?.["audit:all"] ?? "";
if (!auditAll.includes("audit:release-consolidation")) {
failures.push("audit:all must include the release consolidation audit.");
}
if (failures.length) {
console.error("ProjectBrain release consolidation readiness: FAIL");
for (const failure of failures) console.error(`- ${failure}`);
process.exit(1);
}
console.log("ProjectBrain release consolidation readiness: PASS");
@@ -0,0 +1,115 @@
#!/usr/bin/env node
import { existsSync, readFileSync } from "node:fs";
const requiredFiles = [
"src/shared/lib/buildLoopWalkthrough.ts",
"src/shared/lib/buildLoop.ts",
"src/features/build-loop/pages/BuildLoopDashboard.tsx",
"src/features/projects/pages/Projects.tsx",
"src/features/projects/pages/Workspace.tsx",
"src/features/repo-builder/pages/RepoBuilder.tsx",
"src/features/handoffs/pages/CodexHandoffs.tsx",
"src/features/execution-feedback/pages/ExecutionFeedback.tsx",
"src/features/skills/pages/SkillLibrary.tsx",
"src/features/source-library/pages/SourceLibrary.tsx",
"src/features/operations/pages/ReviewQueue.tsx",
"src/features/execution/pages/ExecutionCenter.tsx",
"src/features/history/pages/ProjectHistory.tsx",
"src/features/audit/pages/ProjectAuditCenter.tsx",
"package.json",
];
const failures = [];
for (const file of requiredFiles) {
if (!existsSync(file)) failures.push(`Missing required file: ${file}`);
}
const read = (file) => readFileSync(file, "utf8");
const text = Object.fromEntries(requiredFiles.filter(existsSync).map((file) => [file, read(file)]));
const helper = text["src/shared/lib/buildLoopWalkthrough.ts"] ?? "";
const buildLoop = text["src/features/build-loop/pages/BuildLoopDashboard.tsx"] ?? "";
const projects = text["src/features/projects/pages/Projects.tsx"] ?? "";
const workspace = text["src/features/projects/pages/Workspace.tsx"] ?? "";
const repoBuilder = text["src/features/repo-builder/pages/RepoBuilder.tsx"] ?? "";
const handoffs = text["src/features/handoffs/pages/CodexHandoffs.tsx"] ?? "";
const feedback = text["src/features/execution-feedback/pages/ExecutionFeedback.tsx"] ?? "";
const skills = text["src/features/skills/pages/SkillLibrary.tsx"] ?? "";
const sourceLibrary = text["src/features/source-library/pages/SourceLibrary.tsx"] ?? "";
const reviewQueue = text["src/features/operations/pages/ReviewQueue.tsx"] ?? "";
const execution = text["src/features/execution/pages/ExecutionCenter.tsx"] ?? "";
const history = text["src/features/history/pages/ProjectHistory.tsx"] ?? "";
const audit = text["src/features/audit/pages/ProjectAuditCenter.tsx"] ?? "";
const pkg = text["package.json"] ?? "";
const requiredSteps = [
"start-project",
"add-sources-or-repo-intake",
"review-context",
"configure-skills",
"shape-repo",
"create-delivery-package",
"create-codex-handoff",
"review-impact",
"create-session-pack",
"execute-in-codex",
"add-execution-feedback",
"continue-next-loop",
];
const mustContain = [
[helper, "getFirstTimeBuildLoopSteps", "Walkthrough helper must expose getFirstTimeBuildLoopSteps."],
[helper, "getBuildLoopEmptyState", "Walkthrough helper must expose getBuildLoopEmptyState."],
[helper, "getBuildLoopModuleExplanation", "Walkthrough helper must expose module explanations."],
[helper, "getBuildLoopStepStatus", "Walkthrough helper must expose step status."],
[helper, "getNextBeginnerAction", "Walkthrough helper must expose next beginner action."],
[helper, "formatBuildLoopWalkthroughMarkdown", "Walkthrough helper must export markdown."],
[buildLoop, "Guided walkthrough", "Build Loop page must show guided walkthrough."],
[buildLoop, "Copy walkthrough", "Build Loop page must support walkthrough export."],
[buildLoop, "getFirstTimeBuildLoopSteps", "Build Loop page must use the walkthrough helper."],
[buildLoop, "Use this when:", "Build Loop page must explain the active module."],
[workspace, "getNextBeginnerAction", "Workspace must use beginner next action."],
[projects, "Existing project memories", "Projects page must put existing project memories first."],
[projects, "Start another project through one flow", "Projects page must still route to unified Start for new projects."],
[projects, "Continue build loop", "Project cards must use harmonized build-loop CTA."],
[repoBuilder, "Repository intake", "Repo Builder must show repository intake subflow."],
[repoBuilder, "Create delivery package", "Repo Builder must show delivery-package step."],
[repoBuilder, "Continue build loop", "Repo Builder must link back to the loop."],
[handoffs, "Create handoff", "Handoffs must show create/review/run/feedback mini-flow."],
[handoffs, "Create session pack", "Handoffs must include create-session-pack action."],
[handoffs, "Run in Codex", "Handoffs must include run-in-Codex stage."],
[handoffs, "Stage:", "Handoff cards must show stage."],
[feedback, "user-provided execution evidence", "Execution Feedback must clarify evidence boundary."],
[feedback, "Linked session pack", "Execution Feedback must link session packs."],
[feedback, "Use this feedback template", "Execution Feedback must use session pack feedback templates."],
[feedback, "Create follow-up handoff", "Execution Feedback must keep follow-up handoff CTA."],
[skills, "No active skills yet", "Skill Library must explain empty active skills state."],
[sourceLibrary, "Use this when:", "Source Library must have module explanation."],
[reviewQueue, "Use this when:", "Review Queue must have module explanation."],
[execution, "Use this when:", "Execution Center must have module explanation."],
[history, "Use this when:", "History must have module explanation."],
[audit, "Use this when:", "Audit Center must have module explanation."],
[audit, "Codex session pack readiness", "Audit Center must show session pack readiness."],
[pkg, "audit:build-loop-walkthrough", "package.json must expose audit:build-loop-walkthrough."],
[pkg, "check-build-loop-walkthrough-readiness.mjs", "package.json must wire the walkthrough readiness script."],
];
for (const step of requiredSteps) {
if (!helper.includes(step)) failures.push(`Walkthrough helper missing step: ${step}`);
}
for (const [haystack, needle, message] of mustContain) {
if (!haystack.includes(needle)) failures.push(message);
}
if (!pkg.includes("audit:build-loop-walkthrough") || !pkg.includes("audit:all")) {
failures.push("audit:all must include the build-loop walkthrough readiness check.");
}
if (failures.length) {
console.error("ProjectBrain build loop walkthrough readiness: FAIL");
for (const failure of failures) console.error(`- ${failure}`);
process.exit(1);
}
console.log("ProjectBrain build loop walkthrough readiness: PASS");
+147
View File
@@ -0,0 +1,147 @@
#!/usr/bin/env node
import { existsSync, readFileSync } from "node:fs";
const requiredFiles = [
"src/shared/lib/model.ts",
"src/shared/lib/codexHandoff.ts",
"src/shared/lib/codexSessionPack.ts",
"src/shared/lib/safeData.ts",
"src/shared/api/realClient.ts",
"src/shared/hooks/useProjectData.ts",
"src/shared/contexts/ProjectContext.tsx",
"src/app/routes.tsx",
"src/shared/lib/navigation.tsx",
"src/shared/components/ProjectFlowRail.tsx",
"src/features/handoffs/pages/CodexHandoffs.tsx",
"src/features/execution-feedback/pages/ExecutionFeedback.tsx",
"src/features/repo-builder/pages/RepoBuilder.tsx",
"src/features/execution/pages/ExecutionCenter.tsx",
"src/features/prompts/pages/PromptGenerator.tsx",
"src/features/history/pages/ProjectHistory.tsx",
"src/features/audit/pages/ProjectAuditCenter.tsx",
"src/features/projects/pages/Workspace.tsx",
"package.json",
];
const failures = [];
for (const file of requiredFiles) {
if (!existsSync(file)) failures.push(`Missing required file: ${file}`);
}
const read = (file) => readFileSync(file, "utf8");
const text = Object.fromEntries(requiredFiles.filter(existsSync).map((file) => [file, read(file)]));
const model = text["src/shared/lib/model.ts"] ?? "";
const helper = text["src/shared/lib/codexHandoff.ts"] ?? "";
const sessionPackHelper = text["src/shared/lib/codexSessionPack.ts"] ?? "";
const safeData = text["src/shared/lib/safeData.ts"] ?? "";
const realClient = text["src/shared/api/realClient.ts"] ?? "";
const hooks = text["src/shared/hooks/useProjectData.ts"] ?? "";
const context = text["src/shared/contexts/ProjectContext.tsx"] ?? "";
const routes = text["src/app/routes.tsx"] ?? "";
const navigation = text["src/shared/lib/navigation.tsx"] ?? "";
const flowRail = text["src/shared/components/ProjectFlowRail.tsx"] ?? "";
const handoffPage = text["src/features/handoffs/pages/CodexHandoffs.tsx"] ?? "";
const feedbackPage = text["src/features/execution-feedback/pages/ExecutionFeedback.tsx"] ?? "";
const repoBuilder = text["src/features/repo-builder/pages/RepoBuilder.tsx"] ?? "";
const execution = text["src/features/execution/pages/ExecutionCenter.tsx"] ?? "";
const promptBuilder = text["src/features/prompts/pages/PromptGenerator.tsx"] ?? "";
const history = text["src/features/history/pages/ProjectHistory.tsx"] ?? "";
const audit = text["src/features/audit/pages/ProjectAuditCenter.tsx"] ?? "";
const workspace = text["src/features/projects/pages/Workspace.tsx"] ?? "";
const pkg = text["package.json"] ?? "";
const mustContain = [
[model, "export interface CodexHandoff", "CodexHandoff model missing."],
[model, "export interface CodexHandoffValidationItem", "CodexHandoffValidationItem model missing."],
[model, "export interface CodexHandoffRisk", "CodexHandoffRisk model missing."],
[model, "export interface CodexHandoffScope", "CodexHandoffScope model missing."],
[model, "export interface CodexHandoffQualityScore", "CodexHandoffQualityScore model missing."],
[model, "codexHandoffs?: CodexHandoff[]", "Project model must persist Codex handoffs."],
[model, "codexSessionPacks?: CodexSessionPack[]", "Project model must persist Codex session packs."],
[safeData, "codexHandoffs: asArray(project.codexHandoffs)", "Runtime normalization must preserve Codex handoffs."],
[realClient, "createHandoffBundle", "Real client must expose backend handoff bundle creation."],
[realClient, "createCodexTaskFromHandoff", "Real client must expose backend Codex task creation from handoff bundles."],
[hooks, "useProjectHandoffBundlesData", "Async hooks must expose backend handoff bundles."],
[hooks, "useProjectCodexTasksData", "Async hooks must expose backend Codex tasks."],
[helper, "generateCodexHandoffFromDeliveryPackage", "Handoff helper must generate from delivery packages."],
[helper, "generateCodexHandoffFromRepoGap", "Handoff helper must generate from repository gaps."],
[helper, "generateCodexHandoffFromWorkPlan", "Handoff helper must generate from work plans."],
[helper, "generateCodexHandoffFromRepoState", "Handoff helper must generate from repo state snapshots."],
[helper, "buildAllowedFiles", "Handoff helper must build allowed files."],
[helper, "buildForbiddenChanges", "Handoff helper must build forbidden changes."],
[helper, "buildValidationMatrix", "Handoff helper must build validation matrix."],
[helper, "buildRollbackPlan", "Handoff helper must build rollback plan."],
[helper, "buildDoneCriteria", "Handoff helper must build done criteria."],
[helper, "formatCodexHandoffMarkdown", "Handoff helper must format markdown."],
[helper, "scoreCodexHandoffQuality", "Handoff helper must score quality."],
[helper, "getHandoffWarnings", "Handoff helper must produce warnings."],
[helper, "promptInstructions", "Skill prompt instructions must be included."],
[helper, "validationCommands", "Skill validation commands must be included."],
[helper, "qualityChecklist", "Skill quality checklist must be included."],
[helper, "riskNotes", "Skill risk notes must be included."],
[helper, "This is derived from imported repository evidence, not live filesystem truth", "Repo-gap handoffs must label imported evidence boundary."],
[sessionPackHelper, "generateCodexSessionPack", "Session pack helper must generate packs from handoffs."],
[routes, "/projects/:projectId/handoffs", "Codex Handoffs route missing."],
[routes, "CodexHandoffs", "Codex Handoffs page must be imported in routes."],
[navigation, "Codex Handoffs", "Navigation must expose Codex Handoffs."],
[flowRail, 'id: "handoffs"', "Project Flow Rail must include Handoffs."],
[handoffPage, "Codex Handoffs", "Handoff page must exist."],
[handoffPage, "Live backend handoff path", "Handoff page must expose the live backend handoff bridge."],
[handoffPage, "Create live handoff", "Handoff page must support creating backend-persisted handoff bundles."],
[handoffPage, "Create Codex task", "Handoff page must support creating backend-persisted Codex tasks from handoff bundles."],
[handoffPage, "useProjectHandoffBundlesData", "Handoff page must read backend handoff bundles in real mode."],
[handoffPage, "quality", "Handoff page must show quality score."],
[handoffPage, "Allowed files", "Handoff page must show allowed files."],
[handoffPage, "Forbidden changes", "Handoff page must show forbidden changes."],
[handoffPage, "Validation matrix", "Handoff page must show validation matrix."],
[handoffPage, "Rollback", "Handoff page must show rollback."],
[handoffPage, "Generated prompt", "Handoff page must show generated prompt."],
[handoffPage, "Copy next Codex prompt", "Handoff page must support copying prompts."],
[handoffPage, "Generate impact review", "Handoff page must support impact review generation."],
[handoffPage, "ProjectBrain-derived impact simulation, not a live git diff", "Handoff page must distinguish simulated review from live git diff."],
[handoffPage, "Add execution feedback", "Handoff page must link to execution feedback intake."],
[handoffPage, "Create session pack", "Handoff page must create session packs."],
[handoffPage, "Open session pack", "Handoff page must open session packs."],
[handoffPage, "Copy session pack", "Handoff page must copy session packs."],
[handoffPage, "Download session pack", "Handoff page must download session packs."],
[handoffPage, "Download ZIP", "Handoff page must download session pack ZIP."],
[handoffPage, "Export session pack files", "Handoff page must export session pack files."],
[handoffPage, "controlled input for a Codex build session", "Handoff page must explain session pack purpose."],
[handoffPage, "View in Build Loop", "Handoff page must link to Build Loop."],
[feedbackPage, "Execution Feedback", "Execution feedback page must exist for post-handoff evidence intake."],
[repoBuilder, "Create Codex handoff", "Repo Builder must generate handoffs."],
[repoBuilder, "Create handoff from work plan", "Repo Builder must generate handoffs from work plans."],
[repoBuilder, "Create handoff from repo state", "Repo Builder must generate handoffs from repo state."],
[execution, "Codex Handoff Quality Engine", "Execution Center must show handoff engine."],
[execution, "Copy next Codex prompt", "Execution Center must copy next prompt."],
[execution, "Open Codex Handoff", "Execution Center must link to Handoffs."],
[promptBuilder, "Codex Handoff source", "Prompt Builder must expose handoff source."],
[promptBuilder, "Use handoff promptText", "Prompt Builder must use handoff promptText."],
[context, "generateCodexHandoff", "ProjectContext must expose generateCodexHandoff."],
[context, "generateCodexSessionPack", "ProjectContext must expose generateCodexSessionPack."],
[context, "archiveCodexHandoff", "ProjectContext must expose archiveCodexHandoff."],
[context, "codexHandoffItems", "Project search must index Codex handoffs."],
[history, 'kind: "handoff"', "Project History must include handoff records."],
[audit, "Codex handoff quality", "Audit Center must show handoff quality."],
[workspace, "codexHandoffCount", "Workspace must count Codex handoffs."],
[workspace, "Codex Handoffs", "Workspace must expose Codex Handoffs."],
[pkg, "audit:handoffs", "package.json must expose audit:handoffs."],
[pkg, "check-codex-handoff-readiness.mjs", "package.json must wire handoff readiness script."],
];
for (const [haystack, needle, message] of mustContain) {
if (!haystack.includes(needle)) failures.push(message);
}
if (!pkg.includes("audit:handoffs") || !pkg.includes("audit:all")) {
failures.push("audit:all must include the Codex handoff readiness check.");
}
if (failures.length) {
console.error("ProjectBrain Codex handoff readiness: FAIL");
for (const failure of failures) console.error(`- ${failure}`);
process.exit(1);
}
console.log("ProjectBrain Codex handoff readiness: PASS");
+252
View File
@@ -0,0 +1,252 @@
import fs from "node:fs";
import path from "node:path";
import process from "node:process";
const root = process.cwd();
const migrationsDir = path.join(root, "backend", "prisma", "migrations");
const schemaPath = path.join(root, "backend", "prisma", "schema.prisma");
const dockerComposePath = path.join(root, "docker-compose.yml");
const backendDockerfilePath = path.join(root, "backend", "Dockerfile");
const rootPackagePath = path.join(root, "package.json");
const workspacePath = path.join(root, "pnpm-workspace.yaml");
const backendEntrypointPath = path.join(root, "backend", "scripts", "docker-entrypoint.sh");
const systemReadinessRoutePath = path.join(root, "backend", "src", "routes", "systemReadiness.ts");
const appPath = path.join(root, "backend", "src", "app.ts");
const initiativeRoutesPath = path.join(root, "backend", "src", "routes", "initiatives.ts");
const initiativeRepoPath = path.join(root, "backend", "src", "repositories", "initiativesRepository.ts");
const initiativeSmokePath = path.join(root, "backend", "scripts", "smoke-initiatives.mjs");
const exists = (filePath) => fs.existsSync(filePath);
const read = (filePath) => fs.readFileSync(filePath, "utf8");
const fail = [];
const warn = [];
const requiredPnpmBuiltDeps = ["@prisma/client", "@prisma/engines", "@swc/core", "esbuild", "prisma"];
const parseWorkspaceAllowBuilds = (workspaceYaml) => {
const allowed = new Set();
const allowBuildsIndex = workspaceYaml.indexOf("allowBuilds:");
if (allowBuildsIndex === -1) return allowed;
const lines = workspaceYaml.slice(allowBuildsIndex).split("\n").slice(1);
for (const line of lines) {
if (/^[^\s#]/.test(line)) break;
const match = line.match(/^\s*[\'"]?([^\'":]+)[\'"]?\s*:\s*(true|false)\s*(?:#.*)?$/);
if (match && match[2] === "true") allowed.add(match[1].trim());
}
return allowed;
};
if (!exists(schemaPath)) fail.push("Missing backend/prisma/schema.prisma.");
if (!exists(migrationsDir)) fail.push("Missing backend/prisma/migrations directory.");
const migrationFolders = exists(migrationsDir)
? fs.readdirSync(migrationsDir).filter((entry) => fs.statSync(path.join(migrationsDir, entry)).isDirectory()).sort()
: [];
const migrationSql = migrationFolders.map((folder) => ({
folder,
path: path.join(migrationsDir, folder, "migration.sql"),
})).filter((item) => exists(item.path));
if (migrationSql.length === 0) {
fail.push("No Prisma migration.sql files were found.");
}
const legacyDuplicateInitMigration = "20260606200000_init_project_workspace";
if (migrationFolders.includes(legacyDuplicateInitMigration)) {
fail.push(`${legacyDuplicateInitMigration} must not exist. It is an obsolete duplicate init migration that recreates ProjectStatus and breaks fresh Docker database startup.`);
}
const initMigrations = migrationSql.filter((item) => item.folder.includes("init_project_workspace"));
if (initMigrations.length !== 1) {
fail.push(`Expected exactly one init_project_workspace migration, found ${initMigrations.length}: ${initMigrations.map((item) => item.folder).join(", ") || "none"}.`);
}
const initIndex = migrationSql.findIndex((item) => item.folder.includes("init_project_workspace"));
if (initIndex < 0) {
fail.push("No init_project_workspace migration found.");
} else if (initIndex !== 0) {
fail.push(`Init migration must be first, but it is at position ${initIndex + 1}: ${migrationSql[initIndex].folder}.`);
}
const projectTableIndex = migrationSql.findIndex((item) => read(item.path).includes('CREATE TABLE "Project"'));
const captureTableIndex = migrationSql.findIndex((item) => read(item.path).includes('CREATE TABLE "CaptureItem"'));
if (projectTableIndex < 0) fail.push('No migration creates "Project".');
if (captureTableIndex >= 0 && projectTableIndex >= 0 && projectTableIndex > captureTableIndex) {
fail.push('Migration order is invalid: "CaptureItem" appears before "Project".');
}
const schema = exists(schemaPath) ? read(schemaPath) : "";
const duplicateFields = [];
const modelBlocks = [...schema.matchAll(/model\s+(\w+)\s+\{([\s\S]*?)\n\}/g)];
for (const [, modelName, body] of modelBlocks) {
const seen = new Set();
for (const rawLine of body.split("\n")) {
const line = rawLine.trim();
if (!line || line.startsWith("@@") || line.startsWith("//")) continue;
const fieldName = line.split(/\s+/)[0];
if (!fieldName || fieldName.startsWith("@")) continue;
if (seen.has(fieldName)) duplicateFields.push(`${modelName}.${fieldName}`);
seen.add(fieldName);
}
}
if (duplicateFields.length > 0) fail.push(`Prisma schema contains duplicate field definitions: ${duplicateFields.join(", ")}.`);
if (!schema.includes("linux-musl-openssl-3.0.x")) fail.push("Prisma generator must include linux-musl-openssl-3.0.x binaryTarget for the Docker Alpine runtime.");
for (const model of ["Project", "CaptureItem", "AcceptedTruthItem", "CurrentStateReport", "DocumentationItem", "BlueprintGraph", "BlueprintNode", "BlueprintNodeRun", "BrainstormSession", "Initiative"]) {
if (!schema.includes(`model ${model} `)) fail.push(`Schema is missing model ${model}.`);
}
const migrationText = migrationSql.map((item) => read(item.path)).join("\n");
for (const table of ["Project", "CaptureItem", "AcceptedTruthItem", "CurrentStateReport", "DocumentationItem", "BlueprintGraph", "BlueprintNode", "BlueprintNodeRun", "BrainstormSession", "Initiative"]) {
if (!migrationText.includes(`"${table}"`)) fail.push(`Migrations do not reference table ${table}.`);
}
if (exists(rootPackagePath)) {
const rootPackage = JSON.parse(read(rootPackagePath));
if (rootPackage.packageManager !== "pnpm@11.5.2") {
fail.push("Root package.json must pin packageManager to pnpm@11.5.2 for reproducible Docker/Corepack builds.");
}
if (rootPackage.pnpm) {
fail.push("Root package.json must not contain a legacy pnpm field; pnpm 11 ignores it and emits Docker build warnings. Keep approvals in pnpm-workspace.yaml allowBuilds.");
}
} else {
fail.push("Missing root package.json.");
}
if (exists(workspacePath)) {
const workspaceAllowedBuilds = parseWorkspaceAllowBuilds(read(workspacePath));
for (const dependency of requiredPnpmBuiltDeps) {
if (!workspaceAllowedBuilds.has(dependency)) {
fail.push(`pnpm-workspace.yaml allowBuilds must set ${dependency}: true for Docker installs with pnpm 11.`);
}
}
} else {
fail.push("Missing pnpm-workspace.yaml.");
}
if (exists(dockerComposePath)) {
const compose = read(dockerComposePath);
if (!compose.includes("projectbrain-postgres")) fail.push("docker-compose.yml does not define projectbrain-postgres.");
if (!compose.includes("PROJECTBRAIN_DATA_SOURCE")) fail.push("docker-compose.yml does not configure PROJECTBRAIN_DATA_SOURCE.");
if (!compose.includes("VITE_PROJECTBRAIN_CLIENT_MODE")) warn.push("docker-compose.yml does not pass VITE_PROJECTBRAIN_CLIENT_MODE to frontend build args.");
if (!compose.includes("PROJECTBRAIN_SEED_ON_START")) warn.push("docker-compose.yml does not expose PROJECTBRAIN_SEED_ON_START.");
if (!compose.includes("/api/system/readiness")) fail.push("docker-compose.yml does not healthcheck /api/system/readiness.");
if (!compose.includes("condition: service_healthy")) fail.push("docker-compose.yml does not wait for healthy backend before frontend startup.");
} else {
fail.push("Missing docker-compose.yml.");
}
if (exists(backendDockerfilePath)) {
const dockerfile = read(backendDockerfilePath);
if (!dockerfile.includes("prisma:generate")) fail.push("Backend Dockerfile does not run prisma:generate during build.");
if (!dockerfile.includes("docker-entrypoint.sh")) fail.push("Backend Dockerfile does not use the database-aware docker entrypoint.");
if (!dockerfile.includes("/api/system/readiness")) fail.push("Backend Dockerfile does not expose a system readiness healthcheck.");
if (!dockerfile.includes("apk add --no-cache") || !dockerfile.includes("openssl")) fail.push("Backend Dockerfile does not install OpenSSL; Prisma migrations fail at runtime on Alpine without it.");
if (!dockerfile.includes("libc6-compat")) fail.push("Backend Dockerfile does not install libc6-compat for native engine compatibility on Alpine.");
} else {
fail.push("Missing backend/Dockerfile.");
}
if (exists(backendEntrypointPath)) {
const entrypoint = read(backendEntrypointPath);
if (!entrypoint.includes("prisma migrate deploy")) fail.push("Backend entrypoint does not run prisma migrate deploy.");
if (!entrypoint.includes("PROJECTBRAIN_SEED_ON_START")) fail.push("Backend entrypoint does not support PROJECTBRAIN_SEED_ON_START.");
if (!entrypoint.includes("20260606200000_init_project_workspace")) fail.push("Backend entrypoint must guard against the obsolete duplicate init migration when older overlay deployments left it on disk or in _prisma_migrations.");
if (!entrypoint.includes("prisma migrate resolve --rolled-back 20260606200000_init_project_workspace")) fail.push("Backend entrypoint must resolve failed obsolete duplicate init migration records as rolled back before migrate deploy.");
} else {
fail.push("Missing backend/scripts/docker-entrypoint.sh.");
}
if (!exists(systemReadinessRoutePath)) {
fail.push("Missing backend/src/routes/systemReadiness.ts live readiness route.");
} else {
const route = read(systemReadinessRoutePath);
if (!route.includes('/api/system/readiness')) fail.push("System readiness route does not expose /api/system/readiness.");
if (!route.includes('prisma.$queryRaw`SELECT 1`')) fail.push("System readiness route does not run a live Prisma SELECT 1 probe.");
}
if (exists(appPath)) {
const app = read(appPath);
if (!app.includes("registerSystemReadinessRoutes")) fail.push("Fastify app does not register system readiness routes.");
if (!app.includes("registerBrainstormRoutes")) fail.push("Fastify app does not register Brainstorm CPU routes.");
if (!app.includes("registerInitiativeRoutes")) fail.push("Fastify app does not register Initiative routes.");
} else {
fail.push("Missing backend/src/app.ts.");
}
if (!exists(initiativeRoutesPath)) {
fail.push("Missing backend/src/routes/initiatives.ts.");
} else {
const routes = read(initiativeRoutesPath);
for (const signal of [
"/api/projects/:projectId/initiatives",
"/api/projects/:projectId/initiatives/:initiativeId",
"/api/projects/:projectId/initiatives/:initiativeId/feature-candidates",
]) {
if (!routes.includes(signal)) fail.push(`Initiative routes are missing ${signal}.`);
}
}
if (!exists(initiativeRepoPath)) {
fail.push("Missing backend/src/repositories/initiativesRepository.ts.");
} else {
const repo = read(initiativeRepoPath);
for (const symbol of ["createInitiativeForProject", "updateInitiativeForProject", "getInitiativeForProject", "createFeatureCandidatesForInitiative", "syncInitiativeReviewAction"]) {
if (!repo.includes(symbol)) fail.push(`Initiative repository is missing ${symbol}.`);
}
}
if (!exists(initiativeSmokePath)) fail.push("Missing backend/scripts/smoke-initiatives.mjs.");
else {
const smoke = read(initiativeSmokePath);
if (!smoke.includes("/review-queue/${firstCandidate.reviewItemId}/accept")) fail.push("Initiative smoke does not validate review accept materialization.");
if (!smoke.includes("initiativeSync")) fail.push("Initiative smoke does not assert initiativeSync output.");
}
const result = {
status: fail.length === 0 ? "PASS" : "FAIL",
migrationRoots: [path.relative(root, migrationsDir).replaceAll("\\", "/")],
migrations: migrationSql.map((item) => item.folder),
initMigration: initIndex >= 0 ? migrationSql[initIndex]?.folder : null,
checks: {
projectTableBeforeCapture: projectTableIndex >= 0 && (captureTableIndex < 0 || projectTableIndex < captureTableIndex),
backendEntrypoint: exists(backendEntrypointPath),
dockerCompose: exists(dockerComposePath),
schema: exists(schemaPath),
systemReadinessRoute: exists(systemReadinessRoutePath),
legacyDuplicateInitMigrationAbsent: !migrationFolders.includes("20260606200000_init_project_workspace"),
exactlyOneInitMigration: initMigrations.length === 1,
initiativeRoutes: exists(initiativeRoutesPath),
initiativeSmoke: exists(initiativeSmokePath),
duplicateSchemaFields: duplicateFields.length === 0,
prismaAlpineBinaryTarget: schema.includes("linux-musl-openssl-3.0.x"),
backendDockerfileInstallsOpenSSL: exists(backendDockerfilePath) && read(backendDockerfilePath).includes("openssl"),
pnpmDockerBuildScriptsApproved: exists(workspacePath) && (() => {
const allowedBuilds = parseWorkspaceAllowBuilds(read(workspacePath));
return requiredPnpmBuiltDeps.every((dependency) => allowedBuilds.has(dependency));
})(),
},
warnings: warn,
failures: fail,
};
if (process.argv.includes("--json")) {
console.log(JSON.stringify(result, null, 2));
} else {
console.log(`ProjectBrain database readiness: ${result.status}`);
console.log(`Migration roots checked: ${result.migrationRoots.join(", ")}`);
console.log(`Migrations: ${migrationSql.length}`);
console.log(`Init migration: ${result.initMigration ?? "missing"}`);
if (warn.length > 0) {
console.log("Warnings:");
for (const item of warn) console.log(`- ${item}`);
}
if (fail.length > 0) {
console.log("Failures:");
for (const item of fail) console.log(`- ${item}`);
}
}
if (fail.length > 0) process.exit(1);
+23
View File
@@ -0,0 +1,23 @@
#!/usr/bin/env node
import { readFile, readdir, stat } from "node:fs/promises";
import { dirname, resolve, relative } from "node:path";
import process from "node:process";
const root = resolve(import.meta.dirname, "..");
const docsRoot = resolve(root, "docs");
const walk = async (directory) => (await Promise.all((await readdir(directory, { withFileTypes: true })).map((entry) => entry.isDirectory() ? walk(resolve(directory, entry.name)) : [resolve(directory, entry.name)]))).flat();
const files = (await walk(docsRoot)).filter((path) => path.endsWith(".md") && !path.includes(`${resolve(docsRoot, "archive")}\\`) && !path.includes(`${resolve(docsRoot, "archive")}/`));
const failures = [];
for (const file of files) {
const source = await readFile(file, "utf8");
const links = [...source.matchAll(/\[[^\]]*\]\(([^)]+)\)/g)].map((match) => match[1].trim().replace(/^<|>$/g, ""));
for (const link of links) {
if (!link || /^(https?:|mailto:|#)/i.test(link)) continue;
const targetPart = decodeURIComponent(link.split("#")[0]);
if (!targetPart) continue;
const target = resolve(dirname(file), targetPart);
try { await stat(target); } catch { failures.push(`${relative(root, file)} -> ${link}`); }
}
}
if (failures.length) { console.error(`Broken active documentation links:\n- ${failures.join("\n- ")}`); process.exit(1); }
console.log(`Documentation link audit passed for ${files.length} active Markdown files.`);
+148
View File
@@ -0,0 +1,148 @@
#!/usr/bin/env node
import fs from "node:fs";
import path from "node:path";
const root = process.cwd();
const failures = [];
const warnings = [];
const read = (relativePath) => fs.readFileSync(path.join(root, relativePath), "utf8");
const exists = (relativePath) => fs.existsSync(path.join(root, relativePath));
const requiredBuiltDependencies = ["@prisma/client", "@prisma/engines", "@swc/core", "esbuild", "prisma"];
const parseWorkspaceAllowBuilds = (workspaceYaml) => {
const allowed = new Set();
const allowBuildsIndex = workspaceYaml.indexOf("allowBuilds:");
if (allowBuildsIndex === -1) return allowed;
const afterAllowBuilds = workspaceYaml.slice(allowBuildsIndex).split("\n").slice(1);
for (const line of afterAllowBuilds) {
if (/^[^\s#]/.test(line)) break;
const match = line.match(/^\s*[\'"]?([^\'":]+)[\'"]?\s*:\s*(true|false)\s*(?:#.*)?$/);
if (match && match[2] === "true") allowed.add(match[1].trim());
}
return allowed;
};
if (!exists("package.json")) {
failures.push("Missing package.json.");
} else {
const rootPackage = JSON.parse(read("package.json"));
if (rootPackage.packageManager !== "pnpm@11.5.2") {
failures.push("package.json must pin packageManager to pnpm@11.5.2 for reproducible Docker/Corepack installs.");
}
if (rootPackage.pnpm) {
failures.push("package.json must not contain a legacy pnpm field; pnpm 11 ignores it and emits noisy warnings during Docker builds. Keep build approvals in pnpm-workspace.yaml allowBuilds.");
}
}
if (!exists("pnpm-workspace.yaml")) {
failures.push("Missing pnpm-workspace.yaml; pnpm 11 Docker builds need workspace-level allowBuilds.");
} else {
const workspaceYaml = read("pnpm-workspace.yaml");
const workspaceAllowedBuilds = parseWorkspaceAllowBuilds(workspaceYaml);
if (!workspaceYaml.includes("fetchWarnTimeout: 120000")) {
warnings.push("pnpm-workspace.yaml should set fetchWarnTimeout: 120000 for quiet Docker registry fetch logs.");
}
for (const dependency of requiredBuiltDependencies) {
if (!workspaceAllowedBuilds.has(dependency)) {
failures.push(`pnpm-workspace.yaml allowBuilds must set ${dependency}: true for non-interactive pnpm 11 Docker installs.`);
}
}
}
for (const dockerfilePath of ["backend/Dockerfile", "Dockerfile.frontend"]) {
if (!exists(dockerfilePath)) {
failures.push(`Missing ${dockerfilePath}.`);
continue;
}
const dockerfile = read(dockerfilePath);
if (!dockerfile.includes("corepack enable")) warnings.push(`${dockerfilePath} does not explicitly enable Corepack.`);
if (!dockerfile.includes("ENV CI=true")) warnings.push(`${dockerfilePath} should set CI=true to suppress interactive package-manager notices during Docker builds.`);
if (!dockerfile.includes("COREPACK_ENABLE_DOWNLOAD_PROMPT=0")) warnings.push(`${dockerfilePath} should suppress Corepack download prompts for clean non-interactive Docker logs.`);
if (!dockerfile.includes("npm_config_fetch_warn_timeout=60000")) warnings.push(`${dockerfilePath} should raise npm_config_fetch_warn_timeout to avoid noisy slow-registry warnings in Docker logs.`);
if (!dockerfile.includes("pnpm install --frozen-lockfile")) failures.push(`${dockerfilePath} does not run pnpm install with a frozen lockfile.`);
if (!dockerfile.includes('"[W" "ARN] Request took"')) failures.push(`${dockerfilePath} must filter pnpm slow-registry timing warnings from successful Docker install logs.`);
}
if (exists(".dockerignore")) {
const dockerignore = read(".dockerignore");
if (!dockerignore.includes("backend/prisma/migrations/20260606200000_init_project_workspace")) {
failures.push(".dockerignore must exclude the obsolete duplicate init migration so update overlays cannot copy it into Docker images.");
}
} else {
failures.push("Missing .dockerignore.");
}
if (exists("backend/scripts/docker-entrypoint.sh")) {
const entrypoint = read("backend/scripts/docker-entrypoint.sh");
if (!entrypoint.includes("20260606200000_init_project_workspace")) {
failures.push("backend/scripts/docker-entrypoint.sh must guard against the obsolete duplicate init migration at runtime.");
}
if (!entrypoint.includes("prisma migrate resolve --rolled-back 20260606200000_init_project_workspace")) {
failures.push("backend/scripts/docker-entrypoint.sh must recover failed obsolete duplicate init migration records with prisma migrate resolve --rolled-back.");
}
}
if (exists("backend/prisma/migrations/20260606200000_init_project_workspace")) {
failures.push("backend/prisma/migrations/20260606200000_init_project_workspace must be deleted from source; it duplicates the init migration and breaks fresh database startup.");
}
if (exists("backend/Dockerfile")) {
const backendDockerfile = read("backend/Dockerfile");
if (!backendDockerfile.includes("pnpm run prisma:generate")) failures.push("backend/Dockerfile must generate Prisma client after install and copy.");
if (!backendDockerfile.includes("scripts/docker-entrypoint.sh")) failures.push("backend/Dockerfile must use the database-aware entrypoint.");
if (!backendDockerfile.includes("apk add --no-cache") || !backendDockerfile.includes("openssl")) failures.push("backend/Dockerfile must install openssl in the Alpine image so Prisma schema engines can run during migrate deploy.");
if (!backendDockerfile.includes("libc6-compat")) warnings.push("backend/Dockerfile should install libc6-compat for native engine compatibility on Alpine.");
if (!backendDockerfile.includes("PRISMA_SKIP_POSTINSTALL_GENERATE=true")) warnings.push("backend/Dockerfile should skip Prisma postinstall generate and run the explicit prisma:generate step after the schema is copied.");
}
if (exists("backend/prisma/schema.prisma")) {
const prismaSchema = read("backend/prisma/schema.prisma");
if (!prismaSchema.includes("linux-musl-openssl-3.0.x")) failures.push("Prisma generator must include linux-musl-openssl-3.0.x binaryTarget for the Node Alpine Docker runtime.");
}
if (exists("backend/src/services/blueprintEngine.ts")) {
const backendBlueprintEngine = read("backend/src/services/blueprintEngine.ts");
if (!backendBlueprintEngine.includes("const archetypes: Archetype[]")) {
failures.push("backend/src/services/blueprintEngine.ts must type archetypes as Archetype[] so cluster includes checks accept every BlueprintClusterIdDto during backend builds.");
}
if (backendBlueprintEngine.includes("type Archetype = (typeof archetypes)[number]")) {
failures.push("backend/src/services/blueprintEngine.ts must not infer Archetype from the const archetype array; that narrows clusters per object and breaks Docker backend tsc.");
}
}
const result = {
status: failures.length === 0 ? "PASS" : "FAIL",
migrationRoots: ["backend/prisma/migrations"],
requiredBuiltDependencies,
workspaceAllowBuilds: exists("pnpm-workspace.yaml") ? Array.from(parseWorkspaceAllowBuilds(read("pnpm-workspace.yaml"))).sort() : [],
opensslRuntime: exists("backend/Dockerfile") ? read("backend/Dockerfile").includes("openssl") : false,
prismaMuslTarget: exists("backend/prisma/schema.prisma") ? read("backend/prisma/schema.prisma").includes("linux-musl-openssl-3.0.x") : false,
obsoleteDuplicateInitMigrationExcluded: exists(".dockerignore") ? read(".dockerignore").includes("backend/prisma/migrations/20260606200000_init_project_workspace") : false,
obsoleteDuplicateInitMigrationAbsent: !exists("backend/prisma/migrations/20260606200000_init_project_workspace"),
failures,
warnings,
};
if (process.argv.includes("--json")) {
console.log(JSON.stringify(result, null, 2));
} else {
console.log(`ProjectBrain Docker build readiness: ${result.status}`);
console.log(`Migration roots checked: ${result.migrationRoots.join(", ")}`);
console.log(`Approved build dependencies required: ${requiredBuiltDependencies.join(", ")}`);
console.log(`Workspace allowBuilds: ${exists("pnpm-workspace.yaml") ? Array.from(parseWorkspaceAllowBuilds(read("pnpm-workspace.yaml"))).sort().join(", ") : "missing"}`);
console.log(`Backend OpenSSL runtime: ${exists("backend/Dockerfile") && read("backend/Dockerfile").includes("openssl") ? "yes" : "no"}`);
console.log(`Prisma Alpine binary target: ${exists("backend/prisma/schema.prisma") && read("backend/prisma/schema.prisma").includes("linux-musl-openssl-3.0.x") ? "yes" : "no"}`);
console.log(`Obsolete duplicate init migration excluded: ${exists(".dockerignore") && read(".dockerignore").includes("backend/prisma/migrations/20260606200000_init_project_workspace") ? "yes" : "no"}`);
if (warnings.length > 0) {
console.log("Warnings:");
for (const warning of warnings) console.log(`- ${warning}`);
}
if (failures.length > 0) {
console.log("Failures:");
for (const failure of failures) console.log(`- ${failure}`);
}
}
if (failures.length > 0) process.exit(1);
@@ -0,0 +1,63 @@
import { existsSync, readFileSync } from "node:fs";
const requiredFiles = [
"src/features/execution/pages/ExecutionCenter.tsx",
"src/features/execution-feedback/pages/ExecutionFeedback.tsx",
"src/app/routes.tsx",
"src/shared/lib/navigation.tsx",
"src/shared/components/ProjectFlowRail.tsx",
"src/features/projects/pages/Workspace.tsx",
"package.json",
];
const fail = (message) => {
console.error(`ProjectBrain execution center readiness: FAIL - ${message}`);
process.exit(1);
};
for (const file of requiredFiles) {
if (!existsSync(file)) fail(`missing ${file}`);
}
const read = (file) => readFileSync(file, "utf8");
const page = read("src/features/execution/pages/ExecutionCenter.tsx");
const routes = read("src/app/routes.tsx");
const nav = read("src/shared/lib/navigation.tsx");
const rail = read("src/shared/components/ProjectFlowRail.tsx");
const workspace = read("src/features/projects/pages/Workspace.tsx");
const pkg = read("package.json");
if (!routes.includes('/projects/:projectId/execution')) fail("execution route is not registered");
if (!routes.includes('ExecutionCenter')) fail("ExecutionCenter page is not imported in routes");
if (!nav.includes('Execution Center')) fail("Execution Center is missing from navigation");
if (!nav.includes('/execution')) fail("execution navigation path is missing");
if (!rail.includes('id: "execution"')) fail("project flow rail lacks execution step");
if (!workspace.includes('Open Execution') || !workspace.includes('Execution Center')) fail("workspace lacks execution shortcuts");
if (!page.includes('RuntimeExecutionQueue')) fail("execution page lacks runtime execution queue");
if (!page.includes('RuntimeHandoffBuilder') || !page.includes('RuntimeContextPromptPilot')) fail("execution page lacks handoff/prompt bridge");
if (!page.includes('generateFirstImplementationTask')) fail("execution page cannot create the first implementation task");
if (!page.includes('downloadText') || !page.includes('navigator.clipboard')) fail("execution page lacks copy/download brief support");
if (!page.includes('Review-gated execution inputs')) fail("execution page does not expose review-gated inputs");
if (!page.includes('acceptedStarterReviews')) fail("execution page does not distinguish accepted starter reviews");
if (!page.includes('Active Codex Skills')) fail("execution page does not show active Codex skills");
if (!page.includes('formatSkillsForCodexHandoff')) fail("execution brief does not include skill blocks");
if (!page.includes('QA/QC skill is active')) fail("execution readiness does not check QA/QC skill coverage");
if (!page.includes('Codex Handoff Quality Engine')) fail("execution page does not show Codex handoff quality engine");
if (!page.includes('Codex Session Pack')) fail("execution page does not show Codex session pack.");
if (!page.includes('Run in Codex')) fail("execution page must expose Run in Codex action.");
if (!page.includes('Download ZIP')) fail("execution page does not expose session pack ZIP action.");
if (!page.includes('/session-packs')) fail("execution page does not link to Session Packs.");
if (!page.includes('Copy next Codex prompt')) fail("execution page cannot copy next Codex prompt");
if (!page.includes('Open Codex Handoff')) fail("execution page does not link to Codex handoffs");
if (!page.includes('Review impact')) fail("execution page does not expose handoff impact review");
if (!page.includes('Recommended to approve the impact review before executing this prompt')) fail("execution page must warn before unapproved prompt execution");
if (!page.includes('Codex Execution Feedback')) fail("execution page must show execution feedback status");
if (!page.includes('View build loop')) fail("execution page must link to Build Loop");
if (!routes.includes('/projects/:projectId/execution-feedback')) fail("execution feedback route is not registered");
if (!routes.includes('/projects/:projectId/build-loop')) fail("build loop route is not registered");
if (!pkg.includes('audit:execution-feedback')) fail("package scripts lack audit:execution-feedback");
if (!pkg.includes('audit:build-loop')) fail("package scripts lack audit:build-loop");
if (!pkg.includes('audit:execution-center')) fail("package scripts lack audit:execution-center");
if (!pkg.includes('audit:history-center') || !pkg.includes('audit:source-library') || !pkg.includes('audit:guided-start') || !pkg.includes('audit:skills') || !pkg.includes('audit:repository-intake') || !pkg.includes('audit:handoffs') || !pkg.includes('audit:handoff-review') || !pkg.includes('audit:execution-feedback') || !pkg.includes('audit:build-loop')) fail("audit chain lost previous ProjectBrain gates");
console.log("ProjectBrain execution center readiness: PASS");
@@ -0,0 +1,133 @@
#!/usr/bin/env node
import { existsSync, readFileSync } from "node:fs";
const requiredFiles = [
"src/shared/lib/model.ts",
"src/shared/lib/codexExecutionFeedback.ts",
"src/shared/lib/safeData.ts",
"src/shared/api/realClient.ts",
"src/shared/hooks/useProjectData.ts",
"src/shared/contexts/ProjectContext.tsx",
"src/app/routes.tsx",
"src/shared/lib/navigation.tsx",
"src/shared/components/ProjectFlowRail.tsx",
"src/features/execution-feedback/pages/ExecutionFeedback.tsx",
"src/features/handoffs/pages/CodexHandoffs.tsx",
"src/features/execution/pages/ExecutionCenter.tsx",
"src/features/repo-builder/pages/RepoBuilder.tsx",
"src/features/history/pages/ProjectHistory.tsx",
"src/features/audit/pages/ProjectAuditCenter.tsx",
"src/features/projects/pages/Workspace.tsx",
"src/shared/lib/projectStartFlow.ts",
"package.json",
];
const failures = [];
for (const file of requiredFiles) {
if (!existsSync(file)) failures.push(`Missing required file: ${file}`);
}
const read = (file) => readFileSync(file, "utf8");
const text = Object.fromEntries(requiredFiles.filter(existsSync).map((file) => [file, read(file)]));
const model = text["src/shared/lib/model.ts"] ?? "";
const helper = text["src/shared/lib/codexExecutionFeedback.ts"] ?? "";
const safeData = text["src/shared/lib/safeData.ts"] ?? "";
const realClient = text["src/shared/api/realClient.ts"] ?? "";
const hooks = text["src/shared/hooks/useProjectData.ts"] ?? "";
const context = text["src/shared/contexts/ProjectContext.tsx"] ?? "";
const routes = text["src/app/routes.tsx"] ?? "";
const navigation = text["src/shared/lib/navigation.tsx"] ?? "";
const flowRail = text["src/shared/components/ProjectFlowRail.tsx"] ?? "";
const page = text["src/features/execution-feedback/pages/ExecutionFeedback.tsx"] ?? "";
const handoffs = text["src/features/handoffs/pages/CodexHandoffs.tsx"] ?? "";
const execution = text["src/features/execution/pages/ExecutionCenter.tsx"] ?? "";
const repoBuilder = text["src/features/repo-builder/pages/RepoBuilder.tsx"] ?? "";
const history = text["src/features/history/pages/ProjectHistory.tsx"] ?? "";
const audit = text["src/features/audit/pages/ProjectAuditCenter.tsx"] ?? "";
const workspace = text["src/features/projects/pages/Workspace.tsx"] ?? "";
const startFlow = text["src/shared/lib/projectStartFlow.ts"] ?? "";
const pkg = text["package.json"] ?? "";
const mustContain = [
[model, "export interface CodexExecutionFeedback", "CodexExecutionFeedback type missing."],
[model, "export interface CodexExecutionValidationResult", "CodexExecutionValidationResult type missing."],
[model, "export interface CodexExecutionChangedFile", "CodexExecutionChangedFile type missing."],
[model, "export type CodexExecutionOutcome", "CodexExecutionOutcome type missing."],
[model, "export interface CodexExecutionFollowUp", "CodexExecutionFollowUp type missing."],
[model, "export type CodexExecutionEvidenceStatus", "CodexExecutionEvidenceStatus type missing."],
[model, "codexExecutionFeedback?: CodexExecutionFeedback[]", "Project model must persist execution feedback."],
[safeData, "codexExecutionFeedback: asArray(project.codexExecutionFeedback)", "Runtime normalization must preserve execution feedback."],
[helper, "parseCodexExecutionReport", "Feedback parser helper missing."],
[helper, "extractChangedFilesFromReport", "Changed-file parser missing."],
[helper, "extractValidationResultsFromReport", "Validation parser missing."],
[helper, "compareFeedbackToHandoff", "Feedback-to-handoff comparison missing."],
[helper, "deriveExecutionOutcome", "Outcome derivation missing."],
[helper, "deriveResolvedGaps", "Resolved gap derivation missing."],
[helper, "deriveNewGapCandidates", "New gap candidate derivation missing."],
[helper, "deriveExecutionFollowUps", "Follow-up derivation missing."],
[helper, "formatExecutionFeedbackMarkdown", "Feedback markdown formatter missing."],
[helper, "scoreFeedbackCompleteness", "Feedback completeness scoring missing."],
[helper, "not live git truth", "Helper must state feedback is not live git truth."],
[realClient, "importCodexOutput", "Real client must expose backend Codex output import."],
[hooks, "useProjectCodexTasksData", "Async hooks must expose backend Codex tasks for feedback import."],
[hooks, "useProjectReviewQueueData", "Async hooks must expose Review Queue reload after feedback import."],
[context, "saveCodexExecutionFeedback", "ProjectContext must save feedback."],
[context, "updateCodexExecutionFeedbackStatus", "ProjectContext must update evidence status."],
[context, "archiveCodexExecutionFeedback", "ProjectContext must archive feedback."],
[context, "createFollowUpHandoffFromFeedback", "ProjectContext must create follow-up handoff from feedback."],
[context, "executionFeedbackItems", "Project search must index execution feedback."],
[routes, "/projects/:projectId/execution-feedback", "Execution Feedback route missing."],
[navigation, "Execution Feedback", "Navigation must expose Execution Feedback."],
[flowRail, 'id: "execution-feedback"', "Project Flow Rail must include feedback step."],
[page, "Intake Codex report", "Execution Feedback page must show intake form."],
[page, "Live backend Codex output import", "Execution Feedback page must expose the live backend Codex output import bridge."],
[page, "Import to backend Review Queue", "Execution Feedback page must import backend Codex output into Review Queue."],
[page, "useProjectCodexTasksData", "Execution Feedback page must read live backend Codex tasks."],
[page, "realClient.runtimeV7.importCodexOutput", "Execution Feedback page must call the backend Codex output import endpoint."],
[page, "Linked handoff", "Execution Feedback page must link handoffs."],
[page, "Linked session pack", "Execution Feedback page must link session packs."],
[page, "Session pack feedback boundary", "Execution Feedback page must show session pack feedback boundary."],
[page, "Use this feedback template", "Execution Feedback page must insert session pack feedback template."],
[page, "Download template", "Execution Feedback page must download session pack feedback template."],
[page, "Paste Codex report generated from this session pack", "Execution Feedback page must label reports from session packs."],
[page, "Parsed preview", "Execution Feedback page must show parsed preview."],
[page, "Claimed changed files", "Execution Feedback page must show changed files."],
[page, "Validation results", "Execution Feedback page must show validation results."],
[page, "Accept evidence", "Execution Feedback page must accept evidence."],
[page, "Create follow-up handoff", "Execution Feedback page must create follow-up handoffs."],
[page, "not live git truth", "Execution Feedback page must show evidence boundary."],
[page, "Return to Build Loop", "Execution Feedback page must link back to Build Loop."],
[handoffs, "Add execution feedback", "Codex Handoffs page must link feedback intake."],
[handoffs, "Create session pack", "Codex Handoffs page must create session packs."],
[handoffs, "Feedback status", "Codex Handoffs page must show feedback status."],
[execution, "Codex Execution Feedback", "Execution Center must show execution feedback."],
[execution, "Codex Session Pack", "Execution Center must show session pack status."],
[repoBuilder, "feedback records", "Repo Builder must show feedback status."],
[history, 'kind: "executionFeedback"', "Project History must include feedback records."],
[history, 'kind: "sessionPack"', "Project History must include session pack records."],
[audit, "Execution feedback intake", "Audit Center must show feedback coverage."],
[audit, "Codex session pack readiness", "Audit Center must show session pack coverage."],
[workspace, "Execution Feedback", "Workspace must expose Execution Feedback."],
[startFlow, "add-execution-feedback", "Next action must add execution feedback."],
[startFlow, "review-execution-feedback", "Next action must review execution feedback."],
[startFlow, "create-feedback-follow-up", "Next action must create feedback follow-up."],
[pkg, "audit:execution-feedback", "package.json must expose audit:execution-feedback."],
[pkg, "check-execution-feedback-readiness.mjs", "package.json must wire feedback readiness script."],
];
for (const [haystack, needle, message] of mustContain) {
if (!haystack.includes(needle)) failures.push(message);
}
if (!pkg.includes("audit:execution-feedback") || !pkg.includes("audit:all")) {
failures.push("audit:all must include the execution feedback readiness check.");
}
if (failures.length) {
console.error("ProjectBrain execution feedback readiness: FAIL");
for (const failure of failures) console.error(`- ${failure}`);
process.exit(1);
}
console.log("ProjectBrain execution feedback readiness: PASS");
@@ -0,0 +1,83 @@
#!/usr/bin/env node
import { readFileSync, existsSync } from 'node:fs';
const checks = [];
const fail = (message) => checks.push({ ok: false, message });
const pass = (message) => checks.push({ ok: true, message });
const read = (path) => readFileSync(path, 'utf8');
const boundaryPath = 'src/shared/components/RuntimeErrorBoundary.tsx';
if (existsSync(boundaryPath)) {
const boundary = read(boundaryPath);
boundary.includes('componentDidCatch') ? pass('RuntimeErrorBoundary catches render crashes') : fail('RuntimeErrorBoundary must implement componentDidCatch');
boundary.includes('Frontend runtime guard') ? pass('RuntimeErrorBoundary renders user-facing fallback') : fail('RuntimeErrorBoundary needs visible fallback copy');
boundary.includes('Copy diagnostics') && boundary.includes('navigator.clipboard.writeText')
? pass('RuntimeErrorBoundary can copy diagnostics')
: fail('RuntimeErrorBoundary must support one-click diagnostic copy');
} else {
fail('RuntimeErrorBoundary component is missing');
}
const app = read('src/App.tsx');
app.includes('<RuntimeErrorBoundary>') ? pass('App wraps routes in RuntimeErrorBoundary') : fail('App must wrap routes in RuntimeErrorBoundary');
const routes = read('src/app/routes.tsx');
const projectBrainClient = read('src/shared/api/projectBrainClient.ts');
projectBrainClient.includes('const useRealClient = false')
? pass('Synchronous projectBrainClient cannot call async realClient')
: fail('projectBrainClient must not route sync selectors to async realClient');
projectBrainClient.includes('Real HTTP calls are async')
? pass('Sync/real client boundary is documented')
: fail('Sync/real client boundary must be documented');
const projectContext = read('src/shared/contexts/ProjectContext.tsx');
projectContext.includes('toProjectArray(getProjects())')
? pass('ProjectContext normalizes project arrays')
: fail('ProjectContext must normalize getProjects result before .map');
projectContext.includes('toFeatureGroups(project)')
? pass('ProjectContext guards featureGroups access')
: fail('ProjectContext must guard featureGroups access');
projectContext.includes('realClient.projects.listProjects()') && projectContext.includes('reloadProjectData')
? pass('ProjectContext can hydrate projects from the real backend')
: fail('ProjectContext must expose a real backend project hydration path');
projectContext.includes('realClient.projects.createProject') && projectContext.includes('Project could not be saved to the backend')
? pass('ProjectContext can create projects through the real backend')
: fail('ProjectContext must create projects through the real backend in real mode');
projectContext.includes('realClient.workspace.updateActiveContextPack') && projectContext.includes('Active context pack could not be saved to the backend')
? pass('ProjectContext can persist active context pack changes')
: fail('ProjectContext must persist active context pack changes in real mode');
projectContext.includes('realClient.workspace.getWorkspaceState(projectId)') && projectContext.includes('realClient.projectDNA.getProjectDNA(projectId)')
? pass('ProjectContext hydrates workspace and DNA with project data')
: fail('ProjectContext must hydrate workspace and DNA in real project mode');
projectContext.includes('projectDataMode: "local" | "real"') && projectContext.includes('projectDataLoading') && projectContext.includes('projectDataError')
? pass('ProjectContext exposes data mode, loading and error state')
: fail('ProjectContext must expose project data mode, loading and error state');
routes.includes('ProjectRouteSync') && routes.includes('routeProjectExists') && routes.includes('setActiveProjectId(routeProjectId)')
? pass('Project routes follow known project route changes')
: fail('Project route sync must follow known project route changes without activating unknown slugs');
const projectsPage = read('src/features/projects/pages/Projects.tsx');
projectsPage.includes('Array.isArray(project.featureGroups)')
? pass('Projects page guards featureGroups before stats')
: fail('Projects page must guard featureGroups before stats');
projectsPage.includes('project.workspaceState?.focusAreas?.length')
? pass('Projects page guards focusAreas length')
: fail('Projects page must guard focusAreas length');
projectsPage.includes('Database projects') && projectsPage.includes('Reload projects')
? pass('Projects page exposes real data mode and reload affordance')
: fail('Projects page must expose real data mode and reload affordance');
const workspacePage = read('src/features/projects/pages/Workspace.tsx');
workspacePage.includes('Database-backed project context') && workspacePage.includes('Reload project')
? pass('Workspace exposes real project context and reload affordance')
: fail('Workspace must expose real project context and reload affordance');
const failures = checks.filter((check) => !check.ok);
if (failures.length) {
console.error('ProjectBrain frontend runtime readiness: FAIL');
for (const check of checks) console.error(`${check.ok ? '✓' : '✗'} ${check.message}`);
process.exit(1);
}
console.log('ProjectBrain frontend runtime readiness: PASS');
for (const check of checks) console.log(`✓ ${check.message}`);
+90
View File
@@ -0,0 +1,90 @@
#!/usr/bin/env node
import fs from "node:fs";
import path from "node:path";
const root = process.cwd();
const read = (file) => fs.readFileSync(path.join(root, file), "utf8");
const exists = (file) => fs.existsSync(path.join(root, file));
const failures = [];
const requireFile = (file, reason) => {
if (!exists(file)) failures.push(`${file}: ${reason}`);
};
const requireIncludes = (file, needle, reason) => {
if (!exists(file) || !read(file).includes(needle)) failures.push(`${file}: ${reason}`);
};
const requireExcludes = (file, needle, reason) => {
if (exists(file) && read(file).includes(needle)) failures.push(`${file}: ${reason}`);
};
const requireBefore = (file, firstNeedle, secondNeedle, reason) => {
if (!exists(file)) {
failures.push(`${file}: ${reason}`);
return;
}
const content = read(file);
const firstIndex = content.indexOf(firstNeedle);
const secondIndex = content.indexOf(secondNeedle);
if (firstIndex < 0 || secondIndex < 0 || firstIndex > secondIndex) failures.push(`${file}: ${reason}`);
};
requireFile("src/features/start/pages/GuidedStart.tsx", "Guided Start page must exist.");
requireFile("src/shared/lib/guidedStart.ts", "Guided Start deterministic idea helper must exist.");
requireFile("src/shared/lib/projectStartFlow.ts", "Unified project start flow helper must exist.");
requireIncludes("src/app/routes.tsx", 'path="/start"', "Guided Start route must be registered.");
requireIncludes("src/app/routes.tsx", 'path="/projects/new"', "Project setup compatibility route must stay registered.");
requireIncludes("src/app/routes.tsx", '<GuidedStart />', "Root route should land on Guided Start.");
requireIncludes("src/app/routes.tsx", "ProjectSetupFlow", "Project setup compatibility route should use the shared setup component.");
requireIncludes("src/shared/lib/navigation.tsx", 'label: "Nieuw project"', "Navigation should expose the unified Start flow.");
requireBefore("src/shared/lib/navigation.tsx", 'label: "Projecten"', 'label: "Nieuw project"', "Navigation should list Projects before Start for returning users.");
requireIncludes("src/features/projects/pages/FirstRunProjectWizard.tsx", 'searchParams.get("projectType")', "Wizard should accept guided-start projectType prefill.");
requireIncludes("src/features/projects/pages/FirstRunProjectWizard.tsx", 'searchParams.get("firstSlice")', "Wizard should accept first-slice prefill.");
requireIncludes("src/features/projects/pages/FirstRunProjectWizard.tsx", "Project maken", "Project setup page should identify itself as part of the unified start flow.");
requireIncludes("src/features/projects/pages/FirstRunProjectWizard.tsx", "recommendation.recommendedSkills.length", "Project setup should derive execution profiles automatically.");
requireIncludes("src/shared/lib/guidedStart.ts", ".replace(/[-_/]+/g", "Guided Start should split hyphenated/slashed terms such as self-hosted and repo-builder.");
requireIncludes("src/features/start/pages/GuidedStart.tsx", 'navigate(`/projects/new?', "All new project paths should use the shared setup route.");
requireIncludes("src/features/start/pages/GuidedStart.tsx", "projectEntryPath(continuationProject.id)", "Existing project continuation should use the canonical project entry route.");
requireIncludes("src/app/routes.tsx", "resolveProjectResume", "Canonical project entry should resolve to the live overview or next required project step.");
requireIncludes("src/shared/lib/projectWizard.ts", "recommendSkillIdsForProjectProfile", "Project creation should use the Codex skill catalog for recommendations.");
requireIncludes("src/shared/lib/model.ts", "recommendedSkills?: string[]", "First-run state should persist recommended Codex skills.");
requireIncludes("src/features/projects/components/FirstRunLaunchpad.tsx", "firstImplementationTaskReady", "The guided launchpad should show status-aware first execution guidance.");
requireIncludes("src/features/projects/components/FirstRunLaunchpad.tsx", "Eerste implementatietaak maken", "Launchpad should create the first implementation task.");
requireIncludes("src/features/projects/components/FirstRunLaunchpad.tsx", "Eerste taak beoordelen", "Launchpad should become status-aware after the first task exists.");
requireIncludes("src/features/projects/components/FirstRunLaunchpad.tsx", "firstImplementationTaskReady", "The launchpad should derive first-task status from project review data.");
requireIncludes("src/shared/lib/projectFirstRunGenerationCommands.ts", "first-implementation-task-${projectId}", "First implementation task id should be stable per project.");
requireIncludes("src/shared/contexts/ProjectContext.tsx", "decideStarterReviewItem", "Starter review decisions should persist back to project data.");
requireIncludes("src/features/operations/pages/ReviewQueue.tsx", "item.attribution?.approvalState", "Review Queue should derive starter review status from project approval state.");
requireIncludes("src/features/operations/pages/ReviewQueue.tsx", "decideStarterReviewItem", "Review Queue should persist starter item actions to ProjectContext.");
requireIncludes("src/features/operations/pages/ReviewQueue.tsx", "Bij acceptatie kan dit:", "Review Queue should show an impact preview before accepting an item.");
requireIncludes("src/features/projects/pages/Projects.tsx", "Projectcatalogus", "Projects page should present existing projects as the primary returning-user catalog.");
requireIncludes("src/features/projects/pages/Projects.tsx", "Ga verder waar je stopte", "Projects page should surface the current project continuation path.");
requireIncludes("src/features/projects/pages/Projects.tsx", "Nieuw project", "Projects page should send new projects to the unified Start flow.");
requireIncludes("src/features/projects/pages/Projects.tsx", "sortedProjects", "Projects page should sort project memories by current relevance.");
requireIncludes("src/features/projects/pages/Projects.tsx", "getProjectOperatingStep", "Projects page should use shared next-action logic.");
requireIncludes("src/shared/lib/projectStartFlow.ts", "source-material", "Start flow must support source material.");
requireIncludes("src/shared/lib/projectStartFlow.ts", "existing-repo", "Start flow must support existing repositories.");
requireIncludes("src/shared/lib/projectStartFlow.ts", "direct-project", "Start flow must support direct project creation.");
requireIncludes("src/shared/lib/projectStartFlow.ts", "newProjectStartFlowEntries", "New-project entries should be separated from existing-project continuation.");
requireIncludes("src/shared/lib/projectStartFlow.ts", "Vaardigheden", "Start flow must include the Skills step.");
requireIncludes("src/shared/lib/projectStartFlow.ts", "import-repository-state", "Existing repository projects should next-action into repository intake.");
requireIncludes("src/shared/lib/projectStartFlow.ts", "Momentopname van repository maken", "Existing repository projects should progress from intake to repo state snapshot.");
requireIncludes("src/shared/lib/projectWizard.ts", "Import repository state in Repo Builder", "Project setup should mention repository intake when an existing repo is provided.");
requireIncludes("src/features/projects/pages/Workspace.tsx", "resolveProjectWorkState", "Workspace should derive continuation from canonical delivery and Auto state.");
requireIncludes("src/features/projects/pages/Workspace.tsx", "onClick={() => navigate(workState.primaryRoute)}", "Workspace should route its primary continuation through the shared operating step.");
requireIncludes("src/components/layout/Sidebar.tsx", "staticProjectStep", "Sidebar should prioritize explicit attention and defer live status to overview.");
requireIncludes("src/app/routes.tsx", "/projects/:projectId/skills", "Unified project flow should include Skill Library route.");
requireIncludes("src/shared/components/ProjectFlowRail.tsx", 'id: "skills"', "Unified project flow rail should include Codex Skills.");
requireExcludes("src/features/projects/pages/Projects.tsx", "New Project Wizard", "Projects page should not keep the old competing wizard CTA.");
requireExcludes("src/features/projects/pages/Projects.tsx", "Open Wizard", "Projects page should not keep a separate wizard CTA.");
requireExcludes("src/features/start/pages/GuidedStart.tsx", "Skip to wizard", "Guided Start should not present a separate wizard escape hatch.");
requireExcludes("src/features/brainstorm/pages/BrainstormLab.tsx", "Open wizard", "Brainstorm project conversion should open project setup, not a separate wizard concept.");
requireExcludes("src/features/brainstorm/pages/BrainstormLab.tsx", "first-run wizard", "Brainstorm copy should use unified Start/project setup language.");
requireIncludes("src/features/brainstorm/pages/BrainstormLab.tsx", "Open Review Queue", "Brainstorm review action should state that it opens Review Queue.");
requireExcludes("src/features/projects/components/FirstRunLaunchpad.tsx", "The wizard created", "Launchpad should describe project setup output instead of a separate wizard.");
requireExcludes("src/features/projects/pages/FirstRunProjectWizard.tsx", "The wizard will", "Project setup page should not reintroduce old wizard language.");
if (failures.length) {
console.error("ProjectBrain guided start readiness: FAIL");
failures.forEach((failure) => console.error(`- ${failure}`));
process.exit(1);
}
console.log("ProjectBrain guided start readiness: PASS");
+109
View File
@@ -0,0 +1,109 @@
#!/usr/bin/env node
import { existsSync, readFileSync } from "node:fs";
const requiredFiles = [
"src/shared/lib/model.ts",
"src/shared/lib/handoffReview.ts",
"src/shared/lib/safeData.ts",
"src/shared/contexts/ProjectContext.tsx",
"src/features/handoffs/pages/CodexHandoffs.tsx",
"src/features/execution-feedback/pages/ExecutionFeedback.tsx",
"src/features/execution/pages/ExecutionCenter.tsx",
"src/features/prompts/pages/PromptGenerator.tsx",
"src/features/repo-builder/pages/RepoBuilder.tsx",
"src/features/history/pages/ProjectHistory.tsx",
"src/features/audit/pages/ProjectAuditCenter.tsx",
"src/features/projects/pages/Workspace.tsx",
"src/shared/lib/projectStartFlow.ts",
"package.json",
];
const failures = [];
for (const file of requiredFiles) {
if (!existsSync(file)) failures.push(`Missing required file: ${file}`);
}
const read = (file) => readFileSync(file, "utf8");
const text = Object.fromEntries(requiredFiles.filter(existsSync).map((file) => [file, read(file)]));
const model = text["src/shared/lib/model.ts"] ?? "";
const helper = text["src/shared/lib/handoffReview.ts"] ?? "";
const safeData = text["src/shared/lib/safeData.ts"] ?? "";
const context = text["src/shared/contexts/ProjectContext.tsx"] ?? "";
const handoffs = text["src/features/handoffs/pages/CodexHandoffs.tsx"] ?? "";
const feedbackPage = text["src/features/execution-feedback/pages/ExecutionFeedback.tsx"] ?? "";
const execution = text["src/features/execution/pages/ExecutionCenter.tsx"] ?? "";
const promptBuilder = text["src/features/prompts/pages/PromptGenerator.tsx"] ?? "";
const repoBuilder = text["src/features/repo-builder/pages/RepoBuilder.tsx"] ?? "";
const history = text["src/features/history/pages/ProjectHistory.tsx"] ?? "";
const audit = text["src/features/audit/pages/ProjectAuditCenter.tsx"] ?? "";
const workspace = text["src/features/projects/pages/Workspace.tsx"] ?? "";
const startFlow = text["src/shared/lib/projectStartFlow.ts"] ?? "";
const pkg = text["package.json"] ?? "";
const mustContain = [
[model, "export interface HandoffReview", "HandoffReview type missing."],
[model, "export interface HandoffFileImpact", "HandoffFileImpact type missing."],
[model, "export interface HandoffImpactRisk", "HandoffImpactRisk type missing."],
[model, "export interface HandoffValidationCoverage", "HandoffValidationCoverage type missing."],
[model, "export type HandoffReviewDecision", "HandoffReviewDecision type missing."],
[model, "HandoffSimulationSource", "HandoffSimulationSource type missing."],
[model, "handoffReviews?: HandoffReview[]", "Project model must persist handoff reviews."],
[safeData, "handoffReviews: asArray(project.handoffReviews)", "Runtime normalization must preserve handoff reviews."],
[helper, "generateHandoffReview", "handoffReview helper must generate reviews."],
[helper, "simulateFileImpacts", "handoffReview helper must simulate file impacts."],
[helper, "classifyHandoffFileAction", "handoffReview helper must classify file actions."],
[helper, "scoreFileImpactRisk", "handoffReview helper must score file impact risk."],
[helper, "buildValidationCoverage", "handoffReview helper must build validation coverage."],
[helper, "buildRollbackCoverage", "handoffReview helper must build rollback coverage."],
[helper, "getMissingSafeguards", "handoffReview helper must list missing safeguards."],
[helper, "recommendHandoffReviewDecision", "handoffReview helper must recommend decisions."],
[helper, "formatHandoffReviewMarkdown", "handoffReview helper must format markdown."],
[helper, "not a live git diff", "Review helper must label simulation boundary."],
[context, "generateHandoffReview", "ProjectContext must expose generateHandoffReview."],
[context, "updateHandoffReviewDecision", "ProjectContext must update review decisions."],
[context, "archiveHandoffReview", "ProjectContext must archive handoff reviews."],
[context, "handoffReviewItems", "Project search must index handoff reviews."],
[handoffs, "Generate impact review", "Codex Handoffs page must generate impact reviews."],
[handoffs, "Create session pack", "Codex Handoffs page must create session packs after review."],
[handoffs, "Refresh impact review", "Codex Handoffs page must refresh impact reviews."],
[handoffs, "Copy review", "Codex Handoffs page must copy reviews."],
[handoffs, "Download review", "Codex Handoffs page must download reviews."],
[handoffs, "ProjectBrain-derived impact simulation, not a live git diff", "Codex Handoffs page must show simulation boundary."],
[handoffs, "simulatedFileImpacts", "Codex Handoffs page must show simulated file impacts."],
[handoffs, "missingSafeguards", "Codex Handoffs page must show missing safeguards."],
[handoffs, "recommendedReviewDecision", "Codex Handoffs page must show recommended decision."],
[feedbackPage, "Linked handoff", "Execution Feedback must link feedback to handoffs/reviews."],
[execution, "Review impact", "Execution Center must show review impact action."],
[execution, "Codex Session Pack", "Execution Center must show session pack handoff boundary."],
[execution, "Recommended to approve the impact review before executing this prompt", "Execution Center must warn before unapproved execution."],
[promptBuilder, "Pre-execution impact review", "Prompt Builder must include pre-execution review summary."],
[promptBuilder, "missingSafeguards", "Prompt Builder must include missing safeguards."],
[repoBuilder, "Handoff review status", "Repo Builder must show handoff review status."],
[history, 'kind: "handoffReview"', "Project History must include handoff review records."],
[history, 'kind: "sessionPack"', "Project History must include session pack records."],
[audit, "Handoff review simulation", "Audit Center must show handoff review coverage."],
[audit, "Codex session pack readiness", "Audit Center must show session pack readiness."],
[workspace, "handoffsWithoutReview", "Workspace must detect handoffs without review."],
[workspace, "fix safeguards", "Workspace must expose safeguard-fix next action."],
[startFlow, "review-handoff-impact", "Project next actions must review handoff impact."],
[startFlow, "fix-handoff-safeguards", "Project next actions must fix handoff safeguards."],
[startFlow, "execute-approved-handoff", "Project next actions must execute approved handoffs."],
[pkg, "audit:handoff-review", "package.json must expose audit:handoff-review."],
[pkg, "check-handoff-review-readiness.mjs", "package.json must wire handoff review readiness script."],
];
for (const [haystack, needle, message] of mustContain) {
if (!haystack.includes(needle)) failures.push(message);
}
if (!pkg.includes("audit:handoff-review") || !pkg.includes("audit:all")) {
failures.push("audit:all must include the handoff review readiness check.");
}
if (failures.length) {
console.error("ProjectBrain handoff review readiness: FAIL");
for (const failure of failures) console.error(`- ${failure}`);
process.exit(1);
}
console.log("ProjectBrain handoff review readiness: PASS");
@@ -0,0 +1,50 @@
import { readFileSync, existsSync } from "node:fs";
const requiredFiles = [
"src/features/history/pages/ProjectHistory.tsx",
"src/app/routes.tsx",
"src/shared/lib/navigation.tsx",
"src/shared/contexts/ProjectContext.tsx",
"src/shared/components/ProjectFlowRail.tsx",
"src/features/projects/pages/Workspace.tsx",
];
const fail = (message) => {
console.error(`ProjectBrain history center readiness: FAIL - ${message}`);
process.exit(1);
};
for (const file of requiredFiles) {
if (!existsSync(file)) fail(`missing ${file}`);
}
const read = (file) => readFileSync(file, "utf8");
const page = read("src/features/history/pages/ProjectHistory.tsx");
const routes = read("src/app/routes.tsx");
const nav = read("src/shared/lib/navigation.tsx");
const context = read("src/shared/contexts/ProjectContext.tsx");
const historyCommands = read("src/shared/lib/projectHistoryCommands.ts");
const rail = read("src/shared/components/ProjectFlowRail.tsx");
const workspace = read("src/features/projects/pages/Workspace.tsx");
const routeRegistry = read("src/shared/lib/routeRegistry.ts");
const pkg = read("package.json");
if (!routes.includes('/projects/:projectId/history')) fail("history route is not registered");
if (!routes.includes('ProjectHistory')) fail("ProjectHistory page is not imported in routes");
if (!nav.includes('getPrimaryPathNavigationItems') || !nav.includes('projectRoutePath(projectId, key)')) fail("Project activity/history is missing from primary navigation");
if (!routeRegistry.includes('key: "activity"') || !routeRegistry.includes('segment: "history"')) fail("Project activity/history is missing from the canonical route registry");
if (!nav.includes('/history')) fail("history navigation path is missing");
if (!context.includes('deleteProjectHistoryEntry')) fail("context lacks history entry removal action");
if (!historyCommands.includes('hiddenHistoryEntryIds')) fail("project hidden history ids are not persisted");
if (!page.includes('downloadText')) fail("history page lacks download support");
if (!page.includes('navigator.clipboard')) fail("history page lacks copy/export support");
if (!page.includes('Uit geschiedenis verwijderen')) fail("history page lacks remove action");
if (!page.includes('getSnapshotsForProject') || !page.includes('getBuildsForProject')) fail("history page does not include snapshots and builds");
if (!page.includes('project.sourceLibrary') || !page.includes('project.mediaAssets')) fail("history page does not include source/media history");
if (!page.includes('project.starterReviewItems') || !page.includes('project.brainstorms')) fail("history page does not include review/brainstorm history");
if (!rail.includes('id: "history"')) fail("project flow rail lacks history step");
if (!nav.includes('/history')) fail("workspace navigation lacks history access");
if (!pkg.includes('audit:history-center')) fail("package scripts lack audit:history-center");
if (!pkg.includes('audit:source-library') || !pkg.includes('audit:guided-start')) fail("audit chain lost previous ProjectBrain gates");
console.log("ProjectBrain history center readiness: PASS");
+165
View File
@@ -0,0 +1,165 @@
#!/usr/bin/env node
import { execFileSync } from 'node:child_process';
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
import { dirname, join, relative } from 'node:path';
import { fileURLToPath } from 'node:url';
const root = dirname(dirname(fileURLToPath(import.meta.url)));
const failures = [];
const warnings = [];
const ignoredArtifacts = [];
const showLocalArtifacts =
process.env.PROJECTBRAIN_AUDIT_VERBOSE_LOCAL_ARTIFACTS === '1';
const gitLines = (args) => {
try {
return execFileSync('git', args, { cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] })
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean);
} catch {
return [];
}
};
const isTracked = (path) => gitLines(['ls-files', '--', path]).length > 0;
const isIgnored = (path) => gitLines(['check-ignore', '--', path]).length > 0;
const reportArtifact = (path, kind = 'path') => {
const absolute = join(root, path);
if (!existsSync(absolute)) return;
if (isTracked(path)) {
failures.push(`Forbidden tracked release artifact/${kind} exists: ${path}`);
} else if (isIgnored(path)) {
ignoredArtifacts.push(`Ignored local artifact/${kind} present but not release-blocking: ${path}`);
} else {
failures.push(`Forbidden unignored release artifact/${kind} exists: ${path}`);
}
};
const forbiddenPaths = [
'node_modules',
'backend/node_modules',
'dist',
'backend/dist',
'checkpoints',
'src/App.css',
'src/globals.css',
];
for (const path of forbiddenPaths) {
reportArtifact(path);
}
const forbiddenFilePatterns = [/^vite-.*\.log$/, /\.zip$/i, /\.log$/i];
const rootFiles = existsSync(root) ? readdirSync(root) : [];
for (const file of rootFiles) {
if (forbiddenFilePatterns.some((pattern) => pattern.test(file))) {
reportArtifact(file, 'file');
}
}
const textFilesToScan = [
'package.json',
'vite.config.ts',
'index.html',
'README.md',
'backend/README.md',
];
const forbiddenText = [/componentTagger/i, /component-tagger/i];
for (const path of textFilesToScan) {
const absolute = join(root, path);
if (!existsSync(absolute)) {
warnings.push(`Expected scan file missing: ${path}`);
continue;
}
const content = readFileSync(absolute, 'utf8');
for (const pattern of forbiddenText) {
if (pattern.test(content)) {
failures.push(`Forbidden branding/tooling text ${pattern} found in ${path}`);
}
}
}
const expectedFiles = [
'.env.example',
'backend/.env.example',
'Dockerfile.frontend',
'backend/Dockerfile',
'docker-compose.yml',
'docker-compose.dev.yml',
'docker-compose.release.yml',
'scripts/stage-release-oci.sh',
'docs/PROJECTBRAIN_DOCUMENTATION_INDEX.md',
'docs/PROJECTBRAIN_OPERATOR_RELEASE_GUIDE.md',
'docs/FULL_VERSION_ROADMAP.md',
'docs/VERTICAL_SLICE_CORE_FLOW_PLAN.md',
'public/site.webmanifest',
'public/favicon.svg',
'public/favicon.ico',
];
for (const path of expectedFiles) {
if (!existsSync(join(root, path))) {
warnings.push(`Expected project hygiene file missing: ${path}`);
}
}
const workflowPaths = ['.gitea/workflows/ci.yml', '.gitea/workflows/release.yml'];
const workflow = workflowPaths
.map((path) => readFileSync(join(root, path), 'utf8'))
.join('\n');
for (const marker of [
'file: Dockerfile.unraid',
'Record immutable archive digests',
'Scan Unraid release image',
'archive-sha256.txt',
'trivy-unraid.json',
'VITE_PROJECTBRAIN_COMMIT=${{ github.sha }}',
'org.opencontainers.image.revision=${{ github.sha }}',
]) {
if (!workflow.includes(marker)) failures.push(`Release workflow is missing required artifact evidence marker: ${marker}`);
}
if (workflow.includes('Dockerfile.backend')) {
failures.push('Release workflow references nonexistent Dockerfile.backend; use backend/Dockerfile.');
}
if (/aquasec\/trivy(?::[\w.-]+)(?!@sha256:)/.test(workflow) || !workflow.includes('aquasec/trivy@sha256:')) {
failures.push('Trivy containers must be pinned to an immutable sha256 digest.');
}
if (/uses:\s+(?!docker:\/\/)[^\s@]+@(?![a-f0-9]{40}(?:\s|$))/.test(workflow)) {
failures.push('External workflow actions must be pinned to immutable 40-character commit SHAs.');
}
const maxLargeFileBytes = 5 * 1024 * 1024;
const ignoredDirs = new Set(['.git', 'node_modules', 'backend/node_modules']);
function walk(dir) {
for (const entry of readdirSync(dir)) {
const absolute = join(dir, entry);
const rel = relative(root, absolute).replaceAll('\\\\', '/');
if (ignoredDirs.has(rel) || ignoredDirs.has(entry)) continue;
const stat = statSync(absolute);
if (stat.isDirectory()) {
walk(absolute);
} else if (stat.size > maxLargeFileBytes) {
warnings.push(`Large file in source tree (${Math.round(stat.size / 1024 / 1024)}MB): ${rel}`);
}
}
}
walk(root);
console.log('ProjectBrain release hygiene check');
console.log('====================================');
if (ignoredArtifacts.length) {
if (showLocalArtifacts) {
console.log('\nLocal ignored artifacts:');
for (const artifact of ignoredArtifacts) console.log(`- ${artifact}`);
}
}
if (warnings.length) {
console.log('\nWarnings:');
for (const warning of warnings) console.log(`- ${warning}`);
}
if (failures.length) {
console.log('\nFailures:');
for (const failure of failures) console.log(`- ${failure}`);
process.exit(1);
}
console.log('\nPASS: no blocking release hygiene issues found.');
+156
View File
@@ -0,0 +1,156 @@
import { readFileSync, existsSync } from 'node:fs';
const requiredFiles = [
'src/features/repo-builder/pages/RepoBuilder.tsx',
'src/app/routes.tsx',
'src/shared/lib/navigation.tsx',
'src/shared/components/ProjectFlowRail.tsx',
'src/shared/contexts/ProjectContext.tsx',
'src/shared/lib/model.ts',
'src/shared/lib/safeData.ts',
'src/shared/lib/codexSkills.ts',
'src/shared/lib/repositoryIntake.ts',
'src/shared/lib/projectSearch.ts',
'src/shared/lib/projectOperatingStep.ts',
'src/features/history/pages/ProjectHistory.tsx',
'src/features/execution-feedback/pages/ExecutionFeedback.tsx',
'src/shared/lib/buildLoop.ts',
];
const failures = [];
for (const file of requiredFiles) {
if (!existsSync(file)) failures.push(`Missing required file: ${file}`);
}
const read = (file) => readFileSync(file, 'utf8');
const repoPage = read('src/features/repo-builder/pages/RepoBuilder.tsx');
const routes = read('src/app/routes.tsx');
const navigation = read('src/shared/lib/navigation.tsx');
const flowRail = read('src/shared/components/ProjectFlowRail.tsx');
const context = read('src/shared/contexts/ProjectContext.tsx');
const model = read('src/shared/lib/model.ts');
const safeData = read('src/shared/lib/safeData.ts');
const skills = read('src/shared/lib/codexSkills.ts');
const repositoryIntake = read('src/shared/lib/repositoryIntake.ts');
const projectSearch = read('src/shared/lib/projectSearch.ts');
const operatingStep = read('src/shared/lib/projectOperatingStep.ts');
const history = read('src/features/history/pages/ProjectHistory.tsx');
const pkg = read('package.json');
const mustContain = [
[repoPage, 'Repositorybasis genereren', 'Repo Builder page must generate foundation blueprints.'],
[repoPage, 'Uitbreiding uit selectie maken', 'Repo Builder must generate targeted expansion briefs.'],
[repoPage, 'Ontwerpdocumentatie voor deze uitbreiding', 'Repo Builder must explicitly use design documentation as repo-expansion input.'],
[repoPage, 'Actieve Codex-handoff', 'Repo Builder must expose an active Codex handoff.'],
[repoPage, 'Repository changes', 'Repo Builder must show file-level repository changes.'],
[repoPage, 'goedgekeurde ontwerpdocumentatie', 'Repo Builder must explain its place in the reviewed project flow.'],
[routes, '/projects/:projectId/repo-builder', 'Repo Builder route missing.'],
[navigation, 'label: "Repository"', 'Repository navigation item missing.'],
[navigation, '`${base}/repo-builder`', 'Local repository navigation must route to Repo Builder.'],
[flowRail, 'Repo Builder', 'Repo Builder flow rail step missing.'],
[flowRail, 'shape the repo', 'Flow rail must place Repo Builder logically before delivery.'],
[context, 'generateRepoBlueprint', 'Project context must expose generateRepoBlueprint.'],
[context, 'archiveRepoBlueprint', 'Project context must expose archiveRepoBlueprint.'],
[context, 'generateRepoExpansionBrief', 'Project context must expose generateRepoExpansionBrief.'],
[context, 'archiveRepoExpansionBrief', 'Project context must expose archiveRepoExpansionBrief.'],
[projectSearch, 'repoExpansionBriefItems', 'Search index must include repo expansion briefs.'],
[model, 'export interface RepoBlueprint', 'RepoBlueprint model missing.'],
[model, 'export interface RepoExpansionBrief', 'RepoExpansionBrief model missing.'],
[model, 'repoBlueprints?: RepoBlueprint[]', 'Project model must persist repo blueprints.'],
[model, 'repoExpansionBriefs?: RepoExpansionBrief[]', 'Project model must persist repo expansion briefs.'],
[safeData, 'repoBlueprints: asArray(project.repoBlueprints)', 'Runtime normalization must preserve repo blueprints.'],
[safeData, 'repoExpansionBriefs: asArray(project.repoExpansionBriefs)', 'Runtime normalization must preserve repo expansion briefs.'],
[history, 'Repo Builder / foundation', 'History must surface foundation repo blueprint entries.'],
[history, 'repoExpansionEntries', 'History must surface repo expansion brief entries.'],
[read('src/shared/lib/buildLoop.ts'), 'getBuildLoopStatus', 'Repository shaping must retain technical build-loop guidance.'],
[operatingStep, 'Nieuwe opdracht geven', 'Daily guidance must allow direct assignments.'],
[operatingStep, 'activeWork', 'Daily guidance must account for active assignments.'],
[operatingStep, 'attentionCount', 'Daily guidance must account for explicit human attention.'],
[repoPage, 'Repository-deliverypakket', 'Repo Builder must expose delivery packages as the final repo handoff layer.'],
[repoPage, 'Deliverypakket genereren', 'Repo Builder must generate Codex-ready delivery packages.'],
[repoPage, 'File manifest', 'Repo delivery packages must show a file manifest.'],
[repoPage, 'Validatieplan', 'Repo delivery packages must show a validation plan.'],
[context, 'generateRepoDeliveryPackage', 'Project context must expose generateRepoDeliveryPackage.'],
[context, 'archiveRepoDeliveryPackage', 'Project context must expose archiveRepoDeliveryPackage.'],
[projectSearch, 'repoDeliveryPackageItems', 'Search index must include repo delivery packages.'],
[model, 'export interface RepoDeliveryPackage', 'RepoDeliveryPackage model missing.'],
[model, 'repoDeliveryPackages?: RepoDeliveryPackage[]', 'Project model must persist repo delivery packages.'],
[safeData, 'repoDeliveryPackages: asArray(project.repoDeliveryPackages)', 'Runtime normalization must preserve repo delivery packages.'],
[history, 'repoDeliveryPackageEntries', 'History must surface repo delivery package entries.'],
[repoPage, 'Repositorywerkplan', 'Repo Builder must expose repo work plans as the sequencing layer.'],
[repoPage, 'Werkplan genereren', 'Repo Builder must generate sequenced repo work plans.'],
[repoPage, 'Aanbevolen volgende handoff', 'Repo work plans must show the next recommended handoff.'],
[context, 'generateRepoWorkPlan', 'Project context must expose generateRepoWorkPlan.'],
[context, 'archiveRepoWorkPlan', 'Project context must expose archiveRepoWorkPlan.'],
[projectSearch, 'repoWorkPlanItems', 'Search index must include repo work plans.'],
[model, 'export interface RepoWorkPlan', 'RepoWorkPlan model missing.'],
[model, 'repoWorkPlans?: RepoWorkPlan[]', 'Project model must persist repo work plans.'],
[safeData, 'repoWorkPlans: asArray(project.repoWorkPlans)', 'Runtime normalization must preserve repo work plans.'],
[history, 'repoWorkPlanEntries', 'History must surface repo work plan entries.'],
[repoPage, 'Repositorystatus', 'Repo Builder must expose repo state snapshots as the repository inventory layer.'],
[repoPage, 'Repositorystatus vernieuwen', 'Repo Builder must let users refresh the repo state snapshot.'],
[repoPage, 'Een afgeleide inventaris van bekende en geplande bestanden, geraakte gebieden, hiaten', 'Repo state snapshot must explain inventory, planned files, touched areas and gaps.'],
[context, 'generateRepoStateSnapshot', 'Project context must expose generateRepoStateSnapshot.'],
[context, 'archiveRepoStateSnapshot', 'Project context must expose archiveRepoStateSnapshot.'],
[projectSearch, 'repoStateSnapshotItems', 'Search index must include repo state snapshots.'],
[model, 'export interface RepoStateSnapshot', 'RepoStateSnapshot model missing.'],
[model, 'repoStateSnapshots?: RepoStateSnapshot[]', 'Project model must persist repo state snapshots.'],
[safeData, 'repoStateSnapshots: asArray(project.repoStateSnapshots)', 'Runtime normalization must preserve repo state snapshots.'],
[history, 'repoStateSnapshotEntries', 'History must surface repo state snapshot entries.'],
[repoPage, 'Skill set', 'Repo Builder must expose active project skills.'],
[repoPage, 'formatSkillsForCodexHandoff', 'Repo Builder exports must include selected skill blocks.'],
[context, 'skillIds', 'Repo generation must persist linked skill ids.'],
[model, 'skillIds?: ID[]', 'Repo artifacts must support linked skill ids.'],
[skills, 'getSkillGapsForRepoState', 'Repo state snapshots must be able to derive skill gaps.'],
[repoPage, 'Bestaande repository inventariseren', 'Repo Builder must expose existing repository intake.'],
[repoPage, 'ProjectBrain gebruikt dit als geïmporteerde informatie, niet als bewijs van de live-bestanden.', 'Repo Builder must separate imported repository evidence from live truth.'],
[repoPage, 'Uitbreidingsbrief maken', 'Repo Builder must create expansion briefs from repository gaps.'],
[repoPage, 'Deliverypakket maken', 'Repo Builder must create delivery packages from repository gaps.'],
[repositoryIntake, 'deriveRepositoryGaps', 'Repository intake helper must derive repository gaps.'],
[repositoryIntake, 'mapRepositoryEntriesToSkills', 'Repository intake helper must map imported files to Codex skills.'],
[projectSearch, 'repositoryIntakeItems', 'Search index must include repository intakes.'],
[context, 'linkedRepositoryIntakeIds', 'Repo artifacts must keep repository intake references.'],
[model, 'export interface RepositoryIntake', 'RepositoryIntake model missing.'],
[model, 'repositoryIntakes?: RepositoryIntake[]', 'Project model must persist repository intakes.'],
[safeData, 'repositoryIntakes: asArray(project.repositoryIntakes)', 'Runtime normalization must preserve repository intakes.'],
[history, 'repoIntakeEntries', 'History must surface repository intake entries.'],
[pkg, 'audit:skills', 'package.json must expose audit:skills.'],
[pkg, 'audit:repository-intake', 'package.json must expose audit:repository-intake.'],
[repoPage, 'Codex-handoff maken', 'Repo Builder must create Codex handoffs from repo artifacts.'],
[repoPage, 'Handoff vanuit werkplan maken', 'Repo Builder must create Codex handoffs from work plans.'],
[repoPage, 'Handoff vanuit status maken', 'Repo Builder must create Codex handoffs from repo state snapshots.'],
[repoPage, 'Controle van handoffs', 'Repo Builder must show handoff review status.'],
[repoPage, 'impactcontroles', 'Repo Builder must show impact review count.'],
[repoPage, 'feedbackitems', 'Repo Builder must show execution feedback count.'],
[repoPage, 'Naar de buildloop', 'Repo Builder must link to Build Loop.'],
[context, 'generateCodexHandoff', 'Project context must expose generateCodexHandoff.'],
[context, 'generateHandoffReview', 'Project context must expose generateHandoffReview.'],
[context, 'saveCodexExecutionFeedback', 'Project context must expose saveCodexExecutionFeedback.'],
[model, 'export interface CodexHandoff', 'CodexHandoff model missing.'],
[model, 'export interface HandoffReview', 'HandoffReview model missing.'],
[model, 'export interface CodexExecutionFeedback', 'CodexExecutionFeedback model missing.'],
[safeData, 'codexHandoffs: asArray(project.codexHandoffs)', 'Runtime normalization must preserve Codex handoffs.'],
[safeData, 'handoffReviews: asArray(project.handoffReviews)', 'Runtime normalization must preserve handoff reviews.'],
[safeData, 'codexExecutionFeedback: asArray(project.codexExecutionFeedback)', 'Runtime normalization must preserve execution feedback.'],
[pkg, 'audit:handoffs', 'package.json must expose audit:handoffs.'],
[pkg, 'audit:handoff-review', 'package.json must expose audit:handoff-review.'],
[pkg, 'audit:execution-feedback', 'package.json must expose audit:execution-feedback.'],
[pkg, 'audit:build-loop', 'package.json must expose audit:build-loop.'],
[pkg, 'audit:repo-builder', 'package.json must expose audit:repo-builder.'],
];
for (const [haystack, needle, message] of mustContain) {
if (!haystack.includes(needle)) failures.push(message);
}
if (!repoPage.includes('Open reviewitems blijven zichtbaar') || !repoPage.includes('alleen goedgekeurde invoer mag de uitvoering sturen') || !repoPage.includes('uiteindelijke overdrachtsgrens')) {
failures.push('Repo Builder must communicate review-gated behavior, accepted input boundaries and the final delivery-package handoff boundary.');
}
if (failures.length) {
console.error('ProjectBrain repo builder readiness: FAIL');
for (const failure of failures) console.error(`- ${failure}`);
process.exit(1);
}
console.log('ProjectBrain repo builder readiness: PASS');
@@ -0,0 +1,110 @@
#!/usr/bin/env node
import { existsSync, readFileSync } from "node:fs";
const requiredFiles = [
"src/shared/lib/model.ts",
"src/shared/lib/repositoryIntake.ts",
"src/shared/lib/safeData.ts",
"src/shared/lib/projectStartFlow.ts",
"src/shared/lib/projectWizard.ts",
"src/shared/contexts/ProjectContext.tsx",
"src/features/repo-builder/pages/RepoBuilder.tsx",
"src/features/history/pages/ProjectHistory.tsx",
"src/features/audit/pages/ProjectAuditCenter.tsx",
"src/features/projects/pages/Workspace.tsx",
"src/shared/components/ProjectFlowRail.tsx",
"package.json",
];
const failures = [];
for (const file of requiredFiles) {
if (!existsSync(file)) failures.push(`Missing required file: ${file}`);
}
const read = (file) => readFileSync(file, "utf8");
const text = Object.fromEntries(requiredFiles.filter(existsSync).map((file) => [file, read(file)]));
const model = text["src/shared/lib/model.ts"] ?? "";
const helper = text["src/shared/lib/repositoryIntake.ts"] ?? "";
const safeData = text["src/shared/lib/safeData.ts"] ?? "";
const startFlow = text["src/shared/lib/projectStartFlow.ts"] ?? "";
const wizard = text["src/shared/lib/projectWizard.ts"] ?? "";
const context = text["src/shared/contexts/ProjectContext.tsx"] ?? "";
const repoBuilder = text["src/features/repo-builder/pages/RepoBuilder.tsx"] ?? "";
const history = text["src/features/history/pages/ProjectHistory.tsx"] ?? "";
const audit = text["src/features/audit/pages/ProjectAuditCenter.tsx"] ?? "";
const workspace = text["src/features/projects/pages/Workspace.tsx"] ?? "";
const flowRail = text["src/shared/components/ProjectFlowRail.tsx"] ?? "";
const pkg = text["package.json"] ?? "";
const mustContain = [
[model, "export interface RepositoryIntake", "RepositoryIntake model missing."],
[model, "export interface RepositoryTreeEntry", "RepositoryTreeEntry model missing."],
[model, "export interface RepositoryGap", "RepositoryGap model missing."],
[model, "RepositorySkillMapping", "RepositorySkillMapping model missing."],
[model, "RepositoryImportMode", "RepositoryImportMode model missing."],
[model, "repositoryIntakes?: RepositoryIntake[]", "Project model must persist repository intakes."],
[model, "linkedRepositoryIntakeIds?: ID[]", "Repo artifacts must preserve linked repository intake ids."],
[safeData, "repositoryIntakes: asArray(project.repositoryIntakes)", "Runtime normalization must preserve repository intakes."],
[helper, "parseRepositoryTreeInput", "Repository intake parser helper missing."],
[helper, "classifyRepositoryEntry", "Repository entry classifier missing."],
[helper, "detectRepositoryStack", "Repository stack detector missing."],
[helper, "detectPackageManagers", "Package manager detector missing."],
[helper, "detectFrameworks", "Framework detector missing."],
[helper, "mapRepositoryEntriesToSkills", "Repository-to-skill mapping helper missing."],
[helper, "deriveRepositoryGaps", "Repository gap derivation helper missing."],
[helper, "formatRepositoryIntakeMarkdown", "Repository intake export formatter missing."],
[helper, "recommendRepoBuilderActionsFromIntake", "Repository intake recommendation helper missing."],
[helper, "frontend-react-vite-build", "Skill mapping must cover React/Vite evidence."],
[helper, "docker-runtime-hardening", "Skill mapping must cover Docker evidence."],
[helper, "database-migration", "Skill mapping must cover database evidence."],
[helper, "documentation-system-build", "Skill mapping must cover documentation evidence."],
[helper, "qa-qc-review", "Skill mapping must cover test evidence."],
[helper, "Imported repository evidence is not live filesystem truth", "Intake export must label imported evidence as non-live truth."],
[repoBuilder, "Existing repository intake", "Repo Builder must expose Existing repository intake section."],
[repoBuilder, "Paste a file tree, package manifest, repo summary or current-state notes", "Repo Builder must explain repository intake source types."],
[repoBuilder, "will not treat it as live disk truth", "Repo Builder must communicate the intake trust boundary."],
[repoBuilder, "saveRepositoryIntake", "Repo Builder must save repository intakes."],
[repoBuilder, "archiveRepositoryIntake", "Repo Builder must archive repository intakes."],
[repoBuilder, "formatRepositoryIntakeMarkdown", "Repo Builder must copy/download intake markdown."],
[repoBuilder, "Create expansion brief", "Repository gaps must create expansion briefs."],
[repoBuilder, "Create delivery package", "Repository gaps must create delivery packages."],
[repoBuilder, "Create Codex handoff", "Repository gaps must create Codex handoffs."],
[context, "generateCodexHandoff", "ProjectContext must generate Codex handoffs from repository evidence."],
[context, "generateHandoffReview", "ProjectContext must generate impact reviews for handoffs from repository evidence."],
[context, "repositoryIntakeItems", "Project search index must include repository intakes."],
[context, "saveRepositoryIntake", "ProjectContext must expose saveRepositoryIntake."],
[context, "archiveRepositoryIntake", "ProjectContext must expose archiveRepositoryIntake."],
[context, "createRepoExpansionFromGap", "ProjectContext must create expansion briefs from repository gaps."],
[context, "createRepoDeliveryPackageFromGap", "ProjectContext must create delivery packages from repository gaps."],
[context, "deriveRepositoryGaps", "Repo state snapshots must derive repository gaps from intakes."],
[context, "imported files", "Repo state snapshots must include imported file evidence."],
[context, "not live disk truth", "Generated handoffs must keep imported evidence separate from live truth."],
[history, "repoIntakeEntries", "Project History must include repository intake records."],
[history, "Repo Builder / repository intake", "History entries must label repository intake source."],
[audit, "Repository intake coverage", "Audit Center must show repository intake coverage."],
[workspace, "repositoryIntakeCount", "Workspace must count repository intakes."],
[workspace, "repositoryGapCount", "Workspace must count repository gaps."],
[startFlow, "import-repository-state", "Unified start next-actions must include repository intake for existing repo projects."],
[startFlow, "Generate repo state snapshot", "Unified start next-actions must lead from intake to repo state snapshot."],
[wizard, "Import repository state in Repo Builder", "First-run wizard recommendations must mention repository intake for existing repos."],
[flowRail, "Import repo evidence, derive repo state, resolve gaps", "Flow rail must describe repository intake/state/gap/delivery flow."],
[pkg, "audit:repository-intake", "package.json must expose audit:repository-intake."],
[pkg, "check-repository-intake-readiness.mjs", "package.json must wire repository intake readiness script."],
];
for (const [haystack, needle, message] of mustContain) {
if (!haystack.includes(needle)) failures.push(message);
}
if (!pkg.includes("audit:repository-intake") || !pkg.includes("audit:all")) {
failures.push("audit:all must include the repository intake readiness check.");
}
if (failures.length) {
console.error("ProjectBrain repository intake readiness: FAIL");
for (const failure of failures) console.error(`- ${failure}`);
process.exit(1);
}
console.log("ProjectBrain repository intake readiness: PASS");
@@ -0,0 +1,66 @@
#!/usr/bin/env node
import fs from "node:fs";
import path from "node:path";
const root = process.cwd();
const failures = [];
const read = (file) => fs.readFileSync(path.join(root, file), "utf8");
const exists = (file) => fs.existsSync(path.join(root, file));
const requireFile = (file) => {
if (!exists(file)) failures.push(`Missing required file: ${file}`);
};
const requireIncludes = (file, needle, message) => {
if (!exists(file) || !read(file).includes(needle)) failures.push(`${file}: ${message}`);
};
[
"backend/src/routes/runtimePersistence.ts",
"backend/src/repositories/runtimeRepository.ts",
"backend/src/routes/p7Runtime.ts",
"src/shared/api/realClient.ts",
"src/shared/hooks/useProjectData.ts",
"src/features/operations/pages/ReviewQueue.tsx",
"package.json",
].forEach(requireFile);
requireIncludes("backend/src/routes/runtimePersistence.ts", "/api/projects/:projectId/review-queue/:reviewItemId/materialize-truth", "Review materialization endpoint must exist.");
requireIncludes("backend/src/routes/runtimePersistence.ts", "/api/projects/:projectId/accepted-truth/:truthItemId/project-updates", "Project update creation endpoint must exist.");
requireIncludes("backend/src/routes/runtimePersistence.ts", "/api/projects/:projectId/project-updates/:projectUpdateId/apply", "Project update apply endpoint must exist.");
requireIncludes("backend/src/routes/p7Runtime.ts", "/api/projects/:projectId/current-state/generate", "Current state generation endpoint must exist.");
requireIncludes("backend/src/repositories/runtimeRepository.ts", "materializeReviewItemAsTruthInDatabase", "Repository must materialize review items as accepted truth.");
requireIncludes("backend/src/repositories/runtimeRepository.ts", "createProjectUpdateFromTruthInDatabase", "Repository must prepare project updates from accepted truth.");
requireIncludes("backend/src/repositories/runtimeRepository.ts", "applyProjectUpdateInDatabase", "Repository must apply project updates.");
requireIncludes("src/shared/api/realClient.ts", "materializeReviewItemAsTruth", "Real client must expose materialize review as truth.");
requireIncludes("src/shared/api/realClient.ts", "createProjectUpdateFromTruth", "Real client must expose project update creation.");
requireIncludes("src/shared/api/realClient.ts", "applyProjectUpdate", "Real client must expose project update apply.");
requireIncludes("src/shared/api/realClient.ts", "generateCurrentStateReport", "Real client must expose current state generation.");
requireIncludes("src/shared/hooks/useProjectData.ts", "useProjectAcceptedTruthData", "Async hooks must expose accepted truth.");
requireIncludes("src/shared/hooks/useProjectData.ts", "useProjectProjectUpdatesData", "Async hooks must expose project updates.");
requireIncludes("src/shared/hooks/useProjectData.ts", "useProjectCurrentStateReportsData", "Async hooks must expose current state reports.");
requireIncludes("src/features/operations/pages/ReviewQueue.tsx", "Live review closure", "Review Queue must expose live closure bridge.");
requireIncludes("src/features/operations/pages/ReviewQueue.tsx", "Accept + close selected review", "Review Queue must provide explicit closure action.");
requireIncludes("src/features/operations/pages/ReviewQueue.tsx", "closeSelectedReviewIntoState", "Review Queue must run the closure workflow.");
requireIncludes("src/features/operations/pages/ReviewQueue.tsx", "realClient.runtimePersistence.materializeReviewItemAsTruth", "Closure workflow must materialize accepted truth.");
requireIncludes("src/features/operations/pages/ReviewQueue.tsx", "realClient.runtimePersistence.createProjectUpdateFromTruth", "Closure workflow must prepare project updates.");
requireIncludes("src/features/operations/pages/ReviewQueue.tsx", "realClient.runtimePersistence.applyProjectUpdate", "Closure workflow must apply project updates.");
requireIncludes("src/features/operations/pages/ReviewQueue.tsx", "realClient.runtimeV7.generateCurrentStateReport", "Closure workflow must regenerate current state.");
requireIncludes("package.json", "audit:review-closure", "package.json must expose audit:review-closure.");
requireIncludes("package.json", "check-review-closure-readiness.mjs", "package.json must wire the review closure readiness script.");
const scripts = JSON.parse(read("package.json")).scripts ?? {};
const experienceAudit = scripts["audit:experience"] ?? "";
const allAudit = scripts["audit:all"] ?? "";
const reviewClosureIndex = experienceAudit.indexOf("audit:review-closure");
const buildLoopIndex = experienceAudit.indexOf("audit:build-loop");
if (!allAudit.includes("audit:experience") || reviewClosureIndex < 0 || buildLoopIndex < 0 || reviewClosureIndex > buildLoopIndex) {
failures.push("package.json: audit:all must run the experience gate with review closure before build-loop readiness.");
}
if (failures.length) {
console.error("ProjectBrain review closure readiness: FAIL");
for (const failure of failures) console.error(`- ${failure}`);
process.exit(1);
}
console.log("ProjectBrain review closure readiness: PASS");
+440
View File
@@ -0,0 +1,440 @@
#!/usr/bin/env node
import { existsSync, readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
const root = dirname(dirname(fileURLToPath(import.meta.url)));
const failures = [];
const warnings = [];
const requiredFiles = [
'src/features/projects/pages/RuntimeReadiness.tsx',
'src/shared/hooks/useAsyncResource.ts',
'src/shared/hooks/useProjectData.ts',
'src/shared/api/realClient.ts',
'docs/RUNTIME_ROUND_1_REAL_MODE_PILOT.md',
'docs/RUNTIME_ROUND_2_REAL_READ_PILOT.md',
'docs/RUNTIME_ROUND_3_REAL_READ_PILOT.md',
'backend/scripts/smoke-real-read-pilot.mjs',
'backend/scripts/smoke-vertical-slice.mjs',
'docs/RUNTIME_ROUND_4_VERTICAL_SLICE_SMOKE.md',
'docs/P2_LOCAL_RUNTIME_VERIFICATION.md',
'docs/RUNTIME_ROUND_5_BACKEND_HEALTH_AND_SUITE.md',
'backend/scripts/smoke-runtime-suite.mjs',
'src/features/operations/lib/operationRuntime.ts',
'src/features/operations/pages/RuntimeStateManager.tsx',
'docs/RUNTIME_ROUND_7_SHARED_LOCAL_OPERATING_STATE.md',
'docs/RUNTIME_ROUND_8_LOCAL_RUNTIME_PORTABILITY.md',
'docs/RUNTIME_ROUND_10_LOCAL_TRUTH_LEDGER.md',
'src/features/operations/pages/RuntimeTruthLedger.tsx',
'src/features/operations/pages/RuntimeHandoffBuilder.tsx',
'docs/RUNTIME_ROUND_11_HANDOFF_BUNDLES.md',
'src/features/operations/pages/RuntimeFlowProgress.tsx',
'docs/RUNTIME_ROUND_12_FLOW_PROGRESS_AND_CODEX_HANDOFF.md',
'docs/RUNTIME_ROUND_13_GUIDED_VERTICAL_SLICE_RUNNER.md',
'src/features/operations/pages/RuntimeMissionControl.tsx',
'docs/RUNTIME_ROUND_14_MISSION_CONTROL.md',
'src/features/operations/pages/RuntimeTraceInspector.tsx',
'docs/RUNTIME_ROUND_15_TRACE_INSPECTOR.md',
'src/features/operations/pages/RuntimeTodoBoard.tsx',
'docs/RUNTIME_ROUND_16_TODO_BOARD.md',
'src/features/operations/pages/RuntimeDeliveryPlan.tsx',
'docs/RUNTIME_ROUND_17_DELIVERY_PLAN.md',
'src/features/operations/pages/RuntimeExecutionQueue.tsx',
'docs/RUNTIME_ROUND_18_EXECUTION_QUEUE.md',
'src/features/operations/pages/RuntimeGateChecklist.tsx',
'src/features/operations/pages/RuntimeGateEvidence.tsx',
'docs/RUNTIME_ROUND_19_P2_GATE_CHECKLIST.md',
'docs/RUNTIME_ROUND_20_P2_SMOKE_EVIDENCE.md',
'src/features/operations/pages/RuntimeP3ReadinessAdvisor.tsx',
'docs/RUNTIME_ROUND_21_P3_READINESS_ADVISOR.md',
'src/features/operations/pages/RuntimeP3ImplementationPlanner.tsx',
'docs/RUNTIME_ROUND_22_P3_IMPLEMENTATION_PLANNER.md',
'src/features/operations/pages/RuntimeWorkSessionPanel.tsx',
'docs/RUNTIME_ROUND_23_WORK_SESSIONS.md',
'docs/RUNTIME_ROUND_24_REAL_MODE_FEATURE_DETAIL_PILOT.md',
'docs/STABILIZATION_PASS_V37.md',
'src/features/operations/pages/RuntimeContextPromptPilot.tsx',
'docs/RUNTIME_ROUND_25_CONTEXT_PROMPT_REAL_PILOT.md',
'docs/STABILIZATION_PASS_V38.md',
'src/features/operations/pages/RuntimeProjectUpdates.tsx',
'docs/RUNTIME_ROUND_26_PROJECT_UPDATE_MATERIALIZATION.md',
'docs/STABILIZATION_PASS_V39.md',
'src/features/operations/pages/RuntimeFeatureOverlay.tsx',
'docs/RUNTIME_ROUND_27_FEATURE_MAP_OVERLAY.md',
'docs/STABILIZATION_PASS_V40.md',
'docs/RUNTIME_ROUND_28_CAPTURE_REVIEW_REAL_PILOT.md',
'docs/STABILIZATION_PASS_V43.md',
'docs/PERSISTENCE_P6_TRUTH_UPDATES_OPERATING_LOG.md',
'docs/STABILIZATION_PASS_V44_P6_TRUTH_UPDATES_LOG.md',
'backend/scripts/smoke-p6.mjs',
'src/features/operations/pages/RuntimePersistencePilot.tsx',
'docs/RUNTIME_ROUND_29_P6_FRONTEND_PERSISTENCE_PILOT.md',
'docs/STABILIZATION_PASS_V45.md',
'src/features/operations/pages/RuntimeDeliveryPersistencePilot.tsx',
'docs/PERSISTENCE_P7_CURRENT_STATE_HANDOFF_CODEX.md',
'docs/RUNTIME_ROUND_30_P7_DELIVERY_PERSISTENCE_PILOT.md',
'docs/STABILIZATION_PASS_V46_P7_DELIVERY_PERSISTENCE.md',
'backend/scripts/smoke-p7.mjs',
'backend/scripts/smoke-p8.mjs',
'src/features/operations/pages/RuntimeKnowledgePersistencePilot.tsx',
'docs/PERSISTENCE_P8_DOCUMENTATION_KNOWLEDGE_MEMORY.md',
'docs/RUNTIME_ROUND_31_P8_KNOWLEDGE_PERSISTENCE_PILOT.md',
'docs/STABILIZATION_PASS_V47_P8_KNOWLEDGE_MEMORY_BRANDING.md',
];
for (const file of requiredFiles) {
if (!existsSync(join(root, file))) failures.push(`Missing runtime-readiness file: ${file}`);
}
const routeFile = join(root, 'src/app/routes.tsx');
if (existsSync(routeFile)) {
const routes = readFileSync(routeFile, 'utf8');
if (!routes.includes('/projects/:projectId/runtime')) failures.push('Runtime readiness route is not registered.');
} else {
failures.push('Missing src/app/routes.tsx');
}
const runtimeReadinessFile = join(root, 'src/features/projects/pages/RuntimeReadiness.tsx');
if (existsSync(runtimeReadinessFile)) {
const runtimeReadiness = readFileSync(runtimeReadinessFile, 'utf8');
for (const marker of ['Tower deployment runbook', 'Deployment update path', 'git pull --ff-only', 'scripts/deploy-unraid-single.sh', 'Configurable public URL']) {
if (!runtimeReadiness.includes(marker)) warnings.push(`RuntimeReadiness missing deployment runbook marker: ${marker}`);
}
}
const sidebarFile = join(root, 'src/components/layout/Sidebar.tsx');
if (existsSync(sidebarFile)) {
const sidebar = readFileSync(sidebarFile, 'utf8');
const navigationFile = join(root, 'src/shared/lib/navigation.tsx');
const navigation = existsSync(navigationFile) ? readFileSync(navigationFile, 'utf8') : '';
if (!sidebar.includes('/runtime') && !navigation.includes('/runtime')) warnings.push('Navigation does not expose Runtime Readiness.');
} else {
failures.push('Missing Sidebar.tsx');
}
const realClientFile = join(root, 'src/shared/api/realClient.ts');
if (existsSync(realClientFile)) {
const realClient = readFileSync(realClientFile, 'utf8');
for (const marker of ['health: healthApi', 'unwrapPaginatedItems', 'unwrapWorkspaceState', 'mapBackendFeature', 'mapBackendEvidence', 'listFeatures', 'listEvidence', 'listEvidenceForFeature', 'listContextPacks', 'listChecks', 'listBuildRuns', 'listSnapshots', 'listKnowledge', 'listCaptureItems', 'createCaptureItem', 'classifyCaptureItem', 'sendCaptureToReview', 'listReviewItems', 'acceptReviewItem', 'convertReviewItem', 'listActions', 'runtimePersistence', 'listAcceptedTruth', 'materializeReviewItemAsTruth', 'listProjectUpdates', 'createProjectUpdateFromTruth', 'applyProjectUpdate', 'listOperatingLog', 'createOperatingLogEntry', 'runtimeV7', 'listCurrentStateReports', 'generateCurrentStateReport', 'listHandoffBundles', 'createHandoffBundle', 'listCodexTasks', 'createCodexTaskFromHandoff', 'importCodexOutput', 'createKnowledge', 'updateKnowledge', 'createMemoryRule', 'updateMemoryRule', 'listDocumentationItems', 'createDocumentationItem', 'updateDocumentationItem']) {
if (!realClient.includes(marker)) warnings.push(`realClient missing expected marker: ${marker}`);
}
}
const projectDataHooksFile = join(root, 'src/shared/hooks/useProjectData.ts');
if (existsSync(projectDataHooksFile)) {
const hooks = readFileSync(projectDataHooksFile, 'utf8');
for (const marker of ['useBackendHealthData', 'useProjectFeatureGroupsData', 'useProjectFeaturesData', 'useProjectFeatureDetailData', 'useProjectEvidenceData', 'useFeatureEvidenceData', 'useProjectContextPacksData', 'useProjectChecksData', 'useProjectBuildRunsData', 'useProjectSnapshotsData', 'useProjectKnowledgeData', 'useProjectDocumentationItemsData', 'useProjectCaptureItemsData', 'useProjectReviewQueueData', 'useProjectAcceptedTruthData', 'useProjectProjectUpdatesData', 'useProjectOperatingLogData', 'useProjectCurrentStateReportsData', 'useProjectHandoffBundlesData', 'useProjectCodexTasksData', 'useProjectAIActionsData']) {
if (!hooks.includes(marker)) warnings.push(`useProjectData missing expected runtime hook: ${marker}`);
}
} else {
failures.push('Missing src/shared/hooks/useProjectData.ts');
}
const featureMapFile = join(root, 'src/features/features/pages/FeatureMap.tsx');
if (existsSync(featureMapFile)) {
const featureMap = readFileSync(featureMapFile, 'utf8');
for (const marker of ['Feature data source', 'useProjectFeatureGroupsData', 'Reload features', 'RuntimeFeatureOverlay']) {
if (!featureMap.includes(marker)) warnings.push(`FeatureMap missing real-mode pilot marker: ${marker}`);
}
}
const featureDetailFile = join(root, 'src/features/features/pages/FeatureDetail.tsx');
if (existsSync(featureDetailFile)) {
const featureDetail = readFileSync(featureDetailFile, 'utf8');
for (const marker of ['Feature detail data source', 'useProjectFeatureDetailData', 'useFeatureEvidenceData', 'Reload detail']) {
if (!featureDetail.includes(marker)) warnings.push(`FeatureDetail missing real-mode pilot marker: ${marker}`);
}
}
const operationRuntimeFile = join(root, 'src/features/operations/lib/operationRuntime.ts');
if (existsSync(operationRuntimeFile)) {
const runtime = readFileSync(operationRuntimeFile, 'utf8');
for (const marker of ['exportRuntime', 'exportRuntimeMarkdown', 'importRuntime', 'sendCaptureToReview', 'truthItems', 'RuntimeTruthItem', 'RuntimeHandoffBundle', 'createHandoffBundle', 'handoffBundles', 'runGuidedVerticalSlice', 'runtimeTraceRows', 'exportRuntimeTraceMarkdown', 'RuntimeTodoItem', 'todoItems', 'addTodo', 'toggleTodo', 'createTodosFromPendingReviews', 'exportTodoMarkdown', 'RuntimeDeliveryGate', 'runtimeDeliveryGates', 'createTodosFromTraceGaps', 'createTodosFromDeliveryGaps', 'completeDeliveryGate', 'exportDeliveryPlanMarkdown', 'RuntimeWorkSession', 'workSessions', 'startWorkSession', 'completeWorkSession', 'exportWorkSessionMarkdown', 'RuntimeProjectUpdate', 'projectUpdates', 'materializeAcceptedTruth', 'applyProjectUpdate', 'exportProjectUpdatesMarkdown']) {
if (!runtime.includes(marker)) warnings.push(`operationRuntime missing expected portability marker: ${marker}`);
}
}
const truthLedgerFile = join(root, 'src/features/operations/pages/RuntimeTruthLedger.tsx');
if (existsSync(truthLedgerFile)) {
const ledger = readFileSync(truthLedgerFile, 'utf8');
for (const marker of ['Export truth markdown', 'Create context handoff', 'Create Codex handoff']) {
if (!ledger.includes(marker)) warnings.push(`RuntimeTruthLedger missing expected control: ${marker}`);
}
}
const codexPreviewFile = join(root, 'src/features/operations/pages/CodexTaskPreview.tsx');
if (existsSync(codexPreviewFile)) {
const codex = readFileSync(codexPreviewFile, 'utf8');
for (const marker of ['Create runtime handoff', 'Runtime handoff attached', 'Runtime Handoff Context']) {
if (!codex.includes(marker)) warnings.push(`CodexTaskPreview missing expected runtime-handoff marker: ${marker}`);
}
}
const runtimeStateManagerFile = join(root, 'src/features/operations/pages/RuntimeStateManager.tsx');
if (existsSync(runtimeStateManagerFile)) {
const manager = readFileSync(runtimeStateManagerFile, 'utf8');
for (const marker of ['Export JSON', 'Export Markdown', 'Import JSON', 'Reset local runtime', 'Handoffs']) {
if (!manager.includes(marker)) warnings.push(`RuntimeStateManager missing expected control: ${marker}`);
}
}
const missionControlFile = join(root, 'src/features/operations/pages/RuntimeMissionControl.tsx');
if (existsSync(missionControlFile)) {
const mission = readFileSync(missionControlFile, 'utf8');
for (const marker of ['Runtime Mission Control', 'Run full local flow', 'Create Codex handoff', 'Copy mission markdown', 'Import Codex output preview']) {
if (!mission.includes(marker)) warnings.push(`RuntimeMissionControl missing expected marker: ${marker}`);
}
}
const traceInspectorFile = join(root, 'src/features/operations/pages/RuntimeTraceInspector.tsx');
if (existsSync(traceInspectorFile)) {
const trace = readFileSync(traceInspectorFile, 'utf8');
for (const marker of ['Runtime Trace Inspector', 'Seed trace example', 'Copy trace markdown', 'Create trace handoff']) {
if (!trace.includes(marker)) warnings.push(`RuntimeTraceInspector missing expected marker: ${marker}`);
}
}
const todoBoardFile = join(root, 'src/features/operations/pages/RuntimeTodoBoard.tsx');
if (existsSync(todoBoardFile)) {
const todoBoard = readFileSync(todoBoardFile, 'utf8');
for (const marker of ['Runtime Todo Board', 'Create todos from reviews', 'Export todo markdown', 'Add manual todo']) {
if (!todoBoard.includes(marker)) warnings.push(`RuntimeTodoBoard missing expected marker: ${marker}`);
}
}
const executionQueueFile = join(root, 'src/features/operations/pages/RuntimeExecutionQueue.tsx');
if (existsSync(executionQueueFile)) {
const execution = readFileSync(executionQueueFile, 'utf8');
for (const marker of ['Runtime Execution Queue', 'Prepare execution burst', 'Mark top todo done', 'Export execution queue', 'Create execution handoff']) {
if (!execution.includes(marker)) warnings.push(`RuntimeExecutionQueue missing expected marker: ${marker}`);
}
}
const deliveryPlanFile = join(root, 'src/features/operations/pages/RuntimeDeliveryPlan.tsx');
if (existsSync(deliveryPlanFile)) {
const delivery = readFileSync(deliveryPlanFile, 'utf8');
for (const marker of ['Runtime Delivery Plan', 'Create gate todos', 'Create trace-gap todos', 'Export delivery plan']) {
if (!delivery.includes(marker)) warnings.push(`RuntimeDeliveryPlan missing expected marker: ${marker}`);
}
}
const flowProgressFile = join(root, 'src/features/operations/pages/RuntimeFlowProgress.tsx');
if (existsSync(flowProgressFile)) {
const flow = readFileSync(flowProgressFile, 'utf8');
for (const marker of ['Runtime Vertical Slice Progress', 'Create suggested handoff', 'Run guided local slice', 'Next best runtime step']) {
if (!flow.includes(marker)) warnings.push(`RuntimeFlowProgress missing expected marker: ${marker}`);
}
}
const handoffBuilderFile = join(root, 'src/features/operations/pages/RuntimeHandoffBuilder.tsx');
if (existsSync(handoffBuilderFile)) {
const builder = readFileSync(handoffBuilderFile, 'utf8');
for (const marker of ['Create handoff bundle', 'Copy latest markdown', 'Send latest to Review Queue']) {
if (!builder.includes(marker)) warnings.push(`RuntimeHandoffBuilder missing expected control: ${marker}`);
}
}
const gateChecklistFile = join(root, 'src/features/operations/pages/RuntimeGateChecklist.tsx');
if (existsSync(gateChecklistFile)) {
const gate = readFileSync(gateChecklistFile, 'utf8');
for (const marker of ['P2 Database Gate Checklist', 'Copy commands', 'Create todos for incomplete steps', 'Mark gate complete']) {
if (!gate.includes(marker)) warnings.push(`RuntimeGateChecklist missing expected marker: ${marker}`);
}
}
const gateEvidenceFile = join(root, 'src/features/operations/pages/RuntimeGateEvidence.tsx');
if (existsSync(gateEvidenceFile)) {
const evidence = readFileSync(gateEvidenceFile, 'utf8');
for (const marker of ['P2 Smoke Evidence Recorder', 'Save evidence', 'Export evidence', 'Evidence history']) {
if (!evidence.includes(marker)) warnings.push(`RuntimeGateEvidence missing expected marker: ${marker}`);
}
}
const p3AdvisorFile = join(root, 'src/features/operations/pages/RuntimeP3ReadinessAdvisor.tsx');
if (existsSync(p3AdvisorFile)) {
const p3 = readFileSync(p3AdvisorFile, 'utf8');
for (const marker of ['Feature/Evidence parity proof', 'Create parity proof todos', 'Create parity handoff', 'Export readiness', 'Mark P2 gate complete']) {
if (!p3.includes(marker)) warnings.push(`RuntimeP3ReadinessAdvisor missing expected marker: ${marker}`);
}
}
const workSessionFile = join(root, 'src/features/operations/pages/RuntimeWorkSessionPanel.tsx');
if (existsSync(workSessionFile)) {
const sessions = readFileSync(workSessionFile, 'utf8');
for (const marker of ['Runtime Work Sessions', 'Start next work session', 'Complete active session', 'Export sessions']) {
if (!sessions.includes(marker)) warnings.push(`RuntimeWorkSessionPanel missing expected marker: ${marker}`);
}
}
const projectUpdatesFile = join(root, 'src/features/operations/pages/RuntimeProjectUpdates.tsx');
if (existsSync(projectUpdatesFile)) {
const updates = readFileSync(projectUpdatesFile, 'utf8');
for (const marker of ['Runtime Project Updates', 'Prepare from truth', 'Apply ready', 'Export updates']) {
if (!updates.includes(marker)) warnings.push(`RuntimeProjectUpdates missing expected marker: ${marker}`);
}
}
const runtimePersistencePilotFile = join(root, 'src/features/operations/pages/RuntimePersistencePilot.tsx');
if (existsSync(runtimePersistencePilotFile)) {
const pilot = readFileSync(runtimePersistencePilotFile, 'utf8');
for (const marker of ['Runtime Persistence Pilot', 'Materialize truth', 'Prepare updates', 'Apply ready', 'Add log note', 'realClient.runtimePersistence']) {
if (!pilot.includes(marker)) warnings.push(`RuntimePersistencePilot missing expected marker: ${marker}`);
}
} else {
warnings.push('Missing RuntimePersistencePilot component.');
}
const runtimeDeliveryPersistencePilotFile = join(root, 'src/features/operations/pages/RuntimeDeliveryPersistencePilot.tsx');
if (existsSync(runtimeDeliveryPersistencePilotFile)) {
const pilot = readFileSync(runtimeDeliveryPersistencePilotFile, 'utf8');
for (const marker of ['Runtime Delivery Persistence Pilot', 'Generate Current State', 'Create Handoff', 'Create Codex Task', 'Import Codex Output', 'realClient.runtimeV7']) {
if (!pilot.includes(marker)) warnings.push(`RuntimeDeliveryPersistencePilot missing expected marker: ${marker}`);
}
} else {
warnings.push('Missing RuntimeDeliveryPersistencePilot component.');
}
const runtimeFeatureOverlayFile = join(root, 'src/features/operations/pages/RuntimeFeatureOverlay.tsx');
if (existsSync(runtimeFeatureOverlayFile)) {
const overlay = readFileSync(runtimeFeatureOverlayFile, 'utf8');
for (const marker of ['Runtime Feature Overlay', 'Prepare updates', 'Apply ready', 'Create handoff', 'Export']) {
if (!overlay.includes(marker)) warnings.push(`RuntimeFeatureOverlay missing expected marker: ${marker}`);
}
}
const packageFile = join(root, 'package.json');
if (existsSync(packageFile)) {
const pkg = readFileSync(packageFile, 'utf8');
if (!pkg.includes('smoke:backend:real-read-pilot')) warnings.push('Root package is missing smoke:backend:real-read-pilot script.');
if (!pkg.includes('smoke:backend:vertical-slice')) warnings.push('Root package is missing smoke:backend:vertical-slice script.');
if (!pkg.includes('smoke:backend:runtime-suite')) warnings.push('Root package is missing smoke:backend:runtime-suite script.');
}
console.log('ProjectBrain runtime readiness check');
console.log('====================================');
if (warnings.length) {
console.log('\nWarnings:');
for (const warning of warnings) console.log(`- ${warning}`);
}
const p3ImplementationPlannerFile = join(root, 'src/features/operations/pages/RuntimeP3ImplementationPlanner.tsx');
if (existsSync(p3ImplementationPlannerFile)) {
const planner = readFileSync(p3ImplementationPlannerFile, 'utf8');
for (const marker of ['Feature/Evidence parity reference', 'Create parity todos', 'Create parity handoff', 'Export parity plan']) {
if (!planner.includes(marker)) warnings.push(`RuntimeP3ImplementationPlanner missing expected marker: ${marker}`);
}
}
const contextPromptPilotFile = join(root, 'src/features/operations/pages/RuntimeContextPromptPilot.tsx');
if (existsSync(contextPromptPilotFile)) {
const pilot = readFileSync(contextPromptPilotFile, 'utf8');
for (const marker of ['Runtime Context -> Prompt Pilot', 'Generate runtime prompt', 'realClient.prompts.generatePromptFromContextPack', 'useProjectContextPacksData']) {
if (!pilot.includes(marker)) warnings.push(`RuntimeContextPromptPilot missing expected marker: ${marker}`);
}
}
const promptGeneratorFile = join(root, 'src/features/prompts/pages/PromptGenerator.tsx');
if (existsSync(promptGeneratorFile)) {
const prompt = readFileSync(promptGeneratorFile, 'utf8');
for (const marker of ['Prompt data source', 'realClient.prompts.generatePromptFromContextPack', 'useProjectContextPacksData']) {
if (!prompt.includes(marker)) warnings.push(`PromptGenerator missing real prompt marker: ${marker}`);
}
}
const contextPacksPageFile = join(root, 'src/features/context-packs/pages/ContextPacks.tsx');
if (existsSync(contextPacksPageFile)) {
const packs = readFileSync(contextPacksPageFile, 'utf8');
for (const marker of ['Context pack data source', 'useProjectContextPacksData', 'Fastify R4 read endpoint']) {
if (!packs.includes(marker)) warnings.push(`ContextPacks missing real context-pack marker: ${marker}`);
}
}
const captureCenterFile = join(root, 'src/features/operations/pages/CaptureCenter.tsx');
if (existsSync(captureCenterFile)) {
const captureCenter = readFileSync(captureCenterFile, 'utf8');
for (const marker of ['Capture data source', 'Real API pilot', 'createCaptureItem', 'classifyCaptureItem', 'sendCaptureToReview']) {
if (!captureCenter.includes(marker)) warnings.push(`CaptureCenter missing real API pilot marker: ${marker}`);
}
}
const reviewQueueFile = join(root, 'src/features/operations/pages/ReviewQueue.tsx');
if (existsSync(reviewQueueFile)) {
const reviewQueue = readFileSync(reviewQueueFile, 'utf8');
for (const marker of ['Review Queue data source', 'Real API pilot', 'acceptReviewItem', 'convertReviewItem', 'mergeReviewItem']) {
if (!reviewQueue.includes(marker)) warnings.push(`ReviewQueue missing real API pilot marker: ${marker}`);
}
}
const backendPackageFile = join(root, 'backend/package.json');
if (existsSync(backendPackageFile)) {
const backendPackage = readFileSync(backendPackageFile, 'utf8');
for (const marker of ['smoke:p6', 'smoke-p6.mjs', 'smoke:p7', 'smoke-p7.mjs']) {
if (!backendPackage.includes(marker)) warnings.push(`backend package missing P6 marker: ${marker}`);
}
}
const runtimePersistenceRouteFile = join(root, 'backend/src/routes/runtimePersistence.ts');
if (existsSync(runtimePersistenceRouteFile)) {
const runtimePersistence = readFileSync(runtimePersistenceRouteFile, 'utf8');
for (const marker of ['accepted-truth', 'project-updates', 'operating-log', 'materialize-truth']) {
if (!runtimePersistence.includes(marker)) warnings.push(`runtimePersistence route missing P6 marker: ${marker}`);
}
} else {
warnings.push('Missing P6 runtimePersistence route file.');
}
const p7RuntimeRouteFile = join(root, 'backend/src/routes/p7Runtime.ts');
if (existsSync(p7RuntimeRouteFile)) {
const p7Runtime = readFileSync(p7RuntimeRouteFile, 'utf8');
for (const marker of ['current-state', 'handoff-bundles', 'codex-tasks', 'import-output']) {
if (!p7Runtime.includes(marker)) warnings.push(`p7Runtime route missing P7 marker: ${marker}`);
}
} else {
warnings.push('Missing P7 p7Runtime route file.');
}
const knowledgePilotFile = join(root, 'src/features/operations/pages/RuntimeKnowledgePersistencePilot.tsx');
if (existsSync(knowledgePilotFile)) {
const pilot = readFileSync(knowledgePilotFile, 'utf8');
for (const marker of ['Runtime Knowledge Persistence Pilot', 'Persist documentation', 'Persist knowledge', 'Persist memory rule']) {
if (!pilot.includes(marker)) warnings.push(`RuntimeKnowledgePersistencePilot missing expected marker: ${marker}`);
}
} else {
failures.push('Missing RuntimeKnowledgePersistencePilot.tsx');
}
if (failures.length) {
console.log('\nFailures:');
for (const failure of failures) console.log(`- ${failure}`);
process.exit(1);
}
console.log('\nPASS: runtime readiness scaffolding is present.');
@@ -0,0 +1,122 @@
#!/usr/bin/env node
import { existsSync, readFileSync } from "node:fs";
const requiredFiles = [
"src/shared/lib/codexSessionPackExport.ts",
"src/shared/lib/downloads.ts",
"src/features/session-packs/pages/SessionPacks.tsx",
"src/features/handoffs/pages/CodexHandoffs.tsx",
"src/features/build-loop/pages/BuildLoopDashboard.tsx",
"src/shared/lib/buildLoop.ts",
"src/features/execution-feedback/pages/ExecutionFeedback.tsx",
"src/shared/lib/codexExecutionFeedback.ts",
"src/features/prompts/pages/PromptGenerator.tsx",
"src/features/audit/pages/ProjectAuditCenter.tsx",
"src/shared/contexts/ProjectContext.tsx",
"pnpm-lock.yaml",
"package.json",
];
const failures = [];
for (const file of requiredFiles) {
if (!existsSync(file)) failures.push(`Missing required file: ${file}`);
}
const read = (file) => readFileSync(file, "utf8");
const text = Object.fromEntries(requiredFiles.filter(existsSync).map((file) => [file, read(file)]));
const exportHelper = text["src/shared/lib/codexSessionPackExport.ts"] ?? "";
const downloads = text["src/shared/lib/downloads.ts"] ?? "";
const sessionPacks = text["src/features/session-packs/pages/SessionPacks.tsx"] ?? "";
const handoffs = text["src/features/handoffs/pages/CodexHandoffs.tsx"] ?? "";
const buildLoopPage = text["src/features/build-loop/pages/BuildLoopDashboard.tsx"] ?? "";
const buildLoop = text["src/shared/lib/buildLoop.ts"] ?? "";
const feedback = text["src/features/execution-feedback/pages/ExecutionFeedback.tsx"] ?? "";
const feedbackHelper = text["src/shared/lib/codexExecutionFeedback.ts"] ?? "";
const promptBuilder = text["src/features/prompts/pages/PromptGenerator.tsx"] ?? "";
const audit = text["src/features/audit/pages/ProjectAuditCenter.tsx"] ?? "";
const context = text["src/shared/contexts/ProjectContext.tsx"] ?? "";
const lockfile = text["pnpm-lock.yaml"] ?? "";
const pkg = text["package.json"] ?? "";
const mustContain = [
[exportHelper, "buildCodexSessionPackFiles", "Export helper must build bundle files."],
[exportHelper, "formatAllowedFilesManifest", "Export helper must format allowed files manifest."],
[exportHelper, "formatForbiddenChangesManifest", "Export helper must format forbidden changes manifest."],
[exportHelper, "formatValidationPlanMarkdown", "Export helper must format validation plan."],
[exportHelper, "formatRollbackPlanMarkdown", "Export helper must format rollback plan."],
[exportHelper, "formatFeedbackTemplateMarkdown", "Export helper must format feedback template."],
[exportHelper, "formatSessionPackReadme", "Export helper must format quick-start README."],
[exportHelper, "formatImportableFeedbackTemplate", "Export helper must format importable feedback template."],
[exportHelper, "downloadCodexSessionPackBundle", "Export helper must expose bundle download."],
[exportHelper, "buildZipEntriesFromSessionPack", "Export helper must expose ZIP entries helper."],
[exportHelper, "buildCodexSessionPackZipBlob", "Export helper must expose ZIP Blob builder."],
[exportHelper, "downloadCodexSessionPackZip", "Export helper must expose ZIP download."],
[exportHelper, "getCodexSessionPackZipFilename", "Export helper must expose ZIP filename helper."],
[exportHelper, "zipExport: true", "Bundle manifest must state ZIP export is available."],
[exportHelper, "fflate", "Export helper must use the selected browser-safe ZIP dependency."],
[exportHelper, "codex-session-pack.md", "Bundle must include markdown handoff file."],
[exportHelper, "codex-session-pack.json", "Bundle must include JSON file."],
[exportHelper, "allowed-files.txt", "Bundle must include allowed files manifest."],
[exportHelper, "forbidden-changes.md", "Bundle must include forbidden changes manifest."],
[exportHelper, "validation-plan.md", "Bundle must include validation plan."],
[exportHelper, "rollback-plan.md", "Bundle must include rollback plan."],
[exportHelper, "feedback-template.md", "Bundle must include feedback template."],
[exportHelper, "README_RUN_THIS_CODEX_SESSION.md", "Bundle must include quick-start README."],
[downloads, "downloadTextFile", "Shared download helper must expose downloadTextFile."],
[downloads, "downloadJsonFile", "Shared download helper must expose downloadJsonFile."],
[downloads, "downloadBlobFile", "Shared download helper must expose downloadBlobFile."],
[sessionPacks, "Bundle contents", "Session Packs page must show bundle contents."],
[sessionPacks, "Download instructions/scripts ZIP", "Session Packs page must expose clear instructions/scripts ZIP download action."],
[sessionPacks, "Export session pack files", "Session Packs page must expose honest session pack file export action."],
[sessionPacks, "Copy feedback template", "Session Packs page must copy feedback template."],
[sessionPacks, "Download feedback template", "Session Packs page must download feedback template."],
[sessionPacks, "Copy quick-start README", "Session Packs page must copy quick-start README."],
[sessionPacks, "ZIP export contains only", "Session Packs page must document ZIP contents."],
[sessionPacks, "Export session pack files", "Session Packs page must keep individual-file fallback."],
[handoffs, "Download ZIP", "Handoffs page must expose ZIP export when session pack exists."],
[handoffs, "Export session pack files", "Handoffs page must export session pack files."],
[handoffs, "controlled input for a Codex build session", "Handoffs page must explain session pack purpose."],
[buildLoopPage, "Download ZIP", "Build Loop page must expose latest session pack ZIP."],
[buildLoopPage, "Bundle readiness", "Build Loop page must show bundle readiness."],
[buildLoopPage, "Feedback template readiness", "Build Loop page must show feedback template readiness."],
[buildLoop, "Bundle readiness", "Build Loop markdown must include bundle readiness."],
[buildLoop, "Feedback template readiness", "Build Loop markdown must include feedback template readiness."],
[feedback, "Use this feedback template", "Execution Feedback must use session pack template."],
[feedback, "Download template", "Execution Feedback must download template."],
[feedback, "Insert template into report field", "Execution Feedback must insert template into report field."],
[feedbackHelper, "Validation output", "Feedback parser/formatter must recognize validation output heading."],
[feedbackHelper, "Final status", "Feedback parser/formatter must recognize final status heading."],
[promptBuilder, "Use session pack prompt", "Prompt Builder must use session pack prompt."],
[promptBuilder, "Copy feedback template", "Prompt Builder must copy feedback template."],
[audit, "Missing allowed/rollback", "Audit Center must show allowed/rollback bundle readiness."],
[context, "validation-plan.md", "Search index must include bundle files."],
[pkg, '"fflate"', "package.json must include selected ZIP dependency."],
[pkg, "audit:session-pack-export", "package.json must expose audit:session-pack-export."],
[pkg, "check-session-pack-export-readiness.mjs", "package.json must wire session pack export readiness script."],
[lockfile, "fflate", "pnpm lockfile must include selected ZIP dependency."],
];
for (const [haystack, needle, message] of mustContain) {
if (!haystack.includes(needle)) failures.push(message);
}
if (sessionPacks.includes("Download Bundle / ZIP")) {
failures.push("Session Packs page must not imply a real ZIP is downloaded.");
}
if (exportHelper.includes("zipExport: false") || exportHelper.includes("reasonZipIsNotIncluded")) {
failures.push("Bundle manifest must no longer document ZIP export as unavailable.");
}
if (!pkg.includes("audit:session-pack-export") || !pkg.includes("audit:all")) {
failures.push("audit:all must include session pack export readiness.");
}
if (failures.length) {
console.error("ProjectBrain session pack export readiness: FAIL");
for (const failure of failures) console.error(`- ${failure}`);
process.exit(1);
}
console.log("ProjectBrain session pack export readiness: PASS");
+135
View File
@@ -0,0 +1,135 @@
#!/usr/bin/env node
import { existsSync, readFileSync } from "node:fs";
const requiredFiles = [
"src/shared/lib/model.ts",
"src/shared/lib/codexSessionPack.ts",
"src/shared/lib/safeData.ts",
"src/shared/contexts/ProjectContext.tsx",
"src/app/routes.tsx",
"src/shared/lib/navigation.tsx",
"src/shared/components/ProjectFlowRail.tsx",
"src/features/session-packs/pages/SessionPacks.tsx",
"src/features/handoffs/pages/CodexHandoffs.tsx",
"src/features/build-loop/pages/BuildLoopDashboard.tsx",
"src/shared/lib/buildLoop.ts",
"src/shared/lib/buildLoopWalkthrough.ts",
"src/features/execution/pages/ExecutionCenter.tsx",
"src/features/prompts/pages/PromptGenerator.tsx",
"src/features/execution-feedback/pages/ExecutionFeedback.tsx",
"src/features/history/pages/ProjectHistory.tsx",
"src/features/audit/pages/ProjectAuditCenter.tsx",
"src/features/projects/pages/Workspace.tsx",
"package.json",
];
const failures = [];
for (const file of requiredFiles) {
if (!existsSync(file)) failures.push(`Missing required file: ${file}`);
}
const read = (file) => readFileSync(file, "utf8");
const text = Object.fromEntries(requiredFiles.filter(existsSync).map((file) => [file, read(file)]));
const model = text["src/shared/lib/model.ts"] ?? "";
const helper = text["src/shared/lib/codexSessionPack.ts"] ?? "";
const safeData = text["src/shared/lib/safeData.ts"] ?? "";
const context = text["src/shared/contexts/ProjectContext.tsx"] ?? "";
const routes = text["src/app/routes.tsx"] ?? "";
const navigation = text["src/shared/lib/navigation.tsx"] ?? "";
const flowRail = text["src/shared/components/ProjectFlowRail.tsx"] ?? "";
const page = text["src/features/session-packs/pages/SessionPacks.tsx"] ?? "";
const handoffs = text["src/features/handoffs/pages/CodexHandoffs.tsx"] ?? "";
const buildLoopPage = text["src/features/build-loop/pages/BuildLoopDashboard.tsx"] ?? "";
const buildLoop = text["src/shared/lib/buildLoop.ts"] ?? "";
const walkthrough = text["src/shared/lib/buildLoopWalkthrough.ts"] ?? "";
const execution = text["src/features/execution/pages/ExecutionCenter.tsx"] ?? "";
const promptBuilder = text["src/features/prompts/pages/PromptGenerator.tsx"] ?? "";
const feedback = text["src/features/execution-feedback/pages/ExecutionFeedback.tsx"] ?? "";
const history = text["src/features/history/pages/ProjectHistory.tsx"] ?? "";
const audit = text["src/features/audit/pages/ProjectAuditCenter.tsx"] ?? "";
const workspace = text["src/features/projects/pages/Workspace.tsx"] ?? "";
const pkg = text["package.json"] ?? "";
const mustContain = [
[model, "export interface CodexSessionPack", "CodexSessionPack type missing."],
[model, "export interface CodexSessionPackSection", "CodexSessionPackSection type missing."],
[model, "export interface CodexSessionPackFileReference", "CodexSessionPackFileReference type missing."],
[model, "export interface CodexSessionPackValidationPlan", "CodexSessionPackValidationPlan type missing."],
[model, "export interface CodexSessionPackFeedbackTemplate", "CodexSessionPackFeedbackTemplate type missing."],
[model, "export type CodexSessionPackExportFormat", "CodexSessionPackExportFormat type missing."],
[model, "codexSessionPacks?: CodexSessionPack[]", "Project model must persist session packs."],
[safeData, "codexSessionPacks: asArray(project.codexSessionPacks)", "Runtime normalization must preserve session packs."],
[helper, "generateCodexSessionPack", "Session pack helper must generate packs."],
[helper, "buildSessionPackContext", "Session pack helper must build context."],
[helper, "buildSessionPackValidationPlan", "Session pack helper must build validation plan."],
[helper, "buildSessionPackFeedbackTemplate", "Session pack helper must build feedback template."],
[helper, "formatCodexSessionPackMarkdown", "Session pack helper must format markdown."],
[helper, "formatCodexSessionPackJson", "Session pack helper must format JSON."],
[helper, "scoreCodexSessionPackQuality", "Session pack helper must score quality."],
[helper, "getCodexSessionPackWarnings", "Session pack helper must produce warnings."],
[helper, "generated from context pack", "Session pack export must keep context-pack provenance language."],
[context, "generateCodexSessionPack", "ProjectContext must expose session pack generation."],
[context, "archiveCodexSessionPack", "ProjectContext must expose session pack archival."],
[context, "sessionPackItems", "Project search must index session packs."],
[routes, "/projects/:projectId/session-packs", "Session Packs route missing."],
[routes, "SessionPacks", "Session Packs page must be imported in routes."],
[navigation, "Session Packs", "Navigation must expose Session Packs."],
[flowRail, 'id: "session-packs"', "Project Flow Rail must include Session Packs."],
[page, "Session Packs", "Session Packs page must exist."],
[page, "Copy markdown", "Session Packs page must copy markdown."],
[page, "Download markdown", "Session Packs page must download markdown."],
[page, "Copy JSON", "Session Packs page must copy JSON."],
[page, "Download JSON", "Session Packs page must download JSON."],
[page, "Download instructions/scripts ZIP", "Session Packs page must expose clear instructions/scripts ZIP export."],
[page, "Export session pack files", "Session Packs page must expose bundle file export."],
[page, "Bundle contents", "Session Packs page must show bundle contents."],
[page, "Validation plan", "Session Packs page must show validation plan."],
[page, "Feedback template", "Session Packs page must show feedback template."],
[handoffs, "Create session pack", "Codex Handoffs must create session packs."],
[handoffs, "Open session pack", "Codex Handoffs must open session packs."],
[handoffs, "Copy session pack", "Codex Handoffs must copy session packs."],
[handoffs, "Download session pack", "Codex Handoffs must download session packs."],
[handoffs, "Download ZIP", "Codex Handoffs must download session pack ZIP."],
[handoffs, "Export session pack files", "Codex Handoffs must export session pack files."],
[buildLoop, "Create Codex session pack", "Build Loop must recommend creating session packs."],
[buildLoop, "Run session and add feedback", "Build Loop must recommend feedback after session pack."],
[buildLoop, "latestSessionPack", "Build Loop status must expose latest session pack."],
[buildLoopPage, "Session pack", "Build Loop page must show session pack status."],
[walkthrough, "create-session-pack", "Build Loop walkthrough must include create-session-pack step."],
[walkthrough, "session-packs", "Build Loop walkthrough must explain Session Packs module."],
[execution, "Codex Session Pack", "Execution Center must show recommended session pack."],
[execution, "Run in Codex", "Execution Center must expose session packs as runnable Codex input."],
[promptBuilder, "Codex Session Pack source", "Prompt Builder must expose session pack source."],
[promptBuilder, "Use session pack prompt", "Prompt Builder must load session pack markdown."],
[feedback, "Linked session pack", "Execution Feedback must link session packs."],
[feedback, "Session pack feedback boundary", "Execution Feedback must show pack feedback template."],
[feedback, "Paste Codex report generated from this session pack", "Execution Feedback textarea must adapt to linked pack."],
[history, 'kind: "sessionPack"', "Project History must include session pack records."],
[audit, "Codex session pack readiness", "Audit Center must show session pack coverage."],
[audit, "Approved handoffs without pack", "Audit Center must count handoffs without session pack."],
[workspace, "Session Packs", "Workspace must expose Session Packs."],
[workspace, "activeSessionPacks", "Workspace must inspect active session packs."],
[pkg, "audit:session-packs", "package.json must expose audit:session-packs."],
[pkg, "check-session-pack-readiness.mjs", "package.json must wire session pack readiness script."],
];
for (const [haystack, needle, message] of mustContain) {
if (!haystack.includes(needle)) failures.push(message);
}
if (page.includes("Download Bundle / ZIP")) {
failures.push("Session Packs page must use no-ZIP wording for bundle file export.");
}
if (!pkg.includes("audit:session-packs") || !pkg.includes("audit:all")) {
failures.push("audit:all must include the session pack readiness check.");
}
if (failures.length) {
console.error("ProjectBrain session pack readiness: FAIL");
for (const failure of failures) console.error(`- ${failure}`);
process.exit(1);
}
console.log("ProjectBrain session pack readiness: PASS");
+126
View File
@@ -0,0 +1,126 @@
#!/usr/bin/env node
import { existsSync, readFileSync } from "node:fs";
const requiredFiles = [
"src/shared/lib/model.ts",
"src/shared/lib/codexSkills.ts",
"src/shared/lib/repositoryIntake.ts",
"src/shared/lib/codexHandoff.ts",
"src/shared/lib/projectWizard.ts",
"src/shared/lib/safeData.ts",
"src/shared/contexts/ProjectContext.tsx",
"src/features/skills/pages/SkillLibrary.tsx",
"src/features/repo-builder/pages/RepoBuilder.tsx",
"src/features/execution/pages/ExecutionCenter.tsx",
"src/features/prompts/pages/PromptGenerator.tsx",
"src/features/history/pages/ProjectHistory.tsx",
"src/features/projects/pages/Workspace.tsx",
"src/shared/components/ProjectFlowRail.tsx",
"src/app/routes.tsx",
"src/shared/lib/navigation.tsx",
"package.json",
];
const failures = [];
for (const file of requiredFiles) {
if (!existsSync(file)) failures.push(`Missing required file: ${file}`);
}
const read = (file) => readFileSync(file, "utf8");
const fileText = Object.fromEntries(requiredFiles.filter(existsSync).map((file) => [file, read(file)]));
const catalog = fileText["src/shared/lib/codexSkills.ts"] ?? "";
const repositoryIntake = fileText["src/shared/lib/repositoryIntake.ts"] ?? "";
const codexHandoff = fileText["src/shared/lib/codexHandoff.ts"] ?? "";
const model = fileText["src/shared/lib/model.ts"] ?? "";
const routes = fileText["src/app/routes.tsx"] ?? "";
const navigation = fileText["src/shared/lib/navigation.tsx"] ?? "";
const context = fileText["src/shared/contexts/ProjectContext.tsx"] ?? "";
const skillPage = fileText["src/features/skills/pages/SkillLibrary.tsx"] ?? "";
const repoBuilder = fileText["src/features/repo-builder/pages/RepoBuilder.tsx"] ?? "";
const execution = fileText["src/features/execution/pages/ExecutionCenter.tsx"] ?? "";
const promptBuilder = fileText["src/features/prompts/pages/PromptGenerator.tsx"] ?? "";
const history = fileText["src/features/history/pages/ProjectHistory.tsx"] ?? "";
const projectWizard = fileText["src/shared/lib/projectWizard.ts"] ?? "";
const safeData = fileText["src/shared/lib/safeData.ts"] ?? "";
const flowRail = fileText["src/shared/components/ProjectFlowRail.tsx"] ?? "";
const workspace = fileText["src/features/projects/pages/Workspace.tsx"] ?? "";
const pkg = fileText["package.json"] ?? "";
const mustContain = [
[model, "export interface CodexSkill", "CodexSkill model missing."],
[model, "ProjectSkillSelection", "Project skill selection model missing."],
[model, "codexSkills?: ProjectSkillSelection[]", "Project model must persist skill selections."],
[catalog, "getBuiltInCodexSkills", "Built-in skill catalog helper missing."],
[catalog, "recommendSkillsForProject", "Project skill recommendation helper missing."],
[catalog, "formatSkillsForCodexHandoff", "Codex handoff formatter missing."],
[safeData, "codexSkills: asArray(project.codexSkills)", "Runtime normalization must preserve project skills."],
[projectWizard, "Recommended Codex skills", "Project creation output must include recommended Codex skills."],
[context, "setProjectSkillEnabled", "ProjectContext must allow enabling/disabling project skills."],
[context, "applyRecommendedProjectSkills", "ProjectContext must apply recommended skills."],
[context, "codexSkillItems", "Global search must include Codex skills."],
[routes, "/projects/:projectId/skills", "Skill Library route missing."],
[routes, "SkillLibrary", "Skill Library page missing from routes."],
[navigation, "Codex Skills", "Navigation must expose Codex Skills."],
[flowRail, 'id: "skills"', "Project flow rail must include the Skills step."],
[workspace, "Codex Skills", "Workspace must expose Codex Skills."],
[skillPage, "Built-in skills", "Skill Library must show built-in skills."],
[skillPage, "Recommended", "Skill Library must show recommended skills."],
[skillPage, "Active project skills", "Skill Library must show active project skills."],
[skillPage, "requiredContext", "Skill detail must expose required context."],
[skillPage, "validationCommands", "Skill detail must expose validation commands."],
[skillPage, "promptInstructions", "Skill detail must expose prompt instructions."],
[repoBuilder, "Skill set", "Repo Builder must expose a Skill set panel."],
[repoBuilder, "formatSkillsForCodexHandoff", "Repo Builder handoff/export must include skill blocks."],
[repoBuilder, "skillIds", "Repo Builder artifacts must persist skill links."],
[repositoryIntake, "mapRepositoryEntriesToSkills", "Repository intake must map imported repo evidence to Codex skills."],
[repositoryIntake, "linkedSkillIds", "Repository intakes must retain linked skill ids."],
[repoBuilder, "Matched skills", "Repository intake detail must show matched skills."],
[codexHandoff, "promptInstructions", "Codex handoffs must include skill prompt instructions."],
[codexHandoff, "qualityChecklist", "Codex handoffs must include skill quality checklist."],
[codexHandoff, "riskNotes", "Codex handoffs must include skill risk notes."],
[codexHandoff, "typicalFiles", "Codex handoffs must include skill typical files."],
[execution, "Active Codex Skills", "Execution Center must show active skills."],
[execution, "formatSkillsForCodexHandoff", "Execution brief must include skill blocks."],
[promptBuilder, "Active Codex Skills", "Prompt Builder must show active skills."],
[promptBuilder, "withSkillBlock", "Prompt Builder must append skill blocks to generated prompts."],
[history, 'kind: "skill"', "History must include skill records."],
[pkg, "audit:skills", "package.json must expose audit:skills."],
[pkg, "check-skill-library-readiness.mjs", "package.json must wire the skill readiness script."],
];
for (const [haystack, needle, message] of mustContain) {
if (!haystack.includes(needle)) failures.push(message);
}
const requiredSkillIds = [
"repo-foundation-build",
"frontend-react-vite-build",
"backend-api-build",
"database-migration",
"docker-runtime-hardening",
"qa-qc-review",
"documentation-system-build",
"repo-expansion-planning",
"codex-handoff-packaging",
"ui-ux-polish-pass",
"geoai-backend-build",
"postgis-migration",
"raster-pipeline",
"frontend-maplibre",
"geospatial-qa-qc-review",
"ml-pipeline-prototyping",
"etl-data-ingestion",
"auth-security-hardening",
];
for (const skillId of requiredSkillIds) {
if (!catalog.includes(`id: "${skillId}"`)) failures.push(`Built-in skill missing: ${skillId}`);
}
if (failures.length) {
console.error("ProjectBrain skill library readiness: FAIL");
for (const failure of failures) console.error(`- ${failure}`);
process.exit(1);
}
console.log("ProjectBrain skill library readiness: PASS");
@@ -0,0 +1,38 @@
#!/usr/bin/env node
import fs from "node:fs";
import path from "node:path";
const root = process.cwd();
const read = (file) => fs.readFileSync(path.join(root, file), "utf8");
const exists = (file) => fs.existsSync(path.join(root, file));
const failures = [];
const requireFile = (file, reason) => { if (!exists(file)) failures.push(`${file}: ${reason}`); };
const requireIncludes = (file, needle, reason) => {
if (!exists(file) || !read(file).includes(needle)) failures.push(`${file}: ${reason}`);
};
requireFile("src/features/source-library/pages/SourceLibrary.tsx", "Source Library page must exist.");
requireFile("src/shared/lib/projectSourceCommands.ts", "Source intake commands must exist.");
requireFile("src/shared/lib/projectOperatingStep.ts", "Shared project operating-step helper must exist.");
requireIncludes("src/app/routes.tsx", 'path="/projects/:projectId/source-library"', "Source Library route must be registered.");
requireIncludes("src/shared/lib/navigation.tsx", "Source Library", "Navigation should expose Source Library.");
requireIncludes("src/shared/lib/model.ts", "ProjectSourceItem", "Project model must preserve original source material.");
requireIncludes("src/shared/lib/model.ts", "ProjectSourceAsset", "Project model must preserve extracted media/document assets.");
requireIncludes("src/shared/lib/safeData.ts", "sourceLibrary: asArray", "Runtime normalization should keep sourceLibrary arrays safe.");
requireIncludes("src/shared/lib/safeData.ts", "mediaAssets: asArray", "Runtime normalization should keep mediaAssets arrays safe.");
requireIncludes("src/shared/contexts/ProjectContext.tsx", "addProjectSource", "ProjectContext should create source-library items.");
requireIncludes("src/shared/contexts/ProjectContext.tsx", "promoteSourceToReview", "ProjectContext should promote sources to Review Queue.");
requireIncludes("src/shared/lib/projectSourceCommands.ts", "const inferAssets", "Source intake should extract media/document asset candidates.");
requireIncludes("src/shared/lib/projectSourceCommands.ts", "mediaAssets: [...assets", "Extracted source assets should persist with project media.");
requireIncludes("src/features/operations/pages/CaptureCenter.tsx", "Source Library", "Capture Center should link to Source Library.");
requireIncludes("src/features/operations/pages/DocumentationHub.tsx", "Source-backed documentation intake", "Documentation Hub should make the source/docs link visible.");
requireIncludes("src/shared/lib/projectOperatingStep.ts", "Nieuwe opdracht geven", "The shared next-step layer should allow direct assignments without mandatory extra sources.");
requireIncludes("src/shared/components/ProjectFlowRail.tsx", "source-library", "Project flow rail should include the source-library step.");
if (failures.length) {
console.error("ProjectBrain source library readiness: FAIL");
failures.forEach((failure) => console.error(`- ${failure}`));
process.exit(1);
}
console.log("ProjectBrain source library readiness: PASS");
+24
View File
@@ -0,0 +1,24 @@
#!/usr/bin/env node
import { readFile } from "node:fs/promises";
import { resolve } from "node:path";
const root = resolve(import.meta.dirname, "..");
const manifest = JSON.parse(await readFile(resolve(root, "package.json"), "utf8"));
const command = manifest.scripts?.typecheck;
const required = ["tsconfig.app.json", "tsconfig.node.json"];
const missing = required.filter((config) => !command?.includes(`-p ${config}`));
if (missing.length) {
console.error(`TypeScript gate does not compile: ${missing.join(", ")}`);
process.exit(1);
}
for (const config of required) {
const content = await readFile(resolve(root, config), "utf8");
if (!/"noEmit"\s*:\s*true/.test(content) && !command.includes("--noEmit")) {
console.error(`TypeScript gate may emit files for ${config}.`);
process.exit(1);
}
}
console.log("TypeScript gate covers application and Node configurations.");
+155
View File
@@ -0,0 +1,155 @@
#!/usr/bin/env node
import { existsSync, readFileSync } from "node:fs";
const requiredFiles = [
"unraid/install.sh",
"unraid/projectbrain.xml",
"unraid/projectbrain-icon.png",
"unraid/README.md",
"docker-compose.yml",
"Dockerfile.unraid",
"docker/nginx.single.conf",
"scripts/deploy-unraid.sh",
"scripts/deploy-unraid-single.sh",
"scripts/unraid-single-entrypoint.sh",
"docker/unraid/README.md",
"docker/unraid/projectbrain.env.example",
"docker/unraid/projectbrain-container.env.example",
"docker/unraid/projectbrain-compose-template.xml",
"docker/unraid/projectbrain-container-template.xml",
"docker/unraid/projectbrain-icon.png",
"docs/DOCKER_UNRAID_SETUP.md",
];
const failures = [];
const read = (file) => readFileSync(file, "utf8");
for (const file of requiredFiles) {
if (!existsSync(file)) failures.push(`Missing required file: ${file}`);
}
const compose = existsSync("docker-compose.yml") ? read("docker-compose.yml") : "";
const env = existsSync("docker/unraid/projectbrain.env.example") ? read("docker/unraid/projectbrain.env.example") : "";
const containerEnv = existsSync("docker/unraid/projectbrain-container.env.example") ? read("docker/unraid/projectbrain-container.env.example") : "";
const xml = existsSync("docker/unraid/projectbrain-compose-template.xml") ? read("docker/unraid/projectbrain-compose-template.xml") : "";
const containerXml = existsSync("docker/unraid/projectbrain-container-template.xml") ? read("docker/unraid/projectbrain-container-template.xml") : "";
const icon = existsSync("docker/unraid/projectbrain-icon.png") ? "png-icon" : "";
const readme = existsSync("docker/unraid/README.md") ? read("docker/unraid/README.md") : "";
const docs = existsSync("docs/DOCKER_UNRAID_SETUP.md") ? read("docs/DOCKER_UNRAID_SETUP.md") : "";
const deploy = existsSync("scripts/deploy-unraid.sh") ? read("scripts/deploy-unraid.sh") : "";
const singleDockerfile = existsSync("Dockerfile.unraid") ? read("Dockerfile.unraid") : "";
const singleNginx = existsSync("docker/nginx.single.conf") ? read("docker/nginx.single.conf") : "";
const singleEntry = existsSync("scripts/unraid-single-entrypoint.sh") ? read("scripts/unraid-single-entrypoint.sh") : "";
const singleDeploy = existsSync("scripts/deploy-unraid-single.sh") ? read("scripts/deploy-unraid-single.sh") : "";
const unraidInstall = existsSync("unraid/install.sh") ? read("unraid/install.sh") : "";
const unraidTemplate = existsSync("unraid/projectbrain.xml") ? read("unraid/projectbrain.xml") : "";
const unraidIcon = existsSync("unraid/projectbrain-icon.png") ? "png-icon" : "";
const unraidReadme = existsSync("unraid/README.md") ? read("unraid/README.md") : "";
const mustContain = [
[compose, "${PROJECTBRAIN_WEB_PORT:-1209}:80", "docker-compose.yml must keep the frontend host port configurable."],
[compose, "${POSTGRES_PASSWORD:?", "Production compose must require a PostgreSQL password."],
[compose, "${PROJECTBRAIN_OWNER_PASSWORD:?", "Production compose must require the owner password."],
[compose, "${PROJECTBRAIN_AUTH_SECRET:?", "Production compose must require the authentication secret."],
[unraidInstall, "templates-user", "Root Unraid installer must write a DockerMan template."],
[unraidInstall, "dockerMan/images", "Root Unraid installer must write a DockerMan icon."],
[unraidInstall, "git clone", "Root Unraid installer must support installing from Gitea."],
[unraidInstall, "deploy-unraid-single.sh", "Root Unraid installer must deploy the single-container app."],
[unraidTemplate, "<Name>projectbrain</Name>", "Root Unraid template name must match the running container name so DockerMan can show Edit."],
[unraidTemplate, "<Repository>projectbrain-unraid:latest</Repository>", "Root Unraid template must reference the built ProjectBrain image."],
[unraidTemplate, "Target=\"80\"", "Root Unraid template must expose container port 80."],
[unraidTemplate, "projectbrain-icon.png", "Root Unraid template must reference the PNG icon."],
[unraidTemplate, "<ExtraParams>--label net.unraid.docker.managed=dockerman", "Root Unraid template must create DockerMan-managed containers."],
[unraidTemplate, "Target=\"PROJECTBRAIN_OWNER_PASSWORD\" Default=\"\"", "Root Unraid template must require an explicit owner password."],
[unraidTemplate, "Target=\"PROJECTBRAIN_AUTH_SECRET\" Default=\"\"", "Root Unraid template must require an explicit authentication secret."],
[unraidTemplate, "Target=\"POSTGRES_PASSWORD\" Default=\"\"", "Root Unraid template must not ship an embedded PostgreSQL password."],
[unraidIcon, "png-icon", "Root Unraid PNG icon must exist."],
[unraidReadme, "Docker > Add Container > Template: ProjectBrain", "Root Unraid README must explain where the template appears."],
[env, "PROJECTBRAIN_WEB_PORT=1209", "Unraid env template must expose PROJECTBRAIN_WEB_PORT."],
[env, "VITE_PROJECTBRAIN_CLIENT_MODE=real", "Unraid env template must default the frontend to real mode."],
[env, "PROJECTBRAIN_DATA_SOURCE=database", "Unraid env template must default backend data source to database."],
[containerEnv, "PROJECTBRAIN_IMAGE=projectbrain-unraid:latest", "Single-container env template must expose the local image name."],
[containerEnv, "PROJECTBRAIN_DOCKER_NETWORK=auto", "Single-container env template must default Docker network detection to auto."],
[containerEnv, "PROJECTBRAIN_STOP_COMPOSE_APP_SERVICES=true", "Single-container env template must stop old compose app containers by default."],
[containerEnv, "PROJECTBRAIN_USE_EXTERNAL_DATABASE=false", "Single-container env template must default to embedded Postgres."],
[containerEnv, "PROJECTBRAIN_DEPLOY_NO_CACHE=false", "Single-container env template must default to cached deploys."],
[containerEnv, "PROJECTBRAIN_MIN_DOCKER_FREE_MB=2048", "Single-container env template must expose Docker storage threshold."],
[containerEnv, "PROJECTBRAIN_REQUIRE_FRESH_BACKUP=false", "Single-container env template must expose backup freshness gate."],
[containerEnv, "POSTGRES_PASSWORD=", "Single-container env template must expose the embedded Postgres secret input."],
[xml, "<Name>ProjectBrain</Name>", "Unraid XML template must name ProjectBrain."],
[xml, "PROJECTBRAIN_WEB_PORT", "Unraid XML template must expose the web port variable."],
[xml, "<WebUI>http://[IP]:[PORT:1209]</WebUI>", "Unraid XML template must define the WebUI URL."],
[xml, "projectbrain-icon.png", "Unraid XML template must reference the PNG icon asset."],
[xml, "POSTGRES_PASSWORD", "Unraid XML template must expose Postgres password."],
[containerXml, "<Name>projectbrain</Name>", "Single-container XML template name must match the running container name."],
[containerXml, "<Repository>projectbrain-unraid:latest</Repository>", "Single-container XML template must point at the local ProjectBrain image."],
[containerXml, "<ExtraParams>--label net.unraid.docker.managed=dockerman", "Single-container XML template must create DockerMan-managed containers."],
[containerXml, "Target=\"80\"", "Single-container XML template must expose container port 80."],
[containerXml, "Target=\"/var/lib/projectbrain\"", "Single-container XML template must map persistent appdata."],
[containerXml, "Target=\"PROJECTBRAIN_OWNER_PASSWORD\" Default=\"\"", "Single-container XML template must require an explicit owner password."],
[containerXml, "Target=\"PROJECTBRAIN_AUTH_SECRET\" Default=\"\"", "Single-container XML template must require an explicit authentication secret."],
[containerXml, "Target=\"POSTGRES_PASSWORD\" Default=\"\"", "Single-container XML template must not ship an embedded PostgreSQL password."],
[containerXml, "projectbrain-icon.png", "Single-container XML template must reference the PNG icon asset."],
[icon, "png-icon", "Unraid PNG icon must exist."],
[singleDockerfile, "nginx.single.conf", "Single-container Dockerfile must use the single-container nginx config."],
[singleDockerfile, "unraid-single-entrypoint.sh", "Single-container Dockerfile must use the single-container entrypoint."],
[singleDockerfile, "HEALTHCHECK", "Single-container Dockerfile must define a healthcheck."],
[singleNginx, "127.0.0.1:4000", "Single-container nginx config must proxy API calls to the internal backend."],
[singleEntry, "DATABASE_URL is required", "Single-container entrypoint must fail clearly without DATABASE_URL."],
[singleEntry, "POSTGRES_PASSWORD is required for embedded PostgreSQL", "Single-container entrypoint must fail closed without an embedded PostgreSQL password."],
[singleEntry, "PROJECTBRAIN_USE_EXTERNAL_DATABASE", "Single-container entrypoint must default to embedded Postgres unless external DB is requested."],
[singleEntry, "initdb", "Single-container entrypoint must initialize embedded Postgres."],
[singleEntry, "nginx -g", "Single-container entrypoint must start nginx."],
[readme, "Change The External Port", "Unraid README must explain port changes."],
[readme, "Single Container", "Unraid README must document the single-container template."],
[readme, "scripts/deploy-unraid.sh", "Unraid README must reference the deploy helper."],
[readme, "scripts/deploy-unraid-single.sh", "Unraid README must reference the single-container deploy helper."],
[readme, "docker compose up -d --build", "Unraid README must include restart command."],
[docs, "projectbrain.env.example", "Docker/Unraid docs must reference the Unraid env template."],
[docs, "projectbrain-container-template.xml", "Docker/Unraid docs must reference the single-container XML template."],
[docs, "scripts/deploy-unraid.sh", "Docker/Unraid docs must reference the deploy helper."],
[docs, "scripts/deploy-unraid-single.sh", "Docker/Unraid docs must reference the single-container deploy helper."],
[deploy, "git pull --ff-only", "Unraid deploy helper must pull the latest repository state."],
[deploy, "projectbrain.env.example", "Unraid deploy helper must apply the env template."],
[deploy, "docker compose build --no-cache", "Unraid deploy helper must rebuild the stack."],
[deploy, "api/system/readiness", "Unraid deploy helper must check readiness."],
[singleDeploy, "Dockerfile.unraid", "Single-container deploy helper must build Dockerfile.unraid."],
[singleDeploy, "PROJECTBRAIN_DEPLOY_NO_CACHE", "Single-container deploy helper must make no-cache configurable."],
[singleDeploy, "docker build -f Dockerfile.unraid", "Single-container deploy helper must allow cached builds by default."],
[singleDeploy, "Docker storage preflight", "Single-container deploy helper must show Docker storage preflight."],
[singleDeploy, "docker system df", "Single-container deploy helper must print Docker storage usage."],
[singleDeploy, "PROJECTBRAIN_MIN_DOCKER_FREE_MB", "Single-container deploy helper must expose Docker free-space threshold."],
[singleDeploy, "backup freshness preflight", "Single-container deploy helper must check backup freshness."],
[singleDeploy, "PROJECTBRAIN_REQUIRE_FRESH_BACKUP", "Single-container deploy helper must expose strict backup gate."],
[singleDeploy, "git rev-parse --short HEAD", "Single-container deploy helper must report the current git commit."],
[singleDeploy, "started ${CONTAINER_NAME}", "Single-container deploy helper must report container replacement id."],
[singleDeploy, "projectbrain-postgres", "Single-container deploy helper must auto-detect the Postgres container network."],
[singleDeploy, "projectbrain-frontend projectbrain-api", "Single-container deploy helper must remove old compose app containers for live cutover."],
[singleDeploy, "projectbrain-postgres", "Single-container deploy helper must remove the old standalone ProjectBrain Postgres container."],
[singleDeploy, "projectbrain-icon.png", "Single-container deploy helper must set the Unraid PNG icon label."],
[singleDeploy, "docker run -d", "Single-container deploy helper must run one app container."],
[singleDeploy, "projectbrain-container.env.example", "Single-container deploy helper must apply the container env template."],
[singleDeploy, "api/system/readiness", "Single-container deploy helper must check readiness."],
];
for (const [haystack, needle, message] of mustContain) {
if (!haystack.includes(needle)) failures.push(message);
}
for (const secretName of ["POSTGRES_PASSWORD", "PROJECTBRAIN_OWNER_PASSWORD", "PROJECTBRAIN_AUTH_SECRET"]) {
if (new RegExp(`^${secretName}=.+$`, "m").test(containerEnv)) failures.push(`Single-container env template must not ship a default ${secretName}.`);
}
for (const [templateName, template] of [["root", unraidTemplate], ["single-container", containerXml]]) {
if (/Target="POSTGRES_PASSWORD"\s+Default="projectbrain"/i.test(template)) failures.push(`${templateName} Unraid template must not ship the historical PostgreSQL password.`);
if (/Target="PROJECTBRAIN_OWNER_PASSWORD"\s+Default="[^"]+"/i.test(template)) failures.push(`${templateName} Unraid template must not ship an owner password.`);
if (/Target="PROJECTBRAIN_AUTH_SECRET"\s+Default="[^"]+"/i.test(template)) failures.push(`${templateName} Unraid template must not ship an authentication secret.`);
}
if (failures.length) {
console.error("ProjectBrain Unraid template readiness: FAIL");
for (const failure of failures) console.error(`- ${failure}`);
process.exit(1);
}
console.log("ProjectBrain Unraid template readiness: PASS");
+25
View File
@@ -0,0 +1,25 @@
#!/usr/bin/env node
import { createHash } from "node:crypto";
import { readFile, readdir, writeFile } from "node:fs/promises";
import { resolve } from "node:path";
import process from "node:process";
const root = resolve(import.meta.dirname, "..");
const manifestPath = resolve(root, "config/v1-contract-freeze.json");
const files = ["backend/prisma/schema.prisma", "packages/contracts/index.js", "packages/contracts/index.d.ts", "docs/openapi/projectbrain-v1.json"];
const migrations = (await readdir(resolve(root, "backend/prisma/migrations"), { withFileTypes: true })).filter((entry) => entry.isDirectory()).map((entry) => entry.name).sort();
const hashes = Object.fromEntries(await Promise.all(files.map(async (name) => [name, createHash("sha256").update(await readFile(resolve(root, name))).digest("hex")])));
const current = { version: "1.0.0", migrations, hashes };
if (process.argv.includes("--write")) {
await writeFile(manifestPath, `${JSON.stringify(current, null, 2)}\n`);
console.log(`Wrote v1 contract freeze manifest with ${migrations.length} migrations.`);
} else {
const frozen = JSON.parse(await readFile(manifestPath, "utf8"));
if (JSON.stringify(frozen) !== JSON.stringify(current)) {
console.error("The frozen v1 schema/API surface changed. Review compatibility and run pnpm freeze:v1 only for an approved release decision.");
console.error("Current canonical v1 snapshot:");
console.error(JSON.stringify(current, null, 2));
process.exit(1);
}
console.log(`v1 contract freeze verified: ${files.length} canonical surfaces and ${migrations.length} migrations.`);
}
+162
View File
@@ -0,0 +1,162 @@
#!/usr/bin/env node
import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
const requiredFiles = [
"src/app/routes.tsx",
"src/shared/lib/navigation.tsx",
"src/shared/components/ProjectFlowRail.tsx",
"src/shared/components/BuildLoopActionRail.tsx",
"src/shared/components/ProjectRouteFallback.tsx",
"src/shared/lib/projectOperatingStep.ts",
"src/shared/lib/projectStartFlow.ts",
"src/shared/lib/routeRegistry.ts",
"src/features/projects/pages/Projects.tsx",
"src/features/projects/pages/Workspace.tsx",
"src/features/build-loop/pages/BuildLoopDashboard.tsx",
"src/features/repo-builder/pages/RepoBuilder.tsx",
"src/features/handoffs/pages/CodexHandoffs.tsx",
"src/features/session-packs/pages/SessionPacks.tsx",
"src/features/execution/pages/ExecutionCenter.tsx",
"src/features/execution-feedback/pages/ExecutionFeedback.tsx",
"src/features/operations/pages/ReviewQueue.tsx",
"package.json",
];
const failures = [];
for (const file of requiredFiles) {
if (!existsSync(file)) failures.push(`Missing required file: ${file}`);
}
const read = (file) => readFileSync(file, "utf8");
const text = Object.fromEntries(requiredFiles.filter(existsSync).map((file) => [file, read(file)]));
const listSourceFiles = (dir) => {
if (!existsSync(dir)) return [];
return readdirSync(dir).flatMap((entry) => {
const path = `${dir}/${entry}`;
const stat = statSync(path);
if (stat.isDirectory()) return listSourceFiles(path);
return /\.(ts|tsx|js|jsx)$/.test(path) ? [path] : [];
});
};
const sourceText = Object.fromEntries(listSourceFiles("src").map((file) => [file, read(file)]));
const routes = text["src/app/routes.tsx"] ?? "";
const navigation = text["src/shared/lib/navigation.tsx"] ?? "";
const flowRail = text["src/shared/components/ProjectFlowRail.tsx"] ?? "";
const buildLoopActionRail = text["src/shared/components/BuildLoopActionRail.tsx"] ?? "";
const projectRouteFallback = text["src/shared/components/ProjectRouteFallback.tsx"] ?? "";
const operatingStep = text["src/shared/lib/projectOperatingStep.ts"] ?? "";
const startFlow = text["src/shared/lib/projectStartFlow.ts"] ?? "";
const routeRegistry = text["src/shared/lib/routeRegistry.ts"] ?? "";
const projects = text["src/features/projects/pages/Projects.tsx"] ?? "";
const workspace = text["src/features/projects/pages/Workspace.tsx"] ?? "";
const buildLoop = text["src/features/build-loop/pages/BuildLoopDashboard.tsx"] ?? "";
const repoBuilder = text["src/features/repo-builder/pages/RepoBuilder.tsx"] ?? "";
const handoffs = text["src/features/handoffs/pages/CodexHandoffs.tsx"] ?? "";
const sessionPacks = text["src/features/session-packs/pages/SessionPacks.tsx"] ?? "";
const execution = text["src/features/execution/pages/ExecutionCenter.tsx"] ?? "";
const feedback = text["src/features/execution-feedback/pages/ExecutionFeedback.tsx"] ?? "";
const reviewQueue = text["src/features/operations/pages/ReviewQueue.tsx"] ?? "";
const pkg = text["package.json"] ?? "";
const mustContain = [
[routes, 'projectRoutePattern("workspace")', "Workspace route missing."],
[routes, "/projects/:projectId/build-loop", "Build Loop route missing."],
[routes, "/projects/:projectId/source-library", "Source Library route missing."],
[routes, "/projects/:projectId/review-queue", "Review Queue route missing."],
[routes, "/projects/:projectId/skills", "Skill Library route missing."],
[routes, "/projects/:projectId/repo-builder", "Repo Builder route missing."],
[routes, "/projects/:projectId/handoffs", "Codex Handoffs route missing."],
[routes, "/projects/:projectId/session-packs", "Session Packs route missing."],
[routes, "/projects/:projectId/execution", "Execution Center route missing."],
[routes, "/projects/:projectId/execution-feedback", "Execution Feedback route missing."],
[routes, "/projects/:projectId/history", "History route missing."],
[routes, "/projects/:projectId/audit", "Audit Center route missing."],
[routes, "/projects/:projectId/prompts", "Prompt Builder route missing."],
[navigation, "getPrimaryPathNavigationItems", "Primary navigation must expose the workspace that owns the project flow."],
[routeRegistry, 'key: "workspace"', "Canonical route registry must expose the workspace that owns the project flow."],
[flowRail, 'id: "build-loop"', "Project Flow Rail must expose Build Loop."],
[buildLoopActionRail, "Build-loop closure path", "Build Loop action rail must name the handoff-to-current-state path."],
[buildLoopActionRail, "execution output is evidence only", "Build Loop action rail must preserve review-before-truth language."],
[buildLoopActionRail, "/runtime", "Build Loop action rail must link Current State/Runtime endpoint."],
[projectRouteFallback, "Projecten zoeken", "Project route fallback must guide users back to projects."],
[projectRouteFallback, "Nieuw project starten", "Project route fallback must guide users back to the unified start flow."],
[routes, "routeProjectExists", "Route sync must avoid activating unknown project slugs."],
[routes, "ProjectRouteSync", "The app router must synchronize valid project routes."],
[navigation, "getProjectIdFromPath", "Project route parsing must be shared with navigation."],
[projects, "getProjectOperatingStep", "Projects cards must derive the shared next safe action."],
[projects, "operatingState.label", "Projects must make the recommended continuation label explicit."],
[projects, "operatingState.description", "Projects must make the recommended continuation explanation explicit."],
[buildLoop, "getBuildLoopStatus", "Technical Build Loop must derive its state from the shared helper."],
[buildLoop, "getProjectReleaseReadiness", "Technical Build Loop must retain release gates."],
[startFlow, "Repository structureren", "Project start flow must expose repository shaping."],
[workspace, "Technische details", "Workspace must progressively disclose technical execution."],
[navigation, 'level: "advanced"', "Navigation must keep specialist modules outside the primary path."],
[workspace, 'data-page-primary="true"', "Workspace must expose one state-driven primary action."],
[buildLoop, "Technische route openen", "Build Loop must provide linked module CTA."],
[buildLoop, "navigate(step.route)", "Build Loop must link each technical stage to its destination."],
[buildLoop, "navigate(gate.route)", "Build Loop must expose destinations for release evidence gates."],
[buildLoop, "getFirstTimeBuildLoopSteps", "Build Loop must derive its technical stage navigation."],
[buildLoop, "ProjectRouteFallback", "Build Loop must use the shared project fallback."],
[repoBuilder, "Huidige fase:", "Repo Builder must expose the current repository phase."],
[repoBuilder, "Repository-informatie importeren", "Repo Builder must expose repository intake as the first task in its flow."],
[repoBuilder, "ProjectBrain gebruikt dit als geïmporteerde informatie, niet als bewijs van de live-bestanden.", "Repo Builder must show the imported-evidence boundary."],
[repoBuilder, "Dit is een ProjectBrain-simulatie, geen live git-diff.", "Repo Builder must label the impact-review simulation boundary."],
[handoffs, "Review impact", "Codex Handoffs must expose Review impact flow language."],
[handoffs, "Handoff quality gate", "Codex Handoffs must expose the quality gate."],
[handoffs, "ProjectBrain-derived impact simulation, not a live git diff", "Codex Handoffs must show impact simulation boundary."],
[handoffs, "Download ZIP", "Codex Handoffs must expose real session-pack ZIP export."],
[handoffs, "Export session pack files", "Codex Handoffs must keep individual session-pack file export."],
[handoffs, "controlled input for a Codex build session", "Codex Handoffs must describe session packs as controlled input."],
[handoffs, "BuildLoopActionRail", "Codex Handoffs must show the shared build-loop action rail."],
[handoffs, "ProjectRouteFallback", "Codex Handoffs must use the shared project fallback."],
[sessionPacks, "A session pack is controlled input for a Codex build session", "Session Packs must show controlled input boundary."],
[sessionPacks, "Session pack execution path", "Session Packs must expose the execution path."],
[sessionPacks, "Download instructions/scripts ZIP", "Session Packs must expose clear instructions/scripts ZIP download."],
[sessionPacks, "Export session pack files", "Session Packs must use honest export label."],
[sessionPacks, "Create Codex handoff", "Session Packs empty state must link back to handoffs."],
[sessionPacks, "Return to Build Loop", "Session Packs empty state must link back to Build Loop."],
[sessionPacks, "ProjectRouteFallback", "Session Packs must use the shared project fallback."],
[execution, "Run in Codex", "Execution Center must expose Run in Codex step language."],
[execution, "ProjectRouteFallback", "Execution Center must use the shared project fallback."],
[feedback, "Pasted Codex reports are user-provided execution evidence", "Execution Feedback must show user-provided evidence boundary."],
[feedback, "Create follow-up handoff", "Execution Feedback must expose follow-up handoff action."],
[feedback, "BuildLoopActionRail", "Execution Feedback must show the shared build-loop action rail."],
[feedback, "ProjectRouteFallback", "Execution Feedback must use the shared project fallback."],
[reviewQueue, "BuildLoopActionRail", "Review Queue must show the shared build-loop action rail."],
[reviewQueue, "Close evidence into project state", "Review Queue must explain closure into project state."],
[reviewQueue, "ProjectRouteFallback", "Review Queue must use the shared project fallback."],
[pkg, "audit:visual-flow", "package.json must expose audit:visual-flow."],
[pkg, "check-visual-flow-readiness.mjs", "package.json must wire visual flow readiness."],
];
for (const [haystack, needle, message] of mustContain) {
if (!haystack.includes(needle)) failures.push(message);
}
for (const [file, contents] of Object.entries(sourceText)) {
if (file.startsWith("src/") && contents.includes("Download Bundle / ZIP")) {
failures.push(`${file} still contains misleading Download Bundle / ZIP copy.`);
}
if (file.startsWith("src/") && /existing (lint )?warnings remain/i.test(contents)) {
failures.push(`${file} still contains stale existing-warnings copy.`);
}
if (file.startsWith("src/features/") && contents.includes("<div>Project not found</div>")) {
failures.push(`${file} still contains a bare project-not-found fallback.`);
}
}
const scripts = JSON.parse(pkg || "{}").scripts ?? {};
const auditAll = scripts["audit:all"] ?? "";
const experienceAudit = scripts["audit:experience"] ?? "";
if (!auditAll.includes("audit:experience") || !experienceAudit.includes("audit:visual-flow")) {
failures.push("audit:all must include the experience gate with visual flow readiness.");
}
if (failures.length) {
console.error("ProjectBrain visual flow readiness: FAIL");
for (const failure of failures) console.error(`- ${failure}`);
process.exit(1);
}
console.log("ProjectBrain visual flow readiness: PASS");
+157
View File
@@ -0,0 +1,157 @@
import fs from 'node:fs';
import path from 'node:path';
const root = process.cwd();
const read = (file) => fs.readFileSync(path.join(root, file), 'utf8');
const exists = (file) => fs.existsSync(path.join(root, file));
const checks = [];
const pass = (name, ok, detail = '') => checks.push({ name, ok, detail });
const blueprint = read('src/features/blueprint/pages/BlueprintEngine.tsx');
pass('Blueprint ListChecks import is present', blueprint.includes('ListChecks'));
pass('Blueprint route should not trigger missing ListChecks ReferenceError', /import\s*\{[^}]*ListChecks[^}]*\}\s*from\s*["']lucide-react["']/.test(blueprint));
const appLayout = read('src/components/layout/AppLayout.tsx');
pass('Main shell has compact responsive header offset', appLayout.includes('pt-6') && appLayout.includes('lg:pt-10'));
pass('Main shell prevents page-level horizontal overflow', appLayout.includes('overflow-x-hidden') && appLayout.includes('min-w-0'));
const pageHeader = read('src/components/ui/PageHeader.tsx');
pass('PageHeader wraps actions on mobile', pageHeader.includes('flex-wrap') && pageHeader.includes('md:w-auto') && pageHeader.includes('break-words'));
const routes = read('src/app/routes.tsx');
pass('Route scroll reset is enabled', routes.includes('ScrollToTop') && routes.includes('data-projectbrain-main'));
pass('Route loading fallback explains project loading', routes.includes('Project laden…') && routes.includes('aria-label="ProjectBrain-pagina laden"'));
const sidebar = read('src/components/layout/Sidebar.tsx');
pass('Sidebar descriptions are hidden on smaller desktop widths', sidebar.includes('xl:block'));
const prompt = read('src/features/prompts/pages/PromptGenerator.tsx');
pass('Prompt Builder starts with task input', prompt.includes('What should AI/Codex do?'));
pass('Prompt Builder uses multiline output area', prompt.includes('min-h-40 font-mono text-xs'));
const knowledge = read('src/features/knowledge/pages/KnowledgeVault.tsx');
pass('Knowledge Vault avoids preview wording in primary actions', !knowledge.includes('preview'));
const audit = read('src/features/audit/pages/ProjectAuditCenter.tsx');
pass('Audit Center treats old priority labels as historical', audit.includes('was {finding.severity}'));
const header = read('src/components/layout/Header.tsx');
pass('Header New Project menu navigates to unified Start', header.includes('navigate("/start")'));
pass('Header exposes runtime status popover', header.includes('Runtimestatus') && header.includes('useBackendSystemReadinessData'));
pass('Header runtime status links to Runtime page', header.includes('Runtimepagina') && header.includes('/runtime'));
pass('Header labels unavailable backend as local mode', header.includes('Lokale modus') && !header.includes('degraded: "Degraded"'));
const commandMenu = read('src/components/layout/CommandMenu.tsx');
const commandMenuSearch = read('src/components/layout/commandMenuSearch.ts');
pass('Command palette shortcut listens in capture phase', commandMenu.includes('window.addEventListener("keydown", down, { capture: true })'));
pass(
'Command palette prioritizes best-matching navigation while searching',
commandMenu.includes('rankNavigationItems') &&
commandMenu.includes('<CommandGroup heading="Beste overeenkomsten">') &&
commandMenu.includes('{navigationSearchGroup}') &&
commandMenuSearch.includes('getNavigationScore') &&
commandMenuSearch.includes('label.startsWith(normalizedQuery)'),
);
pass('Command palette teaches route-oriented examples', commandMenu.includes('Zoek op repo, review, contextpack, historiek, bron of skill'));
const commandUi = read('src/components/ui/command.tsx');
pass('Command dialog has screenreader title and description', commandUi.includes('DialogTitle className="sr-only"') && commandUi.includes('DialogDescription className="sr-only"'));
const projectsPage = read('src/features/projects/pages/Projects.tsx');
pass(
'Projects mobile hero actions are full-width and stacked',
projectsPage.includes('flex w-full flex-col gap-2 sm:w-auto sm:flex-row') &&
projectsPage.includes('className="w-full sm:w-auto"') &&
projectsPage.includes('{refreshing ? "Bijwerken…" : "Bijwerken"}') &&
projectsPage.includes('disabled={refreshing}'),
);
pass('Projects progressively reveal large portfolios', projectsPage.includes('PROJECT_PAGE_SIZE') && projectsPage.includes('remainingProjectCount'));
const runtimeReadiness = read('src/features/projects/pages/RuntimeReadiness.tsx');
pass(
'System status prevents page-level mobile overflow',
runtimeReadiness.includes('min-w-0 space-y-6 overflow-x-hidden') &&
runtimeReadiness.includes('grid min-w-0 gap-3 sm:grid-cols-2 xl:grid-cols-3'),
);
pass(
'System status capability cards wrap long runtime evidence safely',
runtimeReadiness.includes('className="min-w-0 overflow-hidden"') &&
runtimeReadiness.includes('[overflow-wrap:anywhere]') &&
runtimeReadiness.includes('max-w-[45%] shrink-0 whitespace-normal text-center'),
);
pass(
'System status release/build proof avoids mobile overflow',
runtimeReadiness.includes('min-w-0 overflow-hidden scroll-mt-24 border-primary/25') &&
runtimeReadiness.includes('break-all font-semibold') &&
runtimeReadiness.includes('break-all font-mono text-xs'),
);
const review = read('src/features/operations/pages/ReviewQueue.tsx');
pass('Review Queue bulk accept requires confirmation', review.includes('Confirm bulk accept'));
pass('V84 documentation exists', exists('docs/STABILIZATION_PASS_V84_VISUAL_RC_POLISH_2.md'));
pass('V85 documentation exists', exists('docs/STABILIZATION_PASS_V85_RUNTIME_STATUS_POLISH.md'));
pass('V87 correction documentation exists', exists('docs/STABILIZATION_PASS_V87_REAL_PRODUCT_IMPROVEMENT_CORRECTION.md'));
pass('Audit Center no longer exposes V86 catalog tab', !audit.includes('V86 1000') && !audit.includes('megaImprovementItems'));
const relationshipGraph = read('src/features/relationships/pages/RelationshipGraph.tsx');
pass('Relationship Graph shows real suggestions instead of only empty state', relationshipGraph.includes('buildRelationshipSuggestions') && relationshipGraph.includes('suggestions.map'));
const repoBuilder = read('src/features/repo-builder/pages/RepoBuilder.tsx');
pass('Repo Builder mobile lifecycle has swipe affordance', repoBuilder.includes('Veeg zijwaarts om de latere fasen te bekijken.') && repoBuilder.includes('overflow-x-auto'));
pass(
'Repo Builder archive actions are confirmation-gated',
repoBuilder.includes('ConfirmActionButton') &&
repoBuilder.includes('title="Geselecteerde repository-intake archiveren?"') &&
repoBuilder.includes('onConfirm={archiveSelectedRepositoryIntake}') &&
repoBuilder.includes('title="Deze repositorybasis archiveren?"') &&
repoBuilder.includes('onConfirm={archiveBlueprint}') &&
repoBuilder.includes('title="Deze uitbreidingsbrief archiveren?"') &&
repoBuilder.includes('onConfirm={archiveExpansion}') &&
repoBuilder.includes('title="Dit deliverypakket archiveren?"') &&
repoBuilder.includes('onConfirm={archiveDeliveryPackage}') &&
repoBuilder.includes('title="Dit repositorywerkplan archiveren?"') &&
repoBuilder.includes('onConfirm={archiveWorkPlan}') &&
repoBuilder.includes('title="Deze repositorystatus archiveren?"') &&
repoBuilder.includes('onConfirm={archiveStateSnapshot}'),
);
const sessionPacks = read('src/features/session-packs/pages/SessionPacks.tsx');
pass('Session Packs exposes top next action for empty state', sessionPacks.includes('Create a Codex handoff before packaging a session.'));
pass('Session Packs archive action is confirmation-gated', sessionPacks.includes('Archive this session pack?') && sessionPacks.includes('AlertDialogAction onClick={archiveSelected}'));
const browserBridge = read('src/features/browser-bridge/pages/BrowserBridge.tsx');
pass('Browser Bridge exposes top session-pack-required action', browserBridge.includes('Session pack required') && browserBridge.includes('Open Session Packs'));
pass('Browser Bridge archive action is confirmation-gated', browserBridge.includes('Archive this browser bridge session?') && browserBridge.includes('AlertDialogAction onClick={archiveSession}'));
const codexHandoffs = read('src/features/handoffs/pages/CodexHandoffs.tsx');
pass('Codex handoff archive actions are confirmation-gated', codexHandoffs.includes('Archive this Codex handoff?') && codexHandoffs.includes('Archive this impact review?'));
const runtimeStateManager = read('src/features/operations/pages/RuntimeStateManager.tsx');
pass('Runtime reset uses accessible AlertDialog instead of native confirm', runtimeStateManager.includes('AlertDialogTitle') && !runtimeStateManager.includes('window.confirm'));
pass('Compact runtime state manager hides import-only control', runtimeStateManager.includes('!compact ? (') && runtimeStateManager.includes('Import JSON'));
const confirmActionButton = read('src/shared/components/ConfirmActionButton.tsx');
pass('Shared confirm action button uses accessible AlertDialog', confirmActionButton.includes('AlertDialogTitle') && confirmActionButton.includes('confirmLabel'));
const executionFeedback = read('src/features/execution-feedback/pages/ExecutionFeedback.tsx');
pass('Execution Feedback archive action is confirmation-gated', executionFeedback.includes('Archive this execution feedback?'));
const sourceLibrary = read('src/features/source-library/pages/SourceLibrary.tsx');
pass(
'Source Library archive action is confirmation-gated',
sourceLibrary.includes('ConfirmActionButton') &&
sourceLibrary.includes('title="Deze bron archiveren?"') &&
sourceLibrary.includes('confirmLabel="Bron archiveren"') &&
sourceLibrary.includes('onConfirm={() => archive(source.id)}'),
);
const projectHistory = read('src/features/history/pages/ProjectHistory.tsx');
pass('Project History remove action is confirmation-gated', projectHistory.includes('Dit geschiedenisitem verbergen?'));
const runtimeGateEvidence = read('src/features/operations/pages/RuntimeGateEvidence.tsx');
pass('Runtime evidence reset is confirmation-gated', runtimeGateEvidence.includes('Reset P2 evidence history?'));
const runtimeGateChecklist = read('src/features/operations/pages/RuntimeGateChecklist.tsx');
pass('Runtime checklist reset is confirmation-gated', runtimeGateChecklist.includes('Reset P2 checklist?'));
const runtimeTodoBoard = read('src/features/operations/pages/RuntimeTodoBoard.tsx');
pass('Runtime todo removal is confirmation-gated', runtimeTodoBoard.includes('Remove this runtime todo?'));
const packageJson = read('package.json');
pass('Mega audit command is removed from package scripts', !packageJson.includes('audit:mega'));
const failed = checks.filter((check) => !check.ok);
if (failed.length > 0) {
console.error('ProjectBrain visual polish audit: FAIL');
for (const check of failed) console.error(`- ${check.name}${check.detail ? `: ${check.detail}` : ''}`);
process.exit(1);
}
console.log('ProjectBrain visual polish audit: PASS');
for (const check of checks) console.log(`- ${check.name}`);
+16
View File
@@ -0,0 +1,16 @@
// Unknown identities and evidence from different container rows never prove a deployment.
export const compareDeploymentCommits = (local, remote) => {
const isCommit = (value) => /^[a-f0-9]{7,40}$/i.test(value ?? "");
if (!isCommit(local) || !isCommit(remote)) return "unknown";
return local.toLowerCase() === remote.toLowerCase() ? "current" : "stale";
};
export const isRunningContainer = (output, name, requiredPort) => output
.split(/\r?\n/)
.some((line) => {
const [actualName, ...fields] = line.trim().split(/\s+/);
const details = fields.join(" ");
return actualName === name && /^Up\b/.test(details)
&& !/\((?:unhealthy|health: starting)\)/.test(details)
&& (!requiredPort || details.includes(requiredPort));
});
+27
View File
@@ -0,0 +1,27 @@
import assert from "node:assert/strict";
import test from "node:test";
import { compareDeploymentCommits, isRunningContainer } from "./deployment-proof-state.mjs";
test("missing identities cannot be reported as current", () => {
for (const value of ["unknown", "", undefined, "not-a-sha"]) {
assert.equal(compareDeploymentCommits(value, value), "unknown");
assert.equal(compareDeploymentCommits("9deb553", value), "unknown");
}
});
test("known revisions compare exactly", () => {
assert.equal(compareDeploymentCommits("9deb553", "9DEB553"), "current");
assert.equal(compareDeploymentCommits("9deb553", "a415d3f"), "stale");
});
test("frontend evidence must belong to its own running container", () => {
const output = "projectbrain-frontend Exited 1\nother Up 1 hour 0.0.0.0:1209->80/tcp";
assert.equal(isRunningContainer(output, "projectbrain-frontend", "1209->80/tcp"), false);
});
test("reject similarly named or unhealthy containers", () => {
for (const row of ["projectbrain-api-old Up 1 hour", "projectbrain-api Up 1 hour (unhealthy)", "projectbrain-api Up 2 seconds (health: starting)"]) {
assert.equal(isRunningContainer(row, "projectbrain-api"), false);
}
});
test("accept exact running containers with matching ports", () => {
assert.equal(isRunningContainer("projectbrain-frontend Up 1 hour (healthy) 0.0.0.0:1209->80/tcp\r\n", "projectbrain-frontend", "1209->80/tcp"), true);
assert.equal(isRunningContainer("projectbrain-api Up 1 hour", "projectbrain-api"), true);
});
+18
View File
@@ -0,0 +1,18 @@
#!/usr/bin/env node
import { mkdir, readFile, writeFile } from "node:fs/promises";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { buildOpenApiDocument } from "@projectbrain/contracts";
const root = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const target = resolve(root, "docs/openapi/projectbrain-v1.json");
const generated = `${JSON.stringify(buildOpenApiDocument(), null, 2)}\n`;
if (process.argv.includes("--check")) {
const existing = await readFile(target, "utf8").catch(() => "");
if (existing !== generated) { console.error("OpenAPI artifact is stale. Run pnpm docs:openapi."); process.exit(1); }
console.log("OpenAPI artifact matches canonical contracts.");
} else {
await mkdir(dirname(target), { recursive: true });
await writeFile(target, generated);
console.log(`Wrote ${target}`);
}
+64
View File
@@ -0,0 +1,64 @@
#!/usr/bin/env node
import { execFileSync } from "node:child_process";
import { createHash, randomUUID } from "node:crypto";
import { mkdir, readFile, readdir, stat, writeFile } from "node:fs/promises";
import { resolve, relative } from "node:path";
const root = resolve(import.meta.dirname, "..");
const outputFlag = process.argv.indexOf("--output");
const output = resolve(root, outputFlag >= 0 ? process.argv[outputFlag + 1] : "release-artifacts");
const sha256 = (value) => createHash("sha256").update(value).digest("hex");
const command = (program, args) => execFileSync(program, args, { cwd: root, encoding: "utf8", windowsHide: true }).trim();
const pnpmOutput = process.env.npm_execpath
? command(process.execPath, [process.env.npm_execpath, "list", "--prod", "--json", "--depth", "Infinity"])
: command("pnpm", ["list", "--prod", "--json", "--depth", "Infinity"]);
const listed = JSON.parse(pnpmOutput);
const components = new Map();
const collect = (dependencies = {}) => {
for (const [name, dependency] of Object.entries(dependencies)) {
if (dependency.version) components.set(`${name}@${dependency.version}`, { type: "library", name, version: dependency.version, purl: `pkg:npm/${encodeURIComponent(name)}@${dependency.version}` });
collect(dependency.dependencies);
}
};
for (const project of listed) collect(project.dependencies);
const packageJson = JSON.parse(await readFile(resolve(root, "package.json"), "utf8"));
const serialNumber = `urn:uuid:${randomUUID()}`;
const sbom = {
bomFormat: "CycloneDX",
specVersion: "1.6",
serialNumber,
version: 1,
metadata: { component: { type: "application", name: packageJson.name, version: packageJson.version }, tools: { components: [{ type: "application", name: "ProjectBrain release metadata generator", version: "1.0.0" }] } },
components: [...components.values()].sort((left, right) => left.purl.localeCompare(right.purl)),
};
const artifactFiles = [];
const collectFiles = async (directory) => {
for (const entry of await readdir(directory, { withFileTypes: true })) {
const path = resolve(directory, entry.name);
if (entry.isDirectory()) await collectFiles(path);
else artifactFiles.push({ name: relative(root, path).replaceAll("\\", "/"), digest: { sha256: sha256(await readFile(path)) }, bytes: (await stat(path)).size });
}
};
await collectFiles(resolve(root, "dist"));
for (const name of ["Dockerfile.frontend", "Dockerfile.backend", "Dockerfile.unraid", "pnpm-lock.yaml"]) {
const path = resolve(root, name);
try { artifactFiles.push({ name, digest: { sha256: sha256(await readFile(path)) }, bytes: (await stat(path)).size }); } catch (error) { if (error.code !== "ENOENT") throw error; }
}
artifactFiles.sort((left, right) => left.name.localeCompare(right.name));
const revision = command("git", ["rev-parse", "HEAD"]);
const provenance = {
predicateType: "https://slsa.dev/provenance/v1",
subject: artifactFiles,
predicate: {
buildDefinition: { buildType: "https://projectbrain.local/build/v1", externalParameters: { node: process.version, packageManager: packageJson.packageManager, revision }, resolvedDependencies: [{ uri: "git+local:projectbrain", digest: { sha1: revision } }] },
runDetails: { builder: { id: "https://projectbrain.local/ci" }, metadata: { invocationId: process.env.GITHUB_RUN_ID ?? `local-${revision.slice(0, 12)}`, startedOn: new Date().toISOString(), sourceDirty: command("git", ["status", "--porcelain"]) !== "" } },
},
sbom: { file: "projectbrain.cdx.json", sha256: sha256(JSON.stringify(sbom)) },
};
await mkdir(output, { recursive: true });
await writeFile(resolve(output, "projectbrain.cdx.json"), `${JSON.stringify(sbom, null, 2)}\n`);
await writeFile(resolve(output, "projectbrain.provenance.json"), `${JSON.stringify(provenance, null, 2)}\n`);
console.log(`Generated ${components.size} component SBOM and provenance for ${artifactFiles.length} artifacts in ${output}.`);
+76
View File
@@ -0,0 +1,76 @@
import { readFileSync, writeFileSync } from "node:fs";
import { resolve } from "node:path";
const root = process.cwd();
const packages = [
{ scope: "root", path: "package.json" },
{ scope: "backend", path: "backend/package.json" },
];
const classify = (name, command) => {
const value = `${name} ${command}`.toLowerCase();
if (value.includes("rollback")) return "rollback";
if (value.includes("prisma migrate") || value.includes("db:deploy") || value.includes("db:migrate") || value.includes("db:status")) return "migration";
if (value.includes("database") || value.includes("db:seed") || /^smoke:p[2-8]$/.test(name)) return "database integration";
if (value.includes("deploy") || value.includes("docker") || value.includes("unraid")) return "deployment";
if (value.includes("playwright") || value.includes("browser") || value.includes("clickthrough")) return "browser E2E";
if (value.includes("axe") || value.includes("accessib")) return "accessibility";
if (value.includes("audit") && (value.includes("security") || value.includes("secret") || value.includes("container"))) return "security";
if (value.includes("backup") || value.includes("restore") || value.includes("recovery") || value.includes("freshness")) return "recovery";
if (name.startsWith("smoke:")) return "API smoke";
if (name.startsWith("test") || value.includes("vitest")) return "unit";
if (name === "typecheck" || name === "lint" || name === "build" || name.startsWith("validate:")) return "static";
if (name.startsWith("audit:") || name === "doctor") return "source guard";
if (name.startsWith("release:")) return "deployment";
return "static";
};
const modeFor = (name, command, category) => {
const value = `${name} ${command}`.toLowerCase();
if (category === "database integration" || category === "migration") return "real database";
if (value.includes("fixture")) return "fixture";
if (value.includes("real")) return "real API (data mode depends on runtime)";
if (category === "source guard" || category === "static" || category === "security") return "source only";
return "runtime-dependent";
};
const rows = [];
for (const pkg of packages) {
const json = JSON.parse(readFileSync(resolve(root, pkg.path), "utf8"));
for (const [name, command] of Object.entries(json.scripts ?? {})) {
const category = classify(name, command);
rows.push({ scope: pkg.scope, name, command, category, mode: modeFor(name, command, category) });
}
}
rows.sort((a, b) => a.scope.localeCompare(b.scope) || a.name.localeCompare(b.name));
const escapeCell = (value) => String(value).replaceAll("|", "\\|").replaceAll("\n", " ");
const lines = [
"# ProjectBrain Validation Matrix",
"",
`Generated: ${new Date().toISOString()}`,
"",
"This inventory classifies every package script. Classification describes what the command actually proves, not what its name claims. Results and artifacts remain `Not run in current matrix` until a categorized runner records them.",
"",
"## Evidence policy",
"",
"Mock, fixture and real-database evidence are never interchangeable. A source guard cannot close a runtime, database, recovery, deployment or rollback gate.",
"",
"| Scope | Script | Category | Mode/environment | Command | Latest result | Evidence | Commit |",
"| --- | --- | --- | --- | --- | --- | --- | --- |",
...rows.map((row) => `| ${escapeCell(row.scope)} | \`${escapeCell(row.name)}\` | ${escapeCell(row.category)} | ${escapeCell(row.mode)} | \`${escapeCell(row.command)}\` | Not run in current matrix | — | — |`),
"",
"## Current release-evidence gaps",
"",
"The repository now contains unit, Fastify injection, disposable-PostgreSQL, Playwright/accessibility, recovery, secret and container-image gates. Script presence and local execution do not prove a release artifact.",
"",
"- The categorized Gitea workflow has not been observed on its remote runner for the candidate commit.",
"- Built OCI image scans, SBOM/provenance and archive digests are encoded in CI but no successful remote artifact run is attached yet.",
"- Clean-room target installation, target-host timed rollback and post-deploy telemetry remain unexecuted.",
"- Independent usability/screen-reader and operator-runbook executions remain unsigned.",
"",
];
writeFileSync(resolve(root, "docs/VALIDATION_MATRIX.md"), lines.join("\n"), "utf8");
console.log(`Wrote docs/VALIDATION_MATRIX.md with ${rows.length} script classifications.`);
@@ -0,0 +1 @@
__pycache__/
@@ -0,0 +1,206 @@
# Controlled legacy deployment migration
`plan.py` is a **pure planner**. `execute.py prepare` is an explicit opt-in,
build-only preparation command. Separate `cutover` and `recover` modes require
`--apply` and the exact verified preparation identity. Tests use fixtures/offline
Compose rendering. The first verified production migration is recorded below;
test results alone must never be presented as proof of a production cutover.
Authoritative broker code: `Unraid-AppOps-Gateway/ops/gitea-deploy-broker/deploy.py`.
Production installs it separately at
`/mnt/pool/appdata/gitea-deploy-runners/controller/deploy.py`. Production projects
configuration is intentionally outside Git. Compare normalized source before changes.
## Why standard retry/bootstrap cannot migrate these two
VacatureRadar and LumaOps have healthy legacy containers but schema3 failed state
with no known live commit or artifacts. `capture_compose_rollback` rightly refuses
unattested rollback. `bootstrap-legacy` requires exact historical successful legacy
state and refuses schema3. Never invent a revision or a successful receipt.
Standard preflight overlays live appdata, and standard migration cleanup removes
retained legacy containers. Neither is suitable for a rollback-retaining migration
with unknown original provenance. Use a reviewed separate one-shot transaction.
## Required transaction design
1. Pin/validate exact Gitea commit and tree. Prepare a separate clean build context;
external credentials remain outside it. Build before disrupting production.
Require immutable OCI source/revision/tree/version/time labels, not container
labels alone. Existing configurations do not enable these checks yet.
2. Acquire the same project then global locks as the broker. Revalidate original
container and immutable image IDs, healthy state, exact mount inventory, free
disk space, candidate network/ports, and absence of attempt targets. Protect the
original image with an immutable rollback reference. Persist a root-only journal
and original configuration snapshot without exposing secrets in output.
3. Stop the original, cold-copy and verify all writable data into new attempt-owned
paths. VacatureRadar runs PostgreSQL17 and Django migrations on startup: restarting
the old image on migrated data is NOT rollback. Its nested `local`, logs and media
bind mounts share one cold copy. LumaOps has bind mounts plus an anonymous `/config`
volume; include both with their nested destination semantics preserved.
4. Retain/rename the exact original container. Start only the candidate under a
distinct Compose project (`<slug>-managed`), preserving production name/ports.
The candidate mounts clones exclusively. A distinct project prevents Compose from
adopting/removing retained original containers or failing exact-container-set checks.
5. Verify immutable built image IDs, provenance and Docker health plus representative
HTTP behavior. Only then obtain the real broker receipt and commit broker config
(exact source, external env, explicit cloned data paths, new project identity,
OCI verification) and state. Keep the originals and cold-copy journal available.
6. On any failure before commit, remove only recorded attempt-created container IDs
after identity checks, restore broker configuration, and rename/restart the exact
original container with original untouched data. Verify old health. A failed restore
is reported as unknown/failed, never as healthy. Keep partial artifacts for recovery.
Process-crash recovery needs a durable phase journal and idempotent identity checks;
exception handlers alone are insufficient. Source-tree, data, broker configuration,
runtime and receipt transitions must be separately recorded. Do not automatically
clean originals after success. Retention cleanup needs separate authorization.
`build_plan` accepts a strict sanitized runtime projection: `name`, `containerId`,
`imageId`, `running`, `health`, `composeProject`, and `mounts` (type/source/destination/rw).
Never feed or print full Docker inspect/environment output. This planner validates
paths lexically; the executor also resolves roots, rejects symlinks/special files,
verifies backup checksums/ownership and revalidates state while locks are held.
Run: `python -m unittest discover -s scripts/legacy-deployment-migration -p 'test_*.py'`.
## Build-only preparation
After the two tracked `docker-compose.migration.yml` files are reviewed and merged,
`execute.py prepare <slug> <full-current-branch-sha> <unique-attempt> --container-id
<expected-id> --image-id <expected-immutable-id> --apply` prepares on the controller.
Without `--apply` it performs no work. It validates the reviewed broker artifact,
current branch head and original healthy container identity, then holds the normal
project/global locks while extracting an isolated exact source tree and building an
candidate image through the exact merged Compose configuration. OCI labels are
checked on its immutable image ID. Source, original runtime and mounts are rechecked
after build. Runtime environment is captured privately for Compose interpolation;
credentials remain outside the build context and are not supplied as build arguments.
Root-only journal/logs retain interrupted/failed attempts; a repeated
attempt refuses to overwrite them. No production configuration/state/data is written.
The build-only journal is NOT a live receipt. `cutover` starts that same image using
Compose `up --no-build --pull never`, then normal broker verification binds the
observed runtime to its prepared immutable image ID. No rebuild occurs during downtime.
Only a genuine normal broker receipt can be committed.
Tracked Compose override field contract (all required, no live-data defaults):
- Shared: `MIGRATION_ATTEMPT`, `MIGRATION_IMAGE`, `MIGRATION_SOURCE_REVISION`, `MIGRATION_SOURCE_TREE`,
`MIGRATION_BUILD_ID`, `MIGRATION_BUILD_DATE`.
- VacatureRadar: `MIGRATION_LOCAL_ROOT`, `VACATURERADAR_ENV_FILE`.
- LumaOps: `MIGRATION_CONFIG_ROOT` (anonymous `/config` clone),
`MIGRATION_OPENRGB_ROOT`, `MIGRATION_LUMA_CONFIG_ROOT`, `MIGRATION_DATA_ROOT`,
`MIGRATION_LOGS_ROOT`, `MIGRATION_ENV_FILE`.
Pass the protected external runtime env file as Compose `--env-file` as well,
preserving base-file interpolation values. Before cutover validate rendered absolute
mounts against the plan, reject originals, and verify `create_host_path:false` so a
missing copy cannot silently become an empty data directory. The normal broker
requires Compose files to exist in the committed tree, which is why these overrides
are tracked app files rather than a generated external override.
## Cutover and recovery
Use the same slug, revision, attempt and expected original IDs as preparation:
`execute.py cutover ... --apply`. Selected project configuration/state hashes,
source, image, runtime env, original IDs/health/mounts and rendered clone mounts/ports
are revalidated before stopping. Both normal deployment locks remain held throughout.
The durable journal records intent before `stopping`, `copying`, `renaming`,
`deploying`, `verifying`, and `committing`. Cold copies use `cp -a --reflink=auto`,
filesystem sync and SHA256/ownership/mode/xattr/hardlink verification. The original
is retained under a distinct name and immutable image tag. Broker config/state and
original container configuration are saved root-only. The candidate never mounts
original data. The new broker project uses dynamic revision/tree/build placeholders
so subsequent normal deployments do not reuse frozen provenance labels.
An ordinary failure attempts verified recovery automatically. After abrupt process
termination use `execute.py recover ... --apply`: it accepts only the saved original
or candidate config/state, checks rollback snapshot integrity, and removes only the
exact owned candidate (nonce, Compose project, recorded ID where available). It
restores original config/state, canonical name and original untouched data, then
verifies original immutable image/mounts, Docker health and HTTP readiness. Candidate
data, logs and all originals are retained. Unrelated containers/config/state or
changed original data block automatic recovery rather than be overwritten/deleted.
Recovery after the final `committed` journal marker reverifies production and resumes activation; it
does not roll back potentially newer application writes. Recovery before that marker
restores the original, including a crash after receipt/config write but before final
journal commit. Failed recovery remains explicitly `recovery_failed`, never healthy.
Independent review and actual prepared-image/maintenance-window checks are required
before first invocation. Health paths were probed read-only and returned HTTP 200 on
2026-09-09: VacatureRadar port1226 `/health/ready/`, LumaOps port1223 `/health/ready`.
These endpoints supplement, not replace, immutable image and normal receipt checks.
VacatureRadar's reviewed source must contain both the supervised Celery startup hold
and first-position HTTP maintenance middleware. The helper writes an attempt-bound
`.migration-worker-hold` only into the verified cold clone before candidate start.
Ordinary traffic receives 503; exact GET/HEAD readiness/liveness probes remain available.
After durable commit, activation journals intent, removes only the matching marker,
fsyncs its directory, and records completion. Activation failure stays committed:
explicit recovery resumes release and never rolls back after workers can run.
A missing marker without durable activation intent fails closed.
Both tracked overrides require `MIGRATION_NETWORK`, bound to the exact inspected
original `<slug>_default` network. Different/additional original networks fail closed;
the rendered candidate must retain that network. Network/device
compatibility and app-specific migration behavior still require operator review;
the helper is not a generalized transaction system for external services.
LumaOps has no application maintenance gate in this migration. Its candidate can
serve requests and interact with RGB hardware before the receipt is committed.
Cold-data rollback restores the original application/container/data, but cannot
undo external hardware effects or preserve candidate writes from that interval.
Perform its cutover in the approved maintenance window with no concurrent user
changes; do not describe this as a zero-effect or zero-data-loss transaction.
## Handoff safety boundary (2026-09-09)
The guarded broker artifact expected by `execute.py` has normalized SHA256
`b5a0467f53acba5c783781a22bf81cf99f8b96ae99535460ad6236fac038330d`.
This reviewed broker revision was installed atomically on 2026-09-09 after
AppOps PR30 passed both validations and merged as `219184267972c596e7b61bbb29f0fab1b6472c9b`.
The previous controller is retained as `deploy.py.before-single-runtime-rollback-20260909`.
The helper refuses to run until the host artifact matches. Candidate devices, capabilities, read-only root,
privilege, security options and tmpfs must match the inspected original exactly.
It rejects stale selected-project configuration after acquiring the project lock.
Preparation also records selected-project configuration and original state hashes;
cutover compares both before making any production change.
Simulated failure tests cover interruption after original stop, after rename and
after receipt/config write before journal commit. Negative adapter tests cover
unrelated candidate nonce/project/IDs and binding broker verification to the prepared
immutable image. Offline Compose tests verify effective clone mounts and missing-input
failures. These tests do not claim a live migration or an end-to-end Docker rollback.
The protected-main approval/release gate for the app overrides and other prerequisite
branches must be satisfied before selecting a preparation SHA. Never run preparation
against an unmerged workaround or claim these local tests prove a live migration.
## Verified production outcome, 2026-09-09
VacatureRadar was prepared and committed from merged revision
`38e87678e1c5b6aa6de2cb49e49366fce8a3cbf2`, image
`sha256:c410c5ec26deb0cbd6a2992d4741e6c356f537d087de7fade74f214a747cca5a`.
The protected journal is under the controller's
`legacy-migrations/vacatureradar/legacy-20260909-a/` directory and records
`committed`, `activationIntent:true`, `activated:true`. The original container
`VacatureRadar-legacy-legacy-20260909-a` remains stopped with its original data.
Docker health, HTTP readiness, the login page and running Celery processes were
checked after activation. Normal Gitea deployment run9748 succeeded against the
same verified revision. No post-commit rollback was attempted.
The initial helper's Docker JSON template incorrectly grouped `index` arguments.
Preparation rejected this before production changes. Revision `4d1fc0d` fixed it,
with a regression test and successful read-only qualification against real Docker.
The actual cutover used that reviewed helper revision. Three additional Linux
storage tests used isolated temporary fixtures, not application data.
LumaOps has **not** been migrated. Its original runtime has `AUTH_ENABLED=false`
and no admin token; the merged target requires authentication on its production
network binding. Preparation stopped before building or changing production.
Provisioning secure login needs an explicit operator decision; never disable the
target's authentication guard or describe the old runtime as a verified new build.
Failed preparation artifacts are retained; no LumaOps cutover journal exists.
@@ -0,0 +1,221 @@
"""Explicit fixed-target preparation, journaled cutover and crash recovery.
Run on the reviewed Unraid controller host. Builds a verified exact-source candidate
Preparation leaves live config/state/containers/data untouched. Cutover is a
separate --apply operation requiring a reviewed, verified preparation. Originals
and failed candidate data are retained; recover never undoes a committed migration.
"""
import argparse
from contextlib import ExitStack
import hashlib
import importlib.util
import json
import os
from pathlib import Path
import re
import subprocess
from plan import ATTEMPT, ID, SHA, TARGETS
BROKER_PATH = Path("/mnt/pool/appdata/gitea-deploy-runners/controller/deploy.py")
BROKER_SHA = "b5a0467f53acba5c783781a22bf81cf99f8b96ae99535460ad6236fac038330d"
JOURNAL_ROOT = Path("/mnt/pool/appdata/gitea-deploy-runners/legacy-migrations")
DOCKERFILES = {"vacatureradar": "Dockerfile.unraid", "lumaops": "Dockerfile"}
def durable_json(path, payload):
temporary = path.with_suffix(".tmp")
descriptor = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
with os.fdopen(descriptor, "w") as stream:
json.dump(payload, stream, sort_keys=True)
stream.flush()
os.fsync(stream.fileno())
os.replace(temporary, path)
descriptor = os.open(path.parent, os.O_RDONLY)
try:
os.fsync(descriptor)
finally:
os.close(descriptor)
def require_real_child(path, parent):
resolved = path.resolve()
root = parent.resolve()
if path.is_symlink() or resolved == root or not resolved.is_relative_to(root):
raise ValueError("Candidate path is not a private child of the fixed root")
return resolved
def checked(command, *, log=None, timeout=30, env=None, cwd=None):
# Do not inherit application credentials into builds or print raw subprocess output.
environment = {key: os.environ[key] for key in ("PATH", "HOME", "DOCKER_CONFIG") if key in os.environ}
result = subprocess.run(command, env=environment if env is None else env, cwd=cwd, stdout=log or subprocess.PIPE,
stderr=log or subprocess.PIPE, text=log is None,
timeout=timeout, check=False)
if result.returncode:
raise RuntimeError("Bounded preparation command failed; inspect the protected attempt log")
return result.stdout if log is None else ""
def docker_json_template(fields):
# Go templates otherwise pass index/map/key as three separate json args.
return "\n".join("{{json (" + field + ")}}" for field in fields)
def original_identity(name, runner=checked):
fields = [".Id", ".Image", ".State.Running", ".State.Health.Status",
'index .Config.Labels "com.docker.compose.project"']
template = docker_json_template(fields)
values = runner(["docker", "inspect", "--format", template, name]).splitlines()
if len(values) != len(fields):
raise ValueError("Unexpected sanitized container inspection")
return dict(zip(("containerId", "imageId", "running", "health", "composeProject"),
(json.loads(value) for value in values)))
def assert_original(observed, slug, container_id, image_id):
if observed != {"containerId": container_id, "imageId": image_id,
"running": True, "health": "healthy", "composeProject": slug}:
raise ValueError("Original runtime identity/health drift; preparation refused")
def mount_inventory(target, runner=checked):
raw = json.loads(runner(["docker", "inspect", "--format", "{{json .Mounts}}", target]))
return sorted(({"type": item["Type"], "source": item["Source"],
"destination": item["Destination"], "rw": item["RW"]} for item in raw),
key=lambda item: item["destination"])
def expected_labels(metadata):
return {
"org.opencontainers.image.source": metadata["repository"],
"org.opencontainers.image.revision": metadata["revision"],
"org.opencontainers.image.source-tree": metadata["tree"],
"org.opencontainers.image.version": metadata["id"],
"org.opencontainers.image.created": metadata["builtAt"],
}
def verify_image(image, metadata, runner=checked):
image_id = runner(["docker", "image", "inspect", "--format", "{{.Id}}", image]).strip()
if not re.fullmatch(r"sha256:[0-9a-f]{64}", image_id):
raise ValueError("Candidate immutable image identity is invalid")
for key, expected in expected_labels(metadata).items():
actual = runner(["docker", "image", "inspect", "--format",
'{{index .Config.Labels "' + key + '"}}', image_id]).strip()
if actual != expected:
raise ValueError("Candidate immutable provenance did not verify")
return image_id
def prepare(broker, slug, revision, attempt, container_id, image_id, runner=checked):
if (slug not in TARGETS or not SHA.fullmatch(revision) or not ATTEMPT.fullmatch(attempt)
or not ID.fullmatch(container_id) or not re.fullmatch(r"sha256:[0-9a-f]{64}", image_id)):
raise ValueError("Invalid fixed target, commit, attempt or expected runtime identity")
repository = f"jens/{slug}"
original_name = TARGETS[slug][0]
workspace = require_real_child(JOURNAL_ROOT / slug / attempt, JOURNAL_ROOT)
source = require_real_child(broker.EXACT_SOURCE_ROOT / f"{slug}-migration-{attempt}", broker.EXACT_SOURCE_ROOT)
with ExitStack() as locks:
locks.enter_context(broker.exclusive_lock(broker.STATE_ROOT / "locks" / f"{slug}.lock", "migration project"))
locks.enter_context(broker.exclusive_lock(broker.GLOBAL_DEPLOY_LOCK, "migration preparation build"))
# Read config only after lock acquisition, unlike starting another broker process.
project = dict(broker.load_projects()[repository])
repo = broker.repository_path(repository, project)
broker.validate_commit(repo, str(project["branch"]), revision)
head = broker.output(["git", f"--git-dir={repo}", "rev-parse", f"refs/heads/{project['branch']}"])
if head != revision:
raise ValueError("Migration candidate must be the current configured branch head")
assert_original(original_identity(original_name, runner), slug, container_id, image_id)
if workspace.exists() or source.exists():
raise ValueError("Attempt artifacts already exist; retain them and review before a new attempt")
metadata = broker.deployment_metadata(repo, revision, repository)
if not broker.git_path_exists(repo, revision, "docker-compose.migration.yml"):
raise ValueError("Candidate commit does not contain the reviewed migration Compose contract")
if not broker.git_path_exists(repo, revision, DOCKERFILES[slug]):
raise ValueError("Candidate Dockerfile missing")
workspace.mkdir(parents=True, mode=0o700)
workspace.chmod(0o700)
journal_path = workspace / "prepare.json"
journal = {"schema": "legacy-prepare.v1", "phase": "preparing", "repository": repository,
"revision": revision, "metadata": metadata, "source": str(source),
"originalContainerId": container_id, "originalImageId": image_id,
"originalMounts": mount_inventory(container_id, runner),
"projectConfigSha256": hashlib.sha256(json.dumps(project, sort_keys=True).encode()).hexdigest(),
"stateSha256": hashlib.sha256((broker.STATE_ROOT / f"{slug}.json").read_bytes()).hexdigest(),
"originalProvenance": "unknown-legacy", "productionMutated": False}
durable_json(journal_path, journal)
try:
broker.sync_source(repo, revision, source, slug, exact=True)
if not broker.exact_source_matches_revision(repo, revision, source):
raise ValueError("Candidate source is not the exact committed tree")
from transaction import prepare_compose
candidate, command, cwd, environment, plan, nonce = prepare_compose(
broker, slug, revision, attempt, project, journal, workspace, runner)
image = environment["MIGRATION_IMAGE"]
journal.update(candidateProject=candidate, plan=plan, nonce=nonce)
journal["runtimeEnvSha256"] = hashlib.sha256((workspace / "runtime.env").read_bytes()).hexdigest()
journal.update(phase="building", candidateImage=image)
durable_json(journal_path, journal)
descriptor = os.open(workspace / "build.log", os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
with os.fdopen(descriptor, "wb") as log:
runner(command, cwd=cwd, env=environment, log=log, timeout=3600)
log.flush()
os.fsync(log.fileno())
immutable = verify_image(image, metadata, runner)
if not broker.exact_source_matches_revision(repo, revision, source):
raise ValueError("Candidate source changed during build")
assert_original(original_identity(original_name, runner), slug, container_id, image_id)
if mount_inventory(container_id, runner) != journal["originalMounts"]:
raise ValueError("Original mount inventory changed during preparation")
journal.update(phase="build_verified", candidateImageId=immutable)
durable_json(journal_path, journal)
return {"phase": journal["phase"], "repository": repository, "revision": revision,
"candidateImageId": immutable, "journal": str(journal_path),
"productionMutated": False, "cutoverAvailable": True}
except Exception:
journal["phase"] = "preparation_failed"
durable_json(journal_path, journal)
raise
def load_broker():
if hashlib.sha256(BROKER_PATH.read_bytes().replace(b"\r\n", b"\n")).hexdigest() != BROKER_SHA:
raise ValueError("Live broker source differs from the reviewed artifact")
spec = importlib.util.spec_from_file_location("reviewed_deploy_broker", BROKER_PATH)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("mode", choices=["prepare", "cutover", "recover"])
parser.add_argument("slug", choices=sorted(TARGETS))
parser.add_argument("revision")
parser.add_argument("attempt")
parser.add_argument("--container-id", required=True)
parser.add_argument("--image-id", required=True)
parser.add_argument("--apply", action="store_true")
args = parser.parse_args()
if not args.apply:
print(json.dumps({"apply": False, "mode": args.mode, "productionMutated": False}))
return
if not hasattr(os, "geteuid") or os.geteuid() != 0:
raise SystemExit("Run only as root on the configured Unraid controller host")
try:
broker = load_broker()
if args.mode == "prepare":
result = prepare(broker, args.slug, args.revision, args.attempt, args.container_id, args.image_id)
else:
from transaction import operate
result = operate(broker, args.mode, args.slug, args.revision, args.attempt, args.container_id, args.image_id)
except Exception:
raise SystemExit("Operation failed. Inspect the protected attempt journal; do not assume production or rollback succeeded. Use explicit recover for an unfinished cutover.") from None
print(json.dumps(result))
if __name__ == "__main__":
main()
+103
View File
@@ -0,0 +1,103 @@
"""Pure, fail-closed migration planning; this module cannot mutate Docker or files.
Accept only the sanitized inspect projection documented in README.md. A plan is
not deployment authorization, a snapshot, a receipt, or proof of successful backup.
"""
import re
from pathlib import PurePosixPath
TARGETS = {
"vacatureradar": ("VacatureRadar", "/mnt/pool/appdata/VacatureRadar"),
"lumaops": ("lumaops", "/mnt/pool/appdata/lumaops"),
}
SHA = re.compile(r"[0-9a-f]{40}")
ID = re.compile(r"[0-9a-f]{64}")
ATTEMPT = re.compile(r"[a-z0-9][a-z0-9-]{7,47}")
def _under(path, parent):
value = PurePosixPath(path)
return (
value.is_absolute()
and ".." not in value.parts
and value != PurePosixPath(parent)
and value.is_relative_to(parent)
)
def build_plan(slug, revision, attempt, observed):
"""Bind one target to retained runtime identity and isolated candidate data."""
if slug not in TARGETS or not SHA.fullmatch(revision) or not ATTEMPT.fullmatch(attempt):
raise ValueError("Invalid fixed target, full revision, or attempt identifier")
expected_name, appdata = TARGETS[slug]
keys = {"name", "containerId", "imageId", "running", "health", "composeProject", "mounts"}
if set(observed) != keys:
raise ValueError("Expected only the sanitized runtime projection")
if (
observed["name"] != expected_name
or observed["composeProject"] != slug
or not ID.fullmatch(observed["containerId"])
or not re.fullmatch(r"sha256:[0-9a-f]{64}", observed["imageId"])
or observed["running"] is not True
or observed["health"] != "healthy"
):
raise ValueError("Legacy runtime identity or health does not match")
mounts = observed["mounts"]
if not mounts or len(mounts) > 16:
raise ValueError("Expected bounded persistent mounts")
destinations = set()
sources = set()
for mount in mounts:
if set(mount) != {"type", "source", "destination", "rw"}:
raise ValueError("Unexpected mount fields")
destination = mount["destination"]
if not _under(destination, "/") or destination in destinations:
raise ValueError("Invalid or duplicate mount destination")
destinations.add(destination)
source = mount["source"]
if mount["type"] == "bind":
valid = _under(source, appdata)
elif mount["type"] == "volume":
valid = bool(re.fullmatch(r"/var/lib/docker/volumes/[0-9a-f]{64}/_data", source))
else:
valid = False
if not valid or mount["rw"] is not True:
raise ValueError("Unreviewed source, mount type, or access mode")
sources.add(source)
# Clone a parent only once; preserve nested bind destinations against the
# corresponding cloned subdirectory, not unrelated independent snapshots.
roots = sorted(source for source in sources if not any(
other != source and _under(source, other) for other in sources
))
candidate_root = f"/mnt/pool/appdata/gitea-deploy-runners/legacy-migrations/{slug}/{attempt}"
clones = {source: f"{candidate_root}/data/{index}" for index, source in enumerate(roots)}
candidate_mounts = []
for mount in mounts:
root = next(source for source in roots if mount["source"] == source or _under(mount["source"], source))
relative = PurePosixPath(mount["source"]).relative_to(root)
candidate_mounts.append({
"source": str(PurePosixPath(clones[root]) / relative),
"destination": mount["destination"],
"type": "bind", "rw": True,
})
return {
"schema": "legacy-migration-plan.v1", "executable": False,
"repository": f"jens/{slug}", "candidateRevision": revision,
"originalProvenance": "unknown-legacy", "originalRevision": None,
"originalContainerId": observed["containerId"], "originalImageId": observed["imageId"],
"originalName": expected_name, "retainedName": f"{expected_name}-legacy-{attempt}",
"candidateComposeProject": f"{slug}-managed",
"workspace": candidate_root,
"coldCopyRoots": [{"original": root, "candidate": clones[root]} for root in roots],
"candidateMounts": candidate_mounts,
"requiredGates": [
"exact-commit-and-tree-validated", "candidate-built-before-cutover",
"immutable-oci-provenance-verified", "project-and-global-locks-held",
"original-identity-revalidated", "legacy-stopped-before-data-copy",
"cold-copy-checksums-verified", "original-data-never-mounted-by-candidate",
"candidate-container-ids-recorded", "docker-and-http-health-verified",
"real-broker-receipt-verified", "broker-config-and-state-committed",
],
}
@@ -0,0 +1,96 @@
"""Private durable journal and fail-closed cold-copy verification primitives."""
import hashlib
import json
import os
from pathlib import Path
import stat
import tempfile
def digest(data):
return hashlib.sha256(data).hexdigest()
def private_write(path, data):
"""Unique temporary files let explicit recovery proceed after process crashes."""
path = Path(path)
if path.is_symlink():
raise ValueError("Refusing a symlink journal/configuration target")
descriptor, temporary = tempfile.mkstemp(prefix=f".{path.name}-", dir=path.parent)
with os.fdopen(descriptor, "wb") as stream:
stream.write(data)
stream.flush()
os.fsync(stream.fileno())
os.replace(temporary, path)
descriptor = os.open(path.parent, os.O_RDONLY)
try:
os.fsync(descriptor)
finally:
os.close(descriptor)
def private_json(path, value):
private_write(path, json.dumps(value, sort_keys=True).encode())
def dotenv(entries):
"""Do not guess escaping for unsupported environment values; never log them."""
import re
result = {}
for entry in entries:
key, separator, value = entry.partition("=")
if not separator or not re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", key):
raise ValueError("Unsupported runtime environment entry")
if key in result or any(character in value for character in "\x00\n\r'\\"):
raise ValueError("Runtime environment needs reviewed dotenv escaping")
result[key] = value
return "".join(f"{key}='{value}'\n" for key, value in sorted(result.items())).encode()
def tree_manifest(root):
"""Hash content plus ownership/modes/xattrs; reject symlinks and special files.
Preserving an unknown symlink could let the candidate write original data.
Hardlink equivalence groups are compared without depending on inode numbers.
"""
root = Path(root)
if root.is_symlink() or not root.is_dir():
raise ValueError("Cold-copy root must be a real directory")
records = {}
inodes = {}
for path in [root, *sorted(root.rglob("*"))]:
info = path.lstat()
relative = str(path.relative_to(root))
if not stat.S_ISREG(info.st_mode) and not stat.S_ISDIR(info.st_mode):
raise ValueError("Cold-copy tree contains an unreviewed symlink/special file")
record = {"mode": stat.S_IMODE(info.st_mode), "uid": info.st_uid, "gid": info.st_gid}
if stat.S_ISREG(info.st_mode):
hasher = hashlib.sha256()
with path.open("rb") as stream:
for block in iter(lambda: stream.read(1024 * 1024), b""):
hasher.update(block)
record.update(kind="file", size=info.st_size, sha256=hasher.hexdigest())
group = (info.st_dev, info.st_ino)
record["hardlink"] = inodes.setdefault(group, relative)
else:
record["kind"] = "directory"
if hasattr(os, "listxattr"):
record["xattrs"] = {name: digest(os.getxattr(path, name)) for name in sorted(os.listxattr(path))}
records[relative] = record
return records
def clone_cold(source, target, runner):
source, target = Path(source), Path(target)
if target.exists() or target.is_symlink():
raise ValueError("Cold-copy target already exists")
before = tree_manifest(source)
target.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
# Destination does not exist: cp -a copies root metadata as well as children.
runner(["cp", "-a", "--reflink=auto", "--", str(source), str(target)], timeout=3600)
runner(["sync", "-f", "--", str(target)], timeout=3600)
copied = tree_manifest(target)
if before != copied or before != tree_manifest(source):
raise ValueError("Cold copy content/metadata does not match unchanged original")
return digest(json.dumps(before, sort_keys=True).encode())
@@ -0,0 +1,84 @@
"""Offline Compose rendering; never builds images or starts containers."""
import json
import os
from pathlib import Path
import shutil
import subprocess
import unittest
PROJECTS = Path(__file__).resolve().parents[3]
CASES = {
"VacatureRadar": ("app", ["docker-compose.unraid.yml"], {
"MIGRATION_LOCAL_ROOT": "/migration-fixture/local",
"VACATURERADAR_ENV_FILE": "/migration-fixture/runtime.env",
}, {"/app/local", "/app/media", "/app/logs"}),
"LumaOps": ("lumaops", ["docker-compose.yml", "docker-compose.unraid.yml"], {
"MIGRATION_CONFIG_ROOT": "/migration-fixture/config",
"MIGRATION_OPENRGB_ROOT": "/migration-fixture/openrgb",
"MIGRATION_LUMA_CONFIG_ROOT": "/migration-fixture/luma-config",
"MIGRATION_DATA_ROOT": "/migration-fixture/data",
"MIGRATION_LOGS_ROOT": "/migration-fixture/logs",
"MIGRATION_ENV_FILE": "/migration-fixture/runtime.env",
}, {"/config", "/config/openrgb", "/config/lumaops", "/data", "/logs"}),
}
@unittest.skipUnless(shutil.which("docker"), "Docker Compose CLI is required; no daemon used")
class ComposeTests(unittest.TestCase):
def render(self, name, missing=None):
_, files, variables, _ = CASES[name]
environment = dict(os.environ)
for key in list(environment):
if key.startswith("MIGRATION_") or key.startswith("VACATURERADAR_"):
environment.pop(key)
environment.update({
"MIGRATION_IMAGE": "migration-fixture:candidate",
"MIGRATION_NETWORK": name.lower() + "_default",
"MIGRATION_ATTEMPT": "fixture-migration-attempt",
"MIGRATION_SOURCE_REVISION": "a" * 40,
"MIGRATION_SOURCE_TREE": "b" * 40,
"MIGRATION_BUILD_ID": "a" * 12,
"MIGRATION_BUILD_DATE": "2026-09-09T00:00:00.000Z",
"LUMAOPS_ADMIN_TOKEN": "fixture-only-not-a-production-token-000000",
**variables,
})
if missing:
environment.pop(missing)
command = ["docker", "compose", "--env-file", os.devnull]
for file in [*files, "docker-compose.migration.yml"]:
command.extend(["-f", file])
command.extend(["config", "--no-env-resolution", "--no-path-resolution", "--format", "json"])
return subprocess.run(command, cwd=PROJECTS / name, env=environment,
capture_output=True, text=True, timeout=20)
def test_merged_mounts_are_exclusively_explicit_clones(self):
for name, (service, _, _, destinations) in CASES.items():
with self.subTest(name=name):
result = self.render(name)
self.assertEqual(result.returncode, 0, "Fixture Compose rendering failed")
config = json.loads(result.stdout)
runtime = config["services"][service]
self.assertEqual(runtime["network_mode"], name.lower() + "_default")
self.assertEqual(config["name"], name.lower() + "-managed")
self.assertEqual({m["target"] for m in runtime["volumes"]}, destinations)
for mount in runtime["volumes"]:
self.assertEqual(mount["type"], "bind")
self.assertTrue(mount["source"].startswith("/migration-fixture/"))
self.assertFalse(mount["bind"]["create_host_path"])
labels = runtime["build"]["labels"]
self.assertEqual(labels["org.opencontainers.image.revision"], "a" * 40)
self.assertEqual(labels["org.opencontainers.image.source-tree"], "b" * 40)
self.assertEqual(labels["org.opencontainers.image.source"], "jens/" + name.lower())
def test_missing_required_inputs_fail_closed(self):
for name, (_, _, variables, _) in CASES.items():
for variable in [*variables, "MIGRATION_NETWORK", "MIGRATION_IMAGE", "MIGRATION_ATTEMPT", "MIGRATION_SOURCE_REVISION",
"MIGRATION_SOURCE_TREE", "MIGRATION_BUILD_ID", "MIGRATION_BUILD_DATE"]:
with self.subTest(name=name, variable=variable):
self.assertNotEqual(self.render(name, variable).returncode, 0)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,49 @@
import unittest
from execute import assert_original, docker_json_template, expected_labels, original_identity, verify_image
class ExecuteTests(unittest.TestCase):
def test_template_groups_function_arguments_before_json_encoding(self):
self.assertEqual(
docker_json_template(['.Id', 'index .Config.Labels "com.docker.compose.project"']),
'{{json (.Id)}}\n{{json (index .Config.Labels "com.docker.compose.project")}}',
)
def test_inspection_never_requests_environment(self):
calls = []
def runner(command):
calls.append(command)
return '"' + "a" * 64 + '"\n"sha256:' + "b" * 64 + '"\ntrue\n"healthy"\n"lumaops"'
observed = original_identity("lumaops", runner)
assert_original(observed, "lumaops", "a" * 64, "sha256:" + "b" * 64)
self.assertNotIn(".Config.Env", str(calls))
self.assertEqual(calls[0][:2], ["docker", "inspect"])
def test_runtime_drift_blocks(self):
with self.assertRaises(ValueError):
assert_original({}, "lumaops", "a" * 64, "sha256:" + "b" * 64)
def test_image_labels_are_checked_on_immutable_id(self):
metadata = {"repository": "jens/lumaops", "revision": "a" * 40,
"tree": "b" * 40, "id": "a" * 12, "builtAt": "2026-09-09T00:00:00.000Z"}
labels = expected_labels(metadata)
calls = []
def runner(command):
calls.append(command)
if command[-2] == "{{.Id}}":
return "sha256:" + "c" * 64
key = command[-2].split('"')[1]
return labels[key]
self.assertEqual(verify_image("candidate", metadata, runner), "sha256:" + "c" * 64)
self.assertTrue(all(command[-1] == "sha256:" + "c" * 64 for command in calls[1:]))
with self.assertRaises(ValueError):
verify_image("candidate", metadata, lambda command: "invalid")
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,61 @@
import unittest
from plan import build_plan
def observation():
return {
"name": "VacatureRadar", "containerId": "a" * 64,
"imageId": "sha256:" + "b" * 64, "running": True,
"health": "healthy", "composeProject": "vacatureradar",
"mounts": [
{"type": "bind", "source": "/mnt/pool/appdata/VacatureRadar/local",
"destination": "/app/local", "rw": True},
{"type": "bind", "source": "/mnt/pool/appdata/VacatureRadar/local/logs",
"destination": "/app/logs", "rw": True},
],
}
class PlanTests(unittest.TestCase):
def test_nested_mounts_share_one_cold_copy(self):
plan = build_plan("vacatureradar", "c" * 40, "migration-20260909", observation())
self.assertEqual(len(plan["coldCopyRoots"]), 1)
self.assertTrue(plan["candidateMounts"][1]["source"].endswith("/data/0/logs"))
self.assertFalse(plan["executable"])
self.assertIsNone(plan["originalRevision"])
self.assertEqual(plan["originalProvenance"], "unknown-legacy")
self.assertNotEqual(plan["candidateComposeProject"], "vacatureradar")
def test_rejects_runtime_drift_and_unsafe_paths(self):
for key, value in [("name", "other"), ("containerId", "short"),
("running", False), ("health", "starting")]:
observed = observation()
observed[key] = value
with self.subTest(key=key), self.assertRaises(ValueError):
build_plan("vacatureradar", "c" * 40, "migration-20260909", observed)
for source in ["/", "/mnt/pool/appdata", "/mnt/pool/appdata/VacatureRadar/../other"]:
observed = observation()
observed["mounts"][0]["source"] = source
with self.subTest(source=source), self.assertRaises(ValueError):
build_plan("vacatureradar", "c" * 40, "migration-20260909", observed)
def test_rejects_secret_bearing_full_inspect(self):
observed = observation()
observed["Config"] = {"Env": ["fixture-only"]}
with self.assertRaises(ValueError):
build_plan("vacatureradar", "c" * 40, "migration-20260909", observed)
def test_anonymous_volume_is_cloned_not_reused(self):
observed = observation()
observed.update(name="lumaops", composeProject="lumaops", mounts=[
{"type": "volume", "source": "/var/lib/docker/volumes/" + "d" * 64 + "/_data",
"destination": "/config", "rw": True},
])
plan = build_plan("lumaops", "c" * 40, "migration-20260909", observed)
self.assertEqual(plan["candidateMounts"][0]["type"], "bind")
self.assertNotIn("/var/lib/docker/volumes/", plan["candidateMounts"][0]["source"])
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,62 @@
"""Linux filesystem qualification with isolated temporary data, never appdata."""
import os
from pathlib import Path
import stat
import subprocess
import tempfile
import unittest
from storage import clone_cold, private_write, tree_manifest
@unittest.skipUnless(os.name == "posix", "Linux ownership/fsync qualification")
class StorageTests(unittest.TestCase):
def test_private_replacement_is_durable_and_private(self):
with tempfile.TemporaryDirectory(prefix="migration-storage-test-") as directory:
path = Path(directory) / "journal.json"
private_write(path, b"first")
private_write(path, b"second")
self.assertEqual(path.read_bytes(), b"second")
self.assertEqual(stat.S_IMODE(path.stat().st_mode), 0o600)
target = Path(directory) / "linked"
target.symlink_to(path)
with self.assertRaises(ValueError):
private_write(target, b"must not replace")
self.assertEqual(path.read_bytes(), b"second")
def test_real_cold_copy_preserves_content_modes_and_hardlinks(self):
with tempfile.TemporaryDirectory(prefix="migration-storage-test-") as directory:
source = Path(directory) / "original"
source.mkdir(mode=0o750)
payload = source / "database"
payload.write_bytes(b"representative fixture data")
payload.chmod(0o640)
os.link(payload, source / "hardlink")
destination = Path(directory) / "candidate"
def runner(command, **kwargs):
subprocess.run(command, check=True, capture_output=True, **kwargs)
checksum = clone_cold(source, destination, runner)
self.assertEqual(tree_manifest(source), tree_manifest(destination))
self.assertEqual(len(checksum), 64)
self.assertNotEqual(payload.stat().st_ino, (destination / "database").stat().st_ino)
(destination / "database").write_bytes(b"candidate mutation")
self.assertEqual(payload.read_bytes(), b"representative fixture data")
with self.assertRaises(ValueError):
clone_cold(source, destination, runner)
def test_special_files_are_rejected_before_copy(self):
with tempfile.TemporaryDirectory(prefix="migration-storage-test-") as directory:
root = Path(directory)
(root / "link").symlink_to("/tmp")
with self.assertRaises(ValueError):
tree_manifest(root)
(root / "link").unlink()
os.mkfifo(root / "fifo")
with self.assertRaises(ValueError):
tree_manifest(root)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,289 @@
"""No Docker/network calls: exercise crash phases and fail-closed recovery gates."""
import json
from pathlib import Path
import tempfile
import unittest
from unittest.mock import patch
from storage import clone_cold, digest, dotenv, tree_manifest
from transaction import (Production, STEPS, candidate_project,
recover_transaction, run_transaction, validate_runtime_safety)
class Crash(BaseException):
pass
class FakeOperations:
def __init__(self, fail=None, crash=False):
self.journal = {"phase": "snapshotted"}
self.fail, self.crash = fail, crash
self.original_running, self.renamed, self.candidate = True, False, False
self.state, self.config = "original", "original"
self.clones = False
self.foreign = False
self.saved = []
def save(self, phase):
self.saved.append(phase)
self.journal["phase"] = phase
def event(self, name):
if self.fail == name:
raise Crash() if self.crash else ValueError("fixture failure")
def stopping(self):
self.original_running = False
self.event("after_stop")
def copying(self):
assert not self.original_running
self.clones = True
self.event("after_copy")
def renaming(self):
self.renamed = True
self.event("after_rename")
def deploying(self):
self.candidate = True
self.event("after_deploy")
def verifying(self):
self.event("during_verify")
def committing(self):
self.state = "candidate"
self.event("after_receipt")
self.config = "candidate"
self.event("after_config")
def recover(self):
if self.foreign:
raise ValueError("foreign canonical container")
self.candidate = False
self.state = self.config = "original"
self.renamed = False
self.original_running = True
def verify_committed(self):
assert self.state == self.config == "candidate"
def original(self, healthy=False):
assert self.original_running
def http_health(self):
pass
def activate(self):
assert self.journal["phase"] == "committed"
self.event("during_activation")
self.journal["activated"] = True
class TransactionTests(unittest.TestCase):
def test_runtime_safety_parity_rejects_each_security_or_device_drift(self):
host = {"Devices": [{"PathOnHost": "/dev/example", "PathInContainer": "/dev/example", "CgroupPermissions": "rwm"}],
"CapAdd": ["CAP_CHOWN"], "CapDrop": ["ALL"], "ReadonlyRootfs": True,
"SecurityOpt": ["no-new-privileges:true"], "Tmpfs": {"/tmp": "size=128m"}}
service = {"devices": [{"source": "/dev/example", "target": "/dev/example", "permissions": "rwm"}],
"cap_add": ["CHOWN"], "cap_drop": ["ALL"], "read_only": True,
"security_opt": ["no-new-privileges:true"], "tmpfs": ["/tmp:size=128m"]}
validate_runtime_safety(host, service)
for key, value in (("devices", []), ("cap_add", []), ("cap_drop", []), ("read_only", False),
("privileged", True), ("security_opt", []), ("tmpfs", [])):
with self.subTest(key=key), self.assertRaises(ValueError):
validate_runtime_safety(host, {**service, key: value})
def test_activation_failure_never_rolls_back_and_recovery_resumes(self):
ops = FakeOperations(fail="during_activation")
with self.assertRaises(ValueError):
run_transaction(ops)
self.assertEqual(ops.journal["phase"], "committed")
self.assertTrue(ops.candidate)
self.assertFalse(ops.original_running)
ops.fail = None
self.assertEqual(recover_transaction(ops), "committed")
self.assertTrue(ops.journal["activated"])
def test_real_marker_release_requires_matching_nonce_and_resumes_after_unlink(self):
with tempfile.TemporaryDirectory() as temporary:
ops = Production.__new__(Production)
ops.workspace = Path(temporary).resolve()
root = ops.workspace / "local"
root.mkdir()
ops.slug = "vacatureradar"
ops.plan = {"candidateMounts": [{"destination": "/app/local", "source": str(root)}]}
ops.journal = {"phase": "committed", "nonce": "correct", "candidateContainerId": "candidate"}
ops.verify_committed = lambda: None
ops.assert_candidate = lambda value: None
ops.save = lambda phase: None
marker = ops.hold_path()
marker.write_bytes(b"wrong")
with patch("transaction.private_read", side_effect=lambda path: path.read_bytes()):
with self.assertRaises(ValueError):
ops.activate()
self.assertTrue(marker.exists())
marker.write_bytes(ops.hold_bytes())
with patch("transaction.os.open", return_value=123), patch("transaction.os.fsync"), patch("transaction.os.close"):
ops.activate()
self.assertFalse(marker.exists())
self.assertTrue(ops.journal["activationIntent"])
ops.activate()
self.assertTrue(ops.journal["activated"])
ops.journal.pop("activationIntent")
with self.assertRaises(ValueError):
ops.activate()
def test_intent_precedes_each_successful_phase(self):
ops = FakeOperations()
run_transaction(ops)
self.assertEqual(ops.saved, [*STEPS, "committed"])
self.assertFalse(ops.original_running)
self.assertTrue(ops.renamed and ops.clones and ops.candidate)
self.assertEqual(recover_transaction(ops), "committed")
self.assertTrue(ops.candidate, "Recovery cannot discard writes after a committed migration")
def test_each_failure_restores_original_and_keeps_clones(self):
for failure in ("after_stop", "after_copy", "after_rename", "after_deploy",
"during_verify", "after_receipt", "after_config"):
with self.subTest(failure=failure):
ops = FakeOperations(fail=failure)
with self.assertRaisesRegex(RuntimeError, "original runtime restored"):
run_transaction(ops)
self.assertTrue(ops.original_running)
self.assertEqual(ops.state, "original")
self.assertEqual(ops.config, "original")
self.assertFalse(ops.candidate)
self.assertEqual(ops.clones, failure != "after_stop")
self.assertEqual(recover_transaction(ops), "rolled_back")
def test_abrupt_crash_after_stop_rename_and_receipt_requires_explicit_recovery(self):
for failure in ("after_stop", "after_rename", "after_receipt", "after_config"):
with self.subTest(failure=failure):
ops = FakeOperations(fail=failure, crash=True)
with self.assertRaises(Crash):
run_transaction(ops)
self.assertNotEqual(ops.journal["phase"], "committed")
self.assertEqual(recover_transaction(ops), "rolled_back")
self.assertTrue(ops.original_running)
def test_foreign_runtime_blocks_automatic_recovery(self):
ops = FakeOperations(fail="during_verify")
ops.foreign = True
with self.assertRaisesRegex(RuntimeError, "requires explicit recovery"):
run_transaction(ops)
self.assertEqual(ops.journal["phase"], "recovery_failed")
self.assertTrue(ops.candidate)
def test_production_candidate_gate_rejects_wrong_nonce_project_and_replaced_id(self):
ops = Production.__new__(Production)
ops.prepared = {"originalContainerId": "original"}
ops.plan = {"candidateComposeProject": "lumaops-managed"}
ops.journal = {"nonce": "expected", "candidateContainerId": "candidate"}
ops.runner = object()
for fields in (["candidate", "image", "lumaops-managed", "wrong"],
["candidate", "image", "foreign-project", "expected"],
["original", "image", "lumaops-managed", "expected"],
["replacement", "image", "lumaops-managed", "expected"]):
with self.subTest(fields=fields), patch("transaction.inspect_fields", return_value=fields):
with self.assertRaises(ValueError):
ops.assert_candidate(fields[0])
def test_future_broker_metadata_remains_dynamic(self):
original = {"source_dir": "/old", "compose_files": ["docker-compose.unraid.yml"],
"clean_untracked_paths": ["old"], "deploy_script": "old.sh"}
prepared = {"source": "/exact/new"}
plan = {"repository": "jens/vacatureradar", "candidateComposeProject": "vacatureradar-managed",
"candidateMounts": [{"source": "/clone/local", "destination": "/app/local"}]}
project = candidate_project(original, prepared, plan, "nonce", Path("/private/runtime.env"))
self.assertTrue(project["exact_source"] and project["verify_oci_revision"])
self.assertNotIn("deploy_script", project)
self.assertEqual(project["environment"]["MIGRATION_SOURCE_REVISION"], "{revision}")
self.assertEqual(project["environment"]["MIGRATION_SOURCE_TREE"], "{tree}")
self.assertEqual(project["environment"]["MIGRATION_BUILD_ID"], "{build_id}")
self.assertEqual(project["environment"]["MIGRATION_BUILD_DATE"], "{build_date}")
def test_dotenv_is_literal_and_rejects_unsupported_escaping(self):
self.assertEqual(dotenv(["KEY=has $literal #value"]), b"KEY='has $literal #value'\n")
for entries in (["KEY=one", "KEY=two"], ["BAD KEY=value"], ["KEY=has'quote"], ["KEY=line\nbreak"]):
with self.assertRaises(ValueError):
dotenv(entries)
def test_manifest_detects_content_or_mode_change(self):
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
file = root / "data"
file.write_bytes(b"fixture-only")
before = digest(json.dumps(tree_manifest(root), sort_keys=True).encode())
file.write_bytes(b"changed")
after = digest(json.dumps(tree_manifest(root), sort_keys=True).encode())
self.assertNotEqual(before, after)
def test_cold_copy_uses_archive_mode_and_rejects_metadata_mismatch(self):
with tempfile.TemporaryDirectory() as temporary:
source, target = Path(temporary) / "source", Path(temporary) / "target"
source.mkdir()
calls = []
identical = {".": {"mode": 448, "uid": 999, "gid": 999}}
with patch("storage.tree_manifest", return_value=identical):
clone_cold(source, target, lambda command, **kwargs: calls.append(command))
self.assertEqual(calls[0][:4], ["cp", "-a", "--reflink=auto", "--"])
with patch("storage.tree_manifest", side_effect=[identical, {}]):
with self.assertRaisesRegex(ValueError, "content/metadata"):
clone_cold(source, target, lambda *args, **kwargs: None)
def test_real_recovery_adapter_refuses_foreign_container_before_remove(self):
with tempfile.TemporaryDirectory() as temporary:
workspace = Path(temporary)
config, state = b"{}", b'{"status":"failed"}'
for name, value in [("original-projects.json", config), ("original-state.json", state),
("projects.json", config), ("state.json", state)]:
(workspace / name).write_bytes(value)
ops = Production.__new__(Production)
ops.workspace = workspace
ops.broker = type("Broker", (), {"CONFIG_PATH": workspace / "projects.json"})()
ops.state_path = workspace / "state.json"
ops.prepared = {"originalContainerId": "original"}
ops.plan = {"originalName": "lumaops", "candidateComposeProject": "lumaops-managed"}
ops.journal = {"originalConfigSha256": digest(config), "candidateConfigSha256": "other",
"originalStateSha256": digest(state), "nonce": "expected"}
ops.original = lambda **kwargs: {"running": False}
ops.canonical_id = lambda name: "foreign"
calls = []
ops.runner = lambda command, **kwargs: calls.append(command)
with patch("transaction.inspect_fields", return_value=["foreign", "image", "lumaops-managed", "wrong"]):
with self.assertRaisesRegex(ValueError, "unrelated"):
ops.recover()
self.assertEqual(calls, [])
def test_verification_binds_normal_broker_receipt_to_prepared_image(self):
ops = Production.__new__(Production)
ops.journal = {"candidateContainerId": "candidate"}
ops.slug = "lumaops"
ops.plan = {"originalName": "lumaops", "candidateMounts": []}
ops.prepared = {"metadata": {"fixture": "metadata"}, "candidateImageId": "immutable", "revision": "rev"}
ops.project, ops.repository, ops.runner = {}, "jens/lumaops", None
ops.assert_candidate = lambda value: None
ops.http_health = lambda: None
ops.save = lambda value: None
seen = []
class Broker:
def verify_deployment(self, project, metadata, **kwargs):
seen.append(kwargs)
return {"fixture": "real-broker-receipt"}
def validate_recorded_receipt(self, *args):
return None
ops.broker = Broker()
with patch("transaction.mounts", return_value=[]), patch("transaction.inspect_fields", return_value=[{"lumaops_default": {}}]):
ops.verifying()
self.assertEqual(seen, [{"expected_image_ids": {"lumaops": "immutable"}}])
self.assertEqual(ops.journal["receipt"], {"fixture": "real-broker-receipt"})
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,517 @@
"""Journaled legacy cutover through normal broker functions; fixed two targets only.
No provenance is written for the original runtime. Recovery retains every data
clone and original image/container. Completed migrations are never automatically
rolled back: new writes may have reached candidate data after commit.
"""
from contextlib import ExitStack
import json
import os
from pathlib import Path
import secrets
import shutil
import time
from urllib.request import urlopen
from execute import (JOURNAL_ROOT, assert_original, checked, docker_json_template, mount_inventory, original_identity,
require_real_child, verify_image)
from plan import ATTEMPT, SHA, TARGETS, build_plan
from storage import clone_cold, digest, dotenv, private_json, private_write, tree_manifest
STEPS = ("stopping", "copying", "renaming", "deploying", "verifying", "committing")
def run_transaction(ops):
"""Write intent before every side effect, allowing recovery after abrupt exit."""
try:
for phase in STEPS:
ops.save(phase)
getattr(ops, phase)()
ops.save("committed")
except Exception:
try:
ops.recover()
ops.save("rolled_back")
except Exception:
ops.save("recovery_failed")
raise RuntimeError("Migration failed and rollback requires explicit recovery") from None
raise RuntimeError("Migration failed; original runtime restored and candidate data retained") from None
# Outside rollback scope: releasing workers permits new external effects/data.
ops.activate()
def recover_transaction(ops):
if ops.journal["phase"] == "committed":
ops.verify_committed()
ops.activate()
return "committed"
if ops.journal["phase"] == "rolled_back":
ops.original(healthy=True)
ops.http_health()
return "rolled_back"
ops.recover()
ops.save("rolled_back")
return "rolled_back"
def inspect_fields(runner, target, expressions):
template = docker_json_template(expressions)
return [json.loads(line) for line in runner(["docker", "inspect", "--format", template, target]).splitlines()]
def mounts(runner, target):
raw = inspect_fields(runner, target, [".Mounts"])[0]
return sorted(({"type": item["Type"], "source": item["Source"],
"destination": item["Destination"], "rw": item["RW"]} for item in raw),
key=lambda item: item["destination"])
def candidate_project(original, prepared, plan, nonce, env_path):
slug = plan["repository"].split("/")[1]
by_target = {item["destination"]: item["source"] for item in plan["candidateMounts"]}
names = ({"MIGRATION_LOCAL_ROOT": "/app/local"} if slug == "vacatureradar" else {
"MIGRATION_CONFIG_ROOT": "/config", "MIGRATION_OPENRGB_ROOT": "/config/openrgb",
"MIGRATION_LUMA_CONFIG_ROOT": "/config/lumaops", "MIGRATION_DATA_ROOT": "/data",
"MIGRATION_LOGS_ROOT": "/logs"})
environment = {**dict(original.get("environment", {})),
**{key: by_target[target] for key, target in names.items()},
"MIGRATION_ATTEMPT": nonce, "MIGRATION_IMAGE": f"gitea-managed/{slug}:{{revision}}",
"MIGRATION_NETWORK": slug + "_default",
"MIGRATION_SOURCE_REVISION": "{revision}", "MIGRATION_SOURCE_TREE": "{tree}",
"MIGRATION_BUILD_ID": "{build_id}", "MIGRATION_BUILD_DATE": "{build_date}",
"MIGRATION_ENV_FILE": str(env_path), "VACATURERADAR_ENV_FILE": str(env_path)}
result = dict(original)
for key in ("deploy_script", "archive_deploy_script", "manual", "clean_untracked_paths", "legacy_containers"):
result.pop(key, None)
result.update(source_dir=prepared["source"], compose_project=plan["candidateComposeProject"],
exact_source=True, verify_oci_revision=True, env_source=str(env_path),
environment=environment,
compose_files=[*original["compose_files"], "docker-compose.migration.yml"])
return result
def render_contract(broker, project, prepared, plan, runner):
cwd, command = broker.compose_command(project)
environment = broker.deployment_environment(project, prepared["revision"], plan["repository"], prepared["metadata"])
rendered = json.loads(runner(command + ["config", "--format", "json"], cwd=cwd, env=environment))
services = rendered.get("services", {})
if len(services) != 1:
raise ValueError("Migration supports exactly one reviewed application container")
service = next(iter(services.values()))
if service.get("container_name") != plan["originalName"]:
raise ValueError("Candidate canonical name differs from the plan")
network = plan["repository"].split("/")[1] + "_default"
original_networks = inspect_fields(runner, prepared["originalContainerId"], [".NetworkSettings.Networks"])[0]
if set(original_networks) != {network} or service.get("network_mode") != network:
raise ValueError("Candidate network must exactly preserve the reviewed original network")
if prepared.get("originalNetwork") not in {None, network}:
raise ValueError("Original network changed since preparation")
host = inspect_fields(runner, prepared["originalContainerId"], [".HostConfig"])[0]
validate_runtime_safety(host, service)
expected = {item["destination"]: item["source"] for item in plan["candidateMounts"]}
observed = {}
for item in service.get("volumes", []):
if item.get("type") != "bind" or item.get("read_only") or item.get("bind", {}).get("create_host_path") is not False:
raise ValueError("Candidate contains an unreviewed writable mount")
observed[item["target"]] = item["source"]
if observed != expected or len(observed) != len(service.get("volumes", [])):
raise ValueError("Rendered candidate mounts do not exactly match cold clones")
bindings = inspect_fields(runner, prepared["originalContainerId"], [".HostConfig.PortBindings"])[0]
old_ports = sorted((port, item.get("HostIp") or "0.0.0.0", str(item["HostPort"]))
for port, items in bindings.items() for item in items or [])
new_ports = sorted((f"{item['target']}/{item.get('protocol', 'tcp')}", item.get("host_ip") or "0.0.0.0", str(item["published"]))
for item in service.get("ports", []))
if old_ports != new_ports:
raise ValueError("Candidate published ports differ from the original runtime")
if service.get("labels", {}).get("io.itworx.migration-attempt") != environment["MIGRATION_ATTEMPT"]:
raise ValueError("Candidate ownership correlation label is absent")
return cwd, command, environment
def validate_runtime_safety(host, service):
"""Compare nonsecret hardware/security bindings; unsupported shapes fail closed."""
old_devices = sorted((item["PathOnHost"], item["PathInContainer"], item["CgroupPermissions"])
for item in host.get("Devices") or [])
new_devices = sorted((item["source"], item["target"], item.get("permissions", "rwm"))
for item in service.get("devices", []))
if old_devices != new_devices or host.get("DeviceRequests") or host.get("DeviceCgroupRules"):
raise ValueError("Candidate device contract differs or is unsupported")
for actual, desired in (("CapAdd", "cap_add"), ("CapDrop", "cap_drop")):
normalize = lambda values: sorted(value.upper().removeprefix("CAP_") for value in values or [])
if normalize(host.get(actual)) != normalize(service.get(desired)):
raise ValueError("Candidate capability contract differs")
for actual, desired in (("ReadonlyRootfs", "read_only"), ("Privileged", "privileged")):
if bool(host.get(actual)) != bool(service.get(desired)):
raise ValueError("Candidate filesystem/privilege contract differs")
if sorted(host.get("SecurityOpt") or []) != sorted(service.get("security_opt") or []):
raise ValueError("Candidate security options differ")
new_tmpfs = {}
for item in service.get("tmpfs") or []:
target, separator, options = item.partition(":")
if target in new_tmpfs:
raise ValueError("Duplicate candidate tmpfs target")
new_tmpfs[target] = options if separator else ""
if (host.get("Tmpfs") or {}) != new_tmpfs:
raise ValueError("Candidate tmpfs contract differs")
def prepare_compose(broker, slug, revision, attempt, original, prepared, workspace, runner):
if slug == "vacatureradar":
source = Path(prepared["source"])
required = {
"scripts/migration_worker_gate.py": (".migration-worker-hold", "while check():"),
"deployment/unraid/supervisord.conf": ("migration_worker_gate.py worker", "migration_worker_gate.py scheduler"),
"config/settings.py": ("apps.core.middleware.MigrationMaintenanceMiddleware",),
"apps/core/middleware.py": ("class MigrationMaintenanceMiddleware", "migration_maintenance"),
}
for filename, fragments in required.items():
content = (source / filename).read_text()
if any(fragment not in content for fragment in fragments):
raise ValueError("Exact source lacks the reviewed maintenance contract")
observed = {"name": TARGETS[slug][0], **original_identity(prepared["originalContainerId"], runner),
"mounts": prepared["originalMounts"]}
plan = build_plan(slug, revision, attempt, observed)
if Path(plan["workspace"]).resolve() != workspace:
raise ValueError("Plan workspace differs from the protected preparation directory")
nonce = secrets.token_hex(16)
env_path = workspace / "runtime.env"
entries = inspect_fields(runner, prepared["originalContainerId"], [".Config.Env"])[0]
private_write(env_path, dotenv(entries))
prepared["originalNetwork"] = slug + "_default"
candidate = candidate_project(original, prepared, plan, nonce, env_path)
repo = broker.repository_path(plan["repository"], original)
broker.validate_project_config(repo, candidate, revision)
cwd, command, environment = render_contract(broker, candidate, prepared, plan, runner)
return candidate, command + ["build"], cwd, environment, plan, nonce
class Production:
def __init__(self, broker, slug, prepared, workspace, journal, runner=checked):
self.broker, self.slug, self.prepared = broker, slug, prepared
self.workspace, self.journal, self.runner = workspace, journal, runner
self.repository = f"jens/{slug}"
self.plan = journal["plan"]
self.project = dict(journal["candidateProject"])
self.project.update(_repository_name=self.repository,
_repository_path=journal["repositoryPath"],
_deployment_metadata=prepared["metadata"])
self.state_path = broker.STATE_ROOT / f"{slug}.json"
def save(self, phase):
self.journal["phase"] = phase
private_json(self.workspace / "cutover.json", self.journal)
def canonical_id(self, name):
result = self.runner(["docker", "container", "ls", "--all", "--quiet", "--no-trunc",
"--filter", f"name=^/{name}$"]).strip().splitlines()
if len(result) > 1:
raise ValueError("Ambiguous container identity")
return result[0] if result else None
def original(self, healthy=False):
container_id = self.prepared["originalContainerId"]
observed = original_identity(container_id, self.runner)
if observed["imageId"] != self.prepared["originalImageId"] or observed["composeProject"] != self.slug:
raise ValueError("Original immutable runtime identity changed")
if mounts(self.runner, container_id) != self.journal["originalMounts"]:
raise ValueError("Original mounts changed")
networks = inspect_fields(self.runner, container_id, [".NetworkSettings.Networks"])[0]
if set(networks) != {self.slug + "_default"}:
raise ValueError("Original network changed")
if healthy:
assert_original(observed, self.slug, container_id, self.prepared["originalImageId"])
return observed
def stopping(self):
self.original(healthy=True)
self.runner(["docker", "stop", "--time", "60", self.prepared["originalContainerId"]], timeout=90)
if self.original()["running"]:
raise ValueError("Original did not stop")
def copying(self):
if self.original()["running"]:
raise ValueError("Cold copy requires stopped original")
checksums = {}
for item in self.plan["coldCopyRoots"]:
original = Path(item["original"])
if original.is_symlink() or original.resolve() != original:
raise ValueError("Unreviewed symlink in original cold-copy root")
target = require_real_child(Path(item["candidate"]), self.workspace)
checksums[item["original"]] = clone_cold(original, target, self.runner)
self.journal["coldCopyChecksums"] = checksums
self.save("copying")
if self.slug == "vacatureradar":
marker = self.hold_path()
if marker.exists() or marker.is_symlink():
raise ValueError("Cold clone already contains a maintenance hold")
private_write(marker, self.hold_bytes())
def hold_path(self):
roots = [item["source"] for item in self.plan["candidateMounts"]
if item["destination"] == "/app/local"]
if len(roots) != 1:
raise ValueError("Maintenance hold requires one exact cloned local root")
return require_real_child(Path(roots[0]) / ".migration-worker-hold", self.workspace)
def hold_bytes(self):
return ("legacy-migration.v1:" + self.journal["nonce"] + "\n").encode()
def verify_hold(self):
if self.slug == "vacatureradar" and private_read(self.hold_path()) != self.hold_bytes():
raise ValueError("Maintenance hold identity does not match this attempt")
def activate(self):
if self.journal.get("phase") != "committed":
raise ValueError("Activation requires a durable committed transaction")
self.verify_committed()
self.assert_candidate(self.journal["candidateContainerId"])
if self.slug == "vacatureradar":
marker = self.hold_path()
if marker.exists() or marker.is_symlink():
self.verify_hold()
self.journal["activationIntent"] = True
self.save("committed")
marker.unlink()
descriptor = os.open(marker.parent, os.O_RDONLY)
try:
os.fsync(descriptor)
finally:
os.close(descriptor)
elif not self.journal.get("activationIntent"):
raise ValueError("Maintenance hold disappeared before activation intent")
self.journal["activated"] = True
self.save("committed")
def renaming(self):
if self.canonical_id(self.plan["retainedName"]):
raise ValueError("Retained container name is already occupied")
if self.canonical_id(self.plan["originalName"]) != self.prepared["originalContainerId"]:
raise ValueError("Canonical container identity changed")
self.runner(["docker", "rename", self.prepared["originalContainerId"], self.plan["retainedName"]])
def deploying(self):
self.verify_hold()
if self.canonical_id(self.plan["originalName"]):
raise ValueError("Canonical name was claimed before candidate deployment")
# True first deploy relative to the distinct managed Compose project.
if self.broker.capture_compose_rollback(self.project) is not None:
raise ValueError("Candidate project is not a first deployment")
self.broker.revalidate_deployment_candidate(Path(self.journal["repositoryPath"]), self.project,
self.prepared["revision"], self.repository, self.prepared["metadata"])
cwd, command, environment = render_contract(self.broker, self.project, self.prepared, self.plan, self.runner)
image_id = verify_image(environment["MIGRATION_IMAGE"], self.prepared["metadata"], self.runner)
if image_id != self.prepared["candidateImageId"]:
raise ValueError("Prepared image tag changed before candidate start")
self.runner(command + ["up", "--detach", "--no-build", "--pull", "never"],
cwd=cwd, env=environment, timeout=180)
container_id = self.canonical_id(self.plan["originalName"])
if not container_id:
raise ValueError("Candidate deployment produced no canonical container")
self.assert_candidate(container_id)
self.journal["candidateContainerId"] = container_id
self.save("deploying")
def assert_candidate(self, container_id):
values = inspect_fields(self.runner, container_id, [".Id", ".Image",
'index .Config.Labels "com.docker.compose.project"',
'index .Config.Labels "io.itworx.migration-attempt"'])
if (values[0] != container_id or container_id == self.prepared["originalContainerId"]
or values[2] != self.plan["candidateComposeProject"] or values[3] != self.journal["nonce"]):
raise ValueError("Refusing an unrelated candidate container")
recorded = self.journal.get("candidateContainerId")
if recorded and recorded != container_id:
raise ValueError("Recorded candidate container was replaced")
def http_health(self):
path = "http://127.0.0.1:1226/health/ready/" if self.slug == "vacatureradar" else "http://127.0.0.1:1223/health/ready"
with urlopen(path, timeout=10) as response:
if response.status != 200 or response.geturl() != path:
raise ValueError("Representative readiness probe failed")
def verifying(self):
self.verify_hold()
self.assert_candidate(self.journal["candidateContainerId"])
expected = sorted(self.plan["candidateMounts"], key=lambda item: item["destination"])
if mounts(self.runner, self.journal["candidateContainerId"]) != expected:
raise ValueError("Candidate mounts differ from the isolated cold-copy plan")
networks = inspect_fields(self.runner, self.journal["candidateContainerId"], [".NetworkSettings.Networks"])[0]
if set(networks) != {self.slug + "_default"}:
raise ValueError("Candidate runtime network differs from the original")
receipt = self.broker.verify_deployment(self.project, self.prepared["metadata"],
expected_image_ids={self.plan["originalName"]: self.prepared["candidateImageId"]})
self.broker.validate_recorded_receipt(self.project, receipt, self.repository, self.prepared["revision"])
self.http_health()
self.journal["receipt"] = receipt
self.save("verifying")
def committing(self):
self.broker.write_state(self.repository, self.project, self.prepared["revision"], "success",
"Controlled legacy migration passed immutable image and readiness verification",
live_receipt=self.journal["receipt"], attempt_metadata=self.prepared["metadata"])
# Never alter another project's configuration. Both deployment locks held.
current = self.broker.CONFIG_PATH.read_bytes()
if digest(current) != self.journal["originalConfigSha256"]:
raise ValueError("Broker configuration changed during cutover")
private_write(self.broker.CONFIG_PATH, (self.workspace / "candidate-projects.json").read_bytes())
self.verify_committed()
def verify_committed(self):
if digest(self.broker.CONFIG_PATH.read_bytes()) != self.journal["candidateConfigSha256"]:
raise ValueError("Committed broker configuration no longer matches")
receipt = self.broker.verified_current_receipt(self.repository, self.project,
self.prepared["revision"], self.prepared["metadata"]["tree"])
if receipt is None:
raise ValueError("Committed runtime receipt does not reverify")
self.http_health()
def recover(self):
if self.journal.get("phase") == "committed" or self.journal.get("activationIntent"):
raise ValueError("Committed activation must never roll back")
for filename, key in (("original-projects.json", "originalConfigSha256"),
("original-state.json", "originalStateSha256")):
if digest((self.workspace / filename).read_bytes()) != self.journal[key]:
raise ValueError("Protected rollback snapshot integrity failed")
config = self.broker.CONFIG_PATH.read_bytes()
if digest(config) not in {self.journal["originalConfigSha256"], self.journal["candidateConfigSha256"]}:
raise ValueError("Unrelated broker configuration change blocks automatic recovery")
state = self.state_path.read_bytes()
if digest(state) != self.journal["originalStateSha256"]:
payload = json.loads(state)
if payload.get("live_receipt") != self.journal.get("receipt") or not self.journal.get("receipt"):
raise ValueError("Unrelated deployment state blocks automatic recovery")
self.original()
if not self.journal.get("originalRestartIntent"):
for source, expected in self.journal.get("coldCopyChecksums", {}).items():
if digest(json.dumps(tree_manifest(source), sort_keys=True).encode()) != expected:
raise ValueError("Original data changed after verified cold copy")
container_id = self.canonical_id(self.plan["originalName"])
if container_id and container_id != self.prepared["originalContainerId"]:
self.assert_candidate(container_id)
# Persist discovered ownership before removing a crash-created container.
self.journal["candidateContainerId"] = container_id
self.save("recovering")
self.runner(["docker", "rm", "--force", container_id], timeout=90)
if self.canonical_id(self.plan["originalName"]) not in {None, self.prepared["originalContainerId"]}:
raise ValueError("Canonical name is occupied by unrelated runtime")
current_name = inspect_fields(self.runner, self.prepared["originalContainerId"], [".Name"])[0].lstrip("/")
if current_name not in {self.plan["originalName"], self.plan["retainedName"]}:
raise ValueError("Original runtime was renamed by another operator")
if current_name != self.plan["originalName"]:
self.runner(["docker", "rename", self.prepared["originalContainerId"], self.plan["originalName"]])
private_write(self.broker.CONFIG_PATH, (self.workspace / "original-projects.json").read_bytes())
private_write(self.state_path, (self.workspace / "original-state.json").read_bytes())
self.journal["originalRestartIntent"] = True
self.save("restoring_original")
if not self.original()["running"]:
self.runner(["docker", "start", self.prepared["originalContainerId"]], timeout=90)
deadline = time.monotonic() + 240
while time.monotonic() < deadline:
try:
self.original(healthy=True)
self.http_health()
return
except (ValueError, OSError):
time.sleep(3)
raise ValueError("Original runtime recovery health did not verify")
def private_read(path):
import os
import stat
info = path.lstat()
if not stat.S_ISREG(info.st_mode) or info.st_uid != os.geteuid() or stat.S_IMODE(info.st_mode) & 0o077:
raise ValueError("Expected a root-only regular transaction file")
return path.read_bytes()
def operate(broker, mode, slug, revision, attempt, container_id, image_id, runner=checked):
if mode not in {"cutover", "recover"} or slug not in TARGETS or not SHA.fullmatch(revision) or not ATTEMPT.fullmatch(attempt):
raise ValueError("Invalid fixed migration operation")
workspace = require_real_child(JOURNAL_ROOT / slug / attempt, JOURNAL_ROOT)
repository = f"jens/{slug}"
with ExitStack() as locks:
locks.enter_context(broker.exclusive_lock(broker.STATE_ROOT / "locks" / f"{slug}.lock", "migration project"))
locks.enter_context(broker.exclusive_lock(broker.GLOBAL_DEPLOY_LOCK, "migration production"))
prepared_bytes = private_read(workspace / "prepare.json")
prepared = json.loads(prepared_bytes)
if (prepared.get("phase") != "build_verified" or prepared.get("repository") != repository
or prepared.get("revision") != revision or prepared.get("originalContainerId") != container_id
or prepared.get("originalImageId") != image_id):
raise ValueError("Preparation identity does not match the requested operation")
journal_path = workspace / "cutover.json"
if mode == "recover":
journal = json.loads(private_read(journal_path))
if journal.get("preparedSha256") != digest(prepared_bytes):
raise ValueError("Prepared artifact changed after transaction snapshot")
ops = Production(broker, slug, prepared, workspace, journal, runner)
phase = recover_transaction(ops)
return {"phase": phase, "repository": repository, "candidateDataRetained": True}
if journal_path.exists():
raise ValueError("Transaction already exists; use explicit recover, never repeat cutover")
projects_bytes = broker.CONFIG_PATH.read_bytes()
projects = json.loads(projects_bytes)
original = projects[repository]
state_bytes = (broker.STATE_ROOT / f"{slug}.json").read_bytes()
if (digest(json.dumps(original, sort_keys=True).encode()) != prepared.get("projectConfigSha256")
or digest(state_bytes) != prepared.get("stateSha256")):
raise ValueError("Original broker config/state changed since preparation")
if broker.state_live_revision(json.loads(state_bytes)):
raise ValueError("This transaction only supports unattested legacy first deployments")
repo = broker.repository_path(repository, original)
broker.validate_commit(repo, str(original["branch"]), revision)
if broker.output(["git", f"--git-dir={repo}", "rev-parse", f"refs/heads/{original['branch']}"]) != revision:
raise ValueError("Prepared revision is no longer the configured branch head")
source = require_real_child(Path(prepared["source"]), broker.EXACT_SOURCE_ROOT)
if not broker.exact_source_matches_revision(repo, revision, source):
raise ValueError("Prepared source changed before cutover")
if verify_image(prepared["candidateImage"], prepared["metadata"], runner) != prepared["candidateImageId"]:
raise ValueError("Prepared immutable image changed")
assert_original(original_identity(container_id, runner), slug, container_id, image_id)
if mount_inventory(container_id, runner) != prepared["originalMounts"]:
raise ValueError("Original mounts changed after preparation")
if digest(private_read(workspace / "runtime.env")) != prepared["runtimeEnvSha256"]:
raise ValueError("Private runtime environment changed after preparation")
plan = build_plan(slug, revision, attempt, {"name": TARGETS[slug][0],
**original_identity(container_id, runner), "mounts": prepared["originalMounts"]})
if plan != prepared["plan"]:
raise ValueError("Cold-copy plan changed after preparation")
candidate = candidate_project(original, prepared, plan, prepared["nonce"], workspace / "runtime.env")
if candidate != prepared["candidateProject"]:
raise ValueError("Candidate project differs from the build configuration")
broker.validate_project_config(repo, candidate, revision)
render_contract(broker, candidate, prepared, plan, runner)
required = 8 * 1024**3
for item in plan["coldCopyRoots"]:
root = Path(item["original"])
if root.is_symlink() or root.resolve() != root or not root.is_dir():
raise ValueError("Cold-copy source is not an unchanged real directory")
required += sum(path.stat().st_size for path in root.rglob("*") if path.is_file())
if Path(item["candidate"]).exists():
raise ValueError("Cold-copy target already exists")
if shutil.disk_usage(workspace).free < required:
raise ValueError("Insufficient space for retained cold clones plus safety reserve")
updated = {**projects, repository: candidate}
candidate_bytes = json.dumps(updated, sort_keys=True).encode()
private_write(workspace / "original-projects.json", projects_bytes)
private_write(workspace / "original-state.json", state_bytes)
private_write(workspace / "candidate-projects.json", candidate_bytes)
# Configuration may contain secrets: stored privately, never in user output.
snapshot = inspect_fields(runner, container_id, [".Config", ".HostConfig", ".NetworkSettings.Networks"])
private_json(workspace / "original-container.json", snapshot)
journal = {"schema": "legacy-cutover.v1", "phase": "snapshotted", "plan": plan,
"preparedSha256": digest(prepared_bytes), "candidateProject": candidate,
"repositoryPath": str(repo), "nonce": prepared["nonce"], "originalMounts": prepared["originalMounts"],
"originalConfigSha256": digest(projects_bytes), "originalStateSha256": digest(state_bytes),
"candidateConfigSha256": digest(candidate_bytes)}
ops = Production(broker, slug, prepared, workspace, journal, runner)
ops.http_health()
if ops.canonical_id(plan["retainedName"]) or broker.compose_project_container_names(plan["candidateComposeProject"]):
raise ValueError("Attempt runtime/project is already occupied")
runner(["docker", "image", "tag", image_id, f"gitea-legacy-rollback/{slug}:{prepared['nonce']}"])
ops.save("snapshotted")
run_transaction(ops)
return {"phase": "committed", "repository": repository, "revision": revision,
"originalContainerRetained": True, "originalDataRetained": True,
"receiptVerified": True, "journal": str(journal_path)}
+144
View File
@@ -0,0 +1,144 @@
#!/usr/bin/env node
import fs from "node:fs";
import path from "node:path";
const root = process.cwd();
const args = new Set(process.argv.slice(2));
const asJson = args.has("--json");
const read = (file) => fs.readFileSync(path.join(root, file), "utf8");
const exists = (file) => fs.existsSync(path.join(root, file));
const normalizePath = (file) => file.replaceAll(path.sep, "/");
const walk = (dir, filter = () => true) => {
const out = [];
const base = path.join(root, dir);
if (!fs.existsSync(base)) return out;
const stack = [base];
while (stack.length) {
const current = stack.pop();
for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
const full = path.join(current, entry.name);
if (entry.isDirectory()) {
if (!["node_modules", "dist", ".git", ".next", "coverage"].includes(entry.name)) stack.push(full);
} else if (filter(full)) {
out.push(normalizePath(path.relative(root, full)));
}
}
}
return out.sort();
};
const sourceFiles = walk("src", (file) => /\.(ts|tsx)$/.test(file));
const backendFiles = walk("backend/src", (file) => /\.(ts|tsx)$/.test(file));
const docs = walk("docs", (file) => file.endsWith(".md"));
const routeText = exists("src/app/routes.tsx") ? read("src/app/routes.tsx") : "";
const allTextFiles = [...sourceFiles, ...backendFiles].filter((file) => exists(file));
const implementationFiles = allTextFiles.filter((file) => ![
"src/shared/lib/mock-data.ts",
"src/shared/lib/projectAudit.ts",
].includes(file));
const countPattern = (files, pattern) => files.reduce((count, file) => count + ((read(file).match(pattern) ?? []).length), 0);
const filesWith = (files, pattern) => files.filter((file) => pattern.test(read(file)));
const firstMatches = (files, pattern, limit = 20) => {
const out = [];
for (const file of files) {
const lines = read(file).split(/\r?\n/);
for (const [index, line] of lines.entries()) {
if (pattern.test(line)) out.push({ file, line: index + 1, text: line.trim().slice(0, 180) });
pattern.lastIndex = 0;
if (out.length >= limit) return out;
}
}
return out;
};
const rawConsoleMatches = firstMatches(implementationFiles, /console\./g, 50);
const containedConsoleMatches = rawConsoleMatches.filter((match) => normalizePath(match.file) === "src/shared/lib/appLogger.ts");
const uncontainedConsoleMatches = rawConsoleMatches.filter((match) => normalizePath(match.file) !== "src/shared/lib/appLogger.ts");
const directMockDataFiles = filesWith(implementationFiles, /mockData/).filter((file) => file !== "src/shared/lib/projectbrainSelectors.ts");
const anyCastMatches = firstMatches(implementationFiles, /\bas any\b/g, 50);
const localhostMatches = firstMatches(implementationFiles, /localhost|127\.0\.0\.1/g, 50);
const localStorageMatches = firstMatches(implementationFiles, /localStorage/g, 50);
const auditCatalogText = exists("src/shared/lib/projectAudit.ts") ? read("src/shared/lib/projectAudit.ts") : "";
const queuedAuditItems = (auditCatalogText.match(/queued_for_/g) ?? []).length;
const v59AuditItems = (auditCatalogText.match(/implemented_in_v59/g) ?? []).length;
const report = {
generatedAt: new Date().toISOString(),
totals: {
frontendFiles: sourceFiles.length,
backendFiles: backendFiles.length,
docs: docs.length,
routes: (routeText.match(/<Route\s/g) ?? []).length,
},
signals: {
todoFixme: countPattern(implementationFiles, /TODO|FIXME|HACK|XXX/g),
directMockDataFiles: directMockDataFiles.length,
consoleUsage: countPattern(implementationFiles, /console\./g),
uncontainedConsoleUsage: uncontainedConsoleMatches.length,
localStorageUsage: countPattern(implementationFiles, /localStorage/g),
anyCasts: countPattern(implementationFiles, /\bas any\b/g),
localhostReferences: countPattern(implementationFiles, /localhost|127\.0\.0\.1/g),
queuedAuditItems,
v59AuditItems,
},
details: {
directMockDataFiles,
uncontainedConsoleMatches,
containedConsoleMatches,
anyCastMatches,
localStorageMatches,
localhostMatches,
},
gates: {
auditCenterRoute: routeText.includes("/audit"),
auditCatalog: exists("src/shared/lib/projectAudit.ts"),
auditDocs: exists("docs/PROJECTBRAIN_CSI_AUDIT_200_FINDINGS.md"),
blueprintEngineRoute: routeText.includes("/blueprint"),
v57Docs: exists("docs/STABILIZATION_PASS_V57_FULL_CSI_AUDIT.md"),
v58Docs: exists("docs/STABILIZATION_PASS_V58_QUEUE_BURNDOWN.md"),
v59Docs: exists("docs/STABILIZATION_PASS_V59_AUDIT_QUEUE_COMPLETION.md"),
appLogger: exists("src/shared/lib/appLogger.ts"),
},
};
const pass =
report.gates.auditCenterRoute &&
report.gates.auditCatalog &&
report.gates.auditDocs &&
report.gates.appLogger &&
report.totals.routes > 20 &&
report.signals.uncontainedConsoleUsage === 0 &&
report.signals.directMockDataFiles <= 1 &&
report.signals.anyCasts <= 3 &&
report.signals.localhostReferences === 0 &&
report.signals.queuedAuditItems === 0 &&
report.gates.v59Docs;
if (asJson) {
console.log(JSON.stringify({ pass, ...report }, null, 2));
} else {
console.log(`ProjectBrain CSI audit: ${pass ? "PASS" : "WARN"}`);
console.log(`Frontend files: ${report.totals.frontendFiles}`);
console.log(`Backend files: ${report.totals.backendFiles}`);
console.log(`Docs: ${report.totals.docs}`);
console.log(`Routes: ${report.totals.routes}`);
console.log(`TODO/FIXME/HACK/XXX markers: ${report.signals.todoFixme}`);
console.log(`Implementation files with direct mockData usage: ${report.signals.directMockDataFiles}`);
console.log(`console.* usages: ${report.signals.consoleUsage} (${report.signals.uncontainedConsoleUsage} uncontained)`);
console.log(`localStorage usages: ${report.signals.localStorageUsage}`);
console.log(`'as any' casts: ${report.signals.anyCasts}`);
console.log(`localhost references: ${report.signals.localhostReferences}`);
console.log(`queued audit items: ${report.signals.queuedAuditItems}`);
console.log(`V59 audit completions: ${report.signals.v59AuditItems}`);
console.log(`Audit Center route: ${report.gates.auditCenterRoute ? "yes" : "no"}`);
console.log(`Audit catalog: ${report.gates.auditCatalog ? "yes" : "no"}`);
console.log(`Audit docs: ${report.gates.auditDocs ? "yes" : "no"}`);
console.log(`App logger: ${report.gates.appLogger ? "yes" : "no"}`);
console.log(`V59 docs: ${report.gates.v59Docs ? "yes" : "no"}`);
if (!pass) {
if (directMockDataFiles.length) console.log(`Direct mockData files: ${directMockDataFiles.join(", ")}`);
if (uncontainedConsoleMatches.length) console.log(`Uncontained console usage: ${uncontainedConsoleMatches.map((m) => `${m.file}:${m.line}`).join(", ")}`);
}
}
if (!pass) process.exitCode = 1;
+163
View File
@@ -0,0 +1,163 @@
#!/usr/bin/env node
import { existsSync, readFileSync } from "node:fs";
import { join } from "node:path";
const root = process.cwd();
const errors = [];
const localArtifacts = [];
const info = [];
const showLocalArtifacts =
process.env.PROJECTBRAIN_AUDIT_VERBOSE_LOCAL_ARTIFACTS === "1";
const requireFile = (path, label = path) => {
if (!existsSync(join(root, path))) errors.push(`Missing ${label}: ${path}`);
};
const requireText = (path, text, label = text) => {
const fullPath = join(root, path);
if (!existsSync(fullPath)) {
errors.push(`Missing file for text check: ${path}`);
return;
}
const content = readFileSync(fullPath, "utf8");
if (!content.includes(text)) errors.push(`Missing ${label} in ${path}`);
};
const warnIfExists = (path, label = path) => {
if (existsSync(join(root, path))) localArtifacts.push(`Generated or legacy artifact present: ${label}`);
};
const rootPackage = JSON.parse(readFileSync(join(root, "package.json"), "utf8"));
const backendPackage = JSON.parse(readFileSync(join(root, "backend/package.json"), "utf8"));
const parseWorkspaceAllowBuilds = (workspaceYaml) => {
const allowed = new Set();
const allowBuildsIndex = workspaceYaml.indexOf("allowBuilds:");
if (allowBuildsIndex === -1) return allowed;
const lines = workspaceYaml.slice(allowBuildsIndex).split("\n").slice(1);
for (const line of lines) {
if (/^[^\s#]/.test(line)) break;
const match = line.match(/^\s*[\'"]?([^\'":]+)[\'"]?\s*:\s*(true|false)\s*(?:#.*)?$/);
if (match && match[2] === "true") allowed.add(match[1].trim());
}
return allowed;
};
const workspaceAllowBuilds = existsSync(join(root, "pnpm-workspace.yaml"))
? parseWorkspaceAllowBuilds(readFileSync(join(root, "pnpm-workspace.yaml"), "utf8"))
: new Set();
if (rootPackage.packageManager !== "pnpm@11.5.2") {
errors.push("Root package.json must pin packageManager to pnpm@11.5.2 so Docker/Corepack does not float to a different pnpm release.");
}
const requiredPnpmBuiltDeps = ["@prisma/client", "@prisma/engines", "@swc/core", "esbuild", "prisma"];
for (const dependency of requiredPnpmBuiltDeps) {
if (!workspaceAllowBuilds.has(dependency)) {
errors.push(`pnpm-workspace.yaml allowBuilds must approve ${dependency} for Docker builds with pnpm 11.`);
}
}
console.log("ProjectBrain doctor");
console.log("===================");
[
"README.md",
".env.example",
"backend/.env.example",
"docs/PROJECTBRAIN_DOCUMENTATION_INDEX.md",
"docs/FULL_VERSION_ROADMAP.md",
"docs/VERTICAL_SLICE_CORE_FLOW_PLAN.md",
"docs/PROJECTBRAIN_STATUS_AND_GATES.md",
"docs/P2_LOCAL_RUNTIME_VERIFICATION.md",
"docs/RUNTIME_ROUND_5_BACKEND_HEALTH_AND_SUITE.md",
"docs/PROJECTBRAIN_RUNTIME_COMMANDS.md",
"backend/scripts/smoke-p2.mjs",
"backend/scripts/smoke-fixture-api.mjs",
"backend/scripts/smoke-real-read-pilot.mjs",
"backend/scripts/smoke-vertical-slice.mjs",
"backend/scripts/smoke-runtime-suite.mjs",
"backend/scripts/docker-entrypoint.sh",
"scripts/check-database-readiness.mjs",
"public/site.webmanifest",
"public/favicon.svg",
"public/favicon.ico",
].forEach((file) => requireFile(file));
[
["validate:all", "root validation"],
["audit:release", "release hygiene audit"],
["audit:runtime", "runtime readiness audit"],
["audit:database", "database readiness audit"],
["smoke:backend:fixtures", "fixture smoke"],
["smoke:backend:real-read-pilot", "real-read pilot smoke"],
["smoke:backend:vertical-slice", "vertical slice smoke"],
["smoke:backend:runtime-suite", "runtime suite smoke"],
["smoke:backend:database-suite", "database suite smoke"],
["smoke:backend:brainstorms", "Brainstorm CPU smoke"],
["smoke:backend:initiatives", "Initiative smoke"],
].forEach(([script, label]) => {
if (!rootPackage.scripts?.[script]) errors.push(`Missing ${label} script: ${script}`);
});
[
["smoke:p2", "P2 database smoke"],
["smoke:fixtures", "fixture smoke"],
["smoke:real-read-pilot", "real read smoke"],
["smoke:vertical-slice", "vertical slice smoke"],
["smoke:runtime-suite", "runtime suite smoke"],
["smoke:database-suite", "database suite smoke"],
["smoke:brainstorms", "Brainstorm CPU smoke"],
["smoke:initiatives", "Initiative smoke"],
["db:deploy", "Prisma migrate deploy"],
["db:status", "Prisma migration status"],
["db:validate", "Prisma schema validate"],
].forEach(([script, label]) => {
if (!backendPackage.scripts?.[script]) errors.push(`Missing backend ${label} script: ${script}`);
});
requireText("index.html", "<title>ProjectBrain</title>", "ProjectBrain title");
requireText("index.html", "ProjectBrain", "ProjectBrain browser metadata");
requireText("src/features/projects/pages/RuntimeReadiness.tsx", "Backend health", "backend health card");
requireText("src/features/projects/pages/RuntimeReadiness.tsx", "Database readiness", "database readiness card");
requireText("src/features/projects/pages/RuntimeReadiness.tsx", "Vertical slice smoke", "vertical slice smoke card");
requireText("src/shared/api/realClient.ts", "getHealth", "real backend health client");
requireText("src/shared/api/realClient.ts", "getSystemReadiness", "real backend system readiness client");
requireText("src/shared/hooks/useProjectData.ts", "useBackendHealthData", "backend health hook");
requireText("src/shared/hooks/useProjectData.ts", "useBackendSystemReadinessData", "backend system readiness hook");
requireText("docs/PROJECTBRAIN_STATUS_AND_GATES.md", "P2", "P2 gate documentation");
[
"node_modules",
"backend/node_modules",
"dist",
"backend/dist",
"checkpoints",
"src/App.css",
"src/globals.css",
].forEach((path) => warnIfExists(path));
info.push(`Package manager: ${rootPackage.packageManager ?? "not pinned"}`);
info.push(`Approved pnpm workspace allowBuilds: ${Array.from(workspaceAllowBuilds).sort().join(", ") || "none"}`);
info.push(`Frontend package: ${rootPackage.name}@${rootPackage.version}`);
info.push(`Backend package: ${backendPackage.name}@${backendPackage.version}`);
info.push("Recommended local command order:");
info.push(" 1. pnpm install");
info.push(" 2. pnpm run audit:database");
info.push(" 3. pnpm run validate:all");
info.push(" 4. pnpm run audit:all");
info.push(" 5. docker compose up -d --build");
info.push(" 6. pnpm run smoke:backend:system-readiness");
info.push(" 7. pnpm run smoke:backend:runtime-suite && pnpm --filter projectbrain-api run smoke:blueprints");
info.push(" 8. pnpm run smoke:backend:database-suite");
for (const line of info) console.log(`INFO ${line}`);
if (showLocalArtifacts) {
for (const line of localArtifacts) console.log(`INFO ${line}`);
}
for (const line of errors) console.error(`FAIL ${line}`);
if (errors.length) {
console.error(`\nFAIL: ${errors.length} blocking doctor issue(s) found.`);
process.exit(1);
}
console.log("\nPASS: ProjectBrain doctor found no blocking runtime/release scaffold issues.");
+72
View File
@@ -0,0 +1,72 @@
import { spawnSync } from "node:child_process";
const baseUrl = process.env.PROJECTBRAIN_SMOKE_URL ?? "http://127.0.0.1:1209";
const projectId = process.env.PROJECTBRAIN_SMOKE_PROJECT ?? "widefrog";
const archiveOut = process.env.PROJECTBRAIN_PROOF_OUT;
const skipSmokes = process.argv.includes("--skip-smokes") || process.env.PROJECTBRAIN_RELEASE_SKIP_SMOKES === "1";
const includeDeploymentProof = process.argv.includes("--deployment-proof") || process.env.PROJECTBRAIN_RELEASE_DEPLOYMENT_PROOF === "1";
const includeDossier = process.argv.includes("--dossier") || process.env.PROJECTBRAIN_RELEASE_DOSSIER === "1";
const run = (label, command, args, env = {}) => {
console.log(`\n[release-bundle] ${label}`);
const result = spawnSync(command, args, {
env: { ...process.env, ...env },
shell: process.platform === "win32",
stdio: "inherit",
});
if (result.status !== 0) {
throw new Error(`${label} failed with exit code ${result.status}`);
}
};
try {
console.log("ProjectBrain release command bundle");
console.log(`Base URL: ${baseUrl}`);
console.log(`Project: ${projectId}`);
console.log(`Smoke mode: ${skipSmokes ? "skipped" : "enabled"}`);
console.log(`Deployment proof: ${includeDeploymentProof ? "enabled" : "skipped"}`);
console.log(`Evidence dossier: ${includeDossier ? "enabled" : "skipped"}`);
const proofEnv = {
PROJECTBRAIN_SMOKE_URL: baseUrl,
PROJECTBRAIN_SMOKE_PROJECT: projectId,
PROJECTBRAIN_PROOF_WRITE: "1",
PROJECTBRAIN_PROOF_RUN_SMOKES: skipSmokes ? "0" : "1",
};
if (archiveOut) proofEnv.PROJECTBRAIN_PROOF_OUT = archiveOut;
run("Create release proof snapshot", "pnpm", ["run", "release:proof-report"], proofEnv);
run("Index release proof snapshots", "pnpm", ["run", "release:proof-index", "--", "--limit=5"], {
PROJECTBRAIN_SMOKE_PROJECT: projectId,
});
run("Verify release proof freshness", "pnpm", ["run", "release:proof-freshness"], {
PROJECTBRAIN_SMOKE_PROJECT: projectId,
});
run("Create release signoff file", "pnpm", ["run", "release:signoff-file"], {
PROJECTBRAIN_SMOKE_PROJECT: projectId,
});
if (includeDeploymentProof) {
run("Create remote deployment proof", "pnpm", ["run", "release:deployment-proof"], {
PROJECTBRAIN_DEPLOY_URL: baseUrl,
PROJECTBRAIN_DEPLOY_PROOF_WRITE: "1",
});
}
if (includeDossier) {
run("Create release evidence dossier", "pnpm", ["run", "release:dossier"], {
PROJECTBRAIN_SMOKE_PROJECT: projectId,
});
}
console.log("\nRelease command bundle passed.");
console.log(includeDossier
? "Release proof is fresh, signoff and deployment proof passed, and a release evidence dossier was written."
: includeDeploymentProof
? "Release proof is fresh, a signoff file was written and remote deployment proof passed."
: "Release proof is fresh for the current commit and a signoff file was written.");
} catch (error) {
console.error("\nRelease command bundle failed:");
console.error(error instanceof Error ? error.message : String(error));
console.error("\nNext step: rerun with live URL and smokes enabled, then inspect the proof report above.");
process.exitCode = 1;
}
+151
View File
@@ -0,0 +1,151 @@
import { spawnSync } from "node:child_process";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { compareDeploymentCommits, isRunningContainer } from "./deployment-proof-state.mjs";
const baseUrl = process.env.PROJECTBRAIN_DEPLOY_URL ?? process.env.PROJECTBRAIN_SMOKE_URL ?? "http://127.0.0.1:1209";
const sshTarget = process.env.PROJECTBRAIN_DEPLOY_SSH_TARGET ?? "root@127.0.0.1";
const deployPath = process.env.PROJECTBRAIN_DEPLOY_PATH ?? "/mnt/pool/appdata/brain";
const sshKey = process.env.PROJECTBRAIN_DEPLOY_SSH_KEY ?? path.join(os.homedir(), ".ssh", "widefrog_unraid_deploy");
const shouldWrite = process.argv.includes("--write") || process.env.PROJECTBRAIN_DEPLOY_PROOF_WRITE === "1";
const explicitOutputArg = process.argv.find((arg) => arg.startsWith("--out="));
const explicitOutputPath = explicitOutputArg?.slice("--out=".length) || process.env.PROJECTBRAIN_DEPLOY_PROOF_OUT;
const run = (command, args) => spawnSync(command, args, {
encoding: "utf8",
shell: false,
timeout: 10_000,
});
const getGitCommit = () => {
const result = run("git", ["rev-parse", "--short", "HEAD"]);
return result.status === 0 ? result.stdout.trim() : "unknown";
};
const createDefaultProofPath = (commit) => {
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
return `checkpoints/deployment-proof/projectbrain-deployment-proof-${commit}-${stamp}.md`;
};
const checkHttp = async (urlPath) => {
try {
const response = await fetch(`${baseUrl}${urlPath}`, { signal: AbortSignal.timeout(10_000) });
const body = await response.text();
return {
ok: response.ok,
status: String(response.status),
body: body.slice(0, 260).replace(/\s+/g, " ").trim(),
};
} catch (error) {
return {
ok: false,
status: "unreachable",
body: error instanceof Error ? error.message : String(error),
};
}
};
const runSsh = (remoteCommand) => {
if (!fs.existsSync(sshKey)) {
return {
ok: false,
status: "missing-key",
stdout: "",
stderr: `SSH key not found: ${sshKey}`,
};
}
const result = spawnSync("ssh", [
"-i",
sshKey,
"-o",
"BatchMode=yes",
"-o",
"ConnectTimeout=8",
sshTarget,
remoteCommand,
], {
encoding: "utf8",
shell: false,
timeout: 20_000,
});
return {
ok: result.status === 0,
status: String(result.status),
stdout: result.stdout?.trim() ?? "",
stderr: result.stderr?.trim() || result.error?.message || "",
};
};
const currentCommit = getGitCommit();
const health = await checkHttp("/api/health");
const shellProof = runSsh([
`echo deployed_commit=$(git -C ${deployPath} rev-parse --short HEAD 2>/dev/null || echo unknown)`,
"echo containers_begin",
"docker ps --format '{{.Names}} {{.Status}} {{.Ports}}' | grep projectbrain || true",
"echo containers_end",
].join(" && "));
const deployedCommitMatch = shellProof.stdout.match(/^deployed_commit=(.+)$/m);
const deployedCommit = deployedCommitMatch?.[1]?.trim() ?? "unknown";
const containerBlock = shellProof.stdout.match(/containers_begin\n([\s\S]*?)\n?containers_end/)?.[1]?.trim() ?? "";
const hasFrontend = isRunningContainer(containerBlock, "projectbrain-frontend", "1209->80/tcp");
const hasApi = isRunningContainer(containerBlock, "projectbrain-api");
const hasPostgres = isRunningContainer(containerBlock, "projectbrain-postgres");
const commitFreshness = compareDeploymentCommits(currentCommit, deployedCommit);
const verdict = health.ok && shellProof.ok && commitFreshness === "current" && hasFrontend && hasApi && hasPostgres
? "PASS"
: "ATTENTION REQUIRED";
const proofPath = explicitOutputPath || createDefaultProofPath(currentCommit);
const lines = [
"# ProjectBrain Remote Deployment Proof",
"",
`Generated: ${new Date().toISOString()}`,
`Local commit: ${currentCommit}`,
`Deployment URL: ${baseUrl}`,
`SSH target: ${sshTarget}`,
`Deployment path: ${deployPath}`,
`Remote commit: ${deployedCommit}`,
`Commit freshness: ${commitFreshness}`,
`Verdict: ${verdict}`,
"",
"## HTTP Health",
"",
`- Status: ${health.status}`,
`- Result: ${health.ok ? "reachable" : "not reachable"}`,
`- Body: ${health.body}`,
"",
"## Remote Runtime",
"",
`- SSH result: ${shellProof.ok ? "reachable" : shellProof.status}`,
`- Frontend container: ${hasFrontend ? "up" : "not proven"}`,
`- API container: ${hasApi ? "up" : "not proven"}`,
`- Postgres container: ${hasPostgres ? "up" : "not proven"}`,
"",
"```text",
containerBlock || shellProof.stderr || "No remote container output captured.",
"```",
"",
"## Operator Commands",
"",
"```bash",
"PROJECTBRAIN_DEPLOY_PROOF_WRITE=1 pnpm run release:deployment-proof",
"PROJECTBRAIN_RELEASE_DEPLOYMENT_PROOF=1 pnpm run release:bundle",
"```",
].join("\n");
console.log(lines);
if (shouldWrite) {
fs.mkdirSync(path.dirname(proofPath), { recursive: true });
fs.writeFileSync(proofPath, `${lines}\n`, "utf8");
console.log(`\nRemote deployment proof written to ${proofPath}`);
}
if (verdict !== "PASS") {
process.exitCode = 1;
}
+152
View File
@@ -0,0 +1,152 @@
import { spawnSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
const proofDir = process.env.PROJECTBRAIN_PROOF_ARCHIVE_DIR ?? "checkpoints/release-proof";
const signoffDir = process.env.PROJECTBRAIN_SIGNOFF_ARCHIVE_DIR ?? "checkpoints/release-signoff";
const deploymentProofDir = process.env.PROJECTBRAIN_DEPLOY_PROOF_ARCHIVE_DIR ?? "checkpoints/deployment-proof";
const dossierOut = process.env.PROJECTBRAIN_RELEASE_DOSSIER_OUT;
const run = (command, args) => spawnSync(command, args, {
encoding: "utf8",
shell: process.platform === "win32",
});
const getGitCommit = () => {
const result = run("git", ["rev-parse", "--short", "HEAD"]);
return result.status === 0 ? result.stdout.trim() : "unknown";
};
const parseField = (text, label) => {
const match = text.match(new RegExp(`^${label}:\\s*(.+)$`, "m"));
return match?.[1]?.trim() ?? "unknown";
};
const displayPath = (filePath) => filePath.split(path.sep).join("/");
const listMarkdownFiles = (archiveDir) => {
if (!fs.existsSync(archiveDir)) return [];
return fs.readdirSync(archiveDir)
.filter((fileName) => fileName.endsWith(".md"))
.map((fileName) => path.join(archiveDir, fileName))
.filter((filePath) => fs.statSync(filePath).isFile());
};
const sortNewestFirst = (items) => items.sort((a, b) => {
const generatedDelta = Date.parse(b.generated) - Date.parse(a.generated);
if (Number.isFinite(generatedDelta) && generatedDelta !== 0) return generatedDelta;
return b.fileName.localeCompare(a.fileName);
});
const parseArtifact = (filePath, type) => {
const text = fs.readFileSync(filePath, "utf8");
const generated = parseField(text, "Generated");
const commit = type === "deployment"
? parseField(text, "Local commit")
: parseField(text, "Commit");
return {
type,
text,
filePath,
displayPath: displayPath(filePath),
fileName: path.basename(filePath),
generated,
commit,
verdict: parseField(text, "Verdict"),
freshness: type === "deployment"
? parseField(text, "Commit freshness")
: parseField(text, "Freshness"),
};
};
const findLatestCurrentArtifact = (archiveDir, type, currentCommit) => {
const artifacts = sortNewestFirst(listMarkdownFiles(archiveDir).map((filePath) => parseArtifact(filePath, type)));
return artifacts.find((artifact) => {
if (artifact.commit !== currentCommit) return false;
if (type === "proof") return artifact.verdict === "PASS";
if (type === "signoff") return artifact.freshness === "current";
if (type === "deployment") return artifact.verdict === "PASS" && artifact.freshness === "current";
return false;
});
};
const indentArtifact = (artifact) => artifact.text
.split("\n")
.map((line) => line ? `> ${line}` : ">")
.join("\n");
const createDefaultDossierPath = (commit) => {
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
return `checkpoints/release-dossier/projectbrain-release-dossier-${commit}-${stamp}.md`;
};
const currentCommit = getGitCommit();
const proof = findLatestCurrentArtifact(proofDir, "proof", currentCommit);
const signoff = findLatestCurrentArtifact(signoffDir, "signoff", currentCommit);
const deployment = findLatestCurrentArtifact(deploymentProofDir, "deployment", currentCommit);
const missing = [
proof ? null : "current passing release proof",
signoff ? null : "current release signoff",
deployment ? null : "current passing deployment proof",
].filter(Boolean);
if (missing.length) {
console.error("Cannot create release evidence dossier: missing required current artifacts.");
for (const item of missing) console.error(`- ${item}`);
console.error("Run: PROJECTBRAIN_RELEASE_DEPLOYMENT_PROOF=1 pnpm run release:bundle");
process.exit(1);
}
const dossierPath = dossierOut || createDefaultDossierPath(currentCommit);
const dossier = [
"# ProjectBrain Release Evidence Dossier",
"",
`Generated: ${new Date().toISOString()}`,
`Commit: ${currentCommit}`,
`Verdict: PASS`,
"",
"## Included Artifacts",
"",
"| Artifact | Generated | Source |",
"| --- | --- | --- |",
`| Release proof | ${proof.generated} | ${proof.displayPath} |`,
`| Operator signoff | ${signoff.generated} | ${signoff.displayPath} |`,
`| Deployment proof | ${deployment.generated} | ${deployment.displayPath} |`,
"",
"## Operator Summary",
"",
"- Release operator smoke suite passed for the current commit.",
"- Release proof freshness is current.",
"- Operator signoff file exists for the current commit.",
"- Remote deployment proof passed for the current commit.",
"- Tower health and ProjectBrain containers were proven by the deployment proof.",
"",
"## Release Proof",
"",
indentArtifact(proof),
"",
"## Operator Signoff",
"",
indentArtifact(signoff),
"",
"## Remote Deployment Proof",
"",
indentArtifact(deployment),
"",
"## Tower Commands",
"",
"```bash",
"cd /mnt/pool/appdata/brain",
"docker compose build --no-cache frontend",
"docker compose up -d",
"```",
].join("\n");
fs.mkdirSync(path.dirname(dossierPath), { recursive: true });
fs.writeFileSync(dossierPath, `${dossier}\n`, "utf8");
console.log(dossier);
console.log(`\nRelease evidence dossier written to ${dossierPath}`);
+155
View File
@@ -0,0 +1,155 @@
import { spawnSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
const proofDir = process.env.PROJECTBRAIN_PROOF_ARCHIVE_DIR ?? "checkpoints/release-proof";
const signoffDir = process.env.PROJECTBRAIN_SIGNOFF_ARCHIVE_DIR ?? "checkpoints/release-signoff";
const keepArg = process.argv.find((arg) => arg.startsWith("--keep="));
const keep = Number.parseInt(keepArg?.slice("--keep=".length) ?? process.env.PROJECTBRAIN_EVIDENCE_KEEP ?? "20", 10);
const shouldPrune = process.argv.includes("--prune");
const confirmed = process.argv.includes("--yes") || process.env.PROJECTBRAIN_EVIDENCE_PRUNE_CONFIRM === "1";
const run = (command, args) => spawnSync(command, args, {
encoding: "utf8",
shell: process.platform === "win32",
});
const getGitCommit = () => {
const result = run("git", ["rev-parse", "--short", "HEAD"]);
return result.status === 0 ? result.stdout.trim() : "unknown";
};
const parseField = (text, label) => {
const match = text.match(new RegExp(`^${label}:\\s*(.+)$`, "m"));
return match?.[1]?.trim() ?? "unknown";
};
const displayPath = (filePath) => filePath.split(path.sep).join("/");
const safeKeep = Number.isFinite(keep) && keep > 0 ? keep : 20;
const listMarkdownFiles = (archiveDir, type) => {
if (!fs.existsSync(archiveDir)) return [];
return fs.readdirSync(archiveDir)
.filter((fileName) => fileName.endsWith(".md"))
.map((fileName) => path.join(archiveDir, fileName))
.filter((filePath) => fs.statSync(filePath).isFile())
.map((filePath) => {
const text = fs.readFileSync(filePath, "utf8");
const stat = fs.statSync(filePath);
const generated = parseField(text, "Generated");
return {
type,
filePath,
displayPath: displayPath(filePath),
fileName: path.basename(filePath),
generated,
modifiedAt: stat.mtime.toISOString(),
commit: parseField(text, "Commit"),
verdict: parseField(text, "Verdict"),
freshness: parseField(text, "Freshness"),
};
});
};
const sortNewestFirst = (items) => items.sort((a, b) => {
const generatedDelta = Date.parse(b.generated) - Date.parse(a.generated);
if (Number.isFinite(generatedDelta) && generatedDelta !== 0) return generatedDelta;
const modifiedDelta = Date.parse(b.modifiedAt) - Date.parse(a.modifiedAt);
if (Number.isFinite(modifiedDelta) && modifiedDelta !== 0) return modifiedDelta;
return b.fileName.localeCompare(a.fileName);
});
const createArchive = (archiveDir, type) => {
const items = sortNewestFirst(listMarkdownFiles(archiveDir, type));
const keepers = items.slice(0, safeKeep);
const candidates = items.slice(safeKeep);
return {
type,
archiveDir,
items,
keepers,
candidates,
};
};
const deleteCandidate = (candidate, archiveDir) => {
const resolvedArchiveDir = path.resolve(archiveDir);
const resolvedFile = path.resolve(candidate.filePath);
if (!resolvedFile.startsWith(`${resolvedArchiveDir}${path.sep}`)) {
throw new Error(`Refusing to delete outside archive: ${candidate.displayPath}`);
}
if (!candidate.fileName.endsWith(".md")) {
throw new Error(`Refusing to delete non-markdown artifact: ${candidate.displayPath}`);
}
fs.unlinkSync(candidate.filePath);
};
const currentCommit = getGitCommit();
const archives = [
createArchive(proofDir, "proof"),
createArchive(signoffDir, "signoff"),
];
const candidates = archives.flatMap((archive) => archive.candidates.map((candidate) => ({
...candidate,
archiveDir: archive.archiveDir,
})));
const lines = [
"# ProjectBrain Release Evidence Retention",
"",
`Current commit: ${currentCommit}`,
`Mode: ${shouldPrune ? confirmed ? "prune" : "dry-run prune" : "list"}`,
`Keep newest per archive: ${safeKeep}`,
"",
"| Archive | Type | Snapshots | Kept | Prune candidates |",
"| --- | --- | --- | --- | --- |",
];
for (const archive of archives) {
lines.push(`| ${archive.archiveDir} | ${archive.type} | ${archive.items.length} | ${archive.keepers.length} | ${archive.candidates.length} |`);
}
lines.push("");
if (!candidates.length) {
lines.push("No release evidence cleanup candidates found.");
} else {
lines.push("## Cleanup Candidates");
lines.push("");
lines.push("| Generated | Type | Commit | Verdict/Freshness | File |");
lines.push("| --- | --- | --- | --- | --- |");
for (const candidate of candidates) {
const state = candidate.type === "proof" ? candidate.verdict : candidate.freshness;
lines.push(`| ${candidate.generated} | ${candidate.type} | ${candidate.commit} | ${state} | ${candidate.displayPath} |`);
}
}
if (shouldPrune && !confirmed) {
lines.push("");
lines.push("Dry-run only. To delete cleanup candidates, rerun with:");
lines.push("");
lines.push("```bash");
lines.push("pnpm run release:evidence-prune -- --yes");
lines.push("```");
}
console.log(lines.join("\n"));
if (shouldPrune && confirmed) {
for (const candidate of candidates) {
deleteCandidate(candidate, candidate.archiveDir);
}
console.log("");
console.log(`Deleted ${candidates.length} release evidence artifact${candidates.length === 1 ? "" : "s"}.`);
}
+183
View File
@@ -0,0 +1,183 @@
import { spawnSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
const baseUrl = process.env.PROJECTBRAIN_SMOKE_URL ?? "http://127.0.0.1:5173";
const projectId = process.env.PROJECTBRAIN_SMOKE_PROJECT ?? "widefrog";
const shouldRunSmokes = process.argv.includes("--run-smokes") || process.env.PROJECTBRAIN_PROOF_RUN_SMOKES === "1";
const hasExplicitBaseUrl = Boolean(process.env.PROJECTBRAIN_SMOKE_URL);
const shouldWriteReport = process.argv.includes("--write") || process.env.PROJECTBRAIN_PROOF_WRITE === "1";
const explicitOutputArg = process.argv.find((arg) => arg.startsWith("--out="));
const explicitOutputPath = explicitOutputArg?.slice("--out=".length) || process.env.PROJECTBRAIN_PROOF_OUT;
const evidenceRows = [
{
id: "RC63",
label: "Release evidence lane click-through",
script: "smoke:rc63-release-evidence-lane",
proves: "Release evidence routes are reachable from the operator lane.",
},
{
id: "RC60",
label: "Review decision receipt recovery",
script: "smoke:rc60-review-decision-loop",
proves: "Review decisions can be recovered and inspected from history.",
},
{
id: "RC73",
label: "Release evidence capture assist",
script: "smoke:rc73-release-evidence-capture-assist",
proves: "Runtime evidence capture assists the operator instead of leaving proof manual-only.",
},
{
id: "RC88",
label: "Release Center cockpit",
script: "smoke:rc88-release-center",
proves: "Release Center renders the cockpit, archives, quality and operator actions.",
},
{
id: "RC99",
label: "Release checklist click-through",
script: "smoke:rc99-release-checklist-clickthrough",
proves: "Checklist links land on exact target sections and expose return paths.",
},
];
const run = (command, args, options = {}) => spawnSync(command, args, {
encoding: "utf8",
shell: process.platform === "win32",
...options,
});
const readPackage = () => {
const packageJson = JSON.parse(fs.readFileSync("package.json", "utf8"));
return packageJson.scripts ?? {};
};
const getGitCommit = () => {
const result = run("git", ["rev-parse", "--short", "HEAD"]);
return result.status === 0 ? result.stdout.trim() : "unknown";
};
const createDefaultReportPath = (commit) => {
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
return `checkpoints/release-proof/projectbrain-release-proof-${commit}-${stamp}.md`;
};
const checkLocalConfiguration = () => {
const scripts = readPackage();
const missing = [];
if (!scripts["smoke:release-operator"]) missing.push("smoke:release-operator");
if (!scripts["release:proof-report"]) missing.push("release:proof-report");
for (const row of evidenceRows) {
if (!scripts[row.script]) missing.push(row.script);
}
return missing;
};
const checkLiveHealth = async () => {
try {
const response = await fetch(`${baseUrl}/api/health`);
const body = await response.text();
return {
ok: response.ok,
status: response.status,
body: body.slice(0, 220),
};
} catch (error) {
return {
ok: false,
status: "unreachable",
body: error instanceof Error ? error.message : String(error),
};
}
};
const runOperatorSmoke = () => {
const result = run("pnpm", ["run", "smoke:release-operator"], {
stdio: "inherit",
env: process.env,
});
return result.status === 0;
};
const formatTable = (rows) => {
const header = "| Gate | Smoke | Proof |\n| --- | --- | --- |";
const body = rows.map((row) => `| ${row.id} | \`${row.script}\` | ${row.proves} |`).join("\n");
return `${header}\n${body}`;
};
const localMissing = checkLocalConfiguration();
let smokePassed = null;
if (shouldRunSmokes) {
console.log("Running release operator smoke suite before generating proof report...\n");
smokePassed = runOperatorSmoke();
}
const liveHealth = await checkLiveHealth();
const gitCommit = getGitCommit();
const localConfigOk = localMissing.length === 0;
const healthGateOk = liveHealth.ok || !hasExplicitBaseUrl;
const verdict = localConfigOk && healthGateOk && (smokePassed !== false)
? (smokePassed === true ? "PASS" : "CONFIGURED - SMOKE NOT RUN")
: "ATTENTION REQUIRED";
const reportPath = explicitOutputPath || createDefaultReportPath(gitCommit);
const report = [
"# ProjectBrain Release Operator Proof Report",
"",
`Generated: ${new Date().toISOString()}`,
`Commit: ${gitCommit}`,
`Base URL: ${baseUrl}`,
`Project: ${projectId}`,
`Verdict: ${verdict}`,
"",
"## Live Health",
"",
`- Status: ${liveHealth.status}`,
`- Result: ${liveHealth.ok ? "reachable" : hasExplicitBaseUrl ? "not reachable" : "not reachable in local default mode"}`,
`- Body: ${liveHealth.body}`,
"",
"## Proof Coverage",
"",
formatTable(evidenceRows),
"",
"## Local Configuration",
"",
localConfigOk
? "- Required release smoke scripts are registered."
: `- Missing scripts: ${localMissing.map((name) => `\`${name}\``).join(", ")}`,
`- Smoke execution: ${smokePassed === true ? "passed" : smokePassed === false ? "failed" : "not run"}`,
"",
"## Operator Commands",
"",
"```bash",
"pnpm run release:proof-report",
"pnpm run release:proof-report -- --write",
"PROJECTBRAIN_PROOF_RUN_SMOKES=1 pnpm run release:proof-report",
"PROJECTBRAIN_PROOF_RUN_SMOKES=1 PROJECTBRAIN_PROOF_WRITE=1 pnpm run release:proof-report",
"PROJECTBRAIN_SMOKE_URL=http://127.0.0.1:1209 pnpm run smoke:release-operator",
"```",
"",
"## Remaining Manual Gates",
"",
"- Confirm the intended release commit/tag.",
"- Confirm any environment-specific deployment notes.",
"- Confirm operator signoff after the smoke suite is green.",
].join("\n");
console.log(report);
if (shouldWriteReport) {
fs.mkdirSync(path.dirname(reportPath), { recursive: true });
fs.writeFileSync(reportPath, `${report}\n`, "utf8");
console.log(`\nRelease proof report written to ${reportPath}`);
}
if (!localConfigOk || !healthGateOk || smokePassed === false) {
process.exitCode = 1;
}
+90
View File
@@ -0,0 +1,90 @@
import { spawnSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
const archiveDir = process.env.PROJECTBRAIN_PROOF_ARCHIVE_DIR ?? "checkpoints/release-proof";
const allowStale = process.argv.includes("--allow-stale") || process.env.PROJECTBRAIN_PROOF_ALLOW_STALE === "1";
const run = (command, args) => spawnSync(command, args, {
encoding: "utf8",
shell: process.platform === "win32",
});
const getGitCommit = () => {
const result = run("git", ["rev-parse", "--short", "HEAD"]);
return result.status === 0 ? result.stdout.trim() : "unknown";
};
const parseField = (text, label) => {
const match = text.match(new RegExp(`^${label}:\\s*(.+)$`, "m"));
return match?.[1]?.trim() ?? "unknown";
};
const displayPath = (filePath) => filePath.split(path.sep).join("/");
const parseProof = (filePath) => {
const text = fs.readFileSync(filePath, "utf8");
return {
filePath,
displayPath: displayPath(filePath),
fileName: path.basename(filePath),
generated: parseField(text, "Generated"),
commit: parseField(text, "Commit"),
project: parseField(text, "Project"),
verdict: parseField(text, "Verdict"),
smokeExecution: text.match(/^- Smoke execution:\s*(.+)$/m)?.[1]?.trim() ?? "unknown",
};
};
const listProofFiles = () => {
if (!fs.existsSync(archiveDir)) return [];
return fs.readdirSync(archiveDir)
.filter((fileName) => fileName.endsWith(".md"))
.map((fileName) => path.join(archiveDir, fileName))
.filter((filePath) => fs.statSync(filePath).isFile());
};
const currentCommit = getGitCommit();
const proofs = listProofFiles()
.map(parseProof)
.sort((a, b) => Date.parse(b.generated) - Date.parse(a.generated) || b.fileName.localeCompare(a.fileName));
const latestPassing = proofs.find((proof) => proof.verdict === "PASS");
const freshness = !latestPassing
? "missing"
: latestPassing.commit === currentCommit
? "current"
: "stale";
const lines = [
"# ProjectBrain Release Proof Freshness",
"",
`Archive: ${archiveDir}`,
`Current commit: ${currentCommit}`,
`Latest passing proof: ${latestPassing ? latestPassing.displayPath : "none"}`,
`Proof commit: ${latestPassing?.commit ?? "none"}`,
`Freshness: ${freshness}`,
`Strict mode: ${allowStale ? "disabled" : "enabled"}`,
"",
];
if (latestPassing) {
lines.push("| Generated | Verdict | Project | Smoke | File |");
lines.push("| --- | --- | --- | --- | --- |");
lines.push(`| ${latestPassing.generated} | ${latestPassing.verdict} | ${latestPassing.project} | ${latestPassing.smokeExecution} | ${latestPassing.displayPath} |`);
lines.push("");
}
if (freshness !== "current") {
lines.push("Create a fresh passing proof for the current commit with:");
lines.push("");
lines.push("```bash");
lines.push("PROJECTBRAIN_SMOKE_URL=http://127.0.0.1:1209 PROJECTBRAIN_PROOF_RUN_SMOKES=1 PROJECTBRAIN_PROOF_WRITE=1 pnpm run release:proof-report");
lines.push("```");
}
console.log(lines.join("\n"));
if (!allowStale && freshness !== "current") {
process.exitCode = 1;
}
+74
View File
@@ -0,0 +1,74 @@
import fs from "node:fs";
import path from "node:path";
const archiveDir = process.env.PROJECTBRAIN_PROOF_ARCHIVE_DIR ?? "checkpoints/release-proof";
const limitArg = process.argv.find((arg) => arg.startsWith("--limit="));
const limit = Number.parseInt(limitArg?.slice("--limit=".length) ?? process.env.PROJECTBRAIN_PROOF_INDEX_LIMIT ?? "10", 10);
const parseField = (text, label) => {
const match = text.match(new RegExp(`^${label}:\\s*(.+)$`, "m"));
return match?.[1]?.trim() ?? "unknown";
};
const displayPath = (filePath) => filePath.split(path.sep).join("/");
const parseProof = (filePath) => {
const text = fs.readFileSync(filePath, "utf8");
const stat = fs.statSync(filePath);
return {
filePath,
displayPath: displayPath(filePath),
fileName: path.basename(filePath),
modifiedAt: stat.mtime.toISOString(),
generated: parseField(text, "Generated"),
commit: parseField(text, "Commit"),
baseUrl: parseField(text, "Base URL"),
project: parseField(text, "Project"),
verdict: parseField(text, "Verdict"),
smokeExecution: text.match(/^- Smoke execution:\s*(.+)$/m)?.[1]?.trim() ?? "unknown",
};
};
const listProofFiles = () => {
if (!fs.existsSync(archiveDir)) return [];
return fs.readdirSync(archiveDir)
.filter((fileName) => fileName.endsWith(".md"))
.map((fileName) => path.join(archiveDir, fileName))
.filter((filePath) => fs.statSync(filePath).isFile());
};
const proofs = listProofFiles()
.map(parseProof)
.sort((a, b) => Date.parse(b.generated) - Date.parse(a.generated) || b.fileName.localeCompare(a.fileName));
const latestPassing = proofs.find((proof) => proof.verdict === "PASS");
const selectedProofs = proofs.slice(0, Number.isFinite(limit) && limit > 0 ? limit : 10);
const lines = [
"# ProjectBrain Release Proof Index",
"",
`Archive: ${archiveDir}`,
`Proof snapshots: ${proofs.length}`,
`Latest passing proof: ${latestPassing ? latestPassing.displayPath : "none"}`,
"",
];
if (!proofs.length) {
lines.push("No release proof snapshots found.");
lines.push("");
lines.push("Create one with:");
lines.push("");
lines.push("```bash");
lines.push("PROJECTBRAIN_PROOF_WRITE=1 pnpm run release:proof-report");
lines.push("```");
} else {
lines.push("| Generated | Verdict | Commit | Project | Smoke | File |");
lines.push("| --- | --- | --- | --- | --- | --- |");
for (const proof of selectedProofs) {
lines.push(`| ${proof.generated} | ${proof.verdict} | ${proof.commit} | ${proof.project} | ${proof.smokeExecution} | ${proof.displayPath} |`);
}
}
console.log(lines.join("\n"));
+227
View File
@@ -0,0 +1,227 @@
import { spawnSync } from "node:child_process";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
const baseUrl = process.env.PROJECTBRAIN_DEPLOY_URL ?? process.env.PROJECTBRAIN_SMOKE_URL ?? "http://127.0.0.1:1209";
const gitRemote = process.env.PROJECTBRAIN_RELEASE_REMOTE ?? "gitea";
const gitBranch = process.env.PROJECTBRAIN_RELEASE_BRANCH ?? "main";
const sshTarget = process.env.PROJECTBRAIN_DEPLOY_SSH_TARGET ?? "root@127.0.0.1";
const deployPath = process.env.PROJECTBRAIN_DEPLOY_PATH ?? "/mnt/pool/appdata/brain";
const sshKey = process.env.PROJECTBRAIN_DEPLOY_SSH_KEY ?? path.join(os.homedir(), ".ssh", "widefrog_unraid_deploy");
const requestedTag = process.env.PROJECTBRAIN_ROLLBACK_TAG;
const shouldWrite = process.argv.includes("--write") || process.env.PROJECTBRAIN_ROLLBACK_DRILL_WRITE === "1";
const explicitOutputArg = process.argv.find((arg) => arg.startsWith("--out="));
const explicitOutputPath = explicitOutputArg?.slice("--out=".length) || process.env.PROJECTBRAIN_ROLLBACK_DRILL_OUT;
const run = (command, args, options = {}) => spawnSync(command, args, {
encoding: "utf8",
shell: process.platform === "win32",
...options,
});
const git = (args) => {
const result = spawnSync("git", args, {
encoding: "utf8",
shell: false,
});
return {
ok: result.status === 0,
value: result.stdout.trim(),
error: result.stderr.trim(),
};
};
const runSsh = (remoteCommand) => {
if (!fs.existsSync(sshKey)) {
return {
ok: false,
stdout: "",
stderr: `SSH key not found: ${sshKey}`,
};
}
const result = spawnSync("ssh", [
"-i",
sshKey,
"-o",
"BatchMode=yes",
"-o",
"ConnectTimeout=8",
sshTarget,
remoteCommand,
], {
encoding: "utf8",
shell: false,
});
return {
ok: result.status === 0,
stdout: result.stdout.trim(),
stderr: result.stderr.trim(),
};
};
const checkHttp = async (urlPath) => {
try {
const response = await fetch(`${baseUrl}${urlPath}`);
const body = await response.text();
return {
ok: response.ok,
status: String(response.status),
body: body.slice(0, 220).replace(/\s+/g, " ").trim(),
};
} catch (error) {
return {
ok: false,
status: "unreachable",
body: error instanceof Error ? error.message : String(error),
};
}
};
const latestReleaseTag = () => {
const result = git(["tag", "--list", "projectbrain-rc-*", "--sort=-creatordate"]);
return result.value.split(/\r?\n/).find(Boolean) ?? "";
};
const createDefaultReportPath = (tagName) => {
const safeTag = tagName.replace(/[^a-zA-Z0-9._-]/g, "-");
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
return `checkpoints/rollback-drill/projectbrain-rollback-drill-${safeTag}-${stamp}.md`;
};
run("git", ["fetch", "--tags", gitRemote]);
const tagName = requestedTag || latestReleaseTag();
const currentCommit = git(["rev-parse", "--short", "HEAD"]).value || "unknown";
const tagCommit = tagName ? git(["rev-parse", "--short", `${tagName}^{commit}`]) : { ok: false, value: "", error: "No release tag found." };
const tagFullCommit = tagName ? git(["rev-parse", `${tagName}^{commit}`]) : { ok: false, value: "", error: "No release tag found." };
const remoteTag = tagName ? run("git", ["ls-remote", "--tags", gitRemote, tagName]) : { status: 1, stdout: "", stderr: "No release tag found." };
const status = git(["status", "--porcelain"]);
const health = await checkHttp("/api/health");
const tower = tagName
? runSsh([
`git -C ${deployPath} fetch --tags ${gitRemote}`,
`echo tower_head=$(git -C ${deployPath} rev-parse --short HEAD 2>/dev/null || echo unknown)`,
`echo tower_tag=$(git -C ${deployPath} rev-parse --short ${tagName}^{commit} 2>/dev/null || echo unknown)`,
"echo containers_begin",
"docker ps --format '{{.Names}} {{.Status}} {{.Ports}}' | grep projectbrain || true",
"echo containers_end",
].join(" && "))
: { ok: false, stdout: "", stderr: "No release tag found." };
const towerHead = tower.stdout.match(/^tower_head=(.+)$/m)?.[1]?.trim() ?? "unknown";
const towerTag = tower.stdout.match(/^tower_tag=(.+)$/m)?.[1]?.trim() ?? "unknown";
const containerBlock = tower.stdout.match(/containers_begin\n([\s\S]*?)\n?containers_end/)?.[1]?.trim() ?? "";
const hasFrontend = containerBlock.includes("projectbrain-frontend") && containerBlock.includes("1209->80/tcp");
const hasApi = containerBlock.includes("projectbrain-api") && /projectbrain-api\s+Up/.test(containerBlock);
const hasPostgres = containerBlock.includes("projectbrain-postgres") && /projectbrain-postgres\s+Up/.test(containerBlock);
const reportPath = createDefaultReportPath(tagName || "missing-tag");
const outputPath = explicitOutputPath || reportPath;
const gates = [
{
label: "Release tag selected",
ok: Boolean(tagName),
detail: tagName || "missing",
},
{
label: "Local release tag resolves",
ok: Boolean(tagName) && tagCommit.ok,
detail: tagCommit.value || tagCommit.error || "missing",
},
{
label: "Gitea release tag exists",
ok: remoteTag.status === 0 && remoteTag.stdout.includes(`refs/tags/${tagName}`),
detail: remoteTag.status === 0 ? "present" : remoteTag.stderr.trim(),
},
{
label: "Tower release tag resolves",
ok: tower.ok && towerTag === tagCommit.value,
detail: `${towerTag} tower / ${tagCommit.value || "unknown"} local`,
},
{
label: "Tower HEAD captured",
ok: tower.ok && towerHead !== "unknown",
detail: `${towerHead} tower / ${tagCommit.value || "unknown"} rollback target`,
},
{
label: "Tracked worktree clean",
ok: status.ok && status.value.trim() === "",
detail: status.value.trim() || "clean",
},
{
label: "Live health reachable",
ok: health.ok,
detail: `${health.status} ${health.body}`,
},
{
label: "ProjectBrain containers running",
ok: hasFrontend && hasApi && hasPostgres,
detail: `frontend=${hasFrontend ? "up" : "missing"} api=${hasApi ? "up" : "missing"} postgres=${hasPostgres ? "up" : "missing"}`,
},
];
const passed = gates.every((gate) => gate.ok);
const rollbackCommand = `ssh -i ~/.ssh/widefrog_unraid_deploy ${sshTarget} "cd ${deployPath} && git fetch --tags ${gitRemote} && git switch --detach ${tagName} && docker compose build --no-cache frontend && docker compose up -d"`;
const restoreMainCommand = `ssh -i ~/.ssh/widefrog_unraid_deploy ${sshTarget} "cd ${deployPath} && git switch ${gitBranch} && git pull --ff-only && docker compose build --no-cache frontend && docker compose up -d"`;
const lines = [
"# ProjectBrain Rollback Drill",
"",
`Generated: ${new Date().toISOString()}`,
`Selected tag: ${tagName || "missing"}`,
`Tag commit: ${tagCommit.value || "unknown"}`,
`Current commit: ${currentCommit}`,
`Tower HEAD: ${towerHead}`,
`Deployment URL: ${baseUrl}`,
`Verdict: ${passed ? "PASS" : "ATTENTION REQUIRED"}`,
"",
"## Gates",
"",
"| Gate | Result | Detail |",
"| --- | --- | --- |",
...gates.map((gate) => `| ${gate.label} | ${gate.ok ? "PASS" : "FAIL"} | ${String(gate.detail).replace(/\|/g, "\\|")} |`),
"",
"## Container Snapshot",
"",
"```text",
containerBlock || tower.stderr || "No container output captured.",
"```",
"",
"## Rollback Commands",
"",
"These commands are printed for operator review. This drill does not execute checkout, rebuild or rollback.",
"",
"Rollback/redeploy selected tag:",
"",
"```bash",
rollbackCommand,
"```",
"",
"Restore main after drill:",
"",
"```bash",
restoreMainCommand,
"```",
"",
"## Local Verification",
"",
"```bash",
"pnpm run release:deployment-proof",
"pnpm run release:tag-gate",
"```",
].join("\n");
console.log(lines);
if (shouldWrite) {
fs.mkdirSync(path.dirname(outputPath), { recursive: true });
fs.writeFileSync(outputPath, `${lines}\n`, "utf8");
console.log(`\nRollback drill report written to ${outputPath}`);
}
if (!passed) {
process.exitCode = 1;
}
+111
View File
@@ -0,0 +1,111 @@
import { spawnSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
const archiveDir = process.env.PROJECTBRAIN_PROOF_ARCHIVE_DIR ?? "checkpoints/release-proof";
const signoffOut = process.env.PROJECTBRAIN_SIGNOFF_OUT;
const run = (command, args) => spawnSync(command, args, {
encoding: "utf8",
shell: process.platform === "win32",
});
const getGitCommit = () => {
const result = run("git", ["rev-parse", "--short", "HEAD"]);
return result.status === 0 ? result.stdout.trim() : "unknown";
};
const parseField = (text, label) => {
const match = text.match(new RegExp(`^${label}:\\s*(.+)$`, "m"));
return match?.[1]?.trim() ?? "unknown";
};
const displayPath = (filePath) => filePath.split(path.sep).join("/");
const parseProof = (filePath) => {
const text = fs.readFileSync(filePath, "utf8");
return {
filePath,
displayPath: displayPath(filePath),
fileName: path.basename(filePath),
generated: parseField(text, "Generated"),
commit: parseField(text, "Commit"),
baseUrl: parseField(text, "Base URL"),
project: parseField(text, "Project"),
verdict: parseField(text, "Verdict"),
smokeExecution: text.match(/^- Smoke execution:\s*(.+)$/m)?.[1]?.trim() ?? "unknown",
};
};
const listProofFiles = () => {
if (!fs.existsSync(archiveDir)) return [];
return fs.readdirSync(archiveDir)
.filter((fileName) => fileName.endsWith(".md"))
.map((fileName) => path.join(archiveDir, fileName))
.filter((filePath) => fs.statSync(filePath).isFile());
};
const createDefaultSignoffPath = (commit) => {
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
return `checkpoints/release-signoff/projectbrain-release-signoff-${commit}-${stamp}.md`;
};
const currentCommit = getGitCommit();
const proofs = listProofFiles()
.map(parseProof)
.sort((a, b) => Date.parse(b.generated) - Date.parse(a.generated) || b.fileName.localeCompare(a.fileName));
const latestPassing = proofs.find((proof) => proof.verdict === "PASS");
const freshness = latestPassing?.commit === currentCommit ? "current" : latestPassing ? "stale" : "missing";
if (freshness !== "current") {
console.error("Cannot create release signoff file: latest passing proof is not current.");
console.error(`Current commit: ${currentCommit}`);
console.error(`Proof commit: ${latestPassing?.commit ?? "none"}`);
console.error("Run: PROJECTBRAIN_SMOKE_URL=http://127.0.0.1:1209 pnpm run release:bundle");
process.exit(1);
}
const signoffPath = signoffOut || createDefaultSignoffPath(currentCommit);
const signoff = [
"# ProjectBrain Release Operator Signoff",
"",
`Generated: ${new Date().toISOString()}`,
`Commit: ${currentCommit}`,
`Project: ${latestPassing.project}`,
`Base URL: ${latestPassing.baseUrl}`,
`Proof snapshot: ${latestPassing.displayPath}`,
`Proof generated: ${latestPassing.generated}`,
`Proof verdict: ${latestPassing.verdict}`,
`Smoke execution: ${latestPassing.smokeExecution}`,
`Freshness: ${freshness}`,
"",
"## Technical Gates",
"",
"- [x] Release proof report exists for the current commit.",
"- [x] Release operator smoke suite passed.",
"- [x] Release proof freshness is current.",
"- [x] Proof snapshot is stored as a local ignored artifact.",
"",
"## Manual Operator Signoff",
"",
"- [ ] Confirm intended release commit/tag.",
"- [ ] Confirm deployment target and public URL.",
"- [ ] Confirm environment-specific notes are captured.",
"- [ ] Confirm rollback/restart command is known.",
"- [ ] Confirm final human go/no-go decision.",
"",
"## Tower Commands",
"",
"```bash",
"cd /mnt/pool/appdata/brain",
"docker compose build --no-cache frontend",
"docker compose up -d",
"```",
].join("\n");
fs.mkdirSync(path.dirname(signoffPath), { recursive: true });
fs.writeFileSync(signoffPath, `${signoff}\n`, "utf8");
console.log(signoff);
console.log(`\nRelease signoff file written to ${signoffPath}`);
+256
View File
@@ -0,0 +1,256 @@
import { spawnSync } from "node:child_process";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
const proofDir = process.env.PROJECTBRAIN_PROOF_ARCHIVE_DIR ?? "checkpoints/release-proof";
const signoffDir = process.env.PROJECTBRAIN_SIGNOFF_ARCHIVE_DIR ?? "checkpoints/release-signoff";
const deploymentProofDir = process.env.PROJECTBRAIN_DEPLOY_PROOF_ARCHIVE_DIR ?? "checkpoints/deployment-proof";
const dossierDir = process.env.PROJECTBRAIN_RELEASE_DOSSIER_DIR ?? "checkpoints/release-dossier";
const baseUrl = process.env.PROJECTBRAIN_DEPLOY_URL ?? process.env.PROJECTBRAIN_SMOKE_URL ?? "http://127.0.0.1:1209";
const gitRemote = process.env.PROJECTBRAIN_RELEASE_REMOTE ?? "gitea";
const gitBranch = process.env.PROJECTBRAIN_RELEASE_BRANCH ?? "main";
const sshTarget = process.env.PROJECTBRAIN_DEPLOY_SSH_TARGET ?? "root@127.0.0.1";
const deployPath = process.env.PROJECTBRAIN_DEPLOY_PATH ?? "/mnt/pool/appdata/brain";
const sshKey = process.env.PROJECTBRAIN_DEPLOY_SSH_KEY ?? path.join(os.homedir(), ".ssh", "widefrog_unraid_deploy");
const requestedTag = process.env.PROJECTBRAIN_RELEASE_TAG;
const shouldWrite = process.argv.includes("--write") || process.env.PROJECTBRAIN_TAG_GATE_WRITE === "1";
const explicitOutputArg = process.argv.find((arg) => arg.startsWith("--out="));
const explicitOutputPath = explicitOutputArg?.slice("--out=".length) || process.env.PROJECTBRAIN_TAG_GATE_OUT;
const run = (command, args, options = {}) => spawnSync(command, args, {
encoding: "utf8",
shell: process.platform === "win32",
...options,
});
const getGitOutput = (args) => {
const result = run("git", args);
return {
ok: result.status === 0,
value: result.stdout.trim(),
error: result.stderr.trim(),
};
};
const parseField = (text, label) => {
const match = text.match(new RegExp(`^${label}:\\s*(.+)$`, "m"));
return match?.[1]?.trim() ?? "unknown";
};
const displayPath = (filePath) => filePath.split(path.sep).join("/");
const listMarkdownFiles = (archiveDir) => {
if (!fs.existsSync(archiveDir)) return [];
return fs.readdirSync(archiveDir)
.filter((fileName) => fileName.endsWith(".md"))
.map((fileName) => path.join(archiveDir, fileName))
.filter((filePath) => fs.statSync(filePath).isFile());
};
const sortNewestFirst = (items) => items.sort((a, b) => {
const generatedDelta = Date.parse(b.generated) - Date.parse(a.generated);
if (Number.isFinite(generatedDelta) && generatedDelta !== 0) return generatedDelta;
return b.fileName.localeCompare(a.fileName);
});
const parseArtifact = (filePath, type) => {
const text = fs.readFileSync(filePath, "utf8");
const commit = type === "deployment" ? parseField(text, "Local commit") : parseField(text, "Commit");
return {
type,
filePath,
displayPath: displayPath(filePath),
fileName: path.basename(filePath),
generated: parseField(text, "Generated"),
commit,
verdict: parseField(text, "Verdict"),
freshness: type === "deployment" ? parseField(text, "Commit freshness") : parseField(text, "Freshness"),
};
};
const findCurrentArtifact = (archiveDir, type, currentCommit) => {
const artifacts = sortNewestFirst(listMarkdownFiles(archiveDir).map((filePath) => parseArtifact(filePath, type)));
return artifacts.find((artifact) => {
if (artifact.commit !== currentCommit) return false;
if (type === "proof") return artifact.verdict === "PASS";
if (type === "signoff") return artifact.freshness === "current";
if (type === "deployment") return artifact.verdict === "PASS" && artifact.freshness === "current";
if (type === "dossier") return artifact.verdict === "PASS";
return false;
});
};
const checkHttp = async (urlPath) => {
try {
const response = await fetch(`${baseUrl}${urlPath}`);
const body = await response.text();
return {
ok: response.ok,
status: String(response.status),
body: body.slice(0, 220).replace(/\s+/g, " ").trim(),
};
} catch (error) {
return {
ok: false,
status: "unreachable",
body: error instanceof Error ? error.message : String(error),
};
}
};
const runSsh = (remoteCommand) => {
if (!fs.existsSync(sshKey)) {
return {
ok: false,
stdout: "",
stderr: `SSH key not found: ${sshKey}`,
};
}
const result = spawnSync("ssh", [
"-i",
sshKey,
"-o",
"BatchMode=yes",
"-o",
"ConnectTimeout=8",
sshTarget,
remoteCommand,
], {
encoding: "utf8",
shell: false,
});
return {
ok: result.status === 0,
stdout: result.stdout.trim(),
stderr: result.stderr.trim(),
};
};
const createDefaultTagName = (commit) => {
const stamp = new Date().toISOString().slice(0, 16).replace(/[-:T]/g, "");
return `projectbrain-rc-${stamp}-${commit}`;
};
const createDefaultReportPath = (commit) => {
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
return `checkpoints/tag-gate/projectbrain-tag-ready-${commit}-${stamp}.md`;
};
const currentCommit = getGitOutput(["rev-parse", "--short", "HEAD"]).value || "unknown";
const fullCurrentCommit = getGitOutput(["rev-parse", "HEAD"]).value || "unknown";
const status = getGitOutput(["status", "--porcelain"]);
const fetchRemote = run("git", ["fetch", gitRemote, gitBranch]);
const remoteRef = `${gitRemote}/${gitBranch}`;
const remoteCommit = getGitOutput(["rev-parse", "--short", remoteRef]).value || "unknown";
const remoteFullCommit = getGitOutput(["rev-parse", remoteRef]).value || "unknown";
const health = await checkHttp("/api/health");
const remoteShell = runSsh(`git -C ${deployPath} rev-parse --short HEAD 2>/dev/null || echo unknown`);
const towerCommit = remoteShell.stdout.trim() || "unknown";
const tagName = requestedTag || createDefaultTagName(currentCommit);
const reportPath = explicitOutputPath || createDefaultReportPath(currentCommit);
const proof = findCurrentArtifact(proofDir, "proof", currentCommit);
const signoff = findCurrentArtifact(signoffDir, "signoff", currentCommit);
const deployment = findCurrentArtifact(deploymentProofDir, "deployment", currentCommit);
const dossier = findCurrentArtifact(dossierDir, "dossier", currentCommit);
const gates = [
{
label: "Tracked worktree clean",
ok: status.ok && status.value.trim() === "",
detail: status.value.trim() || "clean",
},
{
label: `Fetched ${gitRemote}/${gitBranch}`,
ok: fetchRemote.status === 0,
detail: fetchRemote.status === 0 ? "fetched" : fetchRemote.stderr.trim(),
},
{
label: "Gitea remote matches local commit",
ok: fullCurrentCommit === remoteFullCommit,
detail: `${currentCommit} local / ${remoteCommit} remote`,
},
{
label: "Tower deployment matches local commit",
ok: towerCommit === currentCommit,
detail: `${currentCommit} local / ${towerCommit} tower`,
},
{
label: "Live health reachable",
ok: health.ok,
detail: `${health.status} ${health.body}`,
},
{
label: "Current passing release proof exists",
ok: Boolean(proof),
detail: proof?.displayPath ?? "missing",
},
{
label: "Current operator signoff exists",
ok: Boolean(signoff),
detail: signoff?.displayPath ?? "missing",
},
{
label: "Current passing deployment proof exists",
ok: Boolean(deployment),
detail: deployment?.displayPath ?? "missing",
},
{
label: "Current release evidence dossier exists",
ok: Boolean(dossier),
detail: dossier?.displayPath ?? "missing",
},
];
const passed = gates.every((gate) => gate.ok);
const lines = [
"# ProjectBrain Release Tag Ready Gate",
"",
`Generated: ${new Date().toISOString()}`,
`Commit: ${currentCommit}`,
`Remote: ${remoteRef}`,
`Tower URL: ${baseUrl}`,
`Suggested tag: ${tagName}`,
`Verdict: ${passed ? "PASS" : "ATTENTION REQUIRED"}`,
"",
"## Gates",
"",
"| Gate | Result | Detail |",
"| --- | --- | --- |",
...gates.map((gate) => `| ${gate.label} | ${gate.ok ? "PASS" : "FAIL"} | ${String(gate.detail).replace(/\|/g, "\\|")} |`),
"",
"## Tag Commands",
"",
"Review the suggested tag name before running these commands:",
"",
"```bash",
`git tag -a ${tagName} -m "ProjectBrain release ${tagName}"`,
`git push ${gitRemote} ${tagName}`,
"```",
"",
"## Recovery",
"",
"```bash",
"PROJECTBRAIN_RELEASE_DEPLOYMENT_PROOF=1 PROJECTBRAIN_RELEASE_DOSSIER=1 pnpm run release:bundle",
"pnpm run release:tag-gate",
"```",
].join("\n");
console.log(lines);
if (shouldWrite) {
fs.mkdirSync(path.dirname(reportPath), { recursive: true });
fs.writeFileSync(reportPath, `${lines}\n`, "utf8");
console.log(`\nRelease tag ready report written to ${reportPath}`);
}
if (!passed) {
process.exitCode = 1;
}
+150
View File
@@ -0,0 +1,150 @@
import { spawnSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
const outputPath = process.env.PROJECTBRAIN_USER_NOTES_OUT;
const shouldWrite = process.argv.includes("--write") || process.env.PROJECTBRAIN_USER_NOTES_WRITE === "1";
const run = (command, args) => spawnSync(command, args, {
encoding: "utf8",
shell: process.platform === "win32",
});
const git = (args) => {
const result = run("git", args);
return result.status === 0 ? result.stdout.trim() : "unknown";
};
const latestReleaseTag = () => {
const result = run("git", ["tag", "--list", "projectbrain-rc-*", "--sort=-creatordate"]);
return result.status === 0 ? result.stdout.trim().split(/\r?\n/).find(Boolean) ?? "untagged" : "untagged";
};
const createDefaultOutputPath = (tagName) => {
const safeTag = tagName.replace(/[^a-zA-Z0-9._-]/g, "-");
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
return `checkpoints/user-release-notes/projectbrain-user-release-notes-${safeTag}-${stamp}.md`;
};
const commit = git(["rev-parse", "--short", "HEAD"]);
const tagName = process.env.PROJECTBRAIN_RELEASE_TAG ?? latestReleaseTag();
const out = outputPath || createDefaultOutputPath(tagName);
const notes = [
"# ProjectBrain User Release Notes",
"",
`Generated: ${new Date().toISOString()}`,
`Release tag: ${tagName}`,
`Current commit: ${commit}`,
"",
"## What ProjectBrain Is For",
"",
"ProjectBrain is a project operating system for shaping, expanding and releasing software projects. It is optimized for turning ideas, existing repositories, source material and execution feedback into structured project memory, Codex-ready handoffs and release evidence.",
"",
"The current release is strongest as a local/operator-driven project builder. It combines project planning, repo shaping, session packaging, evidence review and release-readiness checks in one workspace.",
"",
"## Main User Flows",
"",
"### Start Or Open A Project",
"",
"- Use the Projects page to open an existing project.",
"- Use Start when creating or importing a new project direction.",
"- Existing projects remain the primary path for daily use; new-project setup is available when needed.",
"",
"### Shape A Repository",
"",
"- Use Repo Builder to inspect repository direction, gaps, state snapshots and delivery packages.",
"- Use skill-aware handoff content to make Codex work more repeatable.",
"- Use repo state and evidence instead of relying on memory from previous sessions.",
"",
"### Prepare Codex Work",
"",
"- Use Context Packs, Prompt Builder, Codex Handoffs and Session Packs to create a clear execution package.",
"- Use Browser Bridge when work must be copied into ChatGPT/Codex manually without API keys.",
"- Use Execution Feedback to bring results back into ProjectBrain as reviewable evidence.",
"",
"### Review And Close Work",
"",
"- Use Review Queue and History to inspect decisions, receipts and follow-up evidence.",
"- Use Build Loop and Runtime Readiness to track release-relevant validation.",
"- Use Release Center to collect proof before tagging or deploying.",
"",
"## Release And Operator Capabilities",
"",
"This release includes a complete local release-operator chain:",
"",
"- release smoke suite;",
"- proof report generation;",
"- proof freshness gate;",
"- operator signoff file;",
"- remote deployment proof;",
"- release evidence dossier;",
"- tag-ready gate;",
"- rollback drill.",
"",
"Useful commands:",
"",
"```bash",
"pnpm run release:bundle",
"PROJECTBRAIN_RELEASE_DEPLOYMENT_PROOF=1 PROJECTBRAIN_RELEASE_DOSSIER=1 pnpm run release:bundle",
"pnpm run release:tag-gate",
"pnpm run release:rollback-drill",
"pnpm run release:user-notes",
"```",
"",
"## Deployment Notes",
"",
"The known live deployment target is:",
"",
"```text",
"http://127.0.0.1:1209",
"```",
"",
"Tower build/start commands:",
"",
"```bash",
"cd /mnt/pool/appdata/brain",
"docker compose build --no-cache frontend",
"docker compose up -d",
"```",
"",
"## What Remains Local Or Operator-Driven",
"",
"- Release evidence snapshots are local ignored files under `checkpoints/`.",
"- Several release commands are intended to run from the developer/operator workstation, not from inside the Unraid container.",
"- The rollback drill prints commands but does not execute a rollback.",
"- Tagging remains an explicit operator action.",
"",
"## Known Boundaries",
"",
"- This release does not claim full multi-user auth.",
"- This release does not replace all local/mock project surfaces with database-backed behavior.",
"- This release does not automate AI calls or require API keys.",
"- This release does not commit generated release evidence artifacts.",
"",
"## Recommended First Use",
"",
"1. Open an existing project from Projects.",
"2. Review Workspace and Repo Builder for current direction.",
"3. Generate or inspect a Session Pack before asking Codex to make changes.",
"4. Capture execution feedback after a coding session.",
"5. Use Release Center and Runtime Readiness before tagging.",
"",
"## Support Checklist",
"",
"When something looks wrong, collect:",
"",
"- current URL;",
"- project id;",
"- browser console error if present;",
"- latest `pnpm run release:deployment-proof` output;",
"- latest `pnpm run release:rollback-drill` output if deployment state is involved.",
].join("\n");
console.log(notes);
if (shouldWrite) {
fs.mkdirSync(path.dirname(out), { recursive: true });
fs.writeFileSync(out, `${notes}\n`, "utf8");
console.log(`\nUser release notes written to ${out}`);
}
+50
View File
@@ -0,0 +1,50 @@
import { spawnSync } from "node:child_process";
const steps = [
{
label: "Release evidence lane click-through",
script: "scripts/smoke-rc63-release-evidence-lane.mjs",
},
{
label: "Review decision receipt recovery",
script: "scripts/smoke-rc60-review-decision-loop.mjs",
},
{
label: "Release evidence capture assist",
script: "scripts/smoke-rc73-release-evidence-capture-assist.mjs",
},
{
label: "Release Center cockpit",
script: "scripts/smoke-rc88-release-center.mjs",
},
{
label: "Release checklist click-through",
script: "scripts/smoke-rc99-release-checklist-clickthrough.mjs",
},
];
const runStep = (step) => {
console.log(`\n[release-operator] ${step.label}`);
const result = spawnSync(process.execPath, [step.script], {
env: process.env,
shell: false,
detached: process.platform === "win32",
stdio: "inherit",
});
if (result.error) throw result.error;
if (result.status !== 0) throw new Error(`${step.label} failed with exit code ${result.status}`);
};
try {
console.log("ProjectBrain release operator smoke suite");
console.log(`Base URL: ${process.env.PROJECTBRAIN_SMOKE_URL ?? "default smoke URL"}`);
console.log(`Project: ${process.env.PROJECTBRAIN_SMOKE_PROJECT ?? "widefrog"}`);
for (const step of steps) {
runStep(step);
}
console.log("\nRelease operator smoke suite passed.");
} catch (error) {
console.error("\nRelease operator smoke suite failed:");
console.error(error.message);
process.exitCode = 1;
}
+95
View File
@@ -0,0 +1,95 @@
#!/usr/bin/env sh
set -eu
if [ "$#" -ne 2 ]; then
echo "Usage: $0 <40-character-commit-sha> <release-artifact-directory>" >&2
exit 64
fi
RELEASE_SHA="$1"
ARTIFACT_DIR="${2%/}"
case "${RELEASE_SHA}" in
*[!0-9a-f]*|'') echo "Release SHA must contain exactly 40 lowercase hexadecimal characters." >&2; exit 64 ;;
esac
if [ "${#RELEASE_SHA}" -ne 40 ]; then
echo "Release SHA must contain exactly 40 lowercase hexadecimal characters." >&2
exit 64
fi
API_ARCHIVE="${ARTIFACT_DIR}/projectbrain-api-${RELEASE_SHA}.tar"
WEB_ARCHIVE="${ARTIFACT_DIR}/projectbrain-web-${RELEASE_SHA}.tar"
DIGEST_FILE="${ARTIFACT_DIR}/archive-sha256.txt"
for path in "${API_ARCHIVE}" "${WEB_ARCHIVE}" "${DIGEST_FILE}"; do
if [ ! -f "${path}" ]; then echo "Missing release artifact: ${path}" >&2; exit 66; fi
done
for command in sha256sum skopeo docker curl; do
if ! command -v "${command}" >/dev/null 2>&1; then echo "Required command is unavailable: ${command}" >&2; exit 69; fi
done
verify_digest() {
archive="$1"
filename="$(basename "${archive}")"
expected="$(awk -v file="${filename}" '$2 == file || $2 ~ ("/" file "$") { print $1; exit }' "${DIGEST_FILE}")"
actual="$(sha256sum "${archive}" | awk '{ print $1 }')"
if [ -z "${expected}" ] || [ "${actual}" != "${expected}" ]; then
echo "Digest mismatch for ${filename}." >&2
exit 65
fi
printf '%s' "${actual}"
}
API_DIGEST="$(verify_digest "${API_ARCHIVE}")"
WEB_DIGEST="$(verify_digest "${WEB_ARCHIVE}")"
skopeo copy "oci-archive:${API_ARCHIVE}" "docker-daemon:projectbrain-api:${RELEASE_SHA}"
skopeo copy "oci-archive:${WEB_ARCHIVE}" "docker-daemon:projectbrain-web:${RELEASE_SHA}"
API_IMAGE_ID="$(docker image inspect --format '{{.Id}}' "projectbrain-api:${RELEASE_SHA}")"
WEB_IMAGE_ID="$(docker image inspect --format '{{.Id}}' "projectbrain-web:${RELEASE_SHA}")"
: "${POSTGRES_PASSWORD:?set a non-default POSTGRES_PASSWORD in the environment or .env}"
: "${PROJECTBRAIN_OWNER_PASSWORD:?set PROJECTBRAIN_OWNER_PASSWORD in the environment or .env}"
: "${PROJECTBRAIN_AUTH_SECRET:?set PROJECTBRAIN_AUTH_SECRET in the environment or .env}"
if [ "${POSTGRES_PASSWORD}" = "projectbrain" ]; then echo "Default PostgreSQL password is forbidden." >&2; exit 78; fi
if [ "${#PROJECTBRAIN_OWNER_PASSWORD}" -lt 12 ]; then echo "Owner password must contain at least 12 characters." >&2; exit 78; fi
if [ "${#PROJECTBRAIN_AUTH_SECRET}" -lt 32 ]; then echo "Auth secret must contain at least 32 characters." >&2; exit 78; fi
export PROJECTBRAIN_RELEASE_SHA="${RELEASE_SHA}"
export PROJECTBRAIN_DEPLOY_SLOT="${PROJECTBRAIN_DEPLOY_SLOT:-stage}"
export PROJECTBRAIN_WEB_PORT="${PROJECTBRAIN_STAGE_WEB_PORT:-1210}"
COMPOSE_PROJECT="projectbrain-${PROJECTBRAIN_DEPLOY_SLOT}-${RELEASE_SHA%????????????????????????????????}"
docker compose \
--project-name "${COMPOSE_PROJECT}" \
--file docker-compose.yml \
--file docker-compose.release.yml \
config --quiet
docker compose \
--project-name "${COMPOSE_PROJECT}" \
--file docker-compose.yml \
--file docker-compose.release.yml \
up --detach --no-build --pull never
READINESS_URL="http://127.0.0.1:${PROJECTBRAIN_WEB_PORT}/api/system/readiness"
attempt=0
until READINESS="$(curl --fail --silent --show-error "${READINESS_URL}")"; do
attempt=$((attempt + 1))
if [ "${attempt}" -ge 30 ]; then echo "Staged release did not become ready: ${READINESS_URL}" >&2; exit 70; fi
sleep 2
done
EVIDENCE_FILE="${ARTIFACT_DIR}/staged-deployment-${RELEASE_SHA}.txt"
{
echo "release_sha=${RELEASE_SHA}"
echo "compose_project=${COMPOSE_PROJECT}"
echo "web_port=${PROJECTBRAIN_WEB_PORT}"
echo "api_archive_sha256=${API_DIGEST}"
echo "web_archive_sha256=${WEB_DIGEST}"
echo "api_image_id=${API_IMAGE_ID}"
echo "web_image_id=${WEB_IMAGE_ID}"
echo "readiness_url=${READINESS_URL}"
echo "readiness=${READINESS}"
echo "verified_at=$(date -u +%Y-%m-%dT%H:%M:%SZ)"
} > "${EVIDENCE_FILE}"
echo "Staged immutable release ${RELEASE_SHA} as ${COMPOSE_PROJECT}."
echo "Evidence: ${EVIDENCE_FILE}"
+109
View File
@@ -0,0 +1,109 @@
#!/usr/bin/env sh
set -eu
API_LOG="/tmp/projectbrain-api.log"
PG_LOG="/tmp/projectbrain-postgres.log"
ANALYSIS_WORKER_LOG="/tmp/projectbrain-analysis-worker.log"
PROJECTBRAIN_DATA_DIR="${PROJECTBRAIN_DATA_DIR:-/var/lib/projectbrain}"
PGDATA="${PROJECTBRAIN_POSTGRES_DATA_DIR:-${PROJECTBRAIN_DATA_DIR}/postgres}"
POSTGRES_DB="${POSTGRES_DB:-projectbrain}"
POSTGRES_USER="${POSTGRES_USER:-projectbrain}"
POSTGRES_PASSWORD="${POSTGRES_PASSWORD:-}"
if [ "${PROJECTBRAIN_USE_EXTERNAL_DATABASE:-false}" != "true" ]; then
: "${POSTGRES_PASSWORD:?POSTGRES_PASSWORD is required for embedded PostgreSQL}"
export DATABASE_URL="postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@127.0.0.1:5432/${POSTGRES_DB}?schema=public"
else
export DATABASE_URL="${DATABASE_URL:?DATABASE_URL is required when PROJECTBRAIN_USE_EXTERNAL_DATABASE=true}"
fi
mkdir -p "${PGDATA}"
mkdir -p /run/postgresql
chown -R postgres:postgres "${PROJECTBRAIN_DATA_DIR}"
chown postgres:postgres /run/postgresql
if [ ! -s "${PGDATA}/PG_VERSION" ]; then
echo "[ProjectBrain] Initializing embedded PostgreSQL data directory..."
pwfile="/tmp/projectbrain-pgpass"
printf '%s' "${POSTGRES_PASSWORD}" > "${pwfile}"
chown postgres:postgres "${pwfile}"
su postgres -c "initdb -D '${PGDATA}' -U '${POSTGRES_USER}' --pwfile='${pwfile}' --auth-host=md5 --auth-local=trust" >/tmp/projectbrain-initdb.log 2>&1 || {
cat /tmp/projectbrain-initdb.log >&2 || true
exit 1
}
rm -f "${pwfile}"
fi
echo "[ProjectBrain] Starting embedded PostgreSQL..."
su postgres -c "pg_ctl -D '${PGDATA}' -o \"-c listen_addresses='127.0.0.1'\" -w start" > "${PG_LOG}" 2>&1
if ! su postgres -c "psql -U '${POSTGRES_USER}' -d postgres -tAc \"SELECT 1 FROM pg_database WHERE datname='${POSTGRES_DB}'\"" | grep -q 1; then
echo "[ProjectBrain] Creating database ${POSTGRES_DB}..."
su postgres -c "createdb -U '${POSTGRES_USER}' '${POSTGRES_DB}'"
fi
cd /app/backend
echo "[ProjectBrain] Starting API inside single-container runtime..."
NODE_OPTIONS="--max-old-space-size=${PROJECTBRAIN_API_HEAP_MB:-512}" sh scripts/docker-entrypoint.sh > "${API_LOG}" 2>&1 &
API_PID="$!"
ANALYSIS_WORKER_SUPERVISOR_PID=""
NGINX_PID=""
cleanup() {
echo "[ProjectBrain] Stopping runtime..."
if [ -n "${ANALYSIS_WORKER_SUPERVISOR_PID}" ]; then
kill "${ANALYSIS_WORKER_SUPERVISOR_PID}" 2>/dev/null || true
fi
if [ -n "${NGINX_PID}" ]; then
kill "${NGINX_PID}" 2>/dev/null || true
fi
kill "${API_PID}" 2>/dev/null || true
su postgres -c "pg_ctl -D '${PGDATA}' -m fast stop" >/dev/null 2>&1 || true
}
trap cleanup INT TERM
echo "[ProjectBrain] Waiting for API readiness..."
attempt=0
until curl -fsS "http://127.0.0.1:4000/api/system/readiness" >/dev/null 2>&1; do
attempt=$((attempt + 1))
if ! kill -0 "${API_PID}" 2>/dev/null; then
echo "[ProjectBrain] API exited before readiness." >&2
cat "${API_LOG}" >&2 || true
exit 1
fi
if [ "${attempt}" -ge 60 ]; then
echo "[ProjectBrain] API did not become ready in time." >&2
cat "${API_LOG}" >&2 || true
exit 1
fi
sleep 1
done
run_analysis_worker() (
child_pid=""
stop_worker() {
if [ -n "${child_pid}" ]; then
kill "${child_pid}" 2>/dev/null || true
wait "${child_pid}" 2>/dev/null || true
fi
exit 0
}
trap stop_worker INT TERM
while true; do
echo "[ProjectBrain] Starting bounded repository-analysis worker..." >> "${ANALYSIS_WORKER_LOG}"
NODE_OPTIONS="--max-old-space-size=${PROJECTBRAIN_ANALYSIS_WORKER_HEAP_MB:-384}" node dist/workers/analysisWorker.js >> "${ANALYSIS_WORKER_LOG}" 2>&1 &
child_pid="$!"
echo 500 > "/proc/${child_pid}/oom_score_adj" 2>/dev/null || true
wait "${child_pid}" || true
child_pid=""
sleep 5
done
)
run_analysis_worker &
ANALYSIS_WORKER_SUPERVISOR_PID="$!"
echo "[ProjectBrain] API and analysis worker are ready. Starting nginx..."
nginx -g "daemon off;" &
NGINX_PID="$!"
wait "${NGINX_PID}"
+21
View File
@@ -0,0 +1,21 @@
#!/usr/bin/env node
import { createHash } from "node:crypto";
import { readFile } from "node:fs/promises";
import { resolve } from "node:path";
import process from "node:process";
const root = resolve(import.meta.dirname, "..");
const outputFlag = process.argv.indexOf("--output");
const output = resolve(root, outputFlag >= 0 ? process.argv[outputFlag + 1] : "release-artifacts");
const sbomText = await readFile(resolve(output, "projectbrain.cdx.json"), "utf8");
const provenance = JSON.parse(await readFile(resolve(output, "projectbrain.provenance.json"), "utf8"));
const sbom = JSON.parse(sbomText);
const canonicalSbom = `${JSON.stringify(sbom, null, 2)}\n`;
const digest = createHash("sha256").update(JSON.stringify(sbom)).digest("hex");
const failures = [];
if (sbom.bomFormat !== "CycloneDX" || sbom.specVersion !== "1.6" || !Array.isArray(sbom.components) || sbom.components.length === 0) failures.push("SBOM is not a populated CycloneDX 1.6 document");
if (sbomText !== canonicalSbom) failures.push("SBOM is not canonical pretty-printed JSON");
if (provenance.predicateType !== "https://slsa.dev/provenance/v1" || !provenance.subject?.length) failures.push("Provenance has no SLSA v1 subjects");
if (provenance.sbom?.sha256 !== digest) failures.push("Provenance SBOM digest does not match");
if (failures.length) { console.error(failures.join("\n")); process.exit(1); }
console.log(`Release metadata verified: ${sbom.components.length} components and ${provenance.subject.length} subjects.`);