@doquflow/cli 1.5.1 → 1.6.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.
@@ -6,6 +6,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.registerInGlobalRegistry = registerInGlobalRegistry;
7
7
  exports.buildClaudeMd = buildClaudeMd;
8
8
  exports.buildAgentsMd = buildAgentsMd;
9
+ exports.runInit = runInit;
9
10
  exports.run = run;
10
11
  const node_fs_1 = __importDefault(require("node:fs"));
11
12
  const promises_1 = __importDefault(require("node:fs/promises"));
@@ -153,11 +154,17 @@ lint_wiki({ project_path: "${projectDir}" })
153
154
  → fix orphans and broken refs
154
155
  \`\`\`
155
156
 
157
+ ### Maintenance — re-ingest with updated rules
158
+ \`\`\`
159
+ docuflow rewiki --dry-run # preview cleanup
160
+ docuflow rewiki # apply (backs up wiki first)
161
+ \`\`\`
162
+
156
163
  ## Storage Layout
157
164
 
158
165
  \`\`\`
159
166
  .docuflow/
160
- ├── specs/ Legacy spec files written by write_spec
167
+ ├── specs/ Spec files written by write_spec
161
168
  ├── wiki/ LLM-generated wiki pages
162
169
  │ ├── entities/ Named things (services, APIs, databases)
163
170
  │ ├── concepts/ Design patterns, principles, integrations
@@ -276,7 +283,16 @@ async function writeAgentsMd(projectDir) {
276
283
  await promises_1.default.writeFile(agentsMdPath, newSection, "utf8");
277
284
  }
278
285
  }
279
- async function run() {
286
+ /**
287
+ * Core init logic — creates the .docuflow/ structure, registers MCP configs,
288
+ * writes CLAUDE.md/AGENTS.md, installs git hook, and registers in global registry.
289
+ *
290
+ * Accepts the target projectDir explicitly so it can be called from both the CLI
291
+ * (which uses process.cwd()) and the API (/api/init which receives the path from
292
+ * the request body).
293
+ */
294
+ async function runInit(projectDir) {
295
+ const details = [];
280
296
  const configPath = getClaudeDesktopConfigPath();
281
297
  const vscodeConfigPath = getVSCodeMcpConfigPath();
282
298
  const copilotCliConfigPath = getCopilotCliMcpConfigPath();
@@ -284,61 +300,59 @@ async function run() {
284
300
  const serverBin = resolveServerBin();
285
301
  const nodeBin = process.execPath;
286
302
  // Register in Claude Desktop config
287
- let config = {};
288
303
  try {
289
- const raw = await promises_1.default.readFile(configPath, "utf8");
290
- config = JSON.parse(raw);
304
+ let config = {};
305
+ try {
306
+ const raw = await promises_1.default.readFile(configPath, "utf8");
307
+ config = JSON.parse(raw);
308
+ }
309
+ catch { /* File doesn't exist yet — that's fine */ }
310
+ if (!config.mcpServers)
311
+ config.mcpServers = {};
312
+ config.mcpServers.docuflow = { command: nodeBin, args: [serverBin] };
313
+ await promises_1.default.mkdir(node_path_1.default.dirname(configPath), { recursive: true });
314
+ await promises_1.default.writeFile(configPath, JSON.stringify(config, null, 2) + "\n", "utf8");
315
+ details.push("Claude Desktop MCP registered");
291
316
  }
292
317
  catch {
293
- // File doesn't exist yet that's fine, we'll create it
318
+ details.push("Claude Desktopskipped (not installed)");
294
319
  }
295
- if (!config.mcpServers)
296
- config.mcpServers = {};
297
- config.mcpServers.docuflow = { command: nodeBin, args: [serverBin] };
298
- await promises_1.default.mkdir(node_path_1.default.dirname(configPath), { recursive: true });
299
- await promises_1.default.writeFile(configPath, JSON.stringify(config, null, 2) + "\n", "utf8");
300
320
  // Register in VS Code (GitHub Copilot) user MCP config
301
321
  let vscodeRegistered = false;
302
- let vscodeConfig = {};
303
- try {
304
- const raw = await promises_1.default.readFile(vscodeConfigPath, "utf8");
305
- vscodeConfig = JSON.parse(raw);
306
- }
307
- catch {
308
- // File may not exist — create it
309
- }
310
- if (!vscodeConfig.servers)
311
- vscodeConfig.servers = {};
312
- vscodeConfig.servers.docuflow = { command: nodeBin, args: [serverBin], type: "stdio" };
313
322
  try {
323
+ let vscodeConfig = {};
324
+ try {
325
+ const raw = await promises_1.default.readFile(vscodeConfigPath, "utf8");
326
+ vscodeConfig = JSON.parse(raw);
327
+ }
328
+ catch { /* File may not exist */ }
329
+ if (!vscodeConfig.servers)
330
+ vscodeConfig.servers = {};
331
+ vscodeConfig.servers.docuflow = { command: nodeBin, args: [serverBin], type: "stdio" };
314
332
  await promises_1.default.mkdir(node_path_1.default.dirname(vscodeConfigPath), { recursive: true });
315
333
  await promises_1.default.writeFile(vscodeConfigPath, JSON.stringify(vscodeConfig, null, 2) + "\n", "utf8");
316
334
  vscodeRegistered = true;
335
+ details.push("VS Code Copilot MCP registered");
317
336
  }
318
- catch {
319
- // VS Code not installed or config dir not writable — skip silently
320
- }
337
+ catch { /* VS Code not installed — skip */ }
321
338
  // Register in GitHub Copilot CLI MCP config (~/.copilot/mcp-config.json)
322
339
  let copilotCliRegistered = false;
323
- let copilotCliConfig = {};
324
- try {
325
- const raw = await promises_1.default.readFile(copilotCliConfigPath, "utf8");
326
- copilotCliConfig = JSON.parse(raw);
327
- }
328
- catch {
329
- // File may not exist yet
330
- }
331
- if (!copilotCliConfig.mcpServers)
332
- copilotCliConfig.mcpServers = {};
333
- copilotCliConfig.mcpServers.docuflow = { type: "local", command: nodeBin, args: [serverBin], tools: ["*"] };
334
340
  try {
341
+ let copilotCliConfig = {};
342
+ try {
343
+ const raw = await promises_1.default.readFile(copilotCliConfigPath, "utf8");
344
+ copilotCliConfig = JSON.parse(raw);
345
+ }
346
+ catch { /* File may not exist yet */ }
347
+ if (!copilotCliConfig.mcpServers)
348
+ copilotCliConfig.mcpServers = {};
349
+ copilotCliConfig.mcpServers.docuflow = { type: "local", command: nodeBin, args: [serverBin], tools: ["*"] };
335
350
  await promises_1.default.mkdir(node_path_1.default.dirname(copilotCliConfigPath), { recursive: true });
336
351
  await promises_1.default.writeFile(copilotCliConfigPath, JSON.stringify(copilotCliConfig, null, 2) + "\n", "utf8");
337
352
  copilotCliRegistered = true;
353
+ details.push("Copilot CLI MCP registered");
338
354
  }
339
- catch {
340
- // Copilot CLI not installed — skip silently
341
- }
355
+ catch { /* Copilot CLI not installed — skip */ }
342
356
  // Register in OpenAI Codex CLI (~/.codex/config.toml in TOML format)
343
357
  let codexCliRegistered = false;
344
358
  try {
@@ -353,99 +367,107 @@ async function run() {
353
367
  await promises_1.default.writeFile(codexConfigPath, tomlContent + entry, "utf8");
354
368
  }
355
369
  codexCliRegistered = true;
370
+ details.push("Codex CLI MCP registered");
356
371
  }
357
- catch {
358
- // Codex CLI not installed skip silently
359
- }
372
+ catch { /* Codex CLI not installed — skip */ }
373
+ // Suppress unused-variable warnings when variables are only used in console.log branches
374
+ void vscodeRegistered;
375
+ void copilotCliRegistered;
376
+ void codexCliRegistered;
360
377
  // Create .docuflow/ directory structure
361
- const projectDir = process.cwd();
362
378
  const docuflowDir = node_path_1.default.join(projectDir, ".docuflow");
363
379
  const specsDir = node_path_1.default.join(docuflowDir, "specs");
364
380
  const wikiDir = node_path_1.default.join(docuflowDir, "wiki");
365
381
  const sourcesDir = node_path_1.default.join(docuflowDir, "sources");
366
- const entitiesDir = node_path_1.default.join(wikiDir, "entities");
367
- const conceptsDir = node_path_1.default.join(wikiDir, "concepts");
368
- const timelinesDir = node_path_1.default.join(wikiDir, "timelines");
369
- const synthesesDir = node_path_1.default.join(wikiDir, "syntheses");
370
382
  await promises_1.default.mkdir(specsDir, { recursive: true });
371
- await promises_1.default.mkdir(entitiesDir, { recursive: true });
372
- await promises_1.default.mkdir(conceptsDir, { recursive: true });
373
- await promises_1.default.mkdir(timelinesDir, { recursive: true });
374
- await promises_1.default.mkdir(synthesesDir, { recursive: true });
383
+ await promises_1.default.mkdir(node_path_1.default.join(wikiDir, "entities"), { recursive: true });
384
+ await promises_1.default.mkdir(node_path_1.default.join(wikiDir, "concepts"), { recursive: true });
385
+ await promises_1.default.mkdir(node_path_1.default.join(wikiDir, "timelines"), { recursive: true });
386
+ await promises_1.default.mkdir(node_path_1.default.join(wikiDir, "syntheses"), { recursive: true });
375
387
  await promises_1.default.mkdir(sourcesDir, { recursive: true });
388
+ details.push("Created .docuflow/ directory structure");
376
389
  // Copy or create template files
377
390
  await copyTemplateFile("schema.md", node_path_1.default.join(docuflowDir, "schema.md"));
378
391
  await copyTemplateFile("index.md", node_path_1.default.join(docuflowDir, "index.md"));
379
392
  await copyTemplateFile("log.md", node_path_1.default.join(docuflowDir, "log.md"));
380
- // Generate CLAUDE.md so Claude Code picks up DocuFlow automatically
393
+ details.push("Wrote schema.md, index.md, log.md");
394
+ // Generate CLAUDE.md
381
395
  await writeClaudeMd(projectDir);
382
- // Generate AGENTS.md so OpenAI Codex picks up DocuFlow automatically
396
+ details.push("Wrote CLAUDE.md");
397
+ // Generate AGENTS.md
383
398
  await writeAgentsMd(projectDir);
384
- // Write .vscode/mcp.json for project-level workspace MCP config (shareable via git)
385
- // Uses npx so it works on any machine — safe to commit
386
- const vscodeDirPath = node_path_1.default.join(projectDir, ".vscode");
387
- const vscodeWorkspaceMcpPath = node_path_1.default.join(vscodeDirPath, "mcp.json");
388
- let workspaceMcpConfig = {};
399
+ details.push("Wrote AGENTS.md");
400
+ // Write .vscode/mcp.json for project-level workspace MCP config
389
401
  try {
390
- const raw = await promises_1.default.readFile(vscodeWorkspaceMcpPath, "utf8");
391
- workspaceMcpConfig = JSON.parse(raw);
392
- }
393
- catch {
394
- // File doesn't exist yet
402
+ const vscodeDirPath = node_path_1.default.join(projectDir, ".vscode");
403
+ const vscodeWorkspaceMcpPath = node_path_1.default.join(vscodeDirPath, "mcp.json");
404
+ let workspaceMcpConfig = {};
405
+ try {
406
+ const raw = await promises_1.default.readFile(vscodeWorkspaceMcpPath, "utf8");
407
+ workspaceMcpConfig = JSON.parse(raw);
408
+ }
409
+ catch { /* File doesn't exist yet */ }
410
+ if (!workspaceMcpConfig.servers)
411
+ workspaceMcpConfig.servers = {};
412
+ workspaceMcpConfig.servers.docuflow = {
413
+ command: "npx",
414
+ args: ["-y", "-p", "@doquflow/server", "docuflow-server"],
415
+ type: "stdio",
416
+ };
417
+ await promises_1.default.mkdir(vscodeDirPath, { recursive: true });
418
+ await promises_1.default.writeFile(vscodeWorkspaceMcpPath, JSON.stringify(workspaceMcpConfig, null, 2) + "\n", "utf8");
419
+ details.push("Wrote .vscode/mcp.json");
395
420
  }
396
- if (!workspaceMcpConfig.servers)
397
- workspaceMcpConfig.servers = {};
398
- workspaceMcpConfig.servers.docuflow = {
399
- command: "npx",
400
- args: ["-y", "-p", "@doquflow/server", "docuflow-server"],
401
- type: "stdio",
402
- };
403
- await promises_1.default.mkdir(vscodeDirPath, { recursive: true });
404
- await promises_1.default.writeFile(vscodeWorkspaceMcpPath, JSON.stringify(workspaceMcpConfig, null, 2) + "\n", "utf8");
421
+ catch { /* non-fatal */ }
405
422
  // Add .docuflow/ to .gitignore if present and not already listed
406
- const gitignorePath = node_path_1.default.join(process.cwd(), ".gitignore");
407
- if (node_fs_1.default.existsSync(gitignorePath)) {
408
- const gitignore = await promises_1.default.readFile(gitignorePath, "utf8");
409
- if (!gitignore.includes(".docuflow/") && !gitignore.includes(".docuflow")) {
410
- await promises_1.default.appendFile(gitignorePath, "\n# Docuflow\n.docuflow/\n");
423
+ try {
424
+ const gitignorePath = node_path_1.default.join(projectDir, ".gitignore");
425
+ if (node_fs_1.default.existsSync(gitignorePath)) {
426
+ const gitignore = await promises_1.default.readFile(gitignorePath, "utf8");
427
+ if (!gitignore.includes(".docuflow/") && !gitignore.includes(".docuflow")) {
428
+ await promises_1.default.appendFile(gitignorePath, "\n# Docuflow\n.docuflow/\n");
429
+ details.push("Added .docuflow/ to .gitignore");
430
+ }
411
431
  }
412
432
  }
413
- // Install git post-commit hook (auto-sync on every commit)
433
+ catch { /* non-fatal */ }
434
+ // Install git post-commit hook
414
435
  await installGitHook(projectDir);
415
- // Register in global project registry so `docuflow ui` always finds this project
436
+ details.push("Installed git post-commit hook");
437
+ // Register in global project registry
416
438
  await registerInGlobalRegistry(projectDir);
417
- console.log("\u2713 DocuFlow initialised successfully.");
439
+ details.push("Registered in global project registry (~/.docuflow/projects.json)");
440
+ return { ok: true, path: projectDir, details };
441
+ }
442
+ async function run() {
443
+ const result = await runInit(process.cwd());
444
+ const docuflowDir = node_path_1.default.join(result.path, ".docuflow");
445
+ console.log("✓ DocuFlow initialised successfully.");
418
446
  console.log("");
419
- console.log("\ud83d\udcc1 Structure created:");
447
+ console.log("📁 Structure created:");
420
448
  console.log(` ${docuflowDir}/`);
421
- console.log(` \u251c\u2500\u2500 specs/ (code specs written by the agent)`);
422
- console.log(` \u251c\u2500\u2500 wiki/ (LLM-generated wiki pages)`);
423
- console.log(` \u2502 \u251c\u2500\u2500 entities/`);
424
- console.log(` \u2502 \u251c\u2500\u2500 concepts/`);
425
- console.log(` \u2502 \u251c\u2500\u2500 timelines/`);
426
- console.log(` \u2502 \u2514\u2500\u2500 syntheses/`);
427
- console.log(` \u251c\u2500\u2500 sources/ (raw markdown documents to ingest)`);
428
- console.log(` \u251c\u2500\u2500 schema.md (wiki configuration)`);
429
- console.log(` \u251c\u2500\u2500 index.md (auto-maintained catalog)`);
430
- console.log(` \u2514\u2500\u2500 log.md (operation log)`);
449
+ console.log(` ├── specs/ (code specs written by the agent)`);
450
+ console.log(` ├── wiki/ (LLM-generated wiki pages)`);
451
+ console.log(` ├── entities/`);
452
+ console.log(` ├── concepts/`);
453
+ console.log(` ├── timelines/`);
454
+ console.log(` └── syntheses/`);
455
+ console.log(` ├── sources/ (raw markdown documents to ingest)`);
456
+ console.log(` ├── schema.md (wiki configuration)`);
457
+ console.log(` ├── index.md (auto-maintained catalog)`);
458
+ console.log(` └── log.md (operation log)`);
431
459
  console.log("");
432
- console.log("\ud83d\udcdd Instruction files:");
433
- console.log(` CLAUDE.md ✓ ${node_path_1.default.join(projectDir, "CLAUDE.md")}`);
434
- console.log(` AGENTS.md ✓ ${node_path_1.default.join(projectDir, "AGENTS.md")}`);
435
- console.log("");
436
- console.log("\ud83d\udd27 MCP Registration:");
437
- console.log(` Claude Desktop: \u2713 registered`);
438
- console.log(` VS Code Copilot: ${vscodeRegistered ? "\u2713 registered (user-level)" : "\u2014 not detected"}`);
439
- console.log(` Copilot CLI: ${copilotCliRegistered ? "\u2713 registered (~/.copilot/mcp-config.json)" : "\u2014 not detected"}`);
440
- console.log(` Codex CLI: ${codexCliRegistered ? "\u2713 registered (~/.codex/config.toml)" : "\u2014 not detected"}`);
441
- console.log(` Workspace: \u2713 .vscode/mcp.json written (commit to share with team)`);
460
+ console.log("📝 Steps completed:");
461
+ for (const line of result.details) {
462
+ console.log(` ✓ ${line}`);
463
+ }
442
464
  console.log("");
443
- console.log("\ud83d\udcd6 Next steps:");
465
+ console.log("📖 Next steps:");
444
466
  console.log(" 1. Edit .docuflow/schema.md to customize your wiki domain");
445
467
  console.log(" 2. Add markdown docs to .docuflow/sources/ then ingest them");
446
468
  console.log(" 3. Restart Claude Desktop / reload VS Code / restart Copilot CLI");
447
469
  console.log("");
448
- console.log("\u26a1 Auto-sync options:");
470
+ console.log(" Auto-sync options:");
449
471
  console.log(" docuflow watch # background daemon (watches for file changes)");
450
472
  console.log(" docuflow watch --ai # + Claude/Codex documents code changes automatically");
451
473
  console.log(" docuflow sync # one-shot sync (good for CI/CD)");
@@ -0,0 +1,276 @@
1
+ "use strict";
2
+ /**
3
+ * docuflow recent
4
+ *
5
+ * Aggregates recent work by scanning .devloop/specs/TASK-*.md files,
6
+ * correlating git commits by task ID, reading .docuflow/log.md for wiki
7
+ * activity, and rendering a formatted dashboard in the terminal.
8
+ */
9
+ var __importDefault = (this && this.__importDefault) || function (mod) {
10
+ return (mod && mod.__esModule) ? mod : { "default": mod };
11
+ };
12
+ Object.defineProperty(exports, "__esModule", { value: true });
13
+ exports.run = run;
14
+ const promises_1 = __importDefault(require("node:fs/promises"));
15
+ const node_path_1 = __importDefault(require("node:path"));
16
+ const node_child_process_1 = require("node:child_process");
17
+ // ── ANSI helpers ────────────────────────────────────────────────────────────
18
+ const c = {
19
+ green: (s) => `\x1b[32m${s}\x1b[0m`,
20
+ yellow: (s) => `\x1b[33m${s}\x1b[0m`,
21
+ red: (s) => `\x1b[31m${s}\x1b[0m`,
22
+ dim: (s) => `\x1b[2m${s}\x1b[0m`,
23
+ bold: (s) => `\x1b[1m${s}\x1b[0m`,
24
+ };
25
+ function colourStatus(status, text) {
26
+ if (status === "approved")
27
+ return c.green(text);
28
+ if (status === "needs-work")
29
+ return c.yellow(text);
30
+ if (status === "rejected")
31
+ return c.red(text);
32
+ return c.dim(text);
33
+ }
34
+ function normaliseStatus(raw) {
35
+ // Strip emojis (✅ ⚠️ ⏳ ❌) and other non-printable/non-ASCII characters
36
+ const stripped = raw.replace(/[^\x20-\x7E]/g, " ").trim().toLowerCase();
37
+ if (stripped.includes("approved"))
38
+ return "approved";
39
+ if (stripped.includes("needs-work") || stripped.includes("needs_work"))
40
+ return "needs-work";
41
+ if (stripped.includes("rejected"))
42
+ return "rejected";
43
+ if (stripped.includes("pending"))
44
+ return "pending";
45
+ return stripped.split(/\s+/)[0] || "pending";
46
+ }
47
+ function truncate(s, max) {
48
+ return s.length > max ? s.slice(0, max - 1) + "…" : s;
49
+ }
50
+ function formatDateYMD(d) {
51
+ return d.toISOString().slice(0, 10);
52
+ }
53
+ // ── Git helper ───────────────────────────────────────────────────────────────
54
+ function gitLog(taskId) {
55
+ try {
56
+ const out = (0, node_child_process_1.execSync)(`git log --oneline --all --grep="${taskId}"`, { encoding: "utf8", stdio: ["pipe", "pipe", "ignore"] });
57
+ return out.trim().split("\n").filter(Boolean);
58
+ }
59
+ catch {
60
+ return [];
61
+ }
62
+ }
63
+ // ── Spec parser ───────────────────────────────────────────────────────────────
64
+ async function parseSpec(specPath) {
65
+ let content;
66
+ try {
67
+ content = await promises_1.default.readFile(specPath, "utf8");
68
+ }
69
+ catch {
70
+ return {};
71
+ }
72
+ let title = "";
73
+ let feature = "";
74
+ let status = "pending";
75
+ for (const line of content.split("\n")) {
76
+ // First H1 heading: "# TASK-YYYYMMDD-HHMMSS: <title>"
77
+ if (!title && line.startsWith("# TASK-")) {
78
+ const colonIdx = line.indexOf(":");
79
+ if (colonIdx !== -1) {
80
+ title = line.slice(colonIdx + 1).trim();
81
+ }
82
+ }
83
+ // **Feature**: value
84
+ if (!feature && line.startsWith("**Feature**:")) {
85
+ feature = line.slice("**Feature**:".length).trim().slice(0, 120).trimEnd();
86
+ }
87
+ // **Status**: value
88
+ if (line.startsWith("**Status**:")) {
89
+ const raw = line.slice("**Status**:".length).trim();
90
+ status = normaliseStatus(raw);
91
+ }
92
+ }
93
+ return { title, feature, status };
94
+ }
95
+ // ── Review parser ────────────────────────────────────────────────────────────
96
+ async function parseReview(reviewPath) {
97
+ let content;
98
+ try {
99
+ content = await promises_1.default.readFile(reviewPath, "utf8");
100
+ }
101
+ catch {
102
+ return { score: null, reviewVerdict: null };
103
+ }
104
+ let score = null;
105
+ let reviewVerdict = null;
106
+ const scoreMatch = content.match(/Score:\s*(\d+\/\d+)/);
107
+ if (scoreMatch)
108
+ score = scoreMatch[1];
109
+ const verdictMatch = content.match(/Verdict:\s*(APPROVED|NEEDS[\s_]WORK|REJECTED)/i);
110
+ if (verdictMatch) {
111
+ reviewVerdict = verdictMatch[1].toUpperCase().replace(/[\s-]+/g, "_");
112
+ }
113
+ return { score, reviewVerdict };
114
+ }
115
+ // ── Wiki log parser ──────────────────────────────────────────────────────────
116
+ // log.md format: ## [2026-05-01T13:49:38.141Z] operation | description
117
+ async function parseWikiLog(logPath, since) {
118
+ let content;
119
+ try {
120
+ content = await promises_1.default.readFile(logPath, "utf8");
121
+ }
122
+ catch {
123
+ return [];
124
+ }
125
+ const entries = [];
126
+ const headingRe = /^##\s+\[([^\]]+)\]\s+([^|]+)\|\s*(.+)$/;
127
+ for (const line of content.split("\n")) {
128
+ const m = line.match(headingRe);
129
+ if (!m)
130
+ continue;
131
+ const rawDate = m[1].trim();
132
+ const operation = m[2].trim();
133
+ const file = m[3].trim();
134
+ const dateVal = new Date(rawDate);
135
+ if (!isNaN(dateVal.getTime()) && dateVal >= since) {
136
+ entries.push({ date: rawDate.slice(0, 10), operation, file });
137
+ }
138
+ }
139
+ return entries;
140
+ }
141
+ // ── Renderers ────────────────────────────────────────────────────────────────
142
+ function renderTable(tasks, allCommits, wikiEntries, days, fromDate, toDate) {
143
+ const SEP = "─".repeat(72);
144
+ console.log(c.bold(`Recent Work — last ${days} days (${fromDate} → ${toDate})`));
145
+ console.log(SEP);
146
+ console.log(` ${"TASK".padEnd(24)} ${"TITLE".padEnd(40)} ${"STATUS".padEnd(12)} SCORE`);
147
+ console.log(SEP);
148
+ for (const t of tasks) {
149
+ const title = truncate(t.title || t.feature, 40).padEnd(40);
150
+ const status = t.status.padEnd(12);
151
+ const score = t.score ?? "—";
152
+ const row = ` ${t.id.padEnd(24)} ${title} ${status} ${score}`;
153
+ console.log(colourStatus(t.status, row));
154
+ }
155
+ console.log(SEP);
156
+ if (allCommits.length > 0) {
157
+ console.log("");
158
+ console.log(c.bold(`Git Commits (${allCommits.length} matching)`));
159
+ for (const line of allCommits) {
160
+ console.log(` ${line}`);
161
+ }
162
+ }
163
+ if (wikiEntries.length > 0) {
164
+ console.log("");
165
+ console.log(c.bold(`Wiki Activity (last ${days} days)`));
166
+ for (const e of wikiEntries) {
167
+ console.log(` ${e.date} ${e.operation} ${e.file}`);
168
+ }
169
+ }
170
+ }
171
+ function renderMarkdown(tasks, allCommits, wikiEntries, days, fromDate, toDate) {
172
+ console.log(`# Recent Work — last ${days} days (${fromDate} → ${toDate})`);
173
+ console.log("");
174
+ console.log("| TASK | TITLE | STATUS | SCORE |");
175
+ console.log("|------|-------|--------|-------|");
176
+ for (const t of tasks) {
177
+ const title = truncate(t.title || t.feature, 40);
178
+ const score = t.score ?? "—";
179
+ console.log(`| ${t.id} | ${title} | ${t.status} | ${score} |`);
180
+ }
181
+ if (allCommits.length > 0) {
182
+ console.log("");
183
+ console.log(`## Git Commits (${allCommits.length} matching)`);
184
+ console.log("");
185
+ for (const line of allCommits) {
186
+ console.log(`- ${line}`);
187
+ }
188
+ }
189
+ if (wikiEntries.length > 0) {
190
+ console.log("");
191
+ console.log(`## Wiki Activity (last ${days} days)`);
192
+ console.log("");
193
+ console.log("| Date | Operation | File |");
194
+ console.log("|------|-----------|------|");
195
+ for (const e of wikiEntries) {
196
+ console.log(`| ${e.date} | ${e.operation} | ${e.file} |`);
197
+ }
198
+ }
199
+ }
200
+ // ── Main entry point ─────────────────────────────────────────────────────────
201
+ async function run(opts = { days: 7, format: "table" }) {
202
+ const days = Math.max(1, isNaN(opts.days) ? 7 : opts.days);
203
+ const format = opts.format ?? "table";
204
+ const isMd = format === "md";
205
+ const cwd = process.cwd();
206
+ const specsDir = node_path_1.default.join(cwd, ".devloop", "specs");
207
+ const logPath = node_path_1.default.join(cwd, ".docuflow", "log.md");
208
+ const now = new Date();
209
+ const since = new Date(now.getTime() - days * 24 * 60 * 60 * 1000);
210
+ // Collect spec files
211
+ let specFiles = [];
212
+ try {
213
+ const entries = await promises_1.default.readdir(specsDir);
214
+ specFiles = entries
215
+ .filter(f => /^TASK-\d{8}-\d{6}\.md$/.test(f))
216
+ .map(f => node_path_1.default.join(specsDir, f));
217
+ }
218
+ catch {
219
+ console.log(`No tasks found in the last ${days} days.`);
220
+ return;
221
+ }
222
+ // Filter by mtime and parse each spec
223
+ const tasks = [];
224
+ for (const specPath of specFiles) {
225
+ let stat;
226
+ try {
227
+ stat = await promises_1.default.stat(specPath);
228
+ }
229
+ catch {
230
+ continue;
231
+ }
232
+ if (stat.mtime < since)
233
+ continue;
234
+ const stem = node_path_1.default.basename(specPath, ".md"); // TASK-YYYYMMDD-HHMMSS
235
+ const specFields = await parseSpec(specPath);
236
+ if (!specFields.title && !specFields.feature)
237
+ continue; // unreadable
238
+ const reviewPath = node_path_1.default.join(specsDir, `${stem}-review.md`);
239
+ const { score, reviewVerdict } = await parseReview(reviewPath);
240
+ // Override status from review verdict if present
241
+ let status = specFields.status ?? "pending";
242
+ if (reviewVerdict === "APPROVED")
243
+ status = "approved";
244
+ else if (reviewVerdict === "NEEDS_WORK")
245
+ status = "needs-work";
246
+ else if (reviewVerdict === "REJECTED")
247
+ status = "rejected";
248
+ const commits = gitLog(stem);
249
+ tasks.push({
250
+ id: stem,
251
+ title: specFields.title ?? "",
252
+ feature: specFields.feature ?? "",
253
+ status,
254
+ score,
255
+ reviewVerdict,
256
+ commits,
257
+ specMtime: stat.mtime,
258
+ });
259
+ }
260
+ // Sort newest first
261
+ tasks.sort((a, b) => b.specMtime.getTime() - a.specMtime.getTime());
262
+ if (tasks.length === 0) {
263
+ console.log(`No tasks found in the last ${days} days.`);
264
+ return;
265
+ }
266
+ const fromDate = formatDateYMD(since);
267
+ const toDate = formatDateYMD(now);
268
+ const allCommits = tasks.flatMap(t => t.commits);
269
+ const wikiEntries = await parseWikiLog(logPath, since);
270
+ if (isMd) {
271
+ renderMarkdown(tasks, allCommits, wikiEntries, days, fromDate, toDate);
272
+ }
273
+ else {
274
+ renderTable(tasks, allCommits, wikiEntries, days, fromDate, toDate);
275
+ }
276
+ }