autocrew 0.1.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.
Files changed (165) hide show
  1. package/HAMLETDEER.md +562 -0
  2. package/LICENSE +21 -0
  3. package/README.md +190 -0
  4. package/README_CN.md +190 -0
  5. package/adapters/openclaw/index.ts +68 -0
  6. package/bin/autocrew.mjs +23 -0
  7. package/bin/autocrew.ts +13 -0
  8. package/openclaw.plugin.json +36 -0
  9. package/package.json +74 -0
  10. package/skills/_writing-style/SKILL.md +68 -0
  11. package/skills/audience-profiler/SKILL.md +241 -0
  12. package/skills/content-attribution/SKILL.md +128 -0
  13. package/skills/content-review/SKILL.md +257 -0
  14. package/skills/cover-generator/SKILL.md +93 -0
  15. package/skills/humanizer-zh/SKILL.md +75 -0
  16. package/skills/intel-digest/SKILL.md +57 -0
  17. package/skills/intel-pull/SKILL.md +74 -0
  18. package/skills/manage-pipeline/SKILL.md +63 -0
  19. package/skills/memory-distill/SKILL.md +89 -0
  20. package/skills/onboarding/SKILL.md +117 -0
  21. package/skills/pipeline-status/SKILL.md +51 -0
  22. package/skills/platform-rewrite/SKILL.md +125 -0
  23. package/skills/pre-publish/SKILL.md +142 -0
  24. package/skills/publish-content/SKILL.md +500 -0
  25. package/skills/remix-content/SKILL.md +77 -0
  26. package/skills/research/SKILL.md +127 -0
  27. package/skills/setup/SKILL.md +353 -0
  28. package/skills/spawn-batch-writer/SKILL.md +66 -0
  29. package/skills/spawn-planner/SKILL.md +72 -0
  30. package/skills/spawn-writer/SKILL.md +60 -0
  31. package/skills/teardown/SKILL.md +144 -0
  32. package/skills/title-craft/SKILL.md +234 -0
  33. package/skills/topic-ideas/SKILL.md +105 -0
  34. package/skills/video-timeline/SKILL.md +117 -0
  35. package/skills/write-script/SKILL.md +232 -0
  36. package/skills/xhs-cover-review/SKILL.md +48 -0
  37. package/src/adapters/browser/browser-cdp.ts +260 -0
  38. package/src/adapters/browser/browser-relay.ts +236 -0
  39. package/src/adapters/browser/gateway-client.ts +148 -0
  40. package/src/adapters/browser/types.ts +36 -0
  41. package/src/adapters/image/gemini.ts +219 -0
  42. package/src/adapters/research/tikhub.ts +19 -0
  43. package/src/cli/banner.ts +18 -0
  44. package/src/cli/bootstrap.ts +33 -0
  45. package/src/cli/commands/adapt.ts +28 -0
  46. package/src/cli/commands/advance.ts +28 -0
  47. package/src/cli/commands/assets.ts +24 -0
  48. package/src/cli/commands/audit.ts +18 -0
  49. package/src/cli/commands/contents.ts +18 -0
  50. package/src/cli/commands/cover.ts +58 -0
  51. package/src/cli/commands/events.ts +17 -0
  52. package/src/cli/commands/humanize.ts +27 -0
  53. package/src/cli/commands/index.ts +80 -0
  54. package/src/cli/commands/init.ts +28 -0
  55. package/src/cli/commands/intel.ts +55 -0
  56. package/src/cli/commands/learn.ts +34 -0
  57. package/src/cli/commands/memory.ts +18 -0
  58. package/src/cli/commands/migrate.ts +24 -0
  59. package/src/cli/commands/open.ts +21 -0
  60. package/src/cli/commands/pipelines.ts +18 -0
  61. package/src/cli/commands/pre-publish.ts +27 -0
  62. package/src/cli/commands/profile.ts +31 -0
  63. package/src/cli/commands/research.ts +36 -0
  64. package/src/cli/commands/restore.ts +28 -0
  65. package/src/cli/commands/review.ts +61 -0
  66. package/src/cli/commands/start.ts +28 -0
  67. package/src/cli/commands/status.ts +14 -0
  68. package/src/cli/commands/templates.ts +15 -0
  69. package/src/cli/commands/topics.ts +18 -0
  70. package/src/cli/commands/trash.ts +28 -0
  71. package/src/cli/commands/upgrade.ts +48 -0
  72. package/src/cli/commands/versions.ts +24 -0
  73. package/src/cli/index.ts +40 -0
  74. package/src/data/sensitive-words-builtin.json +114 -0
  75. package/src/data/source-presets.yaml +54 -0
  76. package/src/e2e.test.ts +596 -0
  77. package/src/modules/auth/cookie-manager.ts +113 -0
  78. package/src/modules/cards/template-engine.ts +74 -0
  79. package/src/modules/cards/templates/comparison-table.ts +71 -0
  80. package/src/modules/cards/templates/data-chart.ts +76 -0
  81. package/src/modules/cards/templates/flow-chart.ts +49 -0
  82. package/src/modules/cards/templates/key-points.ts +59 -0
  83. package/src/modules/cover/prompt-builder.test.ts +157 -0
  84. package/src/modules/cover/prompt-builder.ts +212 -0
  85. package/src/modules/cover/ratio-adapter.test.ts +122 -0
  86. package/src/modules/cover/ratio-adapter.ts +104 -0
  87. package/src/modules/filter/sensitive-words.test.ts +72 -0
  88. package/src/modules/filter/sensitive-words.ts +212 -0
  89. package/src/modules/humanizer/zh.test.ts +75 -0
  90. package/src/modules/humanizer/zh.ts +175 -0
  91. package/src/modules/intel/collector.ts +19 -0
  92. package/src/modules/intel/collectors/competitor.test.ts +71 -0
  93. package/src/modules/intel/collectors/competitor.ts +65 -0
  94. package/src/modules/intel/collectors/rss.test.ts +56 -0
  95. package/src/modules/intel/collectors/rss.ts +70 -0
  96. package/src/modules/intel/collectors/trends.test.ts +80 -0
  97. package/src/modules/intel/collectors/trends.ts +107 -0
  98. package/src/modules/intel/collectors/web-search.test.ts +85 -0
  99. package/src/modules/intel/collectors/web-search.ts +81 -0
  100. package/src/modules/intel/integration.test.ts +203 -0
  101. package/src/modules/intel/intel-engine.test.ts +103 -0
  102. package/src/modules/intel/intel-engine.ts +96 -0
  103. package/src/modules/intel/source-config.test.ts +113 -0
  104. package/src/modules/intel/source-config.ts +131 -0
  105. package/src/modules/learnings/diff-tracker.test.ts +144 -0
  106. package/src/modules/learnings/diff-tracker.ts +189 -0
  107. package/src/modules/learnings/rule-distiller.ts +141 -0
  108. package/src/modules/memory/distill.ts +208 -0
  109. package/src/modules/migrate/legacy-migrate.test.ts +169 -0
  110. package/src/modules/migrate/legacy-migrate.ts +229 -0
  111. package/src/modules/pro/api-client.ts +192 -0
  112. package/src/modules/pro/gate.test.ts +110 -0
  113. package/src/modules/pro/gate.ts +104 -0
  114. package/src/modules/profile/creator-profile.test.ts +178 -0
  115. package/src/modules/profile/creator-profile.ts +248 -0
  116. package/src/modules/publish/douyin-api.ts +34 -0
  117. package/src/modules/publish/wechat-mp.ts +320 -0
  118. package/src/modules/publish/xiaohongshu-api.ts +127 -0
  119. package/src/modules/research/free-engine.ts +360 -0
  120. package/src/modules/timeline/markup-generator.ts +63 -0
  121. package/src/modules/timeline/parser.ts +275 -0
  122. package/src/modules/workflow/templates.ts +124 -0
  123. package/src/modules/writing/platform-rewrite.ts +190 -0
  124. package/src/modules/writing/title-hashtag.ts +385 -0
  125. package/src/runtime/context.test.ts +97 -0
  126. package/src/runtime/context.ts +129 -0
  127. package/src/runtime/events.test.ts +83 -0
  128. package/src/runtime/events.ts +104 -0
  129. package/src/runtime/hooks.ts +174 -0
  130. package/src/runtime/tool-runner.test.ts +204 -0
  131. package/src/runtime/tool-runner.ts +282 -0
  132. package/src/runtime/workflow-engine.test.ts +455 -0
  133. package/src/runtime/workflow-engine.ts +391 -0
  134. package/src/server/index.ts +409 -0
  135. package/src/server/start.ts +39 -0
  136. package/src/storage/local-store.test.ts +304 -0
  137. package/src/storage/local-store.ts +704 -0
  138. package/src/storage/pipeline-store.test.ts +363 -0
  139. package/src/storage/pipeline-store.ts +698 -0
  140. package/src/tools/asset.ts +96 -0
  141. package/src/tools/content-save.ts +276 -0
  142. package/src/tools/cover-review.ts +221 -0
  143. package/src/tools/humanize.ts +54 -0
  144. package/src/tools/init.ts +133 -0
  145. package/src/tools/intel.ts +92 -0
  146. package/src/tools/memory.ts +76 -0
  147. package/src/tools/pipeline-ops.ts +109 -0
  148. package/src/tools/pipeline.ts +168 -0
  149. package/src/tools/pre-publish.ts +232 -0
  150. package/src/tools/publish.ts +183 -0
  151. package/src/tools/registry.ts +198 -0
  152. package/src/tools/research.ts +304 -0
  153. package/src/tools/review.ts +305 -0
  154. package/src/tools/rewrite.ts +165 -0
  155. package/src/tools/status.ts +30 -0
  156. package/src/tools/timeline.ts +234 -0
  157. package/src/tools/topic-create.ts +50 -0
  158. package/src/types/providers.ts +69 -0
  159. package/src/types/timeline.test.ts +147 -0
  160. package/src/types/timeline.ts +83 -0
  161. package/src/utils/retry.test.ts +97 -0
  162. package/src/utils/retry.ts +85 -0
  163. package/templates/AGENTS.md +99 -0
  164. package/templates/SOUL.md +31 -0
  165. package/templates/TOOLS.md +76 -0
