@guanlin4924/devflow-pi 0.1.62 → 0.1.64

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/paths.ts CHANGED
@@ -32,6 +32,15 @@ export function projectSessionDir(projectId: string): string {
32
32
  return path.join(devflowHome(), "sessions", projectId);
33
33
  }
34
34
 
35
+ export function projectDataDir(projectId: string): string {
36
+ if (!/^[0-9a-f-]{36}$/i.test(projectId)) throw new Error(`Invalid project id: ${projectId}`);
37
+ return path.join(devflowHome(), "projects", projectId);
38
+ }
39
+
40
+ export function projectControlDatabasePath(projectId: string): string {
41
+ return path.join(projectDataDir(projectId), "control.db");
42
+ }
43
+
35
44
  export function ompSdkManifestPath(): string {
36
45
  return fileURLToPath(import.meta.resolve("@oh-my-pi/pi-coding-agent/package.json"));
37
46
  }
@@ -3,6 +3,8 @@ import { access, lstat, mkdir, readFile, realpath, readdir, rename, writeFile }
3
3
  import path from "node:path";
4
4
 
5
5
  import { PROJECT_SCHEMA_VERSION } from "./constants.ts";
6
+ import { InitializationStore } from "./initialization-store.ts";
7
+ import { projectControlDatabasePath } from "./paths.ts";
6
8
 
7
9
  export interface ProjectManifest {
8
10
  schemaVersion: number;
@@ -16,6 +18,8 @@ export interface ProjectContext {
16
18
  manifestPath: string;
17
19
  manifest: ProjectManifest;
18
20
  planningRoot: string;
21
+ workspaceId: string;
22
+ controlDatabasePath: string;
19
23
  }
20
24
 
21
25
  const PROJECT_ID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
@@ -132,7 +136,18 @@ export async function loadProjectContext(root: string): Promise<ProjectContext>
132
136
  const manifestPath = path.join(canonicalRoot, ".devflow", "project.json");
133
137
  const manifest = validateManifest(JSON.parse(await readFile(manifestPath, "utf8")), manifestPath);
134
138
  const planningRoot = await assertPlanningBoundary(canonicalRoot);
135
- return { root: canonicalRoot, manifestPath, manifest, planningRoot };
139
+ return attachInitializationStore({ root: canonicalRoot, manifestPath, manifest, planningRoot });
140
+ }
141
+
142
+ async function attachInitializationStore(context: Omit<ProjectContext, "workspaceId" | "controlDatabasePath">): Promise<ProjectContext> {
143
+ const store = await InitializationStore.open(context);
144
+ try {
145
+ const workspace = await store.registerWorkspace();
146
+ store.putScanConfig({ mode: "full", repositories: [{ name: context.manifest.name, relativePath: ".", role: "other" }] });
147
+ return { ...context, workspaceId: workspace.id, controlDatabasePath: projectControlDatabasePath(context.manifest.projectId) };
148
+ } finally {
149
+ store.close();
150
+ }
136
151
  }
137
152
 
138
153
  async function atomicWriteJson(target: string, value: unknown): Promise<void> {
@@ -160,7 +175,7 @@ export async function initializeProject(root: string): Promise<{ context: Projec
160
175
  };
161
176
  await atomicWriteJson(manifestPath, manifest);
162
177
  return {
163
- context: { root: canonicalRoot, manifestPath, manifest, planningRoot },
178
+ context: await attachInitializationStore({ root: canonicalRoot, manifestPath, manifest, planningRoot }),
164
179
  created: true,
165
180
  };
166
181
  }
