omo-slim-plan 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.
@@ -0,0 +1,158 @@
1
+ #!/usr/bin/env node
2
+ // omo-slim-plan — planflow-notify CLI wrapper around planflow-providers.mjs
3
+ //
4
+ // node planflow-notify.mjs --event plan-ready --plan .plans/foo.md \
5
+ // [--title T] [--message M] [--remaining N] [--session ID] \
6
+ // [--config PATH] [--dry-run]
7
+ //
8
+ // Exit codes:
9
+ // 0 = ok / skipped / not_configured / dry-run (workflow must not break)
10
+ // 1 = unexpected crash / usage error
11
+
12
+ import { loadConfig, resolveConfigPath, send, PROVIDERS } from "./planflow-providers.mjs";
13
+
14
+ const USAGE = `planflow-notify — omo-slim-plan notification CLI
15
+
16
+ Usage:
17
+ node planflow-notify.mjs --event <event> --plan <path> [options]
18
+
19
+ Options:
20
+ --event <event> plan-ready | awaiting-review | task-done | awaiting-acceptance | custom
21
+ --plan <path> plan file path (e.g. .plans/foo.md)
22
+ --title <text> optional notification title
23
+ --message <text> optional notification message
24
+ --remaining <n> remaining todos count (optional)
25
+ --session <id> session id (optional)
26
+ --config <path> planflow.json path (default: OPencode_CONFIG or ~/.config/opencode/planflow.json)
27
+ --dry-run print resolved provider + payload, do not send
28
+ -h, --help show this help
29
+
30
+ Exit 0 on ok/skipped/not_configured/dry-run; exit 1 only on unexpected crash.
31
+ `;
32
+
33
+ function parseArgs(argv) {
34
+ const args = {
35
+ event: "",
36
+ plan: "",
37
+ title: "",
38
+ message: "",
39
+ remaining: "",
40
+ session: "",
41
+ config: "",
42
+ dryRun: false,
43
+ };
44
+ const takesValue = new Set(["--event", "--plan", "--title", "--message", "--remaining", "--session", "--config"]);
45
+ for (let i = 0; i < argv.length; i++) {
46
+ let a = argv[i];
47
+ if (a === "-h" || a === "--help") {
48
+ process.stdout.write(USAGE);
49
+ process.exit(0);
50
+ }
51
+ if (a === "--dry-run") {
52
+ args.dryRun = true;
53
+ continue;
54
+ }
55
+ if (a.startsWith("--") && a.includes("=")) {
56
+ const eq = a.indexOf("=");
57
+ const key = a.slice(0, eq);
58
+ const val = a.slice(eq + 1);
59
+ if (key === "--dry-run") continue;
60
+ if (takesValue.has(key)) {
61
+ args[key.slice(2)] = val;
62
+ continue;
63
+ }
64
+ continue;
65
+ }
66
+ if (takesValue.has(a)) {
67
+ const next = argv[i + 1];
68
+ if (next === undefined || next.startsWith("--")) {
69
+ process.stderr.write(`planflow-notify: missing value for ${a}\n`);
70
+ process.exit(1);
71
+ }
72
+ args[a.slice(2)] = next;
73
+ i++;
74
+ } else {
75
+ process.stderr.write(`planflow-notify: unknown argument ${a}\n`);
76
+ process.exit(1);
77
+ }
78
+ }
79
+ return args;
80
+ }
81
+
82
+ function coerceRemaining(v) {
83
+ if (v === "" || v === undefined || v === null) return undefined;
84
+ const n = Number(v);
85
+ return Number.isFinite(n) ? n : v;
86
+ }
87
+
88
+ async function main() {
89
+ const args = parseArgs(process.argv.slice(2));
90
+ if (!args.event || !args.plan) {
91
+ process.stderr.write(USAGE);
92
+ process.exit(1);
93
+ }
94
+
95
+ const configPath = resolveConfigPath(args.config || undefined);
96
+ const cfg = loadConfig(configPath);
97
+ const providerName = cfg?.webhook?.provider || "telegram";
98
+ const payload = {
99
+ event: args.event,
100
+ plan: args.plan,
101
+ title: args.title || `Plan ${args.event}: ${args.plan}`,
102
+ message: args.message || "",
103
+ remaining: coerceRemaining(args.remaining),
104
+ session: args.session || "",
105
+ };
106
+
107
+ if (args.dryRun) {
108
+ const safe = {
109
+ event: payload.event,
110
+ plan: payload.plan,
111
+ title: payload.title,
112
+ message: payload.message,
113
+ remaining: payload.remaining === undefined ? null : payload.remaining,
114
+ session: payload.session,
115
+ source: "omo-slim-plan",
116
+ };
117
+ process.stdout.write(
118
+ [
119
+ "planflow-notify dry-run",
120
+ `config: ${configPath}`,
121
+ `provider: ${providerName}${PROVIDERS[providerName] ? "" : " (UNKNOWN)"}`,
122
+ `events: ${JSON.stringify(cfg?.webhook?.events || {})}`,
123
+ `payload: ${JSON.stringify(safe, null, 2)}`,
124
+ ].join("\n") + "\n"
125
+ );
126
+ return 0;
127
+ }
128
+
129
+ const result = await send(cfg, payload);
130
+ const reason = result?.reason || "";
131
+ const notConfigured = !result?.ok && /_not_configured$/.test(reason);
132
+
133
+ // Print outcome to stderr (never stdout for machine use of stdout).
134
+ if (result?.ok && !result?.skipped) {
135
+ // providers.send already printed "planflow: notified ..." on success
136
+ } else if (result?.skipped) {
137
+ process.stderr.write(`planflow: skipped ${payload.event} (event disabled in config)\n`);
138
+ } else if (notConfigured) {
139
+ process.stderr.write(
140
+ `planflow: not configured (${reason}) — continuing workflow, configure ~/.config/opencode/planflow.json to enable notifications\n`
141
+ );
142
+ } else {
143
+ process.stderr.write(
144
+ `planflow: notify failed for ${payload.event} — ${reason || result?.error || "unknown"}\n`
145
+ );
146
+ }
147
+
148
+ // Exit 0 for ok / skipped / not_configured / provider errors (never break workflow).
149
+ // Exit 1 only on unexpected crash (caught below) or usage errors.
150
+ return 0;
151
+ }
152
+
153
+ main()
154
+ .then((code) => process.exit(code))
155
+ .catch((err) => {
156
+ process.stderr.write(`planflow-notify: unexpected error: ${err?.message || err}\n`);
157
+ process.exit(1);
158
+ });
@@ -0,0 +1,305 @@
1
+ // omo-slim-plan — notification providers (zero deps, Node 18+ builtins only)
2
+ // Extensible provider interface. Import from planflow.js / planflow-notify.mjs.
3
+ //
4
+ // PROVIDERS contract:
5
+ // async (config, payload) => { ok, skipped?, reason?, status?, error? }
6
+ // payload: { event, plan, title, message, remaining, session, source }
7
+
8
+ import { spawn } from "node:child_process";
9
+ import { readFileSync, existsSync } from "node:fs";
10
+ import os from "node:os";
11
+ import path from "node:path";
12
+
13
+ const TIMEOUT_MS = 10_000;
14
+
15
+ export const DEFAULT_CONFIG = {
16
+ version: 1,
17
+ plansDir: ".plans",
18
+ webhook: {
19
+ provider: "telegram",
20
+ telegram: {
21
+ botToken: "",
22
+ chatId: "",
23
+ },
24
+ generic: {
25
+ url: "",
26
+ headers: {},
27
+ },
28
+ command: {
29
+ cmd: "",
30
+ },
31
+ events: {
32
+ "plan-ready": true,
33
+ "awaiting-review": true,
34
+ "task-done": false,
35
+ "awaiting-acceptance": true,
36
+ },
37
+ titlePrefix: "[omo-slim-plan]",
38
+ },
39
+ };
40
+
41
+ function isPlainObject(v) {
42
+ return v !== null && typeof v === "object" && !Array.isArray(v);
43
+ }
44
+
45
+ /** Deep-merge override onto base. Arrays/scalars replace; plain objects merge. */
46
+ export function deepMerge(base, override) {
47
+ if (override === undefined) return base;
48
+ if (!isPlainObject(base) || !isPlainObject(override)) return override;
49
+ const out = { ...base };
50
+ for (const key of Object.keys(override)) {
51
+ out[key] = key in base ? deepMerge(base[key], override[key]) : override[key];
52
+ }
53
+ return out;
54
+ }
55
+
56
+ /**
57
+ * Resolve planflow.json path.
58
+ * Priority: explicit > OPencode_CONFIG env (config root OR .json path) > ~/.config/opencode
59
+ */
60
+ export function resolveConfigPath(explicit) {
61
+ if (explicit) return path.resolve(explicit);
62
+ const env = process.env.OPencode_CONFIG;
63
+ const base = env ? path.resolve(env) : path.join(os.homedir(), ".config", "opencode");
64
+ if (base.endsWith(".json")) return base;
65
+ return path.join(base, "planflow.json");
66
+ }
67
+
68
+ function safeReadJson(file) {
69
+ try {
70
+ return JSON.parse(readFileSync(file, "utf8"));
71
+ } catch {
72
+ return null;
73
+ }
74
+ }
75
+
76
+ /**
77
+ * Load config: defaults <- planflow.json (explicit path) <- project-local .planflow.json
78
+ * projectDir: optional project root for local override merge (cwd by default when omitted).
79
+ */
80
+ export function loadConfig(configPath, projectDir) {
81
+ const cfgPath = resolveConfigPath(configPath);
82
+ let cfg = deepMerge(DEFAULT_CONFIG, {});
83
+ const user = safeReadJson(cfgPath);
84
+ if (user) cfg = deepMerge(cfg, user);
85
+ const projRoot = projectDir || process.cwd();
86
+ const localPath = path.join(projRoot, ".planflow.json");
87
+ const local = safeReadJson(localPath);
88
+ if (local) cfg = deepMerge(cfg, local);
89
+ return cfg;
90
+ }
91
+
92
+ function payloadFor(cfg, payload) {
93
+ return {
94
+ event: String(payload?.event ?? ""),
95
+ plan: String(payload?.plan ?? ""),
96
+ title: String(payload?.title ?? ""),
97
+ message: String(payload?.message ?? ""),
98
+ remaining:
99
+ payload?.remaining === undefined || payload?.remaining === null || payload?.remaining === ""
100
+ ? null
101
+ : Number(payload.remaining),
102
+ session: payload?.session === undefined || payload?.session === null ? "" : String(payload.session),
103
+ source: "omo-slim-plan",
104
+ };
105
+ }
106
+
107
+ function formatText(cfg, p) {
108
+ const prefix = (cfg?.webhook?.titlePrefix || "[omo-slim-plan]").trim();
109
+ const rem = p.remaining === null || p.remaining === undefined ? "" : ` | remaining: ${p.remaining}`;
110
+ const lines = [
111
+ `${prefix} ${p.title || p.event}`,
112
+ p.message ? `message: ${p.message}` : "",
113
+ `plan: ${p.plan}${rem}`,
114
+ `event: ${p.event}`,
115
+ ].filter(Boolean);
116
+ return lines.join("\n");
117
+ }
118
+
119
+ async function postJson(url, body, headers, timeoutMs) {
120
+ const ctrl = new AbortController();
121
+ const timer = setTimeout(() => ctrl.abort(), timeoutMs || TIMEOUT_MS);
122
+ try {
123
+ const res = await fetch(url, {
124
+ method: "POST",
125
+ headers: { "content-type": "application/json", ...(headers || {}) },
126
+ body: JSON.stringify(body),
127
+ signal: ctrl.signal,
128
+ });
129
+ const status = res.status;
130
+ let text = "";
131
+ try {
132
+ text = await res.text();
133
+ } catch {
134
+ /* ignore body read errors */
135
+ }
136
+ if (status >= 200 && status < 300) return { ok: true, status };
137
+ return { ok: false, status, error: text.slice(0, 300) || `HTTP ${status}` };
138
+ } catch (err) {
139
+ const aborted = err?.name === "AbortError" || err?.name === "TimeoutError";
140
+ return { ok: false, error: aborted ? "timeout" : err?.message || String(err) };
141
+ } finally {
142
+ clearTimeout(timer);
143
+ }
144
+ }
145
+
146
+ /** Strip any accidental token leakage from error text. */
147
+ function redactToken(text, token) {
148
+ if (!text || !token) return text;
149
+ return String(text).split(token).join("<redacted>");
150
+ }
151
+
152
+ async function telegramProvider(cfg, p) {
153
+ const tg = cfg?.webhook?.telegram || {};
154
+ const token = tg.botToken || "";
155
+ const chatId = tg.chatId || "";
156
+ if (!token || !chatId) {
157
+ return { ok: false, reason: "telegram_not_configured" };
158
+ }
159
+ const url = `https://api.telegram.org/bot${token}/sendMessage`;
160
+ const text = formatText(cfg, p);
161
+ const res = await postJson(url, { chat_id: chatId, text }, null, TIMEOUT_MS);
162
+ if (res.ok) return { ok: true, status: res.status };
163
+ return {
164
+ ok: false,
165
+ status: res.status,
166
+ error: redactToken(res.error, token),
167
+ };
168
+ }
169
+
170
+ async function genericProvider(cfg, p) {
171
+ const g = cfg?.webhook?.generic || {};
172
+ const url = g.url || "";
173
+ if (!url) return { ok: false, reason: "generic_not_configured" };
174
+ const res = await postJson(url, p, g.headers || {}, TIMEOUT_MS);
175
+ if (res.ok) return { ok: true, status: res.status };
176
+ return { ok: false, status: res.status, error: res.error };
177
+ }
178
+
179
+ /** POSIX single-quote shell escaping for placeholder substitution values. */
180
+ function shellEscape(s) {
181
+ return `'${String(s).replace(/'/g, `'\\''`)}'`;
182
+ }
183
+
184
+ const PLACEHOLDER_RE = /\{\{\s*(event|plan|title|message|remaining)\s*\}\}/g;
185
+
186
+ function runCommand(cmd, env, timeoutMs) {
187
+ return new Promise((resolve) => {
188
+ let settled = false;
189
+ const done = (result) => {
190
+ if (settled) return;
191
+ settled = true;
192
+ resolve(result);
193
+ };
194
+ let child;
195
+ try {
196
+ child = spawn(cmd, {
197
+ shell: true,
198
+ stdio: ["ignore", "ignore", "pipe"],
199
+ env: { ...process.env, ...env },
200
+ });
201
+ } catch (err) {
202
+ done({ ok: false, error: err?.message || String(err) });
203
+ return;
204
+ }
205
+ let stderr = "";
206
+ try {
207
+ child.stderr?.on("data", (d) => {
208
+ if (stderr.length < 400) stderr += String(d);
209
+ });
210
+ } catch {
211
+ /* ignore */
212
+ }
213
+ const timer = setTimeout(() => {
214
+ try {
215
+ child.kill("SIGTERM");
216
+ } catch {
217
+ /* ignore */
218
+ }
219
+ done({ ok: false, error: "timeout" });
220
+ }, timeoutMs || TIMEOUT_MS);
221
+ child.on("error", (err) => {
222
+ clearTimeout(timer);
223
+ done({ ok: false, error: err?.message || String(err) });
224
+ });
225
+ child.on("close", (code) => {
226
+ clearTimeout(timer);
227
+ if (code === 0) done({ ok: true });
228
+ else done({ ok: false, error: `exit ${code}${stderr ? `: ${stderr.trim().slice(0, 200)}` : ""}` });
229
+ });
230
+ });
231
+ }
232
+
233
+ async function commandProvider(cfg, p) {
234
+ const c = cfg?.webhook?.command || {};
235
+ let cmd = (c.cmd || "").trim();
236
+ if (!cmd) return { ok: false, reason: "command_not_configured" };
237
+
238
+ // Safe values the caller (workflow) controls — same strings that go into env.
239
+ const safe = {
240
+ event: p.event,
241
+ plan: p.plan,
242
+ title: p.title,
243
+ message: p.message,
244
+ remaining: p.remaining === null || p.remaining === undefined ? "" : String(p.remaining),
245
+ };
246
+
247
+ // Prefer env-only (PLANFLOW_*). If cmd contains placeholders, substitute ONLY
248
+ // from these already-built safe strings, shell-escaped.
249
+ if (PLACEHOLDER_RE.test(cmd)) {
250
+ PLACEHOLDER_RE.lastIndex = 0;
251
+ cmd = cmd.replace(PLACEHOLDER_RE, (_, key) => shellEscape(safe[key]));
252
+ }
253
+
254
+ return runCommand(
255
+ cmd,
256
+ {
257
+ PLANFLOW_EVENT: safe.event,
258
+ PLANFLOW_PLAN: safe.plan,
259
+ PLANFLOW_TITLE: safe.title,
260
+ PLANFLOW_MESSAGE: safe.message,
261
+ PLANFLOW_REMAINING: safe.remaining,
262
+ },
263
+ TIMEOUT_MS
264
+ );
265
+ }
266
+
267
+ export const PROVIDERS = {
268
+ telegram: telegramProvider,
269
+ generic: genericProvider,
270
+ command: commandProvider,
271
+ };
272
+
273
+ /**
274
+ * Resolve provider from config, apply event filter, send.
275
+ * Never throws. Returns { ok, skipped?, reason?, status?, error? }.
276
+ */
277
+ export async function send(config, { event, plan, title, message, remaining, session } = {}) {
278
+ try {
279
+ const cfg = config || loadConfig();
280
+ const hook = cfg?.webhook || {};
281
+ const providerName = hook.provider || "telegram";
282
+ const provider = PROVIDERS[providerName];
283
+ const p = payloadFor(cfg, { event, plan, title, message, remaining, session });
284
+
285
+ const events = hook.events || {};
286
+ if (events[p.event] === false) {
287
+ return { ok: true, skipped: true, provider: providerName };
288
+ }
289
+ if (!provider) {
290
+ return { ok: false, reason: `unknown_provider:${providerName}` };
291
+ }
292
+ const result = await provider(cfg, p);
293
+ const out = result || { ok: false, error: "empty_result" };
294
+ if (out.ok && !out.skipped) {
295
+ try {
296
+ process.stderr.write(`planflow: notified ${p.event} via ${providerName}\n`);
297
+ } catch {
298
+ /* ignore */
299
+ }
300
+ }
301
+ return out;
302
+ } catch (err) {
303
+ return { ok: false, error: err?.message || String(err) };
304
+ }
305
+ }
@@ -0,0 +1,217 @@
1
+ // omo-slim-plan — OpenCode plugin
2
+ // Conservative planflow plugin: observes .plans/*.md status transitions and
3
+ // sends webhook notifications via ./planflow-providers.mjs.
4
+ // NEVER edits product code; NEVER fetches anything except through providers.
5
+ //
6
+ // Plugin API assumptions (best-effort, defensive):
7
+ // - Factory receives { directory, project, worktree, client, $ }
8
+ // - Returns a hooks object; hook names observed in this environment:
9
+ // "chat.headers": async (input, output) => {}
10
+ // event: async ({ event }) => {} // event.type e.g. "session.updated"
11
+ // - Unknown event payloads are tolerated; every hook body try/catches.
12
+ import { readdir, readFile, stat } from "node:fs/promises";
13
+ import path from "node:path";
14
+ import { loadConfig, resolveConfigPath, send } from "./planflow-providers.mjs";
15
+
16
+ const NOTIFY_MIN_INTERVAL_MS = 60_000;
17
+
18
+ function isPlanPath(p) {
19
+ return typeof p === "string" && p.includes(".plans/") && p.endsWith(".md");
20
+ }
21
+
22
+ function joinPlanPath(dir, p) {
23
+ return path.isAbsolute(p) ? p : path.join(dir, p);
24
+ }
25
+
26
+ /** Read plan metadata + open checkbox count. Returns null on any failure. */
27
+ async function readPlanMeta(absPath) {
28
+ try {
29
+ const raw = await readFile(absPath, "utf8");
30
+ const statusMatch = raw.match(/^\s*-\s*status:\s*(\S+)\s*$/m);
31
+ const status = statusMatch ? statusMatch[1] : "";
32
+ const openBoxes = (raw.match(/^\s*-\s*\[\s\]\s+/gm) || []).length;
33
+ return { status, openBoxes, raw };
34
+ } catch {
35
+ return null;
36
+ }
37
+ }
38
+
39
+ /** Collect candidate file-path strings from an unknown event shape (bounded depth). */
40
+ function collectPaths(node, out, depth) {
41
+ if (!node || typeof node !== "object" || depth > 4 || out.length >= 8) return out;
42
+ for (const [k, v] of Object.entries(node)) {
43
+ if (out.length >= 8) break;
44
+ if (typeof v === "string") {
45
+ const key = k.toLowerCase();
46
+ if (
47
+ key === "path" ||
48
+ key === "filepath" ||
49
+ key === "file_path" ||
50
+ key === "filename" ||
51
+ key === "file" ||
52
+ key === "file_path"
53
+ ) {
54
+ out.push(v);
55
+ } else if (v.includes(".plans/") && v.endsWith(".md")) {
56
+ out.push(v);
57
+ }
58
+ } else if (v && typeof v === "object") {
59
+ collectPaths(v, out, depth + 1);
60
+ }
61
+ }
62
+ return out;
63
+ }
64
+
65
+ function toolNameOf(event) {
66
+ const props = event?.properties || event?.data || event?.payload || {};
67
+ const cands = [event?.tool, event?.name, props?.tool, props?.name, props?.toolName];
68
+ for (const c of cands) if (typeof c === "string" && c) return c.toLowerCase();
69
+ return "";
70
+ }
71
+
72
+ function eventLooksLikeFileWrite(type, tool) {
73
+ const t = String(type || "").toLowerCase();
74
+ if (t.includes("tool") || t.includes("edit") || t.includes("write") || t.includes("patch")) return true;
75
+ return tool === "edit" || tool === "write" || tool === "patch" || tool === "apply_patch";
76
+ }
77
+
78
+ function eventLooksLikeIdle(type) {
79
+ const t = String(type || "").toLowerCase();
80
+ return (
81
+ t === "session.idle" ||
82
+ t === "idle" ||
83
+ t.includes("idle") ||
84
+ t === "message.updated" ||
85
+ t === "message.completed" ||
86
+ t === "session.updated" ||
87
+ t === "session.completed"
88
+ );
89
+ }
90
+
91
+ export const PlanflowPlugin = async ({ directory, project, worktree, client, $ }) => {
92
+ // Resolve project root; "/" is not a useful scan root.
93
+ const rawDir = directory || worktree || project?.worktree || process.cwd();
94
+ const dir = rawDir && rawDir !== "/" ? path.resolve(rawDir) : null;
95
+
96
+ /** plan abs path -> last notify timestamp */
97
+ const debounced = new Map();
98
+
99
+ async function notifyPlan(relPath, event, title, message, remaining) {
100
+ if (!dir) return;
101
+ const abs = joinPlanPath(dir, relPath);
102
+ const now = Date.now();
103
+ const last = debounced.get(abs) || 0;
104
+ if (now - last < NOTIFY_MIN_INTERVAL_MS) return;
105
+ debounced.set(abs, now);
106
+ try {
107
+ const cfg = loadConfig(resolveConfigPath(undefined), dir);
108
+ await send(cfg, {
109
+ event,
110
+ plan: relPath,
111
+ title,
112
+ message,
113
+ remaining,
114
+ session: "",
115
+ });
116
+ } catch {
117
+ // Notification must never break the host tool call.
118
+ }
119
+ }
120
+
121
+ async function handlePlanFile(absPath, relPath) {
122
+ const meta = await readPlanMeta(absPath);
123
+ if (!meta) return;
124
+ const name = path.basename(absPath);
125
+ if (meta.status === "ready") {
126
+ await notifyPlan(
127
+ relPath,
128
+ "plan-ready",
129
+ `Plan ready: ${name}`,
130
+ `Plan ${relPath} status: ready`,
131
+ meta.openBoxes
132
+ );
133
+ } else if (meta.status === "done") {
134
+ await notifyPlan(
135
+ relPath,
136
+ "awaiting-acceptance",
137
+ `Plan done: ${name}`,
138
+ `Plan ${relPath} status: done — awaiting acceptance`,
139
+ meta.openBoxes
140
+ );
141
+ }
142
+ // status in-progress / accepted / draft: notify nothing
143
+ }
144
+
145
+ /** Idle-time best-effort scan: only remind on status: ready, debounce 60s. */
146
+ async function scanPlansForReady() {
147
+ if (!dir) return;
148
+ try {
149
+ const plansDir = path.join(dir, ".plans");
150
+ const st = await stat(plansDir);
151
+ if (!st.isDirectory()) return;
152
+ const entries = await readdir(plansDir);
153
+ for (const name of entries) {
154
+ if (!name.endsWith(".md")) continue;
155
+ const abs = path.join(plansDir, name);
156
+ const rel = path.posix.join(".plans", name);
157
+ const meta = await readPlanMeta(abs);
158
+ if (!meta) continue;
159
+ if (meta.status === "ready") {
160
+ await handlePlanFile(abs, rel);
161
+ }
162
+ }
163
+ } catch {
164
+ // ignore
165
+ }
166
+ }
167
+
168
+ /** Handle edit/write tool events touching .plans/*.md */
169
+ async function handleToolEvent(event) {
170
+ if (!dir) return;
171
+ const type = event?.type || "";
172
+ const tool = toolNameOf(event);
173
+ if (!eventLooksLikeFileWrite(type, tool)) return;
174
+ const paths = collectPaths(event, [], 0);
175
+ for (const p of paths) {
176
+ if (!isPlanPath(p)) continue;
177
+ const abs = joinPlanPath(dir, p);
178
+ const rel = path.isAbsolute(p) ? path.relative(dir, abs) : p;
179
+ await handlePlanFile(abs, rel);
180
+ }
181
+ }
182
+
183
+ return {
184
+ // Defensive: some plugin hosts expose a config hook to announce capabilities.
185
+ config: async () => {
186
+ return { planflow: "installed" };
187
+ },
188
+
189
+ // Defensive: if a host exposes tool.call/edit/write hooks directly, accept them.
190
+ "tool.call": async (input) => {
191
+ try {
192
+ if (!input) return;
193
+ await handleToolEvent(input.event || input);
194
+ } catch {
195
+ /* never break */
196
+ }
197
+ },
198
+
199
+ event: async ({ event } = {}) => {
200
+ try {
201
+ if (!event) return;
202
+ const type = event.type || "";
203
+ if (eventLooksLikeFileWrite(type, toolNameOf(event))) {
204
+ await handleToolEvent(event);
205
+ }
206
+ if (eventLooksLikeIdle(type)) {
207
+ // Debounce lives inside notifyPlan (60s per plan path).
208
+ await scanPlansForReady();
209
+ }
210
+ } catch {
211
+ /* never break the host session */
212
+ }
213
+ },
214
+ };
215
+ };
216
+
217
+ export default PlanflowPlugin;