@@ -0,0 +1,97 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import {
3
+ createContext,
4
+ getActiveContext,
5
+ updateWorkspace,
6
+ recordAudit,
7
+ resolveGeminiKey,
8
+ resolveGeminiModel,
9
+ } from "../runtime/context.js";
10
+
11
+ describe("createContext", () => {
12
+ it("creates a context with defaults", () => {
13
+ const ctx = createContext();
14
+ expect(ctx.sessionId).toMatch(/^session-/);
15
+ expect(ctx.dataDir).toContain(".autocrew");
16
+ expect(ctx.workspace).toEqual({});
17
+ expect(ctx.audit).toEqual([]);
18
+ });
19
+
20
+ it("uses data_dir from config", () => {
21
+ const ctx = createContext({ data_dir: "/tmp/test-autocrew" });
22
+ expect(ctx.dataDir).toBe("/tmp/test-autocrew");
23
+ });
24
+
25
+ it("sets active context", () => {
26
+ const ctx = createContext();
27
+ expect(getActiveContext()).toBe(ctx);
28
+ });
29
+ });
30
+
31
+ describe("updateWorkspace", () => {
32
+ it("merges partial updates", () => {
33
+ const ctx = createContext();
34
+ updateWorkspace(ctx, { activeContentId: "c1" });
35
+ expect(ctx.workspace.activeContentId).toBe("c1");
36
+
37
+ updateWorkspace(ctx, { activeTopicId: "t1" });
38
+ expect(ctx.workspace.activeContentId).toBe("c1");
39
+ expect(ctx.workspace.activeTopicId).toBe("t1");
40
+ });
41
+ });
42
+
43
+ describe("recordAudit", () => {
44
+ it("adds audit entries", () => {
45
+ const ctx = createContext();
46
+ recordAudit(ctx, {
47
+ tool: "autocrew_content",
48
+ action: "save",
49
+ timestamp: new Date().toISOString(),
50
+ durationMs: 42,
51
+ ok: true,
52
+ });
53
+ expect(ctx.audit).toHaveLength(1);
54
+ expect(ctx.audit[0].tool).toBe("autocrew_content");
55
+ });
56
+
57
+ it("caps at 100 entries", () => {
58
+ const ctx = createContext();
59
+ for (let i = 0; i < 120; i++) {
60
+ recordAudit(ctx, {
61
+ tool: `tool-${i}`,
62
+ timestamp: new Date().toISOString(),
63
+ durationMs: 1,
64
+ ok: true,
65
+ });
66
+ }
67
+ expect(ctx.audit.length).toBeLessThanOrEqual(100);
68
+ });
69
+ });
70
+
71
+ describe("resolveGeminiKey", () => {
72
+ it("returns config key first", () => {
73
+ const ctx = createContext({ gemini_api_key: "from-config" });
74
+ expect(resolveGeminiKey(ctx)).toBe("from-config");
75
+ });
76
+
77
+ it("returns undefined when no key", () => {
78
+ const ctx = createContext();
79
+ // Clear env var if set
80
+ const orig = process.env.GEMINI_API_KEY;
81
+ delete process.env.GEMINI_API_KEY;
82
+ expect(resolveGeminiKey(ctx)).toBeUndefined();
83
+ if (orig) process.env.GEMINI_API_KEY = orig;
84
+ });
85
+ });
86
+
87
+ describe("resolveGeminiModel", () => {
88
+ it("returns config model", () => {
89
+ const ctx = createContext({ gemini_model: "imagen-4" });
90
+ expect(resolveGeminiModel(ctx)).toBe("imagen-4");
91
+ });
92
+
93
+ it("defaults to auto", () => {
94
+ const ctx = createContext();
95
+ expect(resolveGeminiModel(ctx)).toBe("auto");
96
+ });
97
+ });
@@ -0,0 +1,129 @@
1
+ /**
2
+ * ToolContext — shared state across tool invocations within a session.
3
+ *
4
+ * Inspired by Claude Code's State Manager. Provides:
5
+ * - Session-scoped workspace state (activeContentId, activeTopicId)
6
+ * - Plugin config injection (dataDir, gemini key, etc.)
7
+ * - Audit trail for debugging
8
+ */
9
+ import path from "node:path";
10
+
11
+ // --- Types ---
12
+
13
+ export interface PluginConfig {
14
+ data_dir?: string;
15
+ pro_api_key?: string;
16
+ pro_api_url?: string;
17
+ gateway_url?: string;
18
+ gemini_api_key?: string;
19
+ gemini_model?: string;
20
+ [key: string]: unknown;
21
+ }
22
+
23
+ export interface WorkspaceState {
24
+ /** Currently active content id (set by content save/get/update) */
25
+ activeContentId?: string;
26
+ /** Currently active topic id (set by topic create) */
27
+ activeTopicId?: string;
28
+ /** Last tool invocation result (for chaining) */
29
+ lastToolResult?: unknown;
30
+ /** Last tool name that was executed */
31
+ lastToolName?: string;
32
+ }
33
+
34
+ export interface AuditEntry {
35
+ tool: string;
36
+ action?: string;
37
+ timestamp: string;
38
+ durationMs: number;
39
+ ok: boolean;
40
+ error?: string;
41
+ }
42
+
43
+ export interface ToolContext {
44
+ /** Unique session identifier */
45
+ sessionId: string;
46
+ /** Resolved data directory path */
47
+ dataDir: string;
48
+ /** Plugin configuration */
49
+ config: PluginConfig;
50
+ /** Cross-tool workspace state */
51
+ workspace: WorkspaceState;
52
+ /** Audit log for this session */
53
+ audit: AuditEntry[];
54
+ }
55
+
56
+ // --- Factory ---
57
+
58
+ let _activeContext: ToolContext | null = null;
59
+
60
+ function generateSessionId(): string {
61
+ return `session-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
62
+ }
63
+
64
+ function resolveDataDir(config?: PluginConfig): string {
65
+ if (config?.data_dir) return config.data_dir;
66
+ const home = process.env.HOME || process.env.USERPROFILE || "~";
67
+ return path.join(home, ".autocrew");
68
+ }
69
+
70
+ /**
71
+ * Create a new ToolContext. Called once per plugin registration or MCP session.
72
+ */
73
+ export function createContext(config?: PluginConfig): ToolContext {
74
+ const ctx: ToolContext = {
75
+ sessionId: generateSessionId(),
76
+ dataDir: resolveDataDir(config),
77
+ config: config || {},
78
+ workspace: {},
79
+ audit: [],
80
+ };
81
+ _activeContext = ctx;
82
+ return ctx;
83
+ }
84
+
85
+ /**
86
+ * Get the active context. Returns null if no context has been created.
87
+ */
88
+ export function getActiveContext(): ToolContext | null {
89
+ return _activeContext;
90
+ }
91
+
92
+ /**
93
+ * Update workspace state. Merges with existing state.
94
+ */
95
+ export function updateWorkspace(ctx: ToolContext, update: Partial<WorkspaceState>): void {
96
+ Object.assign(ctx.workspace, update);
97
+ }
98
+
99
+ /**
100
+ * Record an audit entry.
101
+ */
102
+ export function recordAudit(ctx: ToolContext, entry: AuditEntry): void {
103
+ ctx.audit.push(entry);
104
+ // Keep last 100 entries to avoid memory bloat
105
+ if (ctx.audit.length > 100) {
106
+ ctx.audit.splice(0, ctx.audit.length - 100);
107
+ }
108
+ }
109
+
110
+ /**
111
+ * Resolve the Gemini API key from config or environment.
112
+ */
113
+ export function resolveGeminiKey(ctx: ToolContext): string | undefined {
114
+ return (ctx.config.gemini_api_key as string) || process.env.GEMINI_API_KEY || undefined;
115
+ }
116
+
117
+ /**
118
+ * Resolve the Gemini model preference.
119
+ */
120
+ export function resolveGeminiModel(ctx: ToolContext): string {
121
+ return (ctx.config.gemini_model as string) || "auto";
122
+ }
123
+
124
+ /**
125
+ * Resolve the OpenClaw Gateway URL from config or environment.
126
+ */
127
+ export function resolveGatewayUrl(ctx: ToolContext): string {
128
+ return (ctx.config.gateway_url as string) || process.env.AUTOCREW_GATEWAY_URL || "http://127.0.0.1:18789";
129
+ }
@@ -0,0 +1,83 @@
1
+ import { describe, it, expect, vi, beforeEach } from "vitest";
2
+ import { EventBus, createEvent } from "../runtime/events.js";
3
+
4
+ describe("EventBus", () => {
5
+ let bus: EventBus;
6
+
7
+ beforeEach(() => {
8
+ bus = new EventBus();
9
+ });
10
+
11
+ it("emits events to matching subscribers", () => {
12
+ const handler = vi.fn();
13
+ bus.on("content:created", handler);
14
+ bus.emit(createEvent("content:created", { contentId: "c1" }));
15
+ expect(handler).toHaveBeenCalledTimes(1);
16
+ expect(handler.mock.calls[0][0].data.contentId).toBe("c1");
17
+ });
18
+
19
+ it("does not fire handler for non-matching events", () => {
20
+ const handler = vi.fn();
21
+ bus.on("content:created", handler);
22
+ bus.emit(createEvent("content:updated", { contentId: "c1" }));
23
+ expect(handler).not.toHaveBeenCalled();
24
+ });
25
+
26
+ it("wildcard * matches all events", () => {
27
+ const handler = vi.fn();
28
+ bus.on("*", handler);
29
+ bus.emit(createEvent("content:created", {}));
30
+ bus.emit(createEvent("tool:pre_execute", {}));
31
+ expect(handler).toHaveBeenCalledTimes(2);
32
+ });
33
+
34
+ it("unsubscribes by id", () => {
35
+ const handler = vi.fn();
36
+ const id = bus.on("content:created", handler);
37
+ bus.off(id);
38
+ bus.emit(createEvent("content:created", {}));
39
+ expect(handler).not.toHaveBeenCalled();
40
+ });
41
+
42
+ it("records event history", () => {
43
+ bus.emit(createEvent("content:created", { id: "1" }));
44
+ bus.emit(createEvent("content:updated", { id: "2" }));
45
+ const history = bus.getHistory();
46
+ expect(history).toHaveLength(2);
47
+ expect(history[0].type).toBe("content:created");
48
+ });
49
+
50
+ it("filters history by type", () => {
51
+ bus.emit(createEvent("content:created", {}));
52
+ bus.emit(createEvent("tool:pre_execute", {}));
53
+ bus.emit(createEvent("content:created", {}));
54
+ const filtered = bus.getHistoryByType("content:created");
55
+ expect(filtered).toHaveLength(2);
56
+ });
57
+
58
+ it("caps history at maxHistory", () => {
59
+ for (let i = 0; i < 250; i++) {
60
+ bus.emit(createEvent("content:created", { i }));
61
+ }
62
+ expect(bus.getHistory(300).length).toBeLessThanOrEqual(200);
63
+ });
64
+
65
+ it("swallows sync errors in handlers", () => {
66
+ bus.on("content:created", () => { throw new Error("boom"); });
67
+ expect(() => bus.emit(createEvent("content:created", {}))).not.toThrow();
68
+ });
69
+
70
+ it("reset clears subscriptions and history", () => {
71
+ const handler = vi.fn();
72
+ bus.on("content:created", handler);
73
+ bus.emit(createEvent("content:created", {}));
74
+ expect(handler).toHaveBeenCalledTimes(1);
75
+
76
+ bus.reset();
77
+ expect(bus.getHistory()).toHaveLength(0);
78
+
79
+ // After reset, handler should no longer fire
80
+ bus.emit(createEvent("content:created", {}));
81
+ expect(handler).toHaveBeenCalledTimes(1); // Still 1, not 2
82
+ });
83
+ });
@@ -0,0 +1,104 @@
1
+ /**
2
+ * EventBus — lightweight event system for AutoCrew workflow automation.
3
+ *
4
+ * Inspired by Claude Code's async events (FileChanged, CwdChanged).
5
+ * Tools emit events, hooks subscribe to them.
6
+ */
7
+
8
+ // --- Event Types ---
9
+
10
+ export type AutoCrewEventType =
11
+ | "tool:pre_execute"
12
+ | "tool:post_execute"
13
+ | "tool:execute_failed"
14
+ | "content:created"
15
+ | "content:updated"
16
+ | "content:status_changed"
17
+ | "content:edited"
18
+ | "topic:created"
19
+ | "cover:candidates_created"
20
+ | "cover:approved"
21
+ | "review:completed"
22
+ | "rule:distilled"
23
+ | "session:started"
24
+ | "session:ended";
25
+
26
+ export interface AutoCrewEvent {
27
+ type: AutoCrewEventType;
28
+ timestamp: string;
29
+ data: Record<string, unknown>;
30
+ }
31
+
32
+ export type EventHandler = (event: AutoCrewEvent) => void | Promise<void>;
33
+
34
+ export interface EventSubscription {
35
+ id: string;
36
+ type: AutoCrewEventType | "*";
37
+ handler: EventHandler;
38
+ }
39
+
40
+ // --- Factory ---
41
+
42
+ export function createEvent(type: AutoCrewEventType, data: Record<string, unknown> = {}): AutoCrewEvent {
43
+ return { type, timestamp: new Date().toISOString(), data };
44
+ }
45
+
46
+ // --- EventBus ---
47
+
48
+ export class EventBus {
49
+ private subscriptions: EventSubscription[] = [];
50
+ private history: AutoCrewEvent[] = [];
51
+ private maxHistory = 200;
52
+
53
+ /** Subscribe to a specific event type, or "*" for all events */
54
+ on(type: AutoCrewEventType | "*", handler: EventHandler): string {
55
+ const id = `sub-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
56
+ this.subscriptions.push({ id, type, handler });
57
+ return id;
58
+ }
59
+
60
+ /** Unsubscribe by subscription id */
61
+ off(id: string): void {
62
+ this.subscriptions = this.subscriptions.filter((s) => s.id !== id);
63
+ }
64
+
65
+ /** Emit an event to all matching subscribers */
66
+ emit(event: AutoCrewEvent): void {
67
+ this.history.push(event);
68
+ if (this.history.length > this.maxHistory) {
69
+ this.history.splice(0, this.history.length - this.maxHistory);
70
+ }
71
+
72
+ for (const sub of this.subscriptions) {
73
+ if (sub.type === "*" || sub.type === event.type) {
74
+ try {
75
+ // Fire and forget — don't block the tool pipeline
76
+ const result = sub.handler(event);
77
+ if (result instanceof Promise) {
78
+ result.catch(() => {}); // Swallow async errors in handlers
79
+ }
80
+ } catch {
81
+ // Swallow sync errors in handlers
82
+ }
83
+ }
84
+ }
85
+ }
86
+
87
+ /** Get recent event history */
88
+ getHistory(limit?: number): AutoCrewEvent[] {
89
+ const n = limit || 50;
90
+ return this.history.slice(-n);
91
+ }
92
+
93
+ /** Get history filtered by event type */
94
+ getHistoryByType(type: AutoCrewEventType, limit?: number): AutoCrewEvent[] {
95
+ const n = limit || 50;
96
+ return this.history.filter((e) => e.type === type).slice(-n);
97
+ }
98
+
99
+ /** Clear all subscriptions and history */
100
+ reset(): void {
101
+ this.subscriptions = [];
102
+ this.history = [];
103
+ }
104
+ }
@@ -0,0 +1,174 @@
1
+ /**
2
+ * Hooks — PreToolUse / PostToolUse lifecycle hooks.
3
+ *
4
+ * Inspired by Claude Code's hook system. Hooks are deterministic callbacks
5
+ * that fire at specific points in the tool execution lifecycle.
6
+ *
7
+ * Built-in hooks handle AutoCrew workflow automation:
8
+ * - content:save → auto scan sensitive words
9
+ * - content:update → auto record diff
10
+ * - cover:approve → auto generate multi-ratio (Pro)
11
+ * - publish → enforce pre-publish check
12
+ *
13
+ * Users can add custom hooks via ~/.autocrew/hooks.json
14
+ */
15
+ import fs from "node:fs/promises";
16
+ import path from "node:path";
17
+ import { type EventBus, type AutoCrewEvent, type AutoCrewEventType } from "./events.js";
18
+ import { type ToolRunner } from "./tool-runner.js";
19
+
20
+ // --- Types ---
21
+
22
+ export interface HookMatcher {
23
+ tool?: string;
24
+ action?: string;
25
+ }
26
+
27
+ export interface HookDefinition {
28
+ /** Human-readable name */
29
+ name: string;
30
+ /** Which event triggers this hook */
31
+ event: AutoCrewEventType;
32
+ /** Optional matcher to filter events */
33
+ matcher?: HookMatcher;
34
+ /** Handler: call another tool */
35
+ handler:
36
+ | { type: "tool"; tool: string; params: Record<string, unknown> }
37
+ | { type: "function"; fn: (event: AutoCrewEvent, runner: ToolRunner) => Promise<void> };
38
+ }
39
+
40
+ // --- Built-in Hooks ---
41
+
42
+ function builtinHooks(): HookDefinition[] {
43
+ return [
44
+ {
45
+ name: "auto-scan-on-save",
46
+ event: "tool:post_execute",
47
+ matcher: { tool: "autocrew_content", action: "save" },
48
+ handler: {
49
+ type: "function",
50
+ fn: async (event, runner) => {
51
+ const contentId = event.data.contentId as string;
52
+ if (!contentId) return;
53
+ // Fire-and-forget: scan for sensitive words
54
+ runner.execute("autocrew_review", { action: "scan_only", content_id: contentId }).catch(() => {});
55
+ },
56
+ },
57
+ },
58
+ {
59
+ name: "auto-diff-on-update",
60
+ event: "tool:post_execute",
61
+ matcher: { tool: "autocrew_content", action: "update" },
62
+ handler: {
63
+ type: "function",
64
+ fn: async (event, _runner) => {
65
+ // Diff tracking is already handled inside the content-save tool
66
+ // This hook is a placeholder for future enhancements
67
+ void event;
68
+ },
69
+ },
70
+ },
71
+ {
72
+ name: "enforce-pre-publish",
73
+ event: "tool:pre_execute",
74
+ matcher: { tool: "autocrew_publish" },
75
+ handler: {
76
+ type: "function",
77
+ fn: async (event, runner) => {
78
+ const contentId = event.data.contentId as string;
79
+ if (!contentId) return;
80
+ const check = await runner.execute("autocrew_pre_publish", {
81
+ action: "check",
82
+ content_id: contentId,
83
+ });
84
+ if (check.ok === false || check.passed === false) {
85
+ // The pre-publish check failed — the tool runner will still proceed,
86
+ // but the result is logged. In a stricter mode, we could throw to block.
87
+ }
88
+ },
89
+ },
90
+ },
91
+ ];
92
+ }
93
+
94
+ // --- User Hooks Loader ---
95
+
96
+ interface UserHookConfig {
97
+ hooks: Array<{
98
+ name?: string;
99
+ event: AutoCrewEventType;
100
+ matcher?: HookMatcher;
101
+ handler: { type: "tool"; tool: string; params: Record<string, unknown> };
102
+ }>;
103
+ }
104
+
105
+ async function loadUserHooks(dataDir: string): Promise<HookDefinition[]> {
106
+ const filePath = path.join(dataDir, "hooks.json");
107
+ try {
108
+ const raw = await fs.readFile(filePath, "utf-8");
109
+ const config = JSON.parse(raw) as UserHookConfig;
110
+ return (config.hooks || []).map((h, i) => ({
111
+ name: h.name || `user-hook-${i}`,
112
+ event: h.event,
113
+ matcher: h.matcher,
114
+ handler: h.handler,
115
+ }));
116
+ } catch {
117
+ return []; // No hooks file or invalid JSON — that's fine
118
+ }
119
+ }
120
+
121
+ // --- Hook Manager ---
122
+
123
+ export class HookManager {
124
+ private hooks: HookDefinition[] = [];
125
+ private runner: ToolRunner | null = null;
126
+
127
+ /** Initialize with built-in + user hooks, and wire up to EventBus */
128
+ async init(eventBus: EventBus, runner: ToolRunner, dataDir: string): Promise<void> {
129
+ this.runner = runner;
130
+
131
+ // Load hooks
132
+ const userHooks = await loadUserHooks(dataDir);
133
+ this.hooks = [...builtinHooks(), ...userHooks];
134
+
135
+ // Subscribe to events
136
+ eventBus.on("*", (event) => this.handleEvent(event));
137
+ }
138
+
139
+ /** Get all registered hooks (for debugging) */
140
+ getHooks(): HookDefinition[] {
141
+ return this.hooks;
142
+ }
143
+
144
+ private async handleEvent(event: AutoCrewEvent): Promise<void> {
145
+ if (!this.runner) return;
146
+
147
+ for (const hook of this.hooks) {
148
+ if (!this.matches(hook, event)) continue;
149
+
150
+ try {
151
+ if (hook.handler.type === "function") {
152
+ await hook.handler.fn(event, this.runner);
153
+ } else if (hook.handler.type === "tool") {
154
+ // Merge event data into handler params
155
+ const params = { ...hook.handler.params };
156
+ if (event.data.contentId && !params.content_id) {
157
+ params.content_id = event.data.contentId;
158
+ }
159
+ await this.runner.execute(hook.handler.tool, params);
160
+ }
161
+ } catch {
162
+ // Hooks should never crash the main pipeline
163
+ }
164
+ }
165
+ }
166
+
167
+ private matches(hook: HookDefinition, event: AutoCrewEvent): boolean {
168
+ if (hook.event !== event.type) return false;
169
+ if (!hook.matcher) return true;
170
+ if (hook.matcher.tool && event.data.tool !== hook.matcher.tool) return false;
171
+ if (hook.matcher.action && event.data.action !== hook.matcher.action) return false;
172
+ return true;
173
+ }
174
+ }