@doquflow/cli 0.4.5 → 0.5.1
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/dist/commands/init.js +174 -3
- package/dist/commands/sync.js +361 -0
- package/dist/commands/watch-stop.js +223 -0
- package/dist/commands/watch.js +587 -0
- package/dist/index.js +92 -8
- package/package.json +2 -2
|
@@ -0,0 +1,587 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* docuflow watch
|
|
4
|
+
*
|
|
5
|
+
* Auto-sync daemon — watches for changes and drives Claude / Copilot / Codex
|
|
6
|
+
* to keep the wiki in sync without human intervention.
|
|
7
|
+
*
|
|
8
|
+
* ┌─── TRIGGER LAYER ──────────────────────────────────────────────────────┐
|
|
9
|
+
* │ ① SOURCE WATCHER .docuflow/sources/ changed → direct ingest │
|
|
10
|
+
* │ ② CODE WATCHER project .ts/.py/etc changed → AI drives MCP tools │
|
|
11
|
+
* │ ③ LINT SCHEDULER every N hours → AI runs lint_wiki, reports issues │
|
|
12
|
+
* └────────────────────────────────────────────────────────────────────────┘
|
|
13
|
+
*
|
|
14
|
+
* ┌─── AI BRIDGE (--ai flag) ──────────────────────────────────────────────┐
|
|
15
|
+
* │ Priority 1: copilot CLI (@github/copilot) — DIRECTLY calls MCP tools │
|
|
16
|
+
* │ Priority 2: claude CLI (Claude Code) — DIRECTLY calls MCP tools │
|
|
17
|
+
* │ Priority 3: codex CLI (OpenAI Codex) — generates doc, then ingest│
|
|
18
|
+
* │ Priority 4: Anthropic API (ANTHROPIC_API_KEY) — generates doc + ingest │
|
|
19
|
+
* │ None: sources-only mode (no AI, direct ingest only) │
|
|
20
|
+
* └────────────────────────────────────────────────────────────────────────┘
|
|
21
|
+
*
|
|
22
|
+
* KEY DIFFERENCE — Copilot & Claude bridge:
|
|
23
|
+
* They DON'T just generate text. They directly call DocuFlow MCP tools
|
|
24
|
+
* (ingest_source, update_index, lint_wiki) because DocuFlow is already
|
|
25
|
+
* registered in ~/.copilot/mcp-config.json and Claude's MCP config.
|
|
26
|
+
* Result: richer, autonomous wiki maintenance with zero extra steps.
|
|
27
|
+
*
|
|
28
|
+
* Usage:
|
|
29
|
+
* docuflow watch # sources/ only, no AI
|
|
30
|
+
* docuflow watch --ai # full: auto-detect best AI bridge
|
|
31
|
+
* docuflow watch --ai --copilot # force copilot CLI (gh @github/copilot)
|
|
32
|
+
* docuflow watch --ai --claude # force Claude Code CLI
|
|
33
|
+
* docuflow watch --ai --codex # force Codex CLI
|
|
34
|
+
* docuflow watch --lint-interval 6 # lint every 6h (default: 24)
|
|
35
|
+
* docuflow watch --code-ext ts,py # watch only these extensions
|
|
36
|
+
*/
|
|
37
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
38
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
39
|
+
};
|
|
40
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
41
|
+
exports.detectBridge = detectBridge;
|
|
42
|
+
exports.getPidFilePath = getPidFilePath;
|
|
43
|
+
exports.writePidFile = writePidFile;
|
|
44
|
+
exports.removePidFile = removePidFile;
|
|
45
|
+
exports.readPidFile = readPidFile;
|
|
46
|
+
exports.isProcessAlive = isProcessAlive;
|
|
47
|
+
exports.run = run;
|
|
48
|
+
const node_fs_1 = __importDefault(require("node:fs"));
|
|
49
|
+
const promises_1 = __importDefault(require("node:fs/promises"));
|
|
50
|
+
const node_path_1 = __importDefault(require("node:path"));
|
|
51
|
+
const node_child_process_1 = require("node:child_process");
|
|
52
|
+
// ─── Dynamic server tool loader ────────────────────────────────────────────────
|
|
53
|
+
function loadServerTool(toolFile) {
|
|
54
|
+
const candidates = [
|
|
55
|
+
() => require(`@doquflow/server/dist/tools/${toolFile}`),
|
|
56
|
+
() => require(node_path_1.default.resolve(__dirname, "../../../server/dist/tools", toolFile)),
|
|
57
|
+
() => require(node_path_1.default.resolve(__dirname, "../../server/dist/tools", toolFile)),
|
|
58
|
+
];
|
|
59
|
+
for (const attempt of candidates) {
|
|
60
|
+
try {
|
|
61
|
+
return attempt();
|
|
62
|
+
}
|
|
63
|
+
catch { }
|
|
64
|
+
}
|
|
65
|
+
throw new Error(`Cannot load server tool "${toolFile}". Run "npm run build" first.`);
|
|
66
|
+
}
|
|
67
|
+
// ─── Colour helpers ─────────────────────────────────────────────────────────
|
|
68
|
+
const c = {
|
|
69
|
+
green: (s) => `\x1b[32m${s}\x1b[0m`,
|
|
70
|
+
yellow: (s) => `\x1b[33m${s}\x1b[0m`,
|
|
71
|
+
red: (s) => `\x1b[31m${s}\x1b[0m`,
|
|
72
|
+
cyan: (s) => `\x1b[36m${s}\x1b[0m`,
|
|
73
|
+
dim: (s) => `\x1b[2m${s}\x1b[0m`,
|
|
74
|
+
bold: (s) => `\x1b[1m${s}\x1b[0m`,
|
|
75
|
+
};
|
|
76
|
+
const ts = () => c.dim(new Date().toLocaleTimeString());
|
|
77
|
+
const log = (icon, msg) => console.log(`${ts()} ${icon} ${msg}`);
|
|
78
|
+
function isInPath(cmd) {
|
|
79
|
+
return (0, node_child_process_1.spawnSync)("which", [cmd], { encoding: "utf8" }).status === 0;
|
|
80
|
+
}
|
|
81
|
+
function detectBridge(opts) {
|
|
82
|
+
if (!opts.useAI)
|
|
83
|
+
return "none";
|
|
84
|
+
if (opts.forceCopilot) {
|
|
85
|
+
if (isInPath("copilot"))
|
|
86
|
+
return "copilot";
|
|
87
|
+
console.warn(c.yellow(" ⚠ copilot not found — checking other bridges"));
|
|
88
|
+
}
|
|
89
|
+
if (opts.forceClaude) {
|
|
90
|
+
if (isInPath("claude"))
|
|
91
|
+
return "claude";
|
|
92
|
+
console.warn(c.yellow(" ⚠ claude not found — checking other bridges"));
|
|
93
|
+
}
|
|
94
|
+
if (opts.forceCodex) {
|
|
95
|
+
if (isInPath("codex"))
|
|
96
|
+
return "codex";
|
|
97
|
+
console.warn(c.yellow(" ⚠ codex not found — checking other bridges"));
|
|
98
|
+
}
|
|
99
|
+
// Auto-detect priority: copilot > claude > codex > api
|
|
100
|
+
if (isInPath("copilot"))
|
|
101
|
+
return "copilot";
|
|
102
|
+
if (isInPath("claude"))
|
|
103
|
+
return "claude";
|
|
104
|
+
if (isInPath("codex"))
|
|
105
|
+
return "codex";
|
|
106
|
+
if (process.env.ANTHROPIC_API_KEY)
|
|
107
|
+
return "api";
|
|
108
|
+
console.warn(c.yellow(" ⚠ No AI bridge detected.\n" +
|
|
109
|
+
" Install @github/copilot, Claude Code CLI, Codex CLI,\n" +
|
|
110
|
+
" or set ANTHROPIC_API_KEY.\n" +
|
|
111
|
+
" Running in sources-only mode."));
|
|
112
|
+
return "none";
|
|
113
|
+
}
|
|
114
|
+
// ─── Copilot bridge (DIRECT MCP tool calling) ────────────────────────────────
|
|
115
|
+
//
|
|
116
|
+
// Since docuflow is already registered in ~/.copilot/mcp-config.json,
|
|
117
|
+
// Copilot CLI can directly call list_wiki, ingest_source, update_index,
|
|
118
|
+
// lint_wiki etc. without any intermediate step.
|
|
119
|
+
//
|
|
120
|
+
function buildCopilotSyncPrompt(projectPath, changedFiles) {
|
|
121
|
+
const fileList = changedFiles.length > 0
|
|
122
|
+
? changedFiles.slice(0, 10).map(f => `- ${node_path_1.default.relative(projectPath, f)}`).join("\n")
|
|
123
|
+
: "(triggered by schedule or manual run)";
|
|
124
|
+
return [
|
|
125
|
+
`You are the DocuFlow wiki maintainer for the project at: ${projectPath}`,
|
|
126
|
+
``,
|
|
127
|
+
`The following source files recently changed:`,
|
|
128
|
+
fileList,
|
|
129
|
+
``,
|
|
130
|
+
`Use the docuflow MCP tools to perform these tasks IN ORDER:`,
|
|
131
|
+
`1. Call list_wiki({ project_path: "${projectPath}" }) — note current page count`,
|
|
132
|
+
`2. For each file in .docuflow/sources/ that relates to the changed files above,`,
|
|
133
|
+
` call ingest_source({ project_path: "${projectPath}", source_filename: "<file>" })`,
|
|
134
|
+
` If no sources relate to the changes, ingest all source files.`,
|
|
135
|
+
`3. Call update_index({ project_path: "${projectPath}" }) — rebuild the catalog`,
|
|
136
|
+
`4. Call lint_wiki({ project_path: "${projectPath}", check_type: "all" }) — health check`,
|
|
137
|
+
`5. Report: pages before/after, health score, and any HIGH severity issues found.`,
|
|
138
|
+
``,
|
|
139
|
+
`Be concise. Do not explain each step — just do it and report results.`,
|
|
140
|
+
].join("\n");
|
|
141
|
+
}
|
|
142
|
+
function buildCopilotLintPrompt(projectPath) {
|
|
143
|
+
return [
|
|
144
|
+
`You are the DocuFlow wiki maintainer for the project at: ${projectPath}`,
|
|
145
|
+
``,
|
|
146
|
+
`Run a scheduled wiki health check using docuflow MCP tools:`,
|
|
147
|
+
`1. Call lint_wiki({ project_path: "${projectPath}", check_type: "all" })`,
|
|
148
|
+
`2. If health_score < 80, call lint_wiki with check_type="orphans" and "stale"`,
|
|
149
|
+
`3. Report: health score, issue counts by type, top 3 recommendations`,
|
|
150
|
+
``,
|
|
151
|
+
`Keep it brief.`,
|
|
152
|
+
].join("\n");
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* Run Copilot CLI non-interactively.
|
|
156
|
+
* Copilot directly calls DocuFlow MCP tools — no intermediate doc generation needed.
|
|
157
|
+
* Returns the assistant's final text response, or null on failure.
|
|
158
|
+
*/
|
|
159
|
+
function runCopilotCLI(prompt, timeoutMs = 120_000) {
|
|
160
|
+
const result = (0, node_child_process_1.spawnSync)("copilot", [
|
|
161
|
+
"--prompt", prompt,
|
|
162
|
+
"--allow-all-tools",
|
|
163
|
+
"--allow-all-paths",
|
|
164
|
+
"--no-ask-user",
|
|
165
|
+
"--output-format", "json",
|
|
166
|
+
], { encoding: "utf8", timeout: timeoutMs });
|
|
167
|
+
if (result.error || result.status !== 0) {
|
|
168
|
+
const err = result.stderr?.slice(0, 200) ?? String(result.error ?? "unknown");
|
|
169
|
+
log("❌", c.red(`Copilot error: ${err}`));
|
|
170
|
+
return null;
|
|
171
|
+
}
|
|
172
|
+
// Parse JSONL stream — extract the final assistant.message content
|
|
173
|
+
let lastMessage = null;
|
|
174
|
+
for (const line of (result.stdout ?? "").split("\n")) {
|
|
175
|
+
if (!line.trim())
|
|
176
|
+
continue;
|
|
177
|
+
try {
|
|
178
|
+
const obj = JSON.parse(line);
|
|
179
|
+
if (obj.type === "assistant.message" && obj.data?.content) {
|
|
180
|
+
lastMessage = obj.data.content;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
catch { }
|
|
184
|
+
}
|
|
185
|
+
return lastMessage;
|
|
186
|
+
}
|
|
187
|
+
// ─── Claude bridge (DIRECT MCP tool calling) ─────────────────────────────────
|
|
188
|
+
//
|
|
189
|
+
// Since docuflow is registered in Claude's MCP config (via claude_desktop_config.json
|
|
190
|
+
// or CLAUDE.md), Claude Code CLI can also directly call DocuFlow MCP tools.
|
|
191
|
+
//
|
|
192
|
+
function buildClaudeSyncPrompt(projectPath, changedFiles) {
|
|
193
|
+
// Same intent as Copilot but Claude uses its own MCP config
|
|
194
|
+
return buildCopilotSyncPrompt(projectPath, changedFiles);
|
|
195
|
+
}
|
|
196
|
+
// Module-level flag: set once in run() before any watchers fire.
|
|
197
|
+
// Controls whether --dangerously-skip-permissions is passed to Claude CLI.
|
|
198
|
+
// Requires explicit --allow-dangerous-permissions opt-in from the user.
|
|
199
|
+
let _allowDangerousPermissions = false;
|
|
200
|
+
function runClaudeCLI(prompt, timeoutMs = 120_000) {
|
|
201
|
+
let serverBin;
|
|
202
|
+
try {
|
|
203
|
+
serverBin = require.resolve("@doquflow/server/dist/index.js");
|
|
204
|
+
}
|
|
205
|
+
catch {
|
|
206
|
+
serverBin = node_path_1.default.resolve(__dirname, "../../server/dist/index.js");
|
|
207
|
+
}
|
|
208
|
+
const mcpConfig = JSON.stringify({
|
|
209
|
+
mcpServers: { docuflow: { type: "stdio", command: process.execPath, args: [serverBin] } }
|
|
210
|
+
});
|
|
211
|
+
const claudeArgs = ["--print", "--mcp-config", mcpConfig];
|
|
212
|
+
if (_allowDangerousPermissions) {
|
|
213
|
+
claudeArgs.splice(1, 0, "--dangerously-skip-permissions");
|
|
214
|
+
}
|
|
215
|
+
const result = (0, node_child_process_1.spawnSync)("claude", claudeArgs, { input: prompt, encoding: "utf8", timeout: timeoutMs, env: { ...process.env } });
|
|
216
|
+
if (result.error || result.status !== 0) {
|
|
217
|
+
log("❌", c.red(`Claude CLI error: ${result.stderr?.slice(0, 200) ?? "unknown"}`));
|
|
218
|
+
return null;
|
|
219
|
+
}
|
|
220
|
+
const out = result.stdout?.trim() ?? "";
|
|
221
|
+
if (!out || out.includes("Invalid API key") || out.includes("authentication"))
|
|
222
|
+
return null;
|
|
223
|
+
return out || null;
|
|
224
|
+
}
|
|
225
|
+
// ─── Codex / API bridges (generate doc → save → ingest fallback) ─────────────
|
|
226
|
+
async function runCodexCLI(prompt, timeoutMs = 90_000) {
|
|
227
|
+
const result = (0, node_child_process_1.spawnSync)("codex", [prompt], { encoding: "utf8", timeout: timeoutMs });
|
|
228
|
+
return result.status === 0 ? result.stdout?.trim() ?? null : null;
|
|
229
|
+
}
|
|
230
|
+
async function callAnthropicAPI(prompt) {
|
|
231
|
+
const apiKey = process.env.ANTHROPIC_API_KEY;
|
|
232
|
+
if (!apiKey)
|
|
233
|
+
return null;
|
|
234
|
+
const https = require("https");
|
|
235
|
+
const body = JSON.stringify({
|
|
236
|
+
model: "claude-3-5-haiku-20241022",
|
|
237
|
+
max_tokens: 1024,
|
|
238
|
+
messages: [{ role: "user", content: prompt }],
|
|
239
|
+
});
|
|
240
|
+
return new Promise((resolve) => {
|
|
241
|
+
const req = https.request({
|
|
242
|
+
hostname: "api.anthropic.com",
|
|
243
|
+
path: "/v1/messages",
|
|
244
|
+
method: "POST",
|
|
245
|
+
headers: {
|
|
246
|
+
"Content-Type": "application/json",
|
|
247
|
+
"x-api-key": apiKey,
|
|
248
|
+
"anthropic-version": "2023-06-01",
|
|
249
|
+
"Content-Length": Buffer.byteLength(body),
|
|
250
|
+
},
|
|
251
|
+
}, (res) => {
|
|
252
|
+
let data = "";
|
|
253
|
+
res.on("data", (chunk) => (data += chunk));
|
|
254
|
+
res.on("end", () => {
|
|
255
|
+
try {
|
|
256
|
+
resolve(JSON.parse(data)?.content?.[0]?.text ?? null);
|
|
257
|
+
}
|
|
258
|
+
catch {
|
|
259
|
+
resolve(null);
|
|
260
|
+
}
|
|
261
|
+
});
|
|
262
|
+
});
|
|
263
|
+
req.on("error", () => resolve(null));
|
|
264
|
+
req.setTimeout(90_000, () => { req.destroy(); resolve(null); });
|
|
265
|
+
req.write(body);
|
|
266
|
+
req.end();
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
// ─── Doc-generation prompt (for codex/api fallback that can't call MCP tools) ─
|
|
270
|
+
function buildDocGenPrompt(projectPath, changedFiles) {
|
|
271
|
+
return [
|
|
272
|
+
`You are maintaining documentation for a software project.`,
|
|
273
|
+
`These files just changed:`,
|
|
274
|
+
changedFiles.slice(0, 5).map(f => `- ${node_path_1.default.relative(projectPath, f)}`).join("\n"),
|
|
275
|
+
``,
|
|
276
|
+
`Write a concise markdown document (200-500 words) capturing:`,
|
|
277
|
+
`1. What these files do / what changed`,
|
|
278
|
+
`2. Key classes, functions, or concepts`,
|
|
279
|
+
`3. Dependencies or config references`,
|
|
280
|
+
``,
|
|
281
|
+
`Markdown only. Start with a # heading. No preamble.`,
|
|
282
|
+
].join("\n");
|
|
283
|
+
}
|
|
284
|
+
// ─── Core sync dispatcher ─────────────────────────────────────────────────────
|
|
285
|
+
async function syncWithAI(projectPath, changedFiles, bridge) {
|
|
286
|
+
const bridgeLabel = bridge === "copilot" ? "Copilot" : bridge === "claude" ? "Claude" : bridge === "codex" ? "Codex" : "API";
|
|
287
|
+
log("🤖", `${changedFiles.length} file(s) changed — asking ${c.cyan(bridgeLabel)} to update wiki...`);
|
|
288
|
+
// Copilot and Claude: DIRECT MCP tool calling
|
|
289
|
+
if (bridge === "copilot") {
|
|
290
|
+
const prompt = buildCopilotSyncPrompt(projectPath, changedFiles);
|
|
291
|
+
const result = runCopilotCLI(prompt);
|
|
292
|
+
if (result) {
|
|
293
|
+
log("✅", c.green(`Copilot updated wiki directly via MCP tools`));
|
|
294
|
+
console.log(c.dim(` ${result.replace(/\n/g, "\n ")}`));
|
|
295
|
+
}
|
|
296
|
+
else {
|
|
297
|
+
log("⚠️ ", c.yellow("Copilot returned no result — falling back to direct ingest"));
|
|
298
|
+
await directIngestAll(projectPath);
|
|
299
|
+
}
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
302
|
+
if (bridge === "claude") {
|
|
303
|
+
const prompt = buildClaudeSyncPrompt(projectPath, changedFiles);
|
|
304
|
+
const result = runClaudeCLI(prompt);
|
|
305
|
+
if (result) {
|
|
306
|
+
log("✅", c.green(`Claude updated wiki directly via MCP tools`));
|
|
307
|
+
console.log(c.dim(` ${result.replace(/\n/g, "\n ")}`));
|
|
308
|
+
}
|
|
309
|
+
else {
|
|
310
|
+
log("⚠️ ", c.yellow("Claude returned no result — falling back to direct ingest"));
|
|
311
|
+
await directIngestAll(projectPath);
|
|
312
|
+
}
|
|
313
|
+
return;
|
|
314
|
+
}
|
|
315
|
+
// Codex and API: generate doc text → save as source → ingest
|
|
316
|
+
let docContent = null;
|
|
317
|
+
const docPrompt = buildDocGenPrompt(projectPath, changedFiles);
|
|
318
|
+
if (bridge === "codex") {
|
|
319
|
+
docContent = await runCodexCLI(docPrompt);
|
|
320
|
+
}
|
|
321
|
+
else if (bridge === "api") {
|
|
322
|
+
docContent = await callAnthropicAPI(docPrompt);
|
|
323
|
+
}
|
|
324
|
+
if (!docContent) {
|
|
325
|
+
log("⚠️ ", c.yellow("AI returned no content — falling back to direct ingest"));
|
|
326
|
+
await directIngestAll(projectPath);
|
|
327
|
+
return;
|
|
328
|
+
}
|
|
329
|
+
// Save AI-generated doc to sources/
|
|
330
|
+
const sourcesDir = node_path_1.default.join(projectPath, ".docuflow", "sources");
|
|
331
|
+
await promises_1.default.mkdir(sourcesDir, { recursive: true });
|
|
332
|
+
const timestamp = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19);
|
|
333
|
+
const autoFilename = `auto_sync_${timestamp}.md`;
|
|
334
|
+
await promises_1.default.writeFile(node_path_1.default.join(sourcesDir, autoFilename), docContent, "utf8");
|
|
335
|
+
log("💾", `AI doc saved → ${c.cyan(autoFilename)}`);
|
|
336
|
+
await directIngest(projectPath, autoFilename);
|
|
337
|
+
}
|
|
338
|
+
async function scheduledLintWithAI(projectPath, bridge) {
|
|
339
|
+
if (bridge === "copilot") {
|
|
340
|
+
log("🔍", `Running scheduled lint via ${c.cyan("Copilot")} (direct MCP call)...`);
|
|
341
|
+
const result = runCopilotCLI(buildCopilotLintPrompt(projectPath));
|
|
342
|
+
if (result) {
|
|
343
|
+
console.log(c.dim(` ${result.replace(/\n/g, "\n ")}`));
|
|
344
|
+
}
|
|
345
|
+
return;
|
|
346
|
+
}
|
|
347
|
+
if (bridge === "claude") {
|
|
348
|
+
log("🔍", `Running scheduled lint via ${c.cyan("Claude")} (direct MCP call)...`);
|
|
349
|
+
const result = runClaudeCLI(buildCopilotLintPrompt(projectPath));
|
|
350
|
+
if (result) {
|
|
351
|
+
console.log(c.dim(` ${result.replace(/\n/g, "\n ")}`));
|
|
352
|
+
}
|
|
353
|
+
return;
|
|
354
|
+
}
|
|
355
|
+
// Fallback: call lint tool directly
|
|
356
|
+
await directLint(projectPath);
|
|
357
|
+
}
|
|
358
|
+
// ─── Direct tool calls (no AI) ───────────────────────────────────────────────
|
|
359
|
+
async function directIngest(projectPath, filename) {
|
|
360
|
+
const { ingestSource } = loadServerTool("ingest-source");
|
|
361
|
+
const { updateIndex } = loadServerTool("update-index");
|
|
362
|
+
log("📥", `Ingesting ${c.cyan(filename)}...`);
|
|
363
|
+
const result = await ingestSource({ project_path: projectPath, source_filename: filename });
|
|
364
|
+
const created = result.pages_created?.length ?? 0;
|
|
365
|
+
log(created > 0 ? "✅" : "⚠️ ", created > 0
|
|
366
|
+
? c.green(`${created} wiki pages created/updated`)
|
|
367
|
+
: c.yellow("No pages created — check source content"));
|
|
368
|
+
await updateIndex({ project_path: projectPath });
|
|
369
|
+
log("📋", "Index rebuilt");
|
|
370
|
+
}
|
|
371
|
+
async function directIngestAll(projectPath) {
|
|
372
|
+
const sourcesDir = node_path_1.default.join(projectPath, ".docuflow", "sources");
|
|
373
|
+
try {
|
|
374
|
+
const files = (await promises_1.default.readdir(sourcesDir)).filter(f => f.endsWith(".md") && !f.startsWith("auto_sync_"));
|
|
375
|
+
for (const f of files) {
|
|
376
|
+
await directIngest(projectPath, f);
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
catch { }
|
|
380
|
+
}
|
|
381
|
+
async function directLint(projectPath) {
|
|
382
|
+
const { lintWiki } = loadServerTool("lint-wiki");
|
|
383
|
+
log("🔍", "Running scheduled lint check...");
|
|
384
|
+
const result = await lintWiki({ project_path: projectPath, check_type: "all" });
|
|
385
|
+
const score = result.health_score ?? 0;
|
|
386
|
+
const scoreLabel = score >= 90 ? c.green(`${score}/100`) : score >= 70 ? c.yellow(`${score}/100`) : c.red(`${score}/100`);
|
|
387
|
+
log("📊", `Health score: ${scoreLabel}`);
|
|
388
|
+
if (result.issues_found?.length > 0) {
|
|
389
|
+
const high = result.issues_found.filter((i) => i.severity === "high").length;
|
|
390
|
+
const med = result.issues_found.filter((i) => i.severity === "medium").length;
|
|
391
|
+
log("⚠️ ", `Issues: 🔴 ${high} high 🟡 ${med} medium`);
|
|
392
|
+
for (const rec of result.recommendations?.slice(0, 3) ?? []) {
|
|
393
|
+
console.log(c.dim(` → ${rec}`));
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
// ─── Debounce helper ─────────────────────────────────────────────────────────
|
|
398
|
+
function debounce(fn, ms) {
|
|
399
|
+
let timer;
|
|
400
|
+
return ((...args) => {
|
|
401
|
+
clearTimeout(timer);
|
|
402
|
+
timer = setTimeout(() => fn(...args), ms);
|
|
403
|
+
});
|
|
404
|
+
}
|
|
405
|
+
// ─── File extension filter ───────────────────────────────────────────────────
|
|
406
|
+
const DEFAULT_CODE_EXTS = new Set([
|
|
407
|
+
".ts", ".tsx", ".js", ".jsx", ".mjs",
|
|
408
|
+
".py", ".go", ".rb", ".java", ".cs",
|
|
409
|
+
".php", ".rs", ".kt", ".swift", ".vue",
|
|
410
|
+
]);
|
|
411
|
+
function getPidFilePath(projectPath) {
|
|
412
|
+
return node_path_1.default.join(projectPath, ".docuflow", "watch.pid");
|
|
413
|
+
}
|
|
414
|
+
async function writePidFile(projectPath, data) {
|
|
415
|
+
const pidFile = getPidFilePath(projectPath);
|
|
416
|
+
await promises_1.default.writeFile(pidFile, JSON.stringify(data, null, 2), "utf8");
|
|
417
|
+
}
|
|
418
|
+
async function removePidFile(projectPath) {
|
|
419
|
+
try {
|
|
420
|
+
await promises_1.default.unlink(getPidFilePath(projectPath));
|
|
421
|
+
}
|
|
422
|
+
catch { }
|
|
423
|
+
}
|
|
424
|
+
async function readPidFile(projectPath) {
|
|
425
|
+
try {
|
|
426
|
+
const content = await promises_1.default.readFile(getPidFilePath(projectPath), "utf8");
|
|
427
|
+
return JSON.parse(content);
|
|
428
|
+
}
|
|
429
|
+
catch {
|
|
430
|
+
return null;
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
/** Returns true if the process in the PID file is still alive. */
|
|
434
|
+
function isProcessAlive(pid) {
|
|
435
|
+
try {
|
|
436
|
+
process.kill(pid, 0);
|
|
437
|
+
return true;
|
|
438
|
+
}
|
|
439
|
+
catch {
|
|
440
|
+
return false;
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
async function run(options = {}) {
|
|
444
|
+
const projectPath = node_path_1.default.resolve(options.projectPath ?? process.cwd());
|
|
445
|
+
const docuDir = node_path_1.default.join(projectPath, ".docuflow");
|
|
446
|
+
const sourcesDir = node_path_1.default.join(docuDir, "sources");
|
|
447
|
+
if (!node_fs_1.default.existsSync(docuDir)) {
|
|
448
|
+
console.error(c.red(` ✗ .docuflow/ not found at ${projectPath}`));
|
|
449
|
+
console.error(` Run "docuflow init" first.`);
|
|
450
|
+
process.exit(1);
|
|
451
|
+
}
|
|
452
|
+
// Set module-level flag before any watchers or timers are created
|
|
453
|
+
_allowDangerousPermissions = !!options.allowDangerousPermissions;
|
|
454
|
+
if (_allowDangerousPermissions) {
|
|
455
|
+
console.warn(c.yellow(" ⚠ --allow-dangerous-permissions: Claude CLI will skip all permission prompts."));
|
|
456
|
+
console.warn(c.yellow(" Ensure file content in this project is trusted — injected instructions will execute without safeguards."));
|
|
457
|
+
}
|
|
458
|
+
await promises_1.default.mkdir(sourcesDir, { recursive: true });
|
|
459
|
+
// ── Check for already-running daemon ───────────────────────────────────
|
|
460
|
+
const existing = await readPidFile(projectPath);
|
|
461
|
+
if (existing && isProcessAlive(existing.pid)) {
|
|
462
|
+
console.error(c.yellow(` ⚠ Watch daemon already running for this project`));
|
|
463
|
+
console.error(` PID: ${existing.pid} Bridge: ${existing.bridge}`);
|
|
464
|
+
console.error(` Started: ${new Date(existing.started_at).toLocaleString()}`);
|
|
465
|
+
console.error(` Run ${c.cyan("docuflow watch stop")} to stop it first.`);
|
|
466
|
+
process.exit(1);
|
|
467
|
+
}
|
|
468
|
+
// Stale PID file (process died without cleanup)
|
|
469
|
+
if (existing)
|
|
470
|
+
await removePidFile(projectPath);
|
|
471
|
+
const bridge = detectBridge({
|
|
472
|
+
useAI: !!options.ai,
|
|
473
|
+
forceCopilot: !!options.forceCopilot,
|
|
474
|
+
forceClaude: !!options.forceClaude,
|
|
475
|
+
forceCodex: !!options.forceCodex,
|
|
476
|
+
});
|
|
477
|
+
// Cap at Node.js max safe setInterval value (24.8 days = 2^31-1 ms)
|
|
478
|
+
const MAX_INTERVAL_MS = 2_147_483_647;
|
|
479
|
+
const lintMs = Math.min((options.lintIntervalHours ?? 24) * 3_600_000, MAX_INTERVAL_MS);
|
|
480
|
+
const customExts = options.codeExtensions
|
|
481
|
+
? new Set(options.codeExtensions.map(e => e.startsWith(".") ? e : `.${e}`))
|
|
482
|
+
: undefined;
|
|
483
|
+
const bridgeLabel = bridge === "none" ? c.dim("off (sources-only)")
|
|
484
|
+
: bridge === "copilot" ? c.green("copilot — direct MCP calling ⚡")
|
|
485
|
+
: bridge === "claude" ? c.green("claude — direct MCP calling ⚡")
|
|
486
|
+
: bridge === "codex" ? c.yellow("codex — doc generation + ingest")
|
|
487
|
+
: c.yellow("api — doc generation + ingest");
|
|
488
|
+
console.log();
|
|
489
|
+
console.log(c.bold(" 🔄 DocuFlow Watch Daemon"));
|
|
490
|
+
console.log(" ─────────────────────────────────────────────────");
|
|
491
|
+
console.log(` Project: ${projectPath}`);
|
|
492
|
+
console.log(` AI bridge: ${bridgeLabel}`);
|
|
493
|
+
console.log(` Lint every: ${options.lintIntervalHours ?? 24}h`);
|
|
494
|
+
if (bridge === "copilot" || bridge === "claude") {
|
|
495
|
+
console.log(` ${c.cyan("⚡ AI drives DocuFlow MCP tools directly (no intermediate step)")}`);
|
|
496
|
+
}
|
|
497
|
+
console.log(" ─────────────────────────────────────────────────");
|
|
498
|
+
console.log(c.dim(" Press Ctrl+C to stop | docuflow watch stop to stop from another terminal\n"));
|
|
499
|
+
// ── Write PID file so other terminals can stop/status this daemon ────────
|
|
500
|
+
await writePidFile(projectPath, {
|
|
501
|
+
pid: process.pid,
|
|
502
|
+
started_at: new Date().toISOString(),
|
|
503
|
+
bridge,
|
|
504
|
+
project_path: projectPath,
|
|
505
|
+
options: {
|
|
506
|
+
ai: !!options.ai,
|
|
507
|
+
forceCopilot: !!options.forceCopilot,
|
|
508
|
+
forceClaude: !!options.forceClaude,
|
|
509
|
+
forceCodex: !!options.forceCodex,
|
|
510
|
+
lintIntervalHours: options.lintIntervalHours ?? 24,
|
|
511
|
+
codeExtensions: options.codeExtensions,
|
|
512
|
+
},
|
|
513
|
+
});
|
|
514
|
+
log("💾", `PID ${process.pid} written to ${c.dim(".docuflow/watch.pid")}`);
|
|
515
|
+
// ── Watch 1: .docuflow/sources/ ─────────────────────────────────────────
|
|
516
|
+
const pendingCodeFiles = new Set();
|
|
517
|
+
const debouncedCodeSync = debounce(async () => {
|
|
518
|
+
const files = Array.from(pendingCodeFiles);
|
|
519
|
+
pendingCodeFiles.clear();
|
|
520
|
+
if (files.length > 0) {
|
|
521
|
+
await syncWithAI(projectPath, files, bridge).catch(e => log("❌", c.red(`Sync error: ${e.message}`)));
|
|
522
|
+
}
|
|
523
|
+
}, 3000);
|
|
524
|
+
// Debounced sources ingest — prevents double-fire on macOS (fs.watch fires 'rename' twice)
|
|
525
|
+
const pendingSourceFiles = new Set();
|
|
526
|
+
const debouncedSourceSync = debounce(async () => {
|
|
527
|
+
const files = Array.from(pendingSourceFiles);
|
|
528
|
+
pendingSourceFiles.clear();
|
|
529
|
+
for (const filename of files) {
|
|
530
|
+
await directIngest(projectPath, filename).catch(e => log("❌", c.red(`Ingest error: ${e.message}`)));
|
|
531
|
+
}
|
|
532
|
+
}, 500); // 500ms debounce catches macOS double-fire
|
|
533
|
+
const sourcesWatcher = node_fs_1.default.watch(sourcesDir, { persistent: true }, (event, filename) => {
|
|
534
|
+
if (!filename || !filename.endsWith(".md"))
|
|
535
|
+
return;
|
|
536
|
+
if (filename.startsWith("auto_sync_"))
|
|
537
|
+
return; // prevent loop
|
|
538
|
+
log("📄", `Source changed: ${c.cyan(filename)} (${event})`);
|
|
539
|
+
pendingSourceFiles.add(filename);
|
|
540
|
+
debouncedSourceSync();
|
|
541
|
+
});
|
|
542
|
+
log("👁 ", `Watching ${c.cyan(".docuflow/sources/")} → direct ingest on change`);
|
|
543
|
+
// Declare outside if-block so shutdown() can reference it
|
|
544
|
+
let codeWatcher = null;
|
|
545
|
+
// ── Watch 2: project code files (AI bridge only) ─────────────────────────
|
|
546
|
+
if (bridge !== "none") {
|
|
547
|
+
codeWatcher = node_fs_1.default.watch(projectPath, { persistent: true, recursive: true }, (event, filename) => {
|
|
548
|
+
if (!filename)
|
|
549
|
+
return;
|
|
550
|
+
if (/^(\.docuflow|node_modules|dist|build|\.git)/.test(filename))
|
|
551
|
+
return;
|
|
552
|
+
const ext = node_path_1.default.extname(filename).toLowerCase();
|
|
553
|
+
if (!(customExts ?? DEFAULT_CODE_EXTS).has(ext))
|
|
554
|
+
return;
|
|
555
|
+
pendingCodeFiles.add(node_path_1.default.join(projectPath, filename));
|
|
556
|
+
debouncedCodeSync();
|
|
557
|
+
});
|
|
558
|
+
const extList = customExts ? [...customExts].join(",") : "ts,js,py,go,rb,java,cs,...";
|
|
559
|
+
log("👁 ", `Watching project code [${c.cyan(extList)}] → ${bridge === "copilot" || bridge === "claude" ? "AI calls MCP tools directly" : "AI generates doc → ingest"}`);
|
|
560
|
+
}
|
|
561
|
+
// ── Scheduled lint ───────────────────────────────────────────────────────
|
|
562
|
+
const lintTimer = setInterval(async () => {
|
|
563
|
+
await scheduledLintWithAI(projectPath, bridge).catch(e => log("❌", c.red(`Lint error: ${e.message}`)));
|
|
564
|
+
}, lintMs);
|
|
565
|
+
// Initial lint after 5s startup delay
|
|
566
|
+
setTimeout(() => {
|
|
567
|
+
scheduledLintWithAI(projectPath, bridge).catch(() => { });
|
|
568
|
+
}, 5000);
|
|
569
|
+
log("⏰", `Lint schedule: every ${options.lintIntervalHours ?? 24}h`);
|
|
570
|
+
// ── Graceful shutdown (single handler — covers both watchers) ───────────
|
|
571
|
+
const shutdown = async (signal) => {
|
|
572
|
+
if (signal === "SIGINT")
|
|
573
|
+
console.log();
|
|
574
|
+
log("🛑", `Stopping watch daemon (${signal})...`);
|
|
575
|
+
sourcesWatcher.close();
|
|
576
|
+
if (codeWatcher)
|
|
577
|
+
codeWatcher.close();
|
|
578
|
+
clearInterval(lintTimer);
|
|
579
|
+
await removePidFile(projectPath);
|
|
580
|
+
log("✅", c.green("Watch daemon stopped. PID file removed."));
|
|
581
|
+
process.exit(0);
|
|
582
|
+
};
|
|
583
|
+
process.on("SIGINT", () => shutdown("SIGINT"));
|
|
584
|
+
process.on("SIGTERM", () => shutdown("SIGTERM"));
|
|
585
|
+
process.on("SIGHUP", () => shutdown("SIGHUP"));
|
|
586
|
+
await new Promise(() => { }); // keep alive
|
|
587
|
+
}
|