autocrew 0.1.0 → 0.2.0

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.
@@ -63,6 +63,21 @@ export interface ContentPillar {
63
63
  exampleAngles: string[];
64
64
  }
65
65
 
66
+ export interface VideoCrawlerConfig {
67
+ type: "mediacrawl" | "playwright" | "manual";
68
+ /** Command to run for mediacrawl mode, e.g. "python3 /path/to/main.py" */
69
+ command?: string;
70
+ }
71
+
72
+ export interface OmniConfig {
73
+ /** API base URL, default "https://api.xiaomimimo.com/v1" */
74
+ baseUrl: string;
75
+ /** Model ID, default "mimo-v2-omni" */
76
+ model: string;
77
+ /** API key */
78
+ apiKey: string;
79
+ }
80
+
66
81
  export interface CreatorProfile {
67
82
  /** User's content industry/niche */
68
83
  industry: string;
@@ -86,6 +101,10 @@ export interface CreatorProfile {
86
101
  secondaryPersonas: AudiencePersona[];
87
102
  /** Content pillars defining the creator's content strategy */
88
103
  contentPillars?: ContentPillar[];
104
+ /** Video crawler configuration for teardown video acquisition */
105
+ videoCrawler?: VideoCrawlerConfig;
106
+ /** Omni model configuration for multimodal video analysis */
107
+ omniConfig?: OmniConfig;
89
108
  /** Whether style calibration has been completed */
90
109
  styleCalibrated: boolean;
91
110
  /** Profile creation timestamp */
@@ -158,15 +177,26 @@ export async function saveProfile(profile: CreatorProfile, dataDir?: string): Pr
158
177
  }
159
178
 
160
179
  /**
161
- * Initialize a new empty profile. No-op if one already exists.
180
+ * Initialize a new empty profile. No-op if the file already exists on disk.
162
181
  * Returns the profile (existing or newly created).
182
+ *
183
+ * IMPORTANT: We check file existence, not parsability. If the file exists but
184
+ * can't be parsed, we return an empty in-memory profile but do NOT overwrite
185
+ * the file — protecting user data from being clobbered by a re-init.
163
186
  */
164
187
  export async function initProfile(dataDir?: string): Promise<CreatorProfile> {
165
- const existing = await loadProfile(dataDir);
166
- if (existing) return existing;
167
- const profile = emptyProfile();
168
- await saveProfile(profile, dataDir);
169
- return profile;
188
+ const filePath = path.join(getDataDir(dataDir), PROFILE_FILE);
189
+ try {
190
+ await fs.access(filePath);
191
+ // File exists — try to load it, but never overwrite
192
+ const existing = await loadProfile(dataDir);
193
+ return existing ?? emptyProfile();
194
+ } catch {
195
+ // File does not exist — safe to create
196
+ const profile = emptyProfile();
197
+ await saveProfile(profile, dataDir);
198
+ return profile;
199
+ }
170
200
  }
171
201
 
172
202
  /**
@@ -0,0 +1,213 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
2
+ import fs from "node:fs/promises";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import {
6
+ initPipeline,
7
+ saveIntel,
8
+ listIntel,
9
+ saveWikiPage,
10
+ getWikiPage,
11
+ listWikiPages,
12
+ regenerateWikiIndex,
13
+ appendWikiLog,
14
+ stagePath,
15
+ type IntelItem,
16
+ type WikiPage,
17
+ } from "../../storage/pipeline-store.js";
18
+
19
+ let testDir: string;
20
+
21
+ beforeEach(async () => {
22
+ testDir = await fs.mkdtemp(path.join(os.tmpdir(), "autocrew-wiki-test-"));
23
+ });
24
+
25
+ afterEach(async () => {
26
+ await fs.rm(testDir, { recursive: true, force: true });
27
+ });
28
+
29
+ describe("Wiki Integration", () => {
30
+ it("full lifecycle: save intel → create wiki page → regenerate index → log", async () => {
31
+ await initPipeline(testDir);
32
+
33
+ // 1. Save two related intel items about the same entity
34
+ const intel1: IntelItem = {
35
+ title: "Cursor reaches 2.6B valuation",
36
+ domain: "ai-tools",
37
+ source: "web_search",
38
+ collectedAt: new Date().toISOString(),
39
+ relevance: 85,
40
+ tags: ["cursor", "funding"],
41
+ expiresAfter: 90,
42
+ summary: "Anysphere's Cursor editor valued at $2.6B",
43
+ keyPoints: ["VS Code fork", "$2.6B valuation", "AI-first editor"],
44
+ topicPotential: "AI tool funding trends",
45
+ };
46
+ const intel2: IntelItem = {
47
+ title: "Cursor Agent Mode launches",
48
+ domain: "ai-tools",
49
+ source: "web_search",
50
+ collectedAt: new Date().toISOString(),
51
+ relevance: 80,
52
+ tags: ["cursor", "agent"],
53
+ expiresAfter: 90,
54
+ summary: "Cursor launches agent mode for autonomous coding",
55
+ keyPoints: ["Agent mode", "Autonomous coding", "Background tasks"],
56
+ topicPotential: "AI coding evolution",
57
+ };
58
+ await saveIntel(intel1, testDir);
59
+ await saveIntel(intel2, testDir);
60
+
61
+ // 2. Create a wiki page synthesizing both
62
+ const today = new Date().toISOString().slice(0, 10);
63
+ const page: WikiPage = {
64
+ type: "entity",
65
+ title: "Cursor",
66
+ aliases: ["Cursor AI", "Cursor Editor"],
67
+ related: [],
68
+ sources: [
69
+ `ai-tools/${today}-cursor-reaches-2-6b-valuation.md`,
70
+ `ai-tools/${today}-cursor-agent-mode-launches.md`,
71
+ ],
72
+ created: today,
73
+ updated: today,
74
+ body: "# Cursor\n\nAI code editor by Anysphere.\n\n## Key Facts\n- VS Code fork with integrated AI\n- $2.6B valuation (2024)\n- Agent Mode for autonomous coding",
75
+ };
76
+ await saveWikiPage(page, testDir);
77
+
78
+ // 3. Verify page saved correctly
79
+ const loaded = await getWikiPage("cursor", testDir);
80
+ expect(loaded).not.toBeNull();
81
+ expect(loaded!.title).toBe("Cursor");
82
+ expect(loaded!.sources).toHaveLength(2);
83
+ expect(loaded!.aliases).toContain("Cursor AI");
84
+
85
+ // 4. Regenerate index
86
+ await regenerateWikiIndex(testDir);
87
+ const indexContent = await fs.readFile(
88
+ path.join(stagePath("wiki", testDir), "index.md"),
89
+ "utf-8",
90
+ );
91
+ expect(indexContent).toContain("[Cursor]");
92
+ expect(indexContent).toContain("## Entities");
93
+
94
+ // 5. Log the sync
95
+ await appendWikiLog("sync", "Created cursor.md from 2 intel sources", testDir);
96
+ const logContent = await fs.readFile(
97
+ path.join(stagePath("wiki", testDir), "log.md"),
98
+ "utf-8",
99
+ );
100
+ expect(logContent).toContain("sync");
101
+ expect(logContent).toContain("cursor.md");
102
+
103
+ // 6. Verify flat structure — no subdirectories
104
+ const wikiFiles = await fs.readdir(stagePath("wiki", testDir));
105
+ expect(wikiFiles).toContain("cursor.md");
106
+ expect(wikiFiles).toContain("index.md");
107
+ expect(wikiFiles).toContain("log.md");
108
+ for (const f of wikiFiles) {
109
+ const stat = await fs.stat(path.join(stagePath("wiki", testDir), f));
110
+ expect(stat.isFile()).toBe(true);
111
+ }
112
+ });
113
+
114
+ it("multiple pages across types with cross-references", async () => {
115
+ await initPipeline(testDir);
116
+ const today = new Date().toISOString().slice(0, 10);
117
+
118
+ await saveWikiPage({
119
+ type: "entity",
120
+ title: "Cursor",
121
+ aliases: ["Cursor AI"],
122
+ related: ["vibe-coding"],
123
+ sources: [],
124
+ created: today,
125
+ updated: today,
126
+ body: "# Cursor\n\nAI code editor.",
127
+ }, testDir);
128
+
129
+ await saveWikiPage({
130
+ type: "concept",
131
+ title: "Vibe Coding",
132
+ aliases: ["vibe coding"],
133
+ related: ["cursor"],
134
+ sources: [],
135
+ created: today,
136
+ updated: today,
137
+ body: "# Vibe Coding\n\nNatural language programming.",
138
+ }, testDir);
139
+
140
+ const pages = await listWikiPages(testDir);
141
+ expect(pages).toHaveLength(2);
142
+
143
+ await regenerateWikiIndex(testDir);
144
+ const indexContent = await fs.readFile(
145
+ path.join(stagePath("wiki", testDir), "index.md"),
146
+ "utf-8",
147
+ );
148
+ expect(indexContent).toContain("## Entities");
149
+ expect(indexContent).toContain("## Concepts");
150
+ expect(indexContent).toContain("[Cursor]");
151
+ expect(indexContent).toContain("[Vibe Coding]");
152
+ });
153
+ });
154
+
155
+ describe("Teardown → Wiki Integration", () => {
156
+ it("teardown intel flows into wiki pipeline", async () => {
157
+ await initPipeline(testDir);
158
+
159
+ // Simulate a video teardown result being ingested as intel
160
+ const teardownIntel: IntelItem = {
161
+ title: "拆解: vibe-coding教程视频分析",
162
+ domain: "content-strategy",
163
+ source: "manual",
164
+ collectedAt: new Date().toISOString(),
165
+ relevance: 90,
166
+ tags: ["teardown", "video", "vibe-coding"],
167
+ expiresAfter: 365,
168
+ summary: "对标账号的vibe-coding教程视频拆解。传播学:信息不对称设计精妙。心理学:认知负荷控制好。内容结构:承诺-兑现完美匹配。视听语言:HKRR主导K维度。",
169
+ keyPoints: [
170
+ "开场3秒用反常识钩子",
171
+ "信息密度曲线合理",
172
+ "HKRR纯粹K维度",
173
+ "框架效应:frame成能力边界变化",
174
+ ],
175
+ topicPotential: "可借鉴:反常识开场+承诺兑现+纯粹K维度",
176
+ };
177
+
178
+ await saveIntel(teardownIntel, testDir);
179
+
180
+ // Verify it's in the intel library
181
+ const items = await listIntel("content-strategy", testDir);
182
+ expect(items.length).toBe(1);
183
+ expect(items[0].tags).toContain("teardown");
184
+ expect(items[0].tags).toContain("video");
185
+
186
+ // Simulate wiki page creation from teardown intel
187
+ const today = new Date().toISOString().slice(0, 10);
188
+ const wikiPage: WikiPage = {
189
+ type: "concept",
190
+ title: "Vibe Coding Teardown Insights",
191
+ aliases: ["vibe coding analysis"],
192
+ related: [],
193
+ sources: [`content-strategy/${today}-chai-jie-vibe-coding-jiao-cheng-shi-pin-fen-xi.md`],
194
+ created: today,
195
+ updated: today,
196
+ body: "# Vibe Coding Teardown Insights\n\n从对标视频拆解中提取:反常识开场+承诺兑现是有效公式。",
197
+ };
198
+ await saveWikiPage(wikiPage, testDir);
199
+
200
+ // Verify wiki page references the teardown
201
+ const loaded = await getWikiPage("vibe-coding-teardown-insights", testDir);
202
+ expect(loaded).not.toBeNull();
203
+ expect(loaded!.sources[0]).toContain("content-strategy");
204
+
205
+ // Verify it shows up in index
206
+ await regenerateWikiIndex(testDir);
207
+ const indexContent = await fs.readFile(
208
+ path.join(stagePath("wiki", testDir), "index.md"),
209
+ "utf-8",
210
+ );
211
+ expect(indexContent).toContain("[Vibe Coding Teardown Insights]");
212
+ });
213
+ });
@@ -25,8 +25,14 @@ import {
25
25
  restoreProject,
26
26
  getProjectMeta,
27
27
  listProjects,
28
+ saveWikiPage,
29
+ getWikiPage,
30
+ listWikiPages,
31
+ regenerateWikiIndex,
32
+ appendWikiLog,
28
33
  type IntelItem,
29
34
  type TopicCandidate,
35
+ type WikiPage,
30
36
  } from "../storage/pipeline-store.js";
31
37
 
32
38
  let testDir: string;
@@ -105,6 +111,12 @@ describe("Pipeline Init", () => {
105
111
  expect(archive.isDirectory()).toBe(true);
106
112
  });
107
113
 
114
+ it("creates wiki directory", async () => {
115
+ await initPipeline(testDir);
116
+ const stat = await fs.stat(stagePath("wiki", testDir));
117
+ expect(stat.isDirectory()).toBe(true);
118
+ });
119
+
108
120
  it("initPipeline is idempotent", async () => {
109
121
  await initPipeline(testDir);
110
122
  await initPipeline(testDir);
@@ -291,12 +303,13 @@ describe("Project Lifecycle", () => {
291
303
  const topics = await listTopics(undefined, testDir);
292
304
  expect(topics.length).toBe(0);
293
305
 
294
- // Project dir should contain meta.yaml, draft-v1.md, draft.md, references/
306
+ // Project dir should contain meta.yaml, draft.md (live), references/.
307
+ // No draft-v*.md exists yet — snapshots are created only when revisions replace draft.md.
295
308
  const files = await fs.readdir(projectDir);
296
309
  expect(files).toContain("meta.yaml");
297
- expect(files).toContain("draft-v1.md");
298
310
  expect(files).toContain("draft.md");
299
311
  expect(files).toContain("references");
312
+ expect(files.filter((f) => f.startsWith("draft-v"))).toHaveLength(0);
300
313
  });
301
314
 
302
315
  it("advances project from drafting to production", async () => {
@@ -318,19 +331,37 @@ describe("Project Lifecycle", () => {
318
331
  await startProject("版本测试", testDir);
319
332
 
320
333
  const projectName = slugify("版本测试");
334
+ const projectDir = path.join(stagePath("drafting", testDir), projectName);
335
+
336
+ // Capture the initial live content before revising
337
+ const fsp = await import("node:fs/promises");
338
+ const originalContent = await fsp.readFile(
339
+ path.join(projectDir, "draft.md"),
340
+ "utf-8",
341
+ );
342
+
321
343
  await addDraftVersion(projectName, "# V2 内容", "second draft", testDir);
322
344
 
323
345
  const meta = await getProjectMeta(projectName, testDir);
324
- expect(meta!.versions.length).toBe(2);
325
- expect(meta!.current).toBe("draft-v2.md");
326
-
327
- const found = await import("node:fs/promises").then((f) =>
328
- f.readFile(
329
- path.join(stagePath("drafting", testDir), projectName, "draft.md"),
330
- "utf-8",
331
- ),
346
+ // One snapshot should have been created (archiving the original)
347
+ expect(meta!.versions.length).toBe(1);
348
+ expect(meta!.versions[0].file).toBe("draft-v1.md");
349
+ expect(meta!.versions[0].note).toBe("second draft");
350
+ expect(meta!.current).toBe("draft.md");
351
+
352
+ // draft.md is now the new content
353
+ const liveContent = await fsp.readFile(
354
+ path.join(projectDir, "draft.md"),
355
+ "utf-8",
356
+ );
357
+ expect(liveContent).toBe("# V2 内容");
358
+
359
+ // draft-v1.md holds the frozen original
360
+ const archivedContent = await fsp.readFile(
361
+ path.join(projectDir, "draft-v1.md"),
362
+ "utf-8",
332
363
  );
333
- expect(found).toBe("# V2 内容");
364
+ expect(archivedContent).toBe(originalContent);
334
365
  });
335
366
 
336
367
  it("trashes and restores project", async () => {
@@ -361,3 +392,79 @@ describe("Project Lifecycle", () => {
361
392
  expect(projects.length).toBeGreaterThan(0);
362
393
  });
363
394
  });
395
+
396
+ // ─── Wiki Storage ──────────────────────────────────────────────────────────
397
+
398
+ function makeWikiPage(overrides: Partial<WikiPage> = {}): WikiPage {
399
+ return {
400
+ type: "entity",
401
+ title: "Claude AI",
402
+ aliases: ["Claude", "Anthropic Claude"],
403
+ related: ["anthropic", "llm"],
404
+ sources: ["intel/2024-01-15-claude.md"],
405
+ created: "2026-04-05T00:00:00.000Z",
406
+ updated: "2026-04-05T00:00:00.000Z",
407
+ body: "Claude is an AI assistant made by Anthropic.",
408
+ ...overrides,
409
+ };
410
+ }
411
+
412
+ describe("Wiki Storage", () => {
413
+ it("saves and reads a wiki page", async () => {
414
+ const page = makeWikiPage();
415
+ const filePath = await saveWikiPage(page, testDir);
416
+ expect(filePath).toContain("wiki");
417
+ expect(filePath.endsWith(".md")).toBe(true);
418
+
419
+ const slug = "claude-ai";
420
+ const loaded = await getWikiPage(slug, testDir);
421
+ expect(loaded).not.toBeNull();
422
+ expect(loaded!.title).toBe(page.title);
423
+ expect(loaded!.type).toBe(page.type);
424
+ expect(loaded!.aliases).toEqual(page.aliases);
425
+ expect(loaded!.related).toEqual(page.related);
426
+ expect(loaded!.sources).toEqual(page.sources);
427
+ expect(loaded!.body).toBe(page.body);
428
+ });
429
+
430
+ it("lists all wiki pages", async () => {
431
+ await saveWikiPage(makeWikiPage({ title: "Claude AI" }), testDir);
432
+ await saveWikiPage(
433
+ makeWikiPage({ title: "GPT-4", type: "comparison" }),
434
+ testDir,
435
+ );
436
+
437
+ const pages = await listWikiPages(testDir);
438
+ expect(pages.length).toBe(2);
439
+ });
440
+
441
+ it("generates index.md grouped by type", async () => {
442
+ await saveWikiPage(makeWikiPage({ title: "Claude AI", type: "entity" }), testDir);
443
+ await saveWikiPage(makeWikiPage({ title: "LLM Basics", type: "concept" }), testDir);
444
+ await saveWikiPage(makeWikiPage({ title: "Claude vs GPT", type: "comparison" }), testDir);
445
+
446
+ await regenerateWikiIndex(testDir);
447
+
448
+ const indexPath = path.join(stagePath("wiki", testDir), "index.md");
449
+ const content = await fs.readFile(indexPath, "utf-8");
450
+ expect(content).toContain("# AutoCrew Knowledge Wiki");
451
+ expect(content).toContain("## Entities");
452
+ expect(content).toContain("## Concepts");
453
+ expect(content).toContain("## Comparisons");
454
+ expect(content).toContain("[Claude AI]");
455
+ expect(content).toContain("[LLM Basics]");
456
+ expect(content).toContain("[Claude vs GPT]");
457
+ });
458
+
459
+ it("appends to log.md", async () => {
460
+ await appendWikiLog("create", "Created page: Claude AI", testDir);
461
+ await appendWikiLog("update", "Updated page: Claude AI", testDir);
462
+
463
+ const logPath = path.join(stagePath("wiki", testDir), "log.md");
464
+ const content = await fs.readFile(logPath, "utf-8");
465
+ const lines = content.trim().split("\n");
466
+ expect(lines.length).toBe(2);
467
+ expect(lines[0]).toContain("[create]");
468
+ expect(lines[1]).toContain("[update]");
469
+ });
470
+ });
@@ -8,6 +8,7 @@ import yaml from "js-yaml";
8
8
  export const PIPELINE_STAGES = [
9
9
  "intel",
10
10
  "topics",
11
+ "wiki",
11
12
  "drafting",
12
13
  "production",
13
14
  "published",
@@ -38,6 +39,17 @@ export interface IntelItem {
38
39
  topicPotential: string;
39
40
  }
40
41
 
42
+ export interface WikiPage {
43
+ type: "entity" | "concept" | "comparison";
44
+ title: string;
45
+ aliases: string[];
46
+ related: string[];
47
+ sources: string[];
48
+ created: string;
49
+ updated: string;
50
+ body: string;
51
+ }
52
+
41
53
  export interface TopicScore {
42
54
  heat: number;
43
55
  differentiation: number;
@@ -330,6 +342,131 @@ export async function archiveExpiredIntel(
330
342
  return { archived };
331
343
  }
332
344
 
345
+ // ─── Wiki Storage ──────────────────────────────────────────────────────────
346
+
347
+ export function wikiPageToMarkdown(page: WikiPage): string {
348
+ const frontmatter: Record<string, unknown> = {
349
+ type: page.type,
350
+ title: page.title,
351
+ aliases: page.aliases,
352
+ related: page.related,
353
+ sources: page.sources,
354
+ created: page.created,
355
+ updated: page.updated,
356
+ };
357
+ const yamlStr = yaml.dump(frontmatter, { lineWidth: -1 }).trimEnd();
358
+ return `---\n${yamlStr}\n---\n\n${page.body}`;
359
+ }
360
+
361
+ export function parseWikiPage(content: string): WikiPage {
362
+ const { data, content: body } = matter(content);
363
+ return {
364
+ type: data.type,
365
+ title: data.title,
366
+ aliases: data.aliases ?? [],
367
+ related: data.related ?? [],
368
+ sources: data.sources ?? [],
369
+ created: data.created,
370
+ updated: data.updated,
371
+ body: body.trim(),
372
+ };
373
+ }
374
+
375
+ export async function saveWikiPage(
376
+ page: WikiPage,
377
+ dataDir?: string,
378
+ ): Promise<string> {
379
+ await initPipeline(dataDir);
380
+ const wikiDir = stagePath("wiki", dataDir);
381
+ const slug = slugify(page.title);
382
+ const filePath = path.join(wikiDir, `${slug}.md`);
383
+ await fs.writeFile(filePath, wikiPageToMarkdown(page), "utf-8");
384
+ return filePath;
385
+ }
386
+
387
+ export async function getWikiPage(
388
+ slug: string,
389
+ dataDir?: string,
390
+ ): Promise<WikiPage | null> {
391
+ const wikiDir = stagePath("wiki", dataDir);
392
+ const filePath = path.join(wikiDir, `${slug}.md`);
393
+ try {
394
+ const content = await fs.readFile(filePath, "utf-8");
395
+ return parseWikiPage(content);
396
+ } catch {
397
+ return null;
398
+ }
399
+ }
400
+
401
+ export async function listWikiPages(
402
+ dataDir?: string,
403
+ ): Promise<WikiPage[]> {
404
+ const wikiDir = stagePath("wiki", dataDir);
405
+ const pages: WikiPage[] = [];
406
+ let files: string[];
407
+ try {
408
+ files = await fs.readdir(wikiDir);
409
+ } catch {
410
+ return [];
411
+ }
412
+ for (const f of files) {
413
+ if (!f.endsWith(".md") || f === "index.md" || f === "log.md") continue;
414
+ const content = await fs.readFile(path.join(wikiDir, f), "utf-8");
415
+ pages.push(parseWikiPage(content));
416
+ }
417
+ return pages;
418
+ }
419
+
420
+ export async function regenerateWikiIndex(
421
+ dataDir?: string,
422
+ ): Promise<void> {
423
+ const pages = await listWikiPages(dataDir);
424
+ const grouped: Record<string, WikiPage[]> = { entity: [], concept: [], comparison: [] };
425
+ for (const page of pages) {
426
+ (grouped[page.type] ??= []).push(page);
427
+ }
428
+
429
+ const headings: Record<string, string> = {
430
+ entity: "Entities",
431
+ concept: "Concepts",
432
+ comparison: "Comparisons",
433
+ };
434
+
435
+ const lines = ["# AutoCrew Knowledge Wiki", ""];
436
+ for (const [type, heading] of Object.entries(headings)) {
437
+ const group = grouped[type] ?? [];
438
+ if (group.length === 0) continue;
439
+ lines.push(`## ${heading}`);
440
+ for (const page of group.sort((a, b) => a.title.localeCompare(b.title))) {
441
+ const slug = slugify(page.title);
442
+ const bodyLines = page.body.split("\n").filter((l) => !l.startsWith("#") && l.trim());
443
+ const summary = bodyLines[0]?.slice(0, 60) ?? "";
444
+ lines.push(`- [${page.title}](${slug}.md) — ${summary}`);
445
+ }
446
+ lines.push("");
447
+ }
448
+
449
+ const wikiDir = stagePath("wiki", dataDir);
450
+ await fs.writeFile(path.join(wikiDir, "index.md"), lines.join("\n"), "utf-8");
451
+ }
452
+
453
+ export async function appendWikiLog(
454
+ operation: string,
455
+ description: string,
456
+ dataDir?: string,
457
+ ): Promise<void> {
458
+ await initPipeline(dataDir);
459
+ const wikiDir = stagePath("wiki", dataDir);
460
+ const logPath = path.join(wikiDir, "log.md");
461
+ const timestamp = new Date().toISOString();
462
+ const entry = `- ${timestamp} [${operation}] ${description}\n`;
463
+ try {
464
+ await fs.appendFile(logPath, entry, "utf-8");
465
+ } catch {
466
+ await fs.writeFile(logPath, entry, "utf-8");
467
+ }
468
+ }
469
+
333
470
  // ─── Topic Pool ─────────────────────────────────────────────────────────────
334
471
 
335
472
  export function topicToMarkdown(topic: TopicCandidate): string {
@@ -511,7 +648,7 @@ export async function findProject(
511
648
  dataDir?: string,
512
649
  ): Promise<{ dir: string; stage: PipelineStage } | null> {
513
650
  for (const stage of PIPELINE_STAGES) {
514
- if (stage === "intel" || stage === "topics") continue;
651
+ if (stage === "intel" || stage === "topics" || stage === "wiki") continue;
515
652
  const dir = path.join(stagePath(stage, dataDir), name);
516
653
  try {
517
654
  const stat = await fs.stat(dir);
@@ -550,6 +687,9 @@ export async function startProject(
550
687
 
551
688
  const now = new Date().toISOString();
552
689
 
690
+ // Semantics: draft.md = live current working file (always latest),
691
+ // draft-v{N}.md = immutable snapshots of content that has been REPLACED by a revision.
692
+ // On initial create there are no snapshots yet — only draft.md exists.
553
693
  const meta: ProjectMeta = {
554
694
  title: topic.title,
555
695
  domain: topic.domain,
@@ -557,18 +697,13 @@ export async function startProject(
557
697
  createdAt: now,
558
698
  sourceTopic: topicFile,
559
699
  intelRefs: topic.intelRefs,
560
- versions: [{ file: "draft-v1.md", createdAt: now, note: "initial draft" }],
561
- current: "draft-v1.md",
700
+ versions: [],
701
+ current: "draft.md",
562
702
  history: [{ stage: "drafting", entered: now }],
563
703
  platforms: [],
564
704
  };
565
705
 
566
706
  await writeMeta(projectDir, meta);
567
- await fs.writeFile(
568
- path.join(projectDir, "draft-v1.md"),
569
- `# ${topic.title}\n\n`,
570
- "utf-8",
571
- );
572
707
  await fs.writeFile(
573
708
  path.join(projectDir, "draft.md"),
574
709
  `# ${topic.title}\n\n`,
@@ -617,22 +752,42 @@ export async function addDraftVersion(
617
752
  if (!found) throw new Error(`Project not found: ${name}`);
618
753
 
619
754
  const meta = await readMeta(found.dir);
620
- const versionNum = meta.versions.length + 1;
621
- const filename = `draft-v${versionNum}.md`;
755
+ const draftPath = path.join(found.dir, "draft.md");
756
+
757
+ // Archive the PREVIOUS live draft.md into an immutable snapshot
758
+ // before overwriting it with the new revision. This is the core invariant:
759
+ // draft.md = always the latest live working file
760
+ // draft-v{N}.md = frozen historical states that have been replaced
761
+ let oldContent = "";
762
+ try {
763
+ oldContent = await fs.readFile(draftPath, "utf-8");
764
+ } catch {
765
+ // No previous draft.md — treat as empty. Shouldn't happen in normal flow.
766
+ }
622
767
 
623
- await fs.writeFile(path.join(found.dir, filename), content, "utf-8");
624
- // Update draft.md to latest content
625
- await fs.writeFile(path.join(found.dir, "draft.md"), content, "utf-8");
768
+ const versionNum = meta.versions.length + 1;
769
+ const archiveFilename = `draft-v${versionNum}.md`;
770
+
771
+ // Snapshot the OLD content (only if there is old content to preserve)
772
+ if (oldContent.length > 0) {
773
+ await fs.writeFile(
774
+ path.join(found.dir, archiveFilename),
775
+ oldContent,
776
+ "utf-8",
777
+ );
778
+ meta.versions.push({
779
+ file: archiveFilename,
780
+ createdAt: new Date().toISOString(),
781
+ note,
782
+ });
783
+ }
626
784
 
627
- meta.versions.push({
628
- file: filename,
629
- createdAt: new Date().toISOString(),
630
- note,
631
- });
632
- meta.current = filename;
785
+ // Write the new content as the live draft
786
+ await fs.writeFile(draftPath, content, "utf-8");
787
+ meta.current = "draft.md";
633
788
  await writeMeta(found.dir, meta);
634
789
 
635
- return path.join(found.dir, filename);
790
+ return draftPath;
636
791
  }
637
792
 
638
793
  export async function trashProject(