@@ -0,0 +1,243 @@
1
+ import { createHash } from "node:crypto";
2
+ import { readFile } from "node:fs/promises";
3
+
4
+ import { InitializationStore } from "./initialization-store.ts";
5
+ import type { ProjectContext } from "./project-context.ts";
6
+
7
+ export interface LowTierDocumentOrganizer {
8
+ role: string;
9
+ model: string;
10
+ organize(input: { document: string; project: string; knownFacts: string[]; unknowns: string[] }): Promise<string>;
11
+ }
12
+
13
+ export type DocumentGenerationMode = "low-tier-model" | "template";
14
+ export type DocumentGenerationResult = { targetPath: string; status: "complete" | "conflict" | "failed"; outputPath?: string; error?: string };
15
+
16
+ type Fact = { kind: string; repository: string; objectKey: string; value: Record<string, unknown>; evidenceLevel: string };
17
+ const TEMPLATE_VERSION = "project-context-v2";
18
+ const KNOWLEDGE_DOCUMENT_TYPES = [
19
+ "requirements",
20
+ "product-design",
21
+ "architecture",
22
+ "frontend-tech",
23
+ "backend-tech",
24
+ "test-cases",
25
+ "api",
26
+ ] as const;
27
+ type KnowledgeDocumentType = typeof KNOWLEDGE_DOCUMENT_TYPES[number];
28
+
29
+ function markdownCell(value: unknown): string {
30
+ return String(value ?? "未确认").replaceAll("|", "\\|").replace(/[\r\n]+/g, " ");
31
+ }
32
+ function sha(value: string): string { return `sha256:${createHash("sha256").update(value).digest("hex")}`; }
33
+ async function currentHash(target: string): Promise<string | null> {
34
+ try { return sha(await readFile(target, "utf8")); } catch { return null; }
35
+ }
36
+ function safeNarrative(value: string): string {
37
+ const text = value.trim();
38
+ if (!text) return "现有数据不足以确认更多项目用途。";
39
+ if (text.includes("```") || /\b(?:schema_version|snapshotId|runId|sha256:|CREATE TABLE)\b/i.test(text)) throw new Error("Model output contains internal structure or unsupported machine details");
40
+ return text;
41
+ }
42
+ function table(headers: string[], rows: unknown[][]): string {
43
+ return [`| ${headers.join(" | ")} |`, `|${headers.map(() => "---").join("|")}|`, ...rows.map(row => `| ${row.map(markdownCell).join(" | ")} |`)].join("\n");
44
+ }
45
+ function yamlString(value: string): string { return JSON.stringify(value); }
46
+ function frontmatter(fields: Record<string, string | boolean | string[] | null | undefined>): string {
47
+ const lines = ["---"];
48
+ for (const [key, value] of Object.entries(fields)) {
49
+ if (value === undefined || value === null) continue;
50
+ if (Array.isArray(value)) lines.push(`${key}: [${value.map(yamlString).join(", ")}]`);
51
+ else if (typeof value === "boolean") lines.push(`${key}: ${value ? "true" : "false"}`);
52
+ else lines.push(`${key}: ${yamlString(value)}`);
53
+ }
54
+ lines.push("---", "");
55
+ return lines.join("\n");
56
+ }
57
+ function documentTitle(type: KnowledgeDocumentType): string {
58
+ return ({
59
+ "requirements": "需求文档",
60
+ "product-design": "产品与交互设计",
61
+ "architecture": "架构设计",
62
+ "frontend-tech": "前端技术方案",
63
+ "backend-tech": "后端技术方案",
64
+ "test-cases": "用例与验收",
65
+ "api": "API 文档",
66
+ } satisfies Record<KnowledgeDocumentType, string>)[type];
67
+ }
68
+ function codeIndexGeneration(snapshot: { indexRef?: unknown }): string | undefined {
69
+ const indexRef = snapshot.indexRef as Record<string, unknown> | undefined;
70
+ return typeof indexRef?.generation === "string" ? indexRef.generation : undefined;
71
+ }
72
+
73
+ async function narrative(context: ProjectContext, document: string, facts: Fact[], mode: DocumentGenerationMode, organizer?: LowTierDocumentOrganizer): Promise<{ text: string; modelRole?: string }> {
74
+ const knownFacts = facts.slice(0, 80).map(fact => `${fact.kind}: ${Object.values(fact.value).filter(value => typeof value === "string" || typeof value === "number").slice(0, 3).join(" / ")}`);
75
+ const unknowns = facts.filter(fact => fact.evidenceLevel === "candidate").map(fact => fact.objectKey).slice(0, 20);
76
+ if (mode === "template") return { text: "以下内容由固定项目上下文快照整理。源码静态声明不代表运行环境已启用,未确认项保持未确认。", modelRole: "template" };
77
+ if (!organizer) throw new Error("Low-tier document generation was requested but no organizer is configured");
78
+ if (!/low|small|mini|flash|lite/i.test(`${organizer.role} ${organizer.model}`)) throw new Error("Document organizer is not configured as a low-tier model");
79
+ return { text: safeNarrative(await organizer.organize({ document, project: context.manifest.name, knownFacts, unknowns })), modelRole: `${organizer.role}:${organizer.model}` };
80
+ }
81
+
82
+ function renderDocuments(context: ProjectContext, facts: Fact[], intro: string, collections: Array<{ repository?: string; kind?: string; status?: string; diagnostics?: unknown[] }>): Array<{ targetPath: string; body: string }> {
83
+ const assets = facts.filter(fact => fact.kind === "asset");
84
+ const technologies = facts.filter(fact => fact.kind === "technology");
85
+ const commands = facts.filter(fact => fact.kind === "toolchain");
86
+ const rules = facts.filter(fact => fact.kind === "rule");
87
+ const capabilities = facts.filter(fact => fact.kind === "capability");
88
+ const repositories = [...new Set(facts.map(fact => fact.repository).filter(Boolean))].sort();
89
+ const outputs: Array<{ targetPath: string; body: string }> = [];
90
+ outputs.push({ targetPath: ".planning/PROJECT.md", body: [
91
+ `# ${context.manifest.name}`, "", intro, "", "## 代码库组成", "",
92
+ table(["代码库", "文件数量", "已观察能力"], repositories.map(repository => [repository, assets.filter(fact => fact.repository === repository).length, [...new Set(facts.filter(fact => fact.repository === repository && ["api", "page", "data_model", "integration"].includes(fact.kind)).map(fact => fact.kind))].join("、") || "未发现"])),
93
+ "", "## 技术概况", "", table(["声明", "来源类型"], technologies.slice(0, 100).map(fact => [fact.value.name ?? fact.value.extension, fact.value.declaration ?? "文件统计"])),
94
+ "", "## 已知边界", "", "当前信息来自本地静态扫描。接口声明不证明服务可访问,页面声明不证明运行时渲染,命令声明不表示已经执行。", "",
95
+ ].join("\n") });
96
+ outputs.push({ targetPath: ".planning/toolchain.md", body: ["# 工具链", "", intro, "", table(["代码库", "名称", "命令", "工作目录", "执行状态"], commands.map(fact => [fact.repository, fact.value.name, fact.value.command, fact.value.workingDirectory || ".", "仅发现,未执行"])), ""].join("\n") });
97
+ outputs.push({ targetPath: ".planning/navigation/INDEX.md", body: ["# 项目导航", "", intro, "", table(["代码库", "导航说明", "扫描文件数"], repositories.map(repository => [repository, `.planning/navigation/${repository.replace(/[^\p{L}\p{N}._-]/gu, "-")}.md`, assets.filter(fact => fact.repository === repository).length])), ""].join("\n") });
98
+ for (const repository of repositories) {
99
+ const local = facts.filter(fact => fact.repository === repository);
100
+ outputs.push({ targetPath: `.planning/navigation/${repository.replace(/[^\p{L}\p{N}._-]/gu, "-")}.md`, body: [
101
+ `# ${repository} 导航`, "", intro, "", "## 文件与入口", "",
102
+ table(["路径", "类型"], local.filter(fact => fact.kind === "asset").slice(0, 200).map(fact => [fact.value.path, fact.value.extension || "无扩展名"])),
103
+ "", "## 已发现能力", "", table(["类型", "对象"], local.filter(fact => ["api", "page", "data_model", "integration"].includes(fact.kind)).slice(0, 200).map(fact => [fact.kind, fact.value.path ?? fact.value.route ?? fact.value.name ?? fact.value.host ?? "未确认"])), "",
104
+ ].join("\n") });
105
+ }
106
+ outputs.push({ targetPath: ".planning/rules/INDEX.md", body: ["# 项目规则来源", "", intro, "", table(["代码库", "规则文件", "性质"], rules.map(fact => [fact.repository, fact.value.path, fact.value.explicit ? "显式规则" : "惯例候选"])), "", "未登记的惯例候选不会自动成为强制规则。", ""].join("\n") });
107
+ outputs.push({ targetPath: ".planning/initialization/report.md", body: [
108
+ "# 项目初始化报告", "", intro, "", "## 数据可用范围", "",
109
+ table(["采集器", "支持范围", "限制"], capabilities.map(fact => [fact.objectKey, Array.isArray(fact.value.supported) ? fact.value.supported.join("、") : "未确认", Array.isArray(fact.value.limitations) ? fact.value.limitations.join(";") : "未确认"])),
110
+ "", "## 分项采集状态", "", table(["代码库", "专题", "状态", "诊断数"], collections.map(item => [item.repository || "全局", item.kind, item.status, item.diagnostics?.length ?? 0])),
111
+ "", "## 未知项", "", ...((facts.some(fact => fact.evidenceLevel === "candidate") ? facts.filter(fact => fact.evidenceLevel === "candidate").slice(0, 30).map(fact => `- ${markdownCell(fact.objectKey)}:候选,尚未核对`) : ["未记录候选未知项。"])), "",
112
+ ].join("\n") });
113
+ return outputs;
114
+ }
115
+
116
+ function projectKnowledgeBody(context: ProjectContext, type: KnowledgeDocumentType, facts: Fact[], intro: string, snapshotId: string, generation?: string): string {
117
+ const apis = facts.filter(fact => fact.kind === "api");
118
+ const pages = facts.filter(fact => fact.kind === "page");
119
+ const models = facts.filter(fact => fact.kind === "data_model");
120
+ const tech = facts.filter(fact => fact.kind === "technology");
121
+ const commands = facts.filter(fact => fact.kind === "toolchain");
122
+ const integrations = facts.filter(fact => fact.kind === "integration");
123
+ const repositoryNames = [...new Set(facts.map(fact => fact.repository).filter(Boolean))].sort();
124
+ const header = frontmatter({
125
+ type: `project-${type}`,
126
+ scope: "project",
127
+ project: context.manifest.name,
128
+ status: "draft",
129
+ source_snapshot: snapshotId,
130
+ code_index_generation: generation,
131
+ managed: true,
132
+ });
133
+ const evidence = [
134
+ "",
135
+ "## 证据绑定",
136
+ "",
137
+ `- 初始化快照:${snapshotId}`,
138
+ `- 代码索引 generation:${generation ?? "未生成或已跳过"}`,
139
+ "- 本文档由项目初始化创建骨架,人工确认前均为 draft。",
140
+ "",
141
+ ].join("\n");
142
+ if (type === "requirements") return `${header}# 项目总体需求\n\n${intro}\n\n## 项目范围\n\n${table(["代码库", "扫描事实数"], repositoryNames.map(repository => [repository, facts.filter(fact => fact.repository === repository).length]))}\n\n## 已观察业务能力\n\n${table(["类型", "对象"], [...pages, ...apis, ...models].slice(0, 80).map(fact => [fact.kind, fact.value.route ?? fact.value.path ?? fact.value.name ?? fact.objectKey]))}\n\n## 待人工补充\n\n- 项目目标与非目标\n- 用户角色与核心流程\n- 业务约束与优先级\n${evidence}`;
143
+ if (type === "product-design") return `${header}# 项目产品与交互设计\n\n${intro}\n\n## 已发现页面/入口\n\n${table(["代码库", "页面或入口"], pages.slice(0, 100).map(fact => [fact.repository, fact.value.route ?? fact.value.componentPath ?? fact.objectKey]))}\n\n## 待人工补充\n\n- 信息架构\n- 页面状态与异常态\n- 文案、权限与可访问性约束\n${evidence}`;
144
+ if (type === "architecture") return `${header}# 项目架构设计\n\n${intro}\n\n## 已观察结构\n\n${table(["代码库", "事实类型", "对象"], facts.filter(fact => ["api", "data_model", "integration", "capability"].includes(fact.kind)).slice(0, 120).map(fact => [fact.repository, fact.kind, fact.value.path ?? fact.value.name ?? fact.value.host ?? fact.objectKey]))}\n\n## 待人工补充\n\n- 系统边界与部署关系\n- 模块职责与依赖方向\n- 数据流、风险与降级策略\n${evidence}`;
145
+ if (type === "frontend-tech") return `${header}# 项目前端技术方案\n\n${intro}\n\n## 已观察前端技术\n\n${table(["声明", "来源"], tech.filter(fact => /react|vue|vite|router|zustand|tailwind|typescript|eslint|vitest/i.test(String(fact.value.name ?? fact.value.extension ?? ""))).slice(0, 120).map(fact => [fact.value.name ?? fact.value.extension, fact.value.declaration ?? fact.value.path ?? "文件统计"]))}\n\n## 待人工补充\n\n- 路由、状态管理与组件边界\n- API 调用约定\n- 构建、测试与发布规则\n${evidence}`;
146
+ if (type === "backend-tech") return `${header}# 项目后端技术方案\n\n${intro}\n\n## 已观察后端事实\n\n${table(["类型", "对象"], [...apis, ...models, ...integrations].slice(0, 120).map(fact => [fact.kind, fact.value.path ?? fact.value.name ?? fact.value.host ?? fact.objectKey]))}\n\n## 待人工补充\n\n- handler/service/repository 分层\n- 数据库迁移与任务调度规则\n- 鉴权、错误码、日志与监控\n${evidence}`;
147
+ if (type === "test-cases") return `${header}# 项目用例与验收\n\n${intro}\n\n## 已发现测试/命令线索\n\n${table(["代码库", "名称", "命令或路径"], commands.slice(0, 120).map(fact => [fact.repository, fact.value.name ?? "test-definition", fact.value.command ?? fact.value.testDefinition ?? fact.objectKey]))}\n\n## 待人工补充\n\n- 核心主流程验收用例\n- 异常路径与权限用例\n- 回归范围与自动化策略\n${evidence}`;
148
+ return `${header}# 项目 API 文档\n\n${intro}\n\n## 已发现 API 声明\n\n${table(["代码库", "方法", "路径"], apis.slice(0, 160).map(fact => [fact.repository, fact.value.method ?? "未确认", fact.value.path ?? fact.objectKey]))}\n\n## 待人工补充\n\n- 认证与公共请求头\n- 通用响应结构与错误码\n- 兼容性与废弃策略\n${evidence}`;
149
+ }
150
+
151
+ function requirementTemplateBody(context: ProjectContext, type: KnowledgeDocumentType, snapshotId: string, generation?: string): string {
152
+ const header = frontmatter({
153
+ type: `requirement-${type}`,
154
+ scope: "requirement",
155
+ project: context.manifest.name,
156
+ requirement_id: "REQ-TEMPLATE",
157
+ status: "template",
158
+ source_snapshot: snapshotId,
159
+ code_index_generation: generation,
160
+ related_paths: [],
161
+ related_apis: [],
162
+ managed: true,
163
+ });
164
+ return `${header}# ${documentTitle(type)}\n\n本文件是需求级文档模板。创建具体需求时复制到 \`.context/requirements/REQ-xxx-短名/${type === "requirements" ? "requirement" : type}.md\`,并把 frontmatter 中的 \`requirement_id\`、\`status\`、关联路径和 API 补全。\n\n## 当前结论\n\n- 待补充。\n\n## 证据与约束\n\n- 初始化快照:${snapshotId}\n- 代码索引 generation:${generation ?? "未生成或已跳过"}\n- 需求级内容必须引用项目级文档和实际源码证据,不能只凭推断确认。\n\n## 待补充\n\n- 背景与目标\n- 范围与非目标\n- 设计决策\n- 验收条件\n`;
165
+ }
166
+
167
+ function renderKnowledgeDocuments(context: ProjectContext, facts: Fact[], intro: string, snapshot: { id?: unknown; indexRef?: unknown }): Array<{ targetPath: string; body: string }> {
168
+ const snapshotId = String(snapshot.id ?? "");
169
+ const generation = codeIndexGeneration(snapshot);
170
+ const outputs: Array<{ targetPath: string; body: string }> = [];
171
+ outputs.push({ targetPath: ".context/index.md", body: `${frontmatter({ type: "knowledge-index", scope: "project", project: context.manifest.name, status: "draft", source_snapshot: snapshotId, code_index_generation: generation, managed: true })}# ${context.manifest.name} 项目知识库\n\n## 项目级文档\n\n${KNOWLEDGE_DOCUMENT_TYPES.map(type => `- [${documentTitle(type)}](/project/${type}.md)`).join("\n")}\n\n## 需求级文档\n\n- 新需求使用 [.context/requirements/_template/index.md](/requirements/_template/index.md) 作为文档包模板。\n\n## 机器索引\n\n- 文档索引:\`.planning/document-index.json\`\n- 代码索引:\`.planning/code-index/\`\n` });
172
+ outputs.push({ targetPath: ".context/project/index.md", body: `${frontmatter({ type: "project-document-index", scope: "project", project: context.manifest.name, status: "draft", source_snapshot: snapshotId, code_index_generation: generation, managed: true })}# 项目级文档\n\n${KNOWLEDGE_DOCUMENT_TYPES.map(type => `- [${documentTitle(type)}](./${type}.md)`).join("\n")}\n` });
173
+ for (const type of KNOWLEDGE_DOCUMENT_TYPES) outputs.push({ targetPath: `.context/project/${type}.md`, body: projectKnowledgeBody(context, type, facts, intro, snapshotId, generation) });
174
+ outputs.push({ targetPath: ".context/requirements/_template/index.md", body: `${frontmatter({ type: "requirement-document-index", scope: "requirement", project: context.manifest.name, requirement_id: "REQ-TEMPLATE", status: "template", source_snapshot: snapshotId, code_index_generation: generation, managed: true })}# 需求级文档模板\n\n${KNOWLEDGE_DOCUMENT_TYPES.map(type => `- [${documentTitle(type)}](./${type === "requirements" ? "requirement" : type}.md)`).join("\n")}\n` });
175
+ for (const type of KNOWLEDGE_DOCUMENT_TYPES) outputs.push({ targetPath: `.context/requirements/_template/${type === "requirements" ? "requirement" : type}.md`, body: requirementTemplateBody(context, type, snapshotId, generation) });
176
+ return outputs;
177
+ }
178
+
179
+ function renderDocumentIndex(context: ProjectContext, snapshot: { id?: unknown; indexRef?: unknown }, outputs: Array<{ targetPath: string }>): { targetPath: string; body: string } {
180
+ const snapshotId = String(snapshot.id ?? "");
181
+ const generation = codeIndexGeneration(snapshot);
182
+ const documents = outputs
183
+ .filter(output => output.targetPath.startsWith(".context/"))
184
+ .map(output => {
185
+ const parts = output.targetPath.split("/");
186
+ const file = parts.at(-1) ?? "";
187
+ const scope = parts[1] === "project" ? "project" : parts[1] === "requirements" ? "requirement" : "project";
188
+ const type = output.targetPath === ".context/index.md" ? "knowledge-index"
189
+ : scope === "project" ? `project-${file.replace(/\.md$/, "")}`
190
+ : `requirement-${file.replace(/^requirement\.md$/, "requirements").replace(/\.md$/, "")}`;
191
+ return {
192
+ path: output.targetPath,
193
+ type,
194
+ scope,
195
+ requirementId: scope === "requirement" ? (parts[2] === "_template" ? "REQ-TEMPLATE" : parts[2]) : undefined,
196
+ status: parts.includes("_template") ? "template" : "draft",
197
+ managed: true,
198
+ sourceSnapshot: snapshotId,
199
+ codeIndexGeneration: generation ?? null,
200
+ };
201
+ });
202
+ return {
203
+ targetPath: ".planning/document-index.json",
204
+ body: `${JSON.stringify({ schemaVersion: 1, project: context.manifest.name, projectId: context.manifest.projectId, sourceSnapshot: snapshotId, codeIndexGeneration: generation ?? null, documents }, null, 2)}\n`,
205
+ };
206
+ }
207
+
208
+ export async function generateProjectDocuments(context: ProjectContext, snapshotId: string, options: { mode: DocumentGenerationMode; organizer?: LowTierDocumentOrganizer }): Promise<DocumentGenerationResult[]> {
209
+ const store = await InitializationStore.open(context);
210
+ try {
211
+ const facts: Fact[] = []; let after: string | undefined; let runId: string | undefined;
212
+ do {
213
+ const page = store.queryContext({ snapshotId, limit: 500, after }) as { snapshot: { runId: string }; facts: Fact[]; truncated: boolean; nextCursor: string | null };
214
+ runId = page.snapshot.runId;
215
+ facts.push(...page.facts); after = page.nextCursor ?? undefined;
216
+ if (facts.length > 50_000) throw new Error("Document manifest exceeds the bounded 50,000 fact limit");
217
+ } while (after);
218
+ const run = runId ? store.getRun(runId) as { collections?: Array<{ repository?: string; kind?: string; status?: string; diagnostics?: unknown[] }> } | null : null;
219
+ const collections = run?.collections ?? [];
220
+ const batchJob = store.createDocumentJob(snapshotId, { targetPath: ".planning/initialization/report.md", ownership: "generated", templateVersion: TEMPLATE_VERSION, modelRole: options.organizer ? `${options.organizer.role}:${options.organizer.model}` : options.mode, input: facts.map(fact => fact.objectKey) });
221
+ let organized;
222
+ try { organized = await narrative(context, "project-initialization", facts, options.mode, options.organizer); }
223
+ catch (error) { store.failDocumentJob(batchJob, error instanceof Error ? error.message : String(error)); throw error; }
224
+ const snapshotMeta = (store.queryContext({ snapshotId, limit: 1 }) as { snapshot: { id?: unknown; indexRef?: unknown } }).snapshot;
225
+ const outputs = [...renderDocuments(context, facts, organized.text, collections), ...renderKnowledgeDocuments(context, facts, organized.text, snapshotMeta)];
226
+ outputs.push(renderDocumentIndex(context, snapshotMeta, outputs));
227
+ const results: DocumentGenerationResult[] = [];
228
+ for (const output of outputs) {
229
+ const job = store.createDocumentJob(snapshotId, { targetPath: output.targetPath, ownership: "generated", templateVersion: TEMPLATE_VERSION, modelRole: organized.modelRole, input: facts.map(fact => fact.objectKey) });
230
+ try {
231
+ const knownHash = store.latestDocumentHash(output.targetPath);
232
+ const existingHash = await currentHash(`${context.root}/${output.targetPath}`);
233
+ const expected = existingHash && !knownHash ? "unmanaged-existing-file" : knownHash ?? undefined;
234
+ const written = await store.writeDocument(job, output.body, { expectedCurrentHash: expected });
235
+ results.push({ targetPath: output.targetPath, status: written.status, outputPath: written.path });
236
+ } catch (error) {
237
+ const message = error instanceof Error ? error.message : String(error); store.failDocumentJob(job, message);
238
+ results.push({ targetPath: output.targetPath, status: "failed", error: message });
239
+ }
240
+ }
241
+ return results;
242
+ } finally { store.close(); }
243
+ }
@@ -0,0 +1,251 @@
1
+ import { createHash } from "node:crypto";
2
+ import { readFile, readdir, stat } from "node:fs/promises";
3
+ import path from "node:path";
4
+
5
+ import { FACT_KINDS, InitializationStore, type FactInput, type FactKind, type ScanConfigInput } from "./initialization-store.ts";
6
+ import type { ProjectContext } from "./project-context.ts";
7
+
8
+ const DEFAULT_EXCLUDED = new Set([".git", ".devflow", ".planning", "node_modules", "dist", "build", "coverage", "target", "vendor", ".next", ".nuxt"]);
9
+ const TEXT_EXTENSIONS = new Set([".ts", ".tsx", ".js", ".jsx", ".py", ".rb", ".go", ".java", ".kt", ".rs", ".php", ".sql", ".json", ".yaml", ".yml", ".toml", ".md"]);
10
+
11
+ type SourceFile = { repository: string; path: string; absolute: string; contentHash: string; size: number; text?: string };
12
+ type ScanDiagnostic = { repository: string; path: string; reason: "symlink" | "excluded" | "too_large" | "unreadable"; message?: string };
13
+ export type InitializationProgress = { runId: string; snapshotId?: string; completeness?: "complete" | "partial"; diagnostics: string[]; documents?: Array<{ targetPath: string; status: string; outputPath?: string; error?: string }> };
14
+
15
+ function digest(value: string | Uint8Array): string { return `sha256:${createHash("sha256").update(value).digest("hex")}`; }
16
+ function extension(file: string): string { return path.extname(file).toLowerCase(); }
17
+ function globMatches(value: string, pattern: string): boolean {
18
+ const compile = (candidate: string) => candidate.replace(/[.+^${}()|[\]\\]/g, "\\$&").replaceAll("**", "\0").replaceAll("*", "[^/]*").replaceAll("\0", ".*");
19
+ return new RegExp(`^${compile(pattern)}$`).test(value)
20
+ || (pattern.startsWith("**/") && new RegExp(`^${compile(pattern.slice(3))}$`).test(value));
21
+ }
22
+ function redactCommand(command: unknown): string {
23
+ if (typeof command !== "string") return "";
24
+ return command
25
+ .replace(/\b([A-Z0-9_]*(?:TOKEN|SECRET|PASSWORD|PASSWD|API_KEY)[A-Z0-9_]*)=([^\s]+)/gi, "$1=<redacted>")
26
+ .replace(/(--?(?:token|secret|password|passwd|api[-_]?key))(?:=|\s+)([^\s]+)/gi, "$1=<redacted>")
27
+ .replace(/(authorization\s*[:=]\s*bearer\s+)([^\s]+)/gi, "$1<redacted>")
28
+ .replace(/(https?:\/\/)[^\s/@:]+:[^\s/@]+@/gi, "$1<redacted>@");
29
+ }
30
+ function source(file: SourceFile, kind: FactKind, objectKey: string, value: Record<string, unknown>, extractor: string, line?: number): FactInput {
31
+ return { kind, objectKey, repository: file.repository, value, evidenceLevel: "observed", sourcePath: file.path, sourceLine: line, sourceFingerprint: file.contentHash, extractor, confidence: "heuristic" };
32
+ }
33
+
34
+ async function walk(repository: string, repositoryRoot: string, includes: string[], excludes: string[], diagnostics: ScanDiagnostic[], relative = ""): Promise<SourceFile[]> {
35
+ const directory = path.join(repositoryRoot, relative);
36
+ let entries;
37
+ try { entries = await readdir(directory, { withFileTypes: true }); }
38
+ catch (error) { diagnostics.push({ repository, path: relative || ".", reason: "unreadable", message: error instanceof Error ? error.message : String(error) }); return []; }
39
+ const result: SourceFile[] = [];
40
+ for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
41
+ if (entry.isSymbolicLink()) { diagnostics.push({ repository, path: path.join(relative, entry.name), reason: "symlink" }); continue; }
42
+ if (DEFAULT_EXCLUDED.has(entry.name)) continue;
43
+ const child = path.join(relative, entry.name);
44
+ const normalized = child.replaceAll("\\", "/");
45
+ if (excludes.some(pattern => globMatches(normalized, pattern) || globMatches(`${normalized}/`, pattern))) { if (diagnostics.length < 1000) diagnostics.push({ repository, path: normalized, reason: "excluded" }); continue; }
46
+ if (entry.isDirectory()) { result.push(...await walk(repository, repositoryRoot, includes, excludes, diagnostics, child)); continue; }
47
+ if (!entry.isFile()) continue;
48
+ if (includes.length && !includes.some(pattern => globMatches(normalized, pattern))) continue;
49
+ const absolute = path.join(repositoryRoot, child); const info = await stat(absolute);
50
+ if (info.size > 2 * 1024 * 1024) { diagnostics.push({ repository, path: normalized, reason: "too_large" }); continue; }
51
+ let bytes: Buffer;
52
+ try { bytes = await readFile(absolute); }
53
+ catch (error) { diagnostics.push({ repository, path: normalized, reason: "unreadable", message: error instanceof Error ? error.message : String(error) }); continue; }
54
+ result.push({ repository, path: child.replaceAll("\\", "/"), absolute, size: info.size, contentHash: digest(bytes), text: TEXT_EXTENSIONS.has(extension(child)) ? bytes.toString("utf8") : undefined });
55
+ }
56
+ return result;
57
+ }
58
+
59
+ async function gitRevision(root: string): Promise<string | null> {
60
+ const child = Bun.spawn(["git", "-C", root, "rev-parse", "HEAD"], { stdout: "pipe", stderr: "ignore" });
61
+ if (await child.exited !== 0) return null;
62
+ const revision = (await new Response(child.stdout).text()).trim();
63
+ return /^[0-9a-f]{40,64}$/i.test(revision) ? revision : null;
64
+ }
65
+
66
+ async function scan(context: ProjectContext, config: ScanConfigInput): Promise<{ files: SourceFile[]; diagnostics: ScanDiagnostic[]; repositoryVersions: Array<{ repository: string; revision: string | null }> }> {
67
+ const all: SourceFile[] = [];
68
+ const diagnostics: ScanDiagnostic[] = [];
69
+ const repositoryVersions: Array<{ repository: string; revision: string | null }> = [];
70
+ const includes = config.includes ?? ["**/*"];
71
+ const excludes = config.excludes ?? [".git/**", ".planning/**", ".devflow/**"];
72
+ for (const repository of config.repositories) {
73
+ const root = path.resolve(context.root, repository.relativePath);
74
+ if (!path.isAbsolute(repository.relativePath) && root !== context.root && !root.startsWith(`${context.root}${path.sep}`)) throw new Error(`Repository escapes project root: ${repository.relativePath}`);
75
+ repositoryVersions.push({ repository: repository.name, revision: await gitRevision(root) });
76
+ all.push(...await walk(repository.name, root, includes, excludes, diagnostics));
77
+ }
78
+ return { files: all.sort((a, b) => `${a.repository}/${a.path}`.localeCompare(`${b.repository}/${b.path}`)), diagnostics, repositoryVersions };
79
+ }
80
+
81
+ function frozenManifest(scanResult: Awaited<ReturnType<typeof scan>>): Array<{ repository: string; path: string; contentHash: string; size?: number }> {
82
+ return [
83
+ ...scanResult.files.map(({ repository, path: sourcePath, contentHash, size }) => ({ repository, path: sourcePath, contentHash, size })),
84
+ ...scanResult.repositoryVersions.filter(item => item.revision).map(item => ({ repository: item.repository, path: "@source/git-head", contentHash: digest(item.revision!), size: item.revision!.length })),
85
+ ];
86
+ }
87
+
88
+ function lineMatches(file: SourceFile, expression: RegExp): Array<{ line: number; match: RegExpMatchArray }> {
89
+ return (file.text ?? "").split(/\r?\n/).flatMap((text, index) => {
90
+ const match = text.match(expression); return match ? [{ line: index + 1, match }] : [];
91
+ });
92
+ }
93
+
94
+ function collect(files: SourceFile[], context: ProjectContext, config: ScanConfigInput): Map<FactKind, FactInput[]> {
95
+ const facts = new Map<FactKind, FactInput[]>(FACT_KINDS.map(kind => [kind, []]));
96
+ const extensions = new Map<string, number>();
97
+ for (const file of files) {
98
+ facts.get("asset")!.push(source(file, "asset", `file:${file.repository}:${file.path}`, { path: file.path, repository: file.repository, size: file.size, extension: extension(file.path) || null }, "filesystem@1"));
99
+ const extensionKey = `${file.repository}\0${extension(file.path) || "none"}`;
100
+ extensions.set(extensionKey, (extensions.get(extensionKey) ?? 0) + 1);
101
+ for (const { line, match } of lineMatches(file, /\b(?:app|router)\.(get|post|put|patch|delete)\s*\(\s*["'`]([^"'`]+)/i)) {
102
+ facts.get("api")!.push(source(file, "api", `route:${match[1].toUpperCase()}:${match[2]}`, { method: match[1].toUpperCase(), path: match[2], runtimeVerified: false }, "static-route@1", line));
103
+ }
104
+ for (const { line, match } of lineMatches(file, /@(Get|Post|Put|Patch|Delete)Mapping\s*\(\s*["'`]([^"'`]+)/i)) {
105
+ facts.get("api")!.push(source(file, "api", `route:${match[1].toUpperCase()}:${match[2]}`, { method: match[1].toUpperCase(), path: match[2], framework: "spring", runtimeVerified: false }, "static-route@1", line));
106
+ }
107
+ for (const { line, match } of lineMatches(file, /^\s*(get|post|put|patch|delete)\s+["'`]([^"'`]+)/i)) {
108
+ facts.get("api")!.push(source(file, "api", `route:${match[1].toUpperCase()}:${match[2]}`, { method: match[1].toUpperCase(), path: match[2], framework: "rails", runtimeVerified: false }, "static-route@1", line));
109
+ }
110
+ for (const { line, match } of lineMatches(file, /\bHandleFunc\s*\(\s*["'`]([^"'`]+)/)) {
111
+ facts.get("api")!.push(source(file, "api", `route:ANY:${match[1]}`, { method: null, path: match[1], framework: "go-http", runtimeVerified: false }, "static-route@1", line));
112
+ }
113
+ for (const { line, match } of lineMatches(file, /(?:path\s*[:=]|<Route[^>]+path=)\s*["'`]([^"'`]+)["'`]/i)) {
114
+ facts.get("page")!.push(source(file, "page", `page:${match[1]}`, { route: match[1], renderedVerified: false }, "static-page@1", line));
115
+ }
116
+ const role = config.repositories.find(item => item.name === file.repository)?.role;
117
+ const pageMatch = file.path.match(/(?:^|\/)(?:pages|app)\/(.+?)\.(?:tsx?|jsx?|vue)$/i);
118
+ if (role === "frontend" && pageMatch) facts.get("page")!.push(source(file, "page", `page-file:${file.repository}:${file.path}`, { route: `/${pageMatch[1].replace(/\/index$/, "").replace(/\[(.+?)\]/g, ":$1")}`, componentPath: file.path, renderedVerified: false }, "filesystem-page@1"));
119
+ for (const { line, match } of lineMatches(file, /\bCREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?["'`]?([A-Za-z_][\w.]*)/i)) {
120
+ facts.get("data_model")!.push(source(file, "data_model", `model:${match[1]}`, { name: match[1], sourceType: extension(file.path) === ".sql" ? "migration" : "code" }, "static-model@1", line));
121
+ }
122
+ for (const { line, match } of lineMatches(file, /^\s*class\s+([A-Za-z_]\w*)\s*(?:extends\s+(?:Model|Entity)|\([^)]*(?:Model|Entity|Base)[^)]*\))/i)) {
123
+ facts.get("data_model")!.push(source(file, "data_model", `model:${file.repository}:${match[1]}`, { name: match[1], sourceType: "code" }, "static-model@1", line));
124
+ }
125
+ for (const { line, match } of lineMatches(file, /https?:\/\/([^\s/"'`]+)/i)) {
126
+ const host = match[1].replace(/^.*@/, "").toLowerCase();
127
+ facts.get("integration")!.push(source(file, "integration", `host:${host}`, { host, enabledInEnvironment: null }, "static-integration@1", line));
128
+ }
129
+ for (const { line, match } of lineMatches(file, /\b(?:cron|schedule|subscribe|consumer)\s*\(/i)) {
130
+ facts.get("integration")!.push(source(file, "integration", `task:${file.repository}:${file.path}:${line}`, { kind: match[0].split("(")[0].trim().toLowerCase(), enabledInEnvironment: null }, "static-task@1", line));
131
+ }
132
+ for (const { line, match } of lineMatches(file, /(?:process\.env\.|os\.getenv\s*\(\s*["']|ENV\s*\[\s*["']|System\.getenv\s*\(\s*["'])([A-Za-z_][A-Za-z0-9_]*)/)) {
133
+ facts.get("integration")!.push(source(file, "integration", `config:${file.repository}:${match[1]}`, { name: match[1], valueStored: false, enabledInEnvironment: null }, "static-config@1", line));
134
+ }
135
+ if (/(^|\/)(AGENTS|CLAUDE|CONTRIBUTING)\.md$/i.test(file.path) || /(^|\/)\.cursorrules$/i.test(file.path)) {
136
+ facts.get("rule")!.push(source(file, "rule", `rule:${file.repository}:${file.path}`, { path: file.path, scope: file.repository, explicit: true }, "rule-file@1"));
137
+ }
138
+ if (path.basename(file.path) === "package.json" && file.text) {
139
+ try {
140
+ const manifest = JSON.parse(file.text) as Record<string, any>;
141
+ for (const group of ["dependencies", "devDependencies", "peerDependencies"]) for (const name of Object.keys(manifest[group] ?? {})) {
142
+ facts.get("technology")!.push(source(file, "technology", `dependency:${file.repository}:${name}`, { name, declaration: group, locked: false }, "package-json@1"));
143
+ }
144
+ for (const [name, command] of Object.entries(manifest.scripts ?? {})) facts.get("toolchain")!.push(source(file, "toolchain", `command:${file.repository}:${name}`, { name, command: redactCommand(command), workingDirectory: path.dirname(file.path), executed: false }, "package-json@1"));
145
+ } catch { /* diagnostics are attached by the caller's collection status */ }
146
+ }
147
+ if (/^(?:package-lock\.json|bun\.lockb?|pnpm-lock\.yaml|yarn\.lock|poetry\.lock|Gemfile\.lock)$/i.test(path.basename(file.path))) facts.get("technology")!.push(source(file, "technology", `lockfile:${file.repository}:${file.path}`, { path: file.path, locked: true }, "lockfile@1"));
148
+ if (/(?:^|\/)(?:test|tests|spec|__tests__)(?:\/|$)|\.(?:test|spec)\.[^.]+$/i.test(file.path)) facts.get("toolchain")!.push(source(file, "toolchain", `test-definition:${file.repository}:${file.path}`, { testDefinition: file.path, executed: false }, "test-discovery@1"));
149
+ }
150
+ for (const [key, count] of extensions) {
151
+ const [repository, ext] = key.split("\0");
152
+ facts.get("technology")!.push({ kind: "technology", repository, objectKey: `language-extension:${ext}`, value: { extension: ext, fileCount: count }, evidenceLevel: "candidate", extractor: "extension-summary@1", confidence: "heuristic" });
153
+ }
154
+ for (const repository of config.repositories) {
155
+ facts.get("evidence")!.push({ kind: "evidence", repository: repository.name, objectKey: "input-policy", value: { source: "local-static", discoveredCommandsExecuted: false, gitMetadataRead: true, secretsStored: false }, evidenceLevel: "explicit_rule", extractor: "initializer@1", confidence: "exact" });
156
+ facts.get("capability")!.push({ kind: "capability", repository: repository.name, objectKey: "collector:static@1", value: { supported: ["asset", "technology", "api", "page", "data_model", "integration", "rule", "toolchain"], limitations: ["static declarations only", "dynamic behavior unresolved", "no environment enablement inference"], project: context.manifest.name }, evidenceLevel: "explicit_rule", extractor: "initializer@1", confidence: "exact" });
157
+ }
158
+ return facts;
159
+ }
160
+
161
+ async function runProcess(argv: string[], cwd: string): Promise<{ stdout: string; stderr: string }> {
162
+ const child = Bun.spawn(argv, { cwd, stdout: "pipe", stderr: "pipe" });
163
+ const [exitCode, stdout, stderr] = await Promise.all([child.exited, new Response(child.stdout).text(), new Response(child.stderr).text()]);
164
+ if (exitCode !== 0) throw new Error(`${path.basename(argv[1] ?? argv[0])} failed (${exitCode}): ${(stderr || stdout).trim()}`);
165
+ return { stdout: stdout.trim(), stderr: stderr.trim() };
166
+ }
167
+
168
+ async function buildCodeIndex(context: ProjectContext, config: ScanConfigInput): Promise<Record<string, unknown>> {
169
+ const script = path.resolve(import.meta.dir, "../plugins/devflow-intel/skills/workflow-code-index/scripts/code_index.py");
170
+ const output = ".planning/code-index";
171
+ const codebases = config.repositories.flatMap(repository => ["--codebase", `${repository.name}:${path.resolve(context.root, repository.relativePath)}:${repository.role}`]);
172
+ await runProcess(["python3", script, "scan", "--root", context.root, "--output", output, ...codebases], context.root);
173
+ await runProcess(["python3", script, "validate", "--index", output], context.root);
174
+ await runProcess(["python3", script, "audit", "--root", context.root, "--index", output, "--skill-root", path.resolve(import.meta.dir, "../plugins/devflow-intel/skills")], context.root);
175
+ const snapshotPath = path.join(context.root, output, "SNAPSHOT.json");
176
+ const bytes = await readFile(snapshotPath); const snapshot = JSON.parse(bytes.toString("utf8")) as Record<string, unknown>;
177
+ if (!snapshot.generation || !snapshot.immutablePath) throw new Error("Code index did not publish an immutable generation");
178
+ return { path: output, generation: snapshot.generation, immutablePath: snapshot.immutablePath, snapshotHash: digest(bytes), status: "complete" };
179
+ }
180
+
181
+ export async function runProjectInitialization(context: ProjectContext, input: { config?: ScanConfigInput; requestId?: string; documentMode?: "low-tier-model" | "template" | "none"; organizer?: import("./project-documents.ts").LowTierDocumentOrganizer; switchAuthority?: boolean } = {}): Promise<InitializationProgress> {
182
+ const config: ScanConfigInput = input.config ?? { mode: "full", repositories: [{ name: context.manifest.name, relativePath: ".", role: "other" }], requiredKinds: [...FACT_KINDS], adapterVersions: { static: "1" } };
183
+ const store = await InitializationStore.open(context); const diagnostics: string[] = [];
184
+ let activeRun: { id: string; leaseToken: string } | undefined;
185
+ try {
186
+ const workspace = await store.registerWorkspace(); const storedConfig = store.putScanConfig(config);
187
+ const initialScan = await scan(context, config); const files = initialScan.files;
188
+ const manifest = frozenManifest(initialScan);
189
+ const inputSnapshot = store.createInputSnapshot(workspace.id, storedConfig.id, manifest, false);
190
+ const run = store.startRun({ workspaceId: workspace.id, configId: storedConfig.id, inputSnapshotId: inputSnapshot.id, requestId: input.requestId });
191
+ activeRun = run;
192
+ if (run.reused) return { runId: run.id, snapshotId: store.getRun(run.id)?.publishedSnapshotId as string | undefined, diagnostics };
193
+ const grouped = collect(files, context, config); const required = new Set(config.requiredKinds ?? FACT_KINDS);
194
+ for (const repository of config.repositories) for (const kind of FACT_KINDS) {
195
+ const repositoryFacts = grouped.get(kind)!.filter(fact => fact.repository === repository.name);
196
+ const requiredKind = required.has(kind);
197
+ const applicable = kind === "page"
198
+ ? repository.role === "frontend" || repository.role === "other"
199
+ : kind === "api" || kind === "data_model"
200
+ ? repository.role === "backend" || repository.role === "other"
201
+ : true;
202
+ const repositoryDiagnostics = initialScan.diagnostics.filter(item => item.repository === repository.name);
203
+ const hasBlockingScanFailure = repositoryDiagnostics.some(item => item.reason === "unreadable");
204
+ const status = !requiredKind
205
+ ? "skipped"
206
+ : !applicable
207
+ ? "not_applicable"
208
+ : hasBlockingScanFailure
209
+ ? "partial"
210
+ : "complete";
211
+ store.recordCollection(run.id, run.leaseToken, {
212
+ repository: repository.name,
213
+ kind,
214
+ status,
215
+ adapter: "static@1",
216
+ capability: { static: true, applicable, completeAbsenceIsObserved: status === "complete" && repositoryFacts.length === 0, scanDiagnostics: repositoryDiagnostics.length },
217
+ diagnostics: repositoryDiagnostics,
218
+ facts: status === "complete" ? repositoryFacts : [],
219
+ });
220
+ }
221
+ const indexRef = config.mode === "full" ? await buildCodeIndex(context, config) : { status: "skipped", reason: "explicit_fast_mode" };
222
+ const after = await scan(context, config);
223
+ const afterManifest = frozenManifest(after);
224
+ const verified = store.createInputSnapshot(workspace.id, storedConfig.id, afterManifest, false);
225
+ if (verified.fingerprint !== inputSnapshot.fingerprint) throw new Error("Source changed during initialization; refusing to publish mixed input");
226
+ store.confirmInputSnapshot(inputSnapshot.id, inputSnapshot.fingerprint);
227
+ const published = store.publishRun(run.id, run.leaseToken, { expectedRevision: undefined, indexRef });
228
+ if (input.switchAuthority !== false) {
229
+ const authority = store.getAuthority();
230
+ if (authority.authority === "legacy") store.switchAuthority("database", authority.revision);
231
+ }
232
+ const result = store.queryContext({ snapshotId: published.snapshotId, limit: 1 }) as any;
233
+ const documentMode = input.documentMode ?? "template";
234
+ let documents: InitializationProgress["documents"];
235
+ if (documentMode !== "none") {
236
+ try {
237
+ documents = await (await import("./project-documents.ts")).generateProjectDocuments(context, published.snapshotId, { mode: documentMode, organizer: input.organizer });
238
+ } catch (error) {
239
+ const message = error instanceof Error ? error.message : String(error);
240
+ diagnostics.push(`Document generation failed independently: ${message}`);
241
+ documents = [{ targetPath: ".planning/initialization/report.md", status: "failed", error: message }];
242
+ }
243
+ }
244
+ return { runId: run.id, snapshotId: published.snapshotId, completeness: result.snapshot.completeness, diagnostics, documents };
245
+ } catch (error) {
246
+ const message = error instanceof Error ? error.message : String(error);
247
+ diagnostics.push(message);
248
+ if (activeRun) store.failRun(activeRun.id, activeRun.leaseToken, message);
249
+ throw error;
250
+ } finally { store.close(); }
251
+ }