autocrew 0.3.4 → 0.3.5

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "autocrew",
3
- "version": "0.3.4",
3
+ "version": "0.3.5",
4
4
  "description": "One-person content studio powered by AI — from trending topics to published posts",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -14,11 +14,14 @@ export const cmd: CommandDef = {
14
14
 
15
15
  // Try pipeline project first (draft versions from meta.yaml)
16
16
  try {
17
- const pipelineResult = await runner.execute("autocrew_pipeline_ops", {
18
- action: "status",
19
- });
20
- // Search for project in pipeline stages
21
- const { getProjectMeta } = await import("../../storage/pipeline-store.js");
17
+ const { getProjectMeta, syncUntrackedChanges } = await import("../../storage/pipeline-store.js");
18
+
19
+ // Auto-detect manual edits to draft.md before reading versions
20
+ const syncResult = await syncUntrackedChanges(id);
21
+ if (syncResult.synced) {
22
+ console.log(` (detected external edit: ${syncResult.reason})`);
23
+ }
24
+
22
25
  const meta = await getProjectMeta(id);
23
26
  if (meta) {
24
27
  console.log(`Draft versions for "${meta.title}":`);
package/src/e2e.test.ts CHANGED
@@ -181,6 +181,49 @@ describe("E2E: Topic Management", () => {
181
181
  });
182
182
  });
183
183
 
184
+ describe("E2E: Draft Action (content generation)", () => {
185
+ it("draft returns writing context and instructions for a topic", async () => {
186
+ const result = await runner.execute("autocrew_content", {
187
+ action: "draft",
188
+ topic_title: "vibe-coding 实践者的真实工作流",
189
+ platform: "xiaohongshu",
190
+ });
191
+ expect(result.ok).toBe(true);
192
+ expect(result.action).toBe("draft");
193
+
194
+ // Should return topic info
195
+ expect((result.topic as any).title).toBe("vibe-coding 实践者的真实工作流");
196
+ expect((result.topic as any).platform).toBe("xiaohongshu");
197
+
198
+ // Should return creator context
199
+ expect(result.creatorContext).toBeDefined();
200
+
201
+ // Should return writing instructions with Operating System principles
202
+ const instructions = result.writingInstructions as string;
203
+ expect(instructions).toContain("EMPATHY FIRST");
204
+ expect(instructions).toContain("THEIR WORDS, NOT YOURS");
205
+ expect(instructions).toContain("SHOW THE MOVIE");
206
+ expect(instructions).toContain("TENSION IS OXYGEN");
207
+ expect(instructions).toContain("THE CREATOR IS THE PROOF");
208
+ expect(instructions).toContain("TWO-PHASE CREATION");
209
+ expect(instructions).toContain("PHASE A");
210
+ expect(instructions).toContain("PHASE B");
211
+
212
+ // Should return next action guidance
213
+ expect((result.nextAction as any).tool).toBe("autocrew_content");
214
+ expect((result.nextAction as any).action).toBe("save");
215
+ });
216
+
217
+ it("draft fails without topic_title", async () => {
218
+ const result = await runner.execute("autocrew_content", {
219
+ action: "draft",
220
+ platform: "xiaohongshu",
221
+ });
222
+ expect(result.ok).toBe(false);
223
+ expect(result.error).toContain("topic_title");
224
+ });
225
+ });
226
+
184
227
  describe("E2E: Topic → Start → Content (cross-system)", () => {
185
228
  it("topic created via autocrew_topic can be started via pipeline_ops", async () => {
186
229
  // Create topic via local-store (autocrew_topic)
@@ -30,6 +30,7 @@ import {
30
30
  listWikiPages,
31
31
  regenerateWikiIndex,
32
32
  appendWikiLog,
33
+ syncUntrackedChanges,
33
34
  type IntelItem,
34
35
  type TopicCandidate,
35
36
  type WikiPage,
@@ -471,3 +472,66 @@ describe("Wiki Storage", () => {
471
472
  expect(lines[1]).toContain("[update]");
472
473
  });
473
474
  });
475
+
476
+ // ─── Untracked Changes Detection ────────────────────────────────────────────
477
+
478
+ describe("syncUntrackedChanges", () => {
479
+ it("detects manually edited draft.md and registers version", async () => {
480
+ await saveTopic(makeTopic({ title: "手动编辑测试" }), testDir);
481
+ await startProject("手动编辑测试", testDir);
482
+
483
+ const projectName = slugify("手动编辑测试");
484
+ const projectDir = path.join(stagePath("drafting", testDir), projectName);
485
+
486
+ // Simulate a manual edit: write new content to draft.md directly
487
+ // Wait >1s so mtime exceeds the 1-second detection threshold
488
+ await new Promise((r) => setTimeout(r, 1200));
489
+ await fs.writeFile(
490
+ path.join(projectDir, "draft.md"),
491
+ "# 手动编辑测试\n\n这是手动编辑的内容,不是通过 autocrew 工具写入的。包含足够长的文本来触发版本检测。",
492
+ "utf-8",
493
+ );
494
+
495
+ // Sync should detect the change
496
+ const result = await syncUntrackedChanges(projectName, testDir);
497
+ expect(result.synced).toBe(true);
498
+ expect(result.reason).toContain("external edit");
499
+
500
+ // Meta should now have a version entry
501
+ const meta = await getProjectMeta(projectName, testDir);
502
+ expect(meta!.versions.length).toBe(1);
503
+ expect(meta!.versions[0].note).toBe("detected external edit");
504
+ });
505
+
506
+ it("creates meta.yaml when only draft.md exists", async () => {
507
+ await initPipeline(testDir);
508
+ const projectDir = path.join(stagePath("drafting", testDir), "orphan-project");
509
+ await fs.mkdir(projectDir, { recursive: true });
510
+
511
+ // Write draft.md without meta.yaml (simulates manual file copy)
512
+ await fs.writeFile(
513
+ path.join(projectDir, "draft.md"),
514
+ "# 孤儿项目\n\n这个项目的 draft.md 是手动复制进来的,没有 meta.yaml。",
515
+ "utf-8",
516
+ );
517
+
518
+ const result = await syncUntrackedChanges("orphan-project", testDir);
519
+ expect(result.synced).toBe(true);
520
+ expect(result.reason).toContain("meta.yaml created");
521
+
522
+ // meta.yaml should now exist
523
+ const meta = await getProjectMeta("orphan-project", testDir);
524
+ expect(meta).not.toBeNull();
525
+ expect(meta!.title).toBe("孤儿项目");
526
+ });
527
+
528
+ it("skips when draft.md has not changed", async () => {
529
+ await saveTopic(makeTopic({ title: "无变更测试" }), testDir);
530
+ await startProject("无变更测试", testDir);
531
+
532
+ const projectName = slugify("无变更测试");
533
+ const result = await syncUntrackedChanges(projectName, testDir);
534
+ expect(result.synced).toBe(false);
535
+ expect(result.reason).toContain("up to date");
536
+ });
537
+ });
@@ -948,6 +948,106 @@ export async function getProjectMeta(
948
948
  return readMeta(found.dir);
949
949
  }
950
950
 
951
+ /**
952
+ * Detect if draft.md has been modified outside of AutoCrew tools
953
+ * (e.g., manually edited, copied in). If so, auto-register the change
954
+ * into meta.yaml so version tracking stays accurate.
955
+ *
956
+ * Returns true if an untracked change was detected and registered.
957
+ */
958
+ export async function syncUntrackedChanges(
959
+ name: string,
960
+ dataDir?: string,
961
+ ): Promise<{ synced: boolean; reason?: string }> {
962
+ const found = await findProject(name, dataDir);
963
+ if (!found) return { synced: false, reason: "project not found" };
964
+
965
+ const draftPath = path.join(found.dir, "draft.md");
966
+ const metaPath = path.join(found.dir, "meta.yaml");
967
+
968
+ // Check if draft.md exists
969
+ let draftStat;
970
+ try {
971
+ draftStat = await fs.stat(draftPath);
972
+ } catch {
973
+ return { synced: false, reason: "draft.md not found" };
974
+ }
975
+
976
+ // Check if meta.yaml exists — if not, create one from draft.md
977
+ let meta: ProjectMeta;
978
+ try {
979
+ meta = await readMeta(found.dir);
980
+ } catch {
981
+ // meta.yaml missing — create from scratch based on draft.md
982
+ const draftContent = await fs.readFile(draftPath, "utf-8");
983
+ const titleMatch = draftContent.match(/^#\s+(.+)/m);
984
+ const now = new Date().toISOString();
985
+ meta = {
986
+ title: titleMatch?.[1]?.trim() || name,
987
+ domain: "",
988
+ format: "article",
989
+ createdAt: now,
990
+ sourceTopic: "",
991
+ intelRefs: [],
992
+ versions: [],
993
+ current: "draft.md",
994
+ history: [{ stage: found.stage, entered: now }],
995
+ platforms: [],
996
+ };
997
+ await writeMeta(found.dir, meta);
998
+ return { synced: true, reason: "meta.yaml created from existing draft.md" };
999
+ }
1000
+
1001
+ // Compare draft.md mtime against meta.yaml's updatedAt or last version timestamp
1002
+ const draftMtime = draftStat.mtime;
1003
+ const lastKnownTime = meta.versions.length > 0
1004
+ ? new Date(meta.versions[meta.versions.length - 1].createdAt)
1005
+ : meta.createdAt
1006
+ ? new Date(meta.createdAt)
1007
+ : new Date(0);
1008
+
1009
+ // If draft.md is newer than the last tracked version by more than 5 seconds,
1010
+ // there was an untracked edit
1011
+ if (draftMtime.getTime() - lastKnownTime.getTime() > 1000) {
1012
+ // Read current draft content and register as a new version
1013
+ const draftContent = await fs.readFile(draftPath, "utf-8");
1014
+
1015
+ // Only register if draft has substantial content
1016
+ if (draftContent.trim().length < 10) {
1017
+ return { synced: false, reason: "draft.md is too short to register" };
1018
+ }
1019
+
1020
+ // Archive the current state if there's something to archive
1021
+ const versionNum = meta.versions.length + 1;
1022
+ const archiveFilename = `draft-v${versionNum}.md`;
1023
+
1024
+ // If this is the first version and draft has content, just register it
1025
+ // (no need to archive — there's nothing to archive from)
1026
+ if (meta.versions.length > 0) {
1027
+ // There's existing version history — this is a new edit on top
1028
+ // Archive current to draft-vN.md before we record the new state
1029
+ // (but we don't overwrite draft.md — the user already wrote it)
1030
+ await fs.writeFile(
1031
+ path.join(found.dir, archiveFilename),
1032
+ draftContent,
1033
+ "utf-8",
1034
+ );
1035
+ }
1036
+
1037
+ meta.versions.push({
1038
+ file: meta.versions.length > 0 ? archiveFilename : "draft.md",
1039
+ createdAt: draftMtime.toISOString(),
1040
+ note: "detected external edit",
1041
+ });
1042
+ meta.current = "draft.md";
1043
+ await writeMeta(found.dir, meta);
1044
+
1045
+ return { synced: true, reason: `registered external edit as version ${versionNum}` };
1046
+ }
1047
+
1048
+ return { synced: false, reason: "draft.md is up to date with meta.yaml" };
1049
+ }
1050
+
951
1051
  export async function listProjects(
952
1052
  stage: PipelineStage,
953
1053
  dataDir?: string,
@@ -31,14 +31,17 @@ const ALL_STATUSES = [
31
31
  ] as const;
32
32
 
33
33
  export const contentSaveSchema = Type.Object({
34
- action: Type.Unsafe<"save" | "list" | "get" | "update" | "transition" | "create_variant" | "siblings" | "allowed_transitions">({
34
+ action: Type.Unsafe<"save" | "list" | "get" | "update" | "transition" | "create_variant" | "siblings" | "allowed_transitions" | "draft">({
35
35
  type: "string",
36
- enum: ["save", "list", "get", "update", "transition", "create_variant", "siblings", "allowed_transitions"],
36
+ enum: ["save", "list", "get", "update", "transition", "create_variant", "siblings", "allowed_transitions", "draft"],
37
37
  description:
38
- "Action: 'save' new content, 'list' all, 'get' by id, 'update' existing, " +
39
- "'transition' change status via state machine, 'create_variant' create platform variant from topic, " +
40
- "'siblings' list sibling content, 'allowed_transitions' show valid next statuses.",
38
+ "Action: 'draft' GENERATE a content draft (provide topic_title + platform, returns writing context + instructions), " +
39
+ "'save' persist content, 'list' all, 'get' by id, 'update' existing, " +
40
+ "'transition' change status, 'create_variant' platform variant, " +
41
+ "'siblings' list siblings, 'allowed_transitions' valid next statuses.",
41
42
  }),
43
+ topic_title: Type.Optional(Type.String({ description: "Topic title for draft action — what to write about" })),
44
+ topic_description: Type.Optional(Type.String({ description: "Topic description/angle for draft action" })),
42
45
  id: Type.Optional(Type.String({ description: "Content id (for get/update/transition/siblings/allowed_transitions)" })),
43
46
  title: Type.Optional(Type.String({ description: "Content title" })),
44
47
  body: Type.Optional(Type.String({ description: "Content body (markdown)" })),
@@ -78,20 +81,137 @@ export const contentSaveSchema = Type.Object({
78
81
  )),
79
82
  });
80
83
 
84
+ async function executeDraft(params: Record<string, unknown>) {
85
+ const dataDir = (params._dataDir as string) || undefined;
86
+ const topicTitle = (params.topic_title as string) || (params.title as string) || "";
87
+ const topicDescription = (params.topic_description as string) || "";
88
+ const platform = (params.platform as string) || "xiaohongshu";
89
+
90
+ if (!topicTitle) {
91
+ return { ok: false, error: "topic_title is required for draft action. What should the content be about?" };
92
+ }
93
+
94
+ const effectiveDataDir = dataDir || path.join(process.env.HOME ?? "~", ".autocrew");
95
+
96
+ // Load creator context
97
+ let style = "";
98
+ let profile: Record<string, unknown> = {};
99
+ let methodology = "";
100
+
101
+ try {
102
+ style = await fs.readFile(path.join(effectiveDataDir, "STYLE.md"), "utf-8");
103
+ } catch { /* no style file */ }
104
+
105
+ try {
106
+ const raw = await fs.readFile(path.join(effectiveDataDir, "creator-profile.json"), "utf-8");
107
+ profile = JSON.parse(raw);
108
+ } catch { /* no profile */ }
109
+
110
+ try {
111
+ // Load HAMLETDEER methodology summary (not the full 562 lines — key principles only)
112
+ const full = await fs.readFile(path.join(effectiveDataDir, "..", "AutoCrew", "HAMLETDEER.md"), "utf-8");
113
+ // Extract the HKRR + Clock Theory sections
114
+ const hkrrMatch = full.match(/### The HKRR Framework[\s\S]*?(?=###\s|---)/);
115
+ const clockMatch = full.match(/### The Clock Theory[\s\S]*?(?=###\s|---)/);
116
+ methodology = [hkrrMatch?.[0] || "", clockMatch?.[0] || ""].filter(Boolean).join("\n\n");
117
+ } catch {
118
+ // HAMLETDEER not found at expected path — use embedded summary
119
+ methodology = `HKRR Framework: H(Happiness), K(Knowledge), R(Resonance), R(Rhythm). Short-form: pick ONE. Long-form: combine all.
120
+ Clock Theory: Bang moments at 12:00(hook), 3:00(escalation), 6:00(payload), 9:00(climax). Every position must have energy.`;
121
+ }
122
+
123
+ // Load wiki knowledge if available
124
+ let wikiContext = "";
125
+ try {
126
+ const { listWikiPages, slugify: wikiSlugify } = await import("../storage/pipeline-store.js");
127
+ const pages = await listWikiPages(dataDir);
128
+ const relevant = pages.filter((p) =>
129
+ topicTitle.toLowerCase().includes(p.title.toLowerCase()) ||
130
+ p.aliases.some((a) => topicTitle.toLowerCase().includes(a.toLowerCase())),
131
+ );
132
+ if (relevant.length > 0) {
133
+ wikiContext = relevant.map((p) => `## Wiki: ${p.title}\n${p.body}`).join("\n\n");
134
+ }
135
+ } catch { /* wiki not available */ }
136
+
137
+ return {
138
+ ok: true,
139
+ action: "draft",
140
+ topic: { title: topicTitle, description: topicDescription, platform },
141
+ creatorContext: {
142
+ industry: profile.industry || "",
143
+ platforms: profile.platforms || [],
144
+ expressionPersona: profile.expressionPersona || "",
145
+ styleBoundaries: profile.styleBoundaries || { never: [], always: [] },
146
+ audiencePersona: profile.audiencePersona || null,
147
+ },
148
+ style: style || "(no style file — write in natural conversational Chinese)",
149
+ methodology,
150
+ wikiContext: wikiContext || "(no wiki knowledge available yet)",
151
+ writingInstructions: `
152
+ You are now writing a content draft for Chinese social media. Follow these instructions EXACTLY.
153
+
154
+ ## THE OPERATING SYSTEM — 5 Principles (override everything else)
155
+
156
+ 1. EMPATHY FIRST — You are sitting across from ONE person (the audiencePersona). They are scrolling, half-distracted. Every sentence must earn their next 3 seconds. If a sentence triggers no curiosity, recognition, surprise, or relief — delete it.
157
+
158
+ 2. THEIR WORDS, NOT YOURS — Every word must pass: would the reader say this to a friend over coffee? If no, replace it. No jargon, no abstractions, no "smart" words the reader wouldn't use.
159
+
160
+ 3. SHOW THE MOVIE — Abstractions are invisible. Stories are visible. Every claim needs a scene: a face, a number, a moment. "She built a product in 3 weeks, alone" > "AI improves efficiency."
161
+
162
+ 4. TENSION IS OXYGEN — Every paragraph must either OPEN a question or CLOSE one. No paragraph should just "sit there" as information. When energy drops, inject a question, contradiction, or surprise.
163
+
164
+ 5. THE CREATOR IS THE PROOF — The creator's own experience is the strongest evidence. Lead with "I did X" before "Company Y did Z." Vulnerability > authority.
165
+
166
+ ## TWO-PHASE CREATION
167
+
168
+ PHASE A — Build the skeleton FIRST (do not write prose yet):
169
+ - A1: Core thesis in ONE sentence (an opinion, not a topic)
170
+ - A2: Argument structure (thesis → evidence → twist → action)
171
+ - A3: Clock Theory — plan 4 bang moments (12:00 hook, 3:00 escalation, 6:00 payload, 9:00 climax)
172
+ - A4: HKRR — choose dominant dimension
173
+ - A5: Place 2-3 micro-retention techniques
174
+
175
+ Present the skeleton to the user. Wait for confirmation. Then:
176
+
177
+ PHASE B — Write the full draft based on the skeleton.
178
+ - Fear short, not long. Every case study deserves full detail.
179
+ - After each clock section, simulate the reader's reaction.
180
+ - Ground every factual claim in real sources.
181
+
182
+ ## AFTER WRITING
183
+
184
+ Call autocrew_content action="save" with the full body text, title, platform, and hypothesis.
185
+ Do NOT use the Write tool to create draft.md directly.
186
+ `,
187
+ nextAction: {
188
+ tool: "autocrew_content",
189
+ action: "save",
190
+ description: "After generating the draft, save it with action='save' providing title, body, platform, hypothesis.",
191
+ },
192
+ };
193
+ }
194
+
81
195
  export async function executeContentSave(params: Record<string, unknown>) {
82
196
  const action = (params.action as string) || "save";
83
197
  const dataDir = (params._dataDir as string) || undefined;
84
198
 
199
+ if (action === "draft") {
200
+ return executeDraft(params);
201
+ }
202
+
85
203
  if (action === "list") {
86
204
  const contents = await listContents(dataDir);
87
205
 
88
206
  // Also list pipeline drafting projects (these may not exist in legacy contents/)
89
207
  const pipelineProjects: Array<{ slug: string; title: string; stage: string; current: string }> = [];
90
208
  try {
91
- const { listProjects, getProjectMeta, stagePath } = await import("../storage/pipeline-store.js");
209
+ const { listProjects, getProjectMeta, syncUntrackedChanges } = await import("../storage/pipeline-store.js");
92
210
  for (const stage of ["drafting", "production", "published"] as const) {
93
211
  const slugs = await listProjects(stage, dataDir);
94
212
  for (const slug of slugs) {
213
+ // Auto-detect manual edits before reading meta
214
+ try { await syncUntrackedChanges(slug, dataDir); } catch { /* ignore */ }
95
215
  const meta = await getProjectMeta(slug, dataDir);
96
216
  if (meta) {
97
217
  pipelineProjects.push({
@@ -47,11 +47,11 @@ export function registerAllTools(runner: ToolRunner): void {
47
47
  name: "autocrew_content",
48
48
  label: "AutoCrew Content",
49
49
  description:
50
- "Content creation and lifecycle management. THIS IS THE PRIMARY CONTENT CREATION TOOL. " +
51
- "To create content: use action='save' with title and body (the full draft text). " +
52
- "The tool handles pipeline project creation, version tracking, and auto-humanization. " +
53
- "Workflow: 1) Research with autocrew_intel, 2) Write the full draft body, " +
54
- "3) Save with autocrew_content action='save' (title, body, platform, hypothesis, tags). " +
50
+ "Content creation and lifecycle management. " +
51
+ "CONTENT CREATION WORKFLOW: " +
52
+ "Step 1: action='draft' with topic_title + platform → returns creator style, methodology, writing instructions, and wiki knowledge. " +
53
+ "Step 2: Generate the draft body following the returned instructions (two-phase: skeleton first, then prose). " +
54
+ "Step 3: action='save' with title + body + platform + hypothesis → persists to pipeline with version tracking + auto-humanization. " +
55
55
  "Other actions: list, get, update, transition, create_variant, siblings, allowed_transitions.",
56
56
  parameters: contentSaveSchema,
57
57
  execute: executeContentSave,