@doquflow/cli 1.5.0 → 1.5.2
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 +119 -103
- package/dist/commands/recent.js +276 -0
- package/dist/commands/ui.js +156 -1
- package/dist/index.js +10 -0
- package/package.json +2 -2
- package/ui-dist/assets/index-CEmTOLCU.js +44 -0
- package/ui-dist/assets/index-DsJuaoK7.js +44 -0
- package/ui-dist/assets/index-PZ-fdAjl.js +44 -0
- package/ui-dist/assets/index-lXOzEPMP.js +44 -0
- package/ui-dist/index.html +1 -1
package/dist/commands/init.js
CHANGED
|
@@ -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"));
|
|
@@ -157,7 +158,7 @@ lint_wiki({ project_path: "${projectDir}" })
|
|
|
157
158
|
|
|
158
159
|
\`\`\`
|
|
159
160
|
.docuflow/
|
|
160
|
-
├── specs/
|
|
161
|
+
├── specs/ Spec files written by write_spec
|
|
161
162
|
├── wiki/ LLM-generated wiki pages
|
|
162
163
|
│ ├── entities/ Named things (services, APIs, databases)
|
|
163
164
|
│ ├── concepts/ Design patterns, principles, integrations
|
|
@@ -276,7 +277,16 @@ async function writeAgentsMd(projectDir) {
|
|
|
276
277
|
await promises_1.default.writeFile(agentsMdPath, newSection, "utf8");
|
|
277
278
|
}
|
|
278
279
|
}
|
|
279
|
-
|
|
280
|
+
/**
|
|
281
|
+
* Core init logic — creates the .docuflow/ structure, registers MCP configs,
|
|
282
|
+
* writes CLAUDE.md/AGENTS.md, installs git hook, and registers in global registry.
|
|
283
|
+
*
|
|
284
|
+
* Accepts the target projectDir explicitly so it can be called from both the CLI
|
|
285
|
+
* (which uses process.cwd()) and the API (/api/init which receives the path from
|
|
286
|
+
* the request body).
|
|
287
|
+
*/
|
|
288
|
+
async function runInit(projectDir) {
|
|
289
|
+
const details = [];
|
|
280
290
|
const configPath = getClaudeDesktopConfigPath();
|
|
281
291
|
const vscodeConfigPath = getVSCodeMcpConfigPath();
|
|
282
292
|
const copilotCliConfigPath = getCopilotCliMcpConfigPath();
|
|
@@ -284,61 +294,59 @@ async function run() {
|
|
|
284
294
|
const serverBin = resolveServerBin();
|
|
285
295
|
const nodeBin = process.execPath;
|
|
286
296
|
// Register in Claude Desktop config
|
|
287
|
-
let config = {};
|
|
288
297
|
try {
|
|
289
|
-
|
|
290
|
-
|
|
298
|
+
let config = {};
|
|
299
|
+
try {
|
|
300
|
+
const raw = await promises_1.default.readFile(configPath, "utf8");
|
|
301
|
+
config = JSON.parse(raw);
|
|
302
|
+
}
|
|
303
|
+
catch { /* File doesn't exist yet — that's fine */ }
|
|
304
|
+
if (!config.mcpServers)
|
|
305
|
+
config.mcpServers = {};
|
|
306
|
+
config.mcpServers.docuflow = { command: nodeBin, args: [serverBin] };
|
|
307
|
+
await promises_1.default.mkdir(node_path_1.default.dirname(configPath), { recursive: true });
|
|
308
|
+
await promises_1.default.writeFile(configPath, JSON.stringify(config, null, 2) + "\n", "utf8");
|
|
309
|
+
details.push("Claude Desktop MCP registered");
|
|
291
310
|
}
|
|
292
311
|
catch {
|
|
293
|
-
|
|
312
|
+
details.push("Claude Desktop — skipped (not installed)");
|
|
294
313
|
}
|
|
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
314
|
// Register in VS Code (GitHub Copilot) user MCP config
|
|
301
315
|
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
316
|
try {
|
|
317
|
+
let vscodeConfig = {};
|
|
318
|
+
try {
|
|
319
|
+
const raw = await promises_1.default.readFile(vscodeConfigPath, "utf8");
|
|
320
|
+
vscodeConfig = JSON.parse(raw);
|
|
321
|
+
}
|
|
322
|
+
catch { /* File may not exist */ }
|
|
323
|
+
if (!vscodeConfig.servers)
|
|
324
|
+
vscodeConfig.servers = {};
|
|
325
|
+
vscodeConfig.servers.docuflow = { command: nodeBin, args: [serverBin], type: "stdio" };
|
|
314
326
|
await promises_1.default.mkdir(node_path_1.default.dirname(vscodeConfigPath), { recursive: true });
|
|
315
327
|
await promises_1.default.writeFile(vscodeConfigPath, JSON.stringify(vscodeConfig, null, 2) + "\n", "utf8");
|
|
316
328
|
vscodeRegistered = true;
|
|
329
|
+
details.push("VS Code Copilot MCP registered");
|
|
317
330
|
}
|
|
318
|
-
catch {
|
|
319
|
-
// VS Code not installed or config dir not writable — skip silently
|
|
320
|
-
}
|
|
331
|
+
catch { /* VS Code not installed — skip */ }
|
|
321
332
|
// Register in GitHub Copilot CLI MCP config (~/.copilot/mcp-config.json)
|
|
322
333
|
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
334
|
try {
|
|
335
|
+
let copilotCliConfig = {};
|
|
336
|
+
try {
|
|
337
|
+
const raw = await promises_1.default.readFile(copilotCliConfigPath, "utf8");
|
|
338
|
+
copilotCliConfig = JSON.parse(raw);
|
|
339
|
+
}
|
|
340
|
+
catch { /* File may not exist yet */ }
|
|
341
|
+
if (!copilotCliConfig.mcpServers)
|
|
342
|
+
copilotCliConfig.mcpServers = {};
|
|
343
|
+
copilotCliConfig.mcpServers.docuflow = { type: "local", command: nodeBin, args: [serverBin], tools: ["*"] };
|
|
335
344
|
await promises_1.default.mkdir(node_path_1.default.dirname(copilotCliConfigPath), { recursive: true });
|
|
336
345
|
await promises_1.default.writeFile(copilotCliConfigPath, JSON.stringify(copilotCliConfig, null, 2) + "\n", "utf8");
|
|
337
346
|
copilotCliRegistered = true;
|
|
347
|
+
details.push("Copilot CLI MCP registered");
|
|
338
348
|
}
|
|
339
|
-
catch {
|
|
340
|
-
// Copilot CLI not installed — skip silently
|
|
341
|
-
}
|
|
349
|
+
catch { /* Copilot CLI not installed — skip */ }
|
|
342
350
|
// Register in OpenAI Codex CLI (~/.codex/config.toml in TOML format)
|
|
343
351
|
let codexCliRegistered = false;
|
|
344
352
|
try {
|
|
@@ -353,99 +361,107 @@ async function run() {
|
|
|
353
361
|
await promises_1.default.writeFile(codexConfigPath, tomlContent + entry, "utf8");
|
|
354
362
|
}
|
|
355
363
|
codexCliRegistered = true;
|
|
364
|
+
details.push("Codex CLI MCP registered");
|
|
356
365
|
}
|
|
357
|
-
catch {
|
|
358
|
-
|
|
359
|
-
|
|
366
|
+
catch { /* Codex CLI not installed — skip */ }
|
|
367
|
+
// Suppress unused-variable warnings when variables are only used in console.log branches
|
|
368
|
+
void vscodeRegistered;
|
|
369
|
+
void copilotCliRegistered;
|
|
370
|
+
void codexCliRegistered;
|
|
360
371
|
// Create .docuflow/ directory structure
|
|
361
|
-
const projectDir = process.cwd();
|
|
362
372
|
const docuflowDir = node_path_1.default.join(projectDir, ".docuflow");
|
|
363
373
|
const specsDir = node_path_1.default.join(docuflowDir, "specs");
|
|
364
374
|
const wikiDir = node_path_1.default.join(docuflowDir, "wiki");
|
|
365
375
|
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
376
|
await promises_1.default.mkdir(specsDir, { recursive: true });
|
|
371
|
-
await promises_1.default.mkdir(
|
|
372
|
-
await promises_1.default.mkdir(
|
|
373
|
-
await promises_1.default.mkdir(
|
|
374
|
-
await promises_1.default.mkdir(
|
|
377
|
+
await promises_1.default.mkdir(node_path_1.default.join(wikiDir, "entities"), { recursive: true });
|
|
378
|
+
await promises_1.default.mkdir(node_path_1.default.join(wikiDir, "concepts"), { recursive: true });
|
|
379
|
+
await promises_1.default.mkdir(node_path_1.default.join(wikiDir, "timelines"), { recursive: true });
|
|
380
|
+
await promises_1.default.mkdir(node_path_1.default.join(wikiDir, "syntheses"), { recursive: true });
|
|
375
381
|
await promises_1.default.mkdir(sourcesDir, { recursive: true });
|
|
382
|
+
details.push("Created .docuflow/ directory structure");
|
|
376
383
|
// Copy or create template files
|
|
377
384
|
await copyTemplateFile("schema.md", node_path_1.default.join(docuflowDir, "schema.md"));
|
|
378
385
|
await copyTemplateFile("index.md", node_path_1.default.join(docuflowDir, "index.md"));
|
|
379
386
|
await copyTemplateFile("log.md", node_path_1.default.join(docuflowDir, "log.md"));
|
|
380
|
-
|
|
387
|
+
details.push("Wrote schema.md, index.md, log.md");
|
|
388
|
+
// Generate CLAUDE.md
|
|
381
389
|
await writeClaudeMd(projectDir);
|
|
382
|
-
|
|
390
|
+
details.push("Wrote CLAUDE.md");
|
|
391
|
+
// Generate AGENTS.md
|
|
383
392
|
await writeAgentsMd(projectDir);
|
|
384
|
-
|
|
385
|
-
//
|
|
386
|
-
const vscodeDirPath = node_path_1.default.join(projectDir, ".vscode");
|
|
387
|
-
const vscodeWorkspaceMcpPath = node_path_1.default.join(vscodeDirPath, "mcp.json");
|
|
388
|
-
let workspaceMcpConfig = {};
|
|
393
|
+
details.push("Wrote AGENTS.md");
|
|
394
|
+
// Write .vscode/mcp.json for project-level workspace MCP config
|
|
389
395
|
try {
|
|
390
|
-
const
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
396
|
+
const vscodeDirPath = node_path_1.default.join(projectDir, ".vscode");
|
|
397
|
+
const vscodeWorkspaceMcpPath = node_path_1.default.join(vscodeDirPath, "mcp.json");
|
|
398
|
+
let workspaceMcpConfig = {};
|
|
399
|
+
try {
|
|
400
|
+
const raw = await promises_1.default.readFile(vscodeWorkspaceMcpPath, "utf8");
|
|
401
|
+
workspaceMcpConfig = JSON.parse(raw);
|
|
402
|
+
}
|
|
403
|
+
catch { /* File doesn't exist yet */ }
|
|
404
|
+
if (!workspaceMcpConfig.servers)
|
|
405
|
+
workspaceMcpConfig.servers = {};
|
|
406
|
+
workspaceMcpConfig.servers.docuflow = {
|
|
407
|
+
command: "npx",
|
|
408
|
+
args: ["-y", "-p", "@doquflow/server", "docuflow-server"],
|
|
409
|
+
type: "stdio",
|
|
410
|
+
};
|
|
411
|
+
await promises_1.default.mkdir(vscodeDirPath, { recursive: true });
|
|
412
|
+
await promises_1.default.writeFile(vscodeWorkspaceMcpPath, JSON.stringify(workspaceMcpConfig, null, 2) + "\n", "utf8");
|
|
413
|
+
details.push("Wrote .vscode/mcp.json");
|
|
395
414
|
}
|
|
396
|
-
|
|
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");
|
|
415
|
+
catch { /* non-fatal */ }
|
|
405
416
|
// Add .docuflow/ to .gitignore if present and not already listed
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
417
|
+
try {
|
|
418
|
+
const gitignorePath = node_path_1.default.join(projectDir, ".gitignore");
|
|
419
|
+
if (node_fs_1.default.existsSync(gitignorePath)) {
|
|
420
|
+
const gitignore = await promises_1.default.readFile(gitignorePath, "utf8");
|
|
421
|
+
if (!gitignore.includes(".docuflow/") && !gitignore.includes(".docuflow")) {
|
|
422
|
+
await promises_1.default.appendFile(gitignorePath, "\n# Docuflow\n.docuflow/\n");
|
|
423
|
+
details.push("Added .docuflow/ to .gitignore");
|
|
424
|
+
}
|
|
411
425
|
}
|
|
412
426
|
}
|
|
413
|
-
|
|
427
|
+
catch { /* non-fatal */ }
|
|
428
|
+
// Install git post-commit hook
|
|
414
429
|
await installGitHook(projectDir);
|
|
415
|
-
|
|
430
|
+
details.push("Installed git post-commit hook");
|
|
431
|
+
// Register in global project registry
|
|
416
432
|
await registerInGlobalRegistry(projectDir);
|
|
417
|
-
|
|
433
|
+
details.push("Registered in global project registry (~/.docuflow/projects.json)");
|
|
434
|
+
return { ok: true, path: projectDir, details };
|
|
435
|
+
}
|
|
436
|
+
async function run() {
|
|
437
|
+
const result = await runInit(process.cwd());
|
|
438
|
+
const docuflowDir = node_path_1.default.join(result.path, ".docuflow");
|
|
439
|
+
console.log("✓ DocuFlow initialised successfully.");
|
|
418
440
|
console.log("");
|
|
419
|
-
console.log("
|
|
441
|
+
console.log("📁 Structure created:");
|
|
420
442
|
console.log(` ${docuflowDir}/`);
|
|
421
|
-
console.log(`
|
|
422
|
-
console.log(`
|
|
423
|
-
console.log(`
|
|
424
|
-
console.log(`
|
|
425
|
-
console.log(`
|
|
426
|
-
console.log(`
|
|
427
|
-
console.log(`
|
|
428
|
-
console.log(`
|
|
429
|
-
console.log(`
|
|
430
|
-
console.log(`
|
|
443
|
+
console.log(` ├── specs/ (code specs written by the agent)`);
|
|
444
|
+
console.log(` ├── wiki/ (LLM-generated wiki pages)`);
|
|
445
|
+
console.log(` │ ├── entities/`);
|
|
446
|
+
console.log(` │ ├── concepts/`);
|
|
447
|
+
console.log(` │ ├── timelines/`);
|
|
448
|
+
console.log(` │ └── syntheses/`);
|
|
449
|
+
console.log(` ├── sources/ (raw markdown documents to ingest)`);
|
|
450
|
+
console.log(` ├── schema.md (wiki configuration)`);
|
|
451
|
+
console.log(` ├── index.md (auto-maintained catalog)`);
|
|
452
|
+
console.log(` └── log.md (operation log)`);
|
|
431
453
|
console.log("");
|
|
432
|
-
console.log("
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
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)`);
|
|
454
|
+
console.log("📝 Steps completed:");
|
|
455
|
+
for (const line of result.details) {
|
|
456
|
+
console.log(` ✓ ${line}`);
|
|
457
|
+
}
|
|
442
458
|
console.log("");
|
|
443
|
-
console.log("
|
|
459
|
+
console.log("📖 Next steps:");
|
|
444
460
|
console.log(" 1. Edit .docuflow/schema.md to customize your wiki domain");
|
|
445
461
|
console.log(" 2. Add markdown docs to .docuflow/sources/ then ingest them");
|
|
446
462
|
console.log(" 3. Restart Claude Desktop / reload VS Code / restart Copilot CLI");
|
|
447
463
|
console.log("");
|
|
448
|
-
console.log("
|
|
464
|
+
console.log("⚡ Auto-sync options:");
|
|
449
465
|
console.log(" docuflow watch # background daemon (watches for file changes)");
|
|
450
466
|
console.log(" docuflow watch --ai # + Claude/Codex documents code changes automatically");
|
|
451
467
|
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
|
+
}
|