@pi-unipi/kanboard 2.0.7 → 2.0.9
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/commands.ts +84 -0
- package/index.ts +79 -0
- package/package.json +10 -4
- package/parser/index.ts +121 -0
- package/parser/milestones.ts +110 -0
- package/parser/plans.ts +133 -0
- package/parser/remaining.ts +386 -0
- package/parser/specs.ts +105 -0
- package/server/index.ts +278 -0
- package/server/routes/milestone.ts +38 -0
- package/server/routes/workflow.ts +41 -0
- package/tui/kanboard-overlay.ts +300 -0
- package/types.ts +67 -0
package/commands.ts
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @pi-unipi/kanboard — Command Registration
|
|
3
|
+
*
|
|
4
|
+
* Registers kanboard and kanboard-doctor commands.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import * as fs from "node:fs";
|
|
8
|
+
import * as path from "node:path";
|
|
9
|
+
import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
10
|
+
import { UNIPI_PREFIX, KANBOARD_COMMANDS, KANBOARD_DIRS, UNIPI_EVENTS, emitEvent } from "@pi-unipi/core";
|
|
11
|
+
import { startServer, KanboardServer } from "./server/index.js";
|
|
12
|
+
import { renderKanboardOverlay } from "./tui/kanboard-overlay.js";
|
|
13
|
+
|
|
14
|
+
/** Module-level reference to running server */
|
|
15
|
+
let runningServer: KanboardServer | null = null;
|
|
16
|
+
|
|
17
|
+
/** Check if a kanboard server is running via PID file */
|
|
18
|
+
function isPidFileRunning(pidFile: string): boolean {
|
|
19
|
+
try {
|
|
20
|
+
if (!fs.existsSync(pidFile)) return false;
|
|
21
|
+
const pid = parseInt(fs.readFileSync(pidFile, "utf-8").trim(), 10);
|
|
22
|
+
if (isNaN(pid)) return false;
|
|
23
|
+
process.kill(pid, 0); // Check if process exists
|
|
24
|
+
return true;
|
|
25
|
+
} catch {
|
|
26
|
+
// Process doesn't exist or can't access PID file
|
|
27
|
+
try {
|
|
28
|
+
fs.unlinkSync(pidFile);
|
|
29
|
+
} catch {}
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Register kanboard commands */
|
|
35
|
+
export function registerCommands(pi: ExtensionAPI): void {
|
|
36
|
+
// kanboard — Toggle server start/stop
|
|
37
|
+
pi.registerCommand(
|
|
38
|
+
`${UNIPI_PREFIX}${KANBOARD_COMMANDS.KANBOARD}`,
|
|
39
|
+
{
|
|
40
|
+
description: "Toggle kanboard visualization server",
|
|
41
|
+
handler: async (_args: string, ctx: ExtensionCommandContext) => {
|
|
42
|
+
const pidFile = path.resolve(KANBOARD_DIRS.PID_FILE);
|
|
43
|
+
|
|
44
|
+
try {
|
|
45
|
+
// Case 1: We have a live reference in this process
|
|
46
|
+
if (runningServer) {
|
|
47
|
+
runningServer.stop();
|
|
48
|
+
runningServer = null;
|
|
49
|
+
ctx.ui.notify("Kanboard stopped", "info");
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Case 2: PID file shows a running instance (from previous process)
|
|
54
|
+
if (isPidFileRunning(pidFile)) {
|
|
55
|
+
// PID file exists but we don't have a reference — stale or external.
|
|
56
|
+
// Remove stale PID file and let user know.
|
|
57
|
+
try { fs.unlinkSync(pidFile); } catch {}
|
|
58
|
+
ctx.ui.notify("Kanboard was running in a previous session. PID file cleaned. Run again to start fresh.", "info");
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Case 3: No running instance — start fresh
|
|
63
|
+
const { server, url } = await startServer();
|
|
64
|
+
runningServer = server;
|
|
65
|
+
ctx.ui.notify(`Kanboard running at ${url}`, "info");
|
|
66
|
+
} catch (err: unknown) {
|
|
67
|
+
ctx.ui.notify(`Kanboard error: ${err instanceof Error ? err.message : String(err)}`, "error");
|
|
68
|
+
}
|
|
69
|
+
},
|
|
70
|
+
},
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
// kanboard-doctor — Load doctor skill
|
|
74
|
+
pi.registerCommand(
|
|
75
|
+
`${UNIPI_PREFIX}${KANBOARD_COMMANDS.KANBOARD_DOCTOR}`,
|
|
76
|
+
{
|
|
77
|
+
description: "Diagnose and fix kanboard parser issues",
|
|
78
|
+
handler: async (_args: string, ctx: ExtensionCommandContext) => {
|
|
79
|
+
ctx.ui.notify("Loading kanboard-doctor skill...", "info");
|
|
80
|
+
// The skill will be loaded by the skill system via resources_discover
|
|
81
|
+
},
|
|
82
|
+
},
|
|
83
|
+
);
|
|
84
|
+
}
|
package/index.ts
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @pi-unipi/kanboard — Extension entry
|
|
3
|
+
*
|
|
4
|
+
* Visualization layer for unipi workflow data.
|
|
5
|
+
* HTTP server with htmx + Alpine.js UI, modular parsers, TUI overlay, and kanban board.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
9
|
+
import { MODULES, KANBOARD_COMMANDS } from "@pi-unipi/core";
|
|
10
|
+
import { registerCommands } from "./commands.js";
|
|
11
|
+
|
|
12
|
+
/** Package version */
|
|
13
|
+
const VERSION = "0.1.0";
|
|
14
|
+
|
|
15
|
+
export default function (pi: ExtensionAPI): void {
|
|
16
|
+
// Register skills directory
|
|
17
|
+
const skillsDir = new URL("./skills", import.meta.url).pathname;
|
|
18
|
+
|
|
19
|
+
pi.on("resources_discover", async () => {
|
|
20
|
+
return {
|
|
21
|
+
skillPaths: [skillsDir],
|
|
22
|
+
};
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
// Register commands
|
|
26
|
+
registerCommands(pi);
|
|
27
|
+
|
|
28
|
+
// Note: Badge generation on first message is handled by the utility module.
|
|
29
|
+
// Kanboard no longer manages badge generation to avoid duplication.
|
|
30
|
+
|
|
31
|
+
// Register info-screen group
|
|
32
|
+
const registry = globalThis.__unipi_info_registry;
|
|
33
|
+
if (registry) {
|
|
34
|
+
registry.registerGroup({
|
|
35
|
+
id: "kanboard",
|
|
36
|
+
name: "Kanboard",
|
|
37
|
+
icon: "📋",
|
|
38
|
+
priority: 50,
|
|
39
|
+
config: {
|
|
40
|
+
showByDefault: true,
|
|
41
|
+
stats: [
|
|
42
|
+
{ id: "status", label: "Server Status", show: true },
|
|
43
|
+
{ id: "url", label: "URL", show: true },
|
|
44
|
+
{ id: "docs", label: "Documents", show: true },
|
|
45
|
+
{ id: "tasks", label: "Tasks", show: true },
|
|
46
|
+
],
|
|
47
|
+
},
|
|
48
|
+
dataProvider: async () => {
|
|
49
|
+
const { createDefaultRegistry } = await import("./parser/index.js");
|
|
50
|
+
const registry = await createDefaultRegistry();
|
|
51
|
+
const docs = registry.parseAll(".unipi/docs");
|
|
52
|
+
const totalItems = docs.reduce((sum, d) => sum + d.items.length, 0);
|
|
53
|
+
const doneItems = docs.reduce(
|
|
54
|
+
(sum, d) => sum + d.items.filter((i) => i.status === "done").length,
|
|
55
|
+
0,
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
return {
|
|
59
|
+
status: {
|
|
60
|
+
value: "Ready",
|
|
61
|
+
detail: "Server not running (use /unipi:kanboard to start)",
|
|
62
|
+
},
|
|
63
|
+
url: {
|
|
64
|
+
value: "—",
|
|
65
|
+
detail: "Start server to get URL",
|
|
66
|
+
},
|
|
67
|
+
docs: {
|
|
68
|
+
value: String(docs.length),
|
|
69
|
+
detail: `${docs.length} documents parsed`,
|
|
70
|
+
},
|
|
71
|
+
tasks: {
|
|
72
|
+
value: `${doneItems}/${totalItems}`,
|
|
73
|
+
detail: `${totalItems > 0 ? Math.round((doneItems / totalItems) * 100) : 0}% complete`,
|
|
74
|
+
},
|
|
75
|
+
};
|
|
76
|
+
},
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
}
|
package/package.json
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pi-unipi/kanboard",
|
|
3
|
-
"version": "2.0.
|
|
3
|
+
"version": "2.0.9",
|
|
4
4
|
"description": "Visualization layer for unipi workflow — HTTP server with htmx/Alpine.js UI, modular parsers, TUI overlay, and kanban board",
|
|
5
5
|
"type": "module",
|
|
6
|
+
"main": "index.ts",
|
|
6
7
|
"license": "MIT",
|
|
7
8
|
"author": "Neuron Mr White",
|
|
8
9
|
"repository": {
|
|
@@ -22,7 +23,12 @@
|
|
|
22
23
|
"workflow"
|
|
23
24
|
],
|
|
24
25
|
"files": [
|
|
25
|
-
"
|
|
26
|
+
"index.ts",
|
|
27
|
+
"commands.ts",
|
|
28
|
+
"types.ts",
|
|
29
|
+
"parser/**/*.ts",
|
|
30
|
+
"server/**/*.ts",
|
|
31
|
+
"tui/**/*.ts",
|
|
26
32
|
"ui/**/*.ts",
|
|
27
33
|
"ui/**/*.css",
|
|
28
34
|
"ui/**/*.js",
|
|
@@ -44,8 +50,8 @@
|
|
|
44
50
|
"@pi-unipi/core": "*"
|
|
45
51
|
},
|
|
46
52
|
"peerDependencies": {
|
|
47
|
-
"@
|
|
48
|
-
"@
|
|
53
|
+
"@earendil-works/pi-coding-agent": "^0.75.5",
|
|
54
|
+
"@earendil-works/pi-tui": "^0.75.5"
|
|
49
55
|
},
|
|
50
56
|
"devDependencies": {
|
|
51
57
|
"@types/node": "^25.6.0",
|
package/parser/index.ts
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @pi-unipi/kanboard — Parser Registry
|
|
3
|
+
*
|
|
4
|
+
* Central registry for document parsers. Auto-detects doc type
|
|
5
|
+
* by file path and routes to the appropriate parser.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import * as fs from "node:fs";
|
|
9
|
+
import * as path from "node:path";
|
|
10
|
+
import type { DocParser, ParsedDoc, DocType } from "../types.js";
|
|
11
|
+
|
|
12
|
+
/** Path patterns for doc type detection */
|
|
13
|
+
const PATH_PATTERNS: Array<{ pattern: RegExp; type: DocType }> = [
|
|
14
|
+
{ pattern: /\/specs\//, type: "spec" },
|
|
15
|
+
{ pattern: /\/plans\//, type: "plan" },
|
|
16
|
+
{ pattern: /MILESTONES\.md$/i, type: "milestone" },
|
|
17
|
+
{ pattern: /\/quick-work\//, type: "quick-work" },
|
|
18
|
+
{ pattern: /\/debug\//, type: "debug" },
|
|
19
|
+
{ pattern: /\/fix\//, type: "fix" },
|
|
20
|
+
{ pattern: /\/chore\//, type: "chore" },
|
|
21
|
+
{ pattern: /\/reviews\//, type: "review" },
|
|
22
|
+
];
|
|
23
|
+
|
|
24
|
+
/** Detect doc type from file path */
|
|
25
|
+
export function detectDocType(filePath: string): DocType | null {
|
|
26
|
+
for (const { pattern, type } of PATH_PATTERNS) {
|
|
27
|
+
if (pattern.test(filePath)) return type;
|
|
28
|
+
}
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Parser registry — manages all document parsers */
|
|
33
|
+
export class ParserRegistry {
|
|
34
|
+
private parsers: DocParser[] = [];
|
|
35
|
+
|
|
36
|
+
/** Register a parser */
|
|
37
|
+
register(parser: DocParser): void {
|
|
38
|
+
this.parsers.push(parser);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Parse a single file using the matching parser */
|
|
42
|
+
parse(filePath: string): ParsedDoc | null {
|
|
43
|
+
for (const parser of this.parsers) {
|
|
44
|
+
if (parser.canParse(filePath)) {
|
|
45
|
+
return parser.parse(filePath);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Parse all matching files in a directory */
|
|
52
|
+
parseAll(dir: string): ParsedDoc[] {
|
|
53
|
+
const results: ParsedDoc[] = [];
|
|
54
|
+
const files = this.findDocFiles(dir);
|
|
55
|
+
|
|
56
|
+
for (const file of files) {
|
|
57
|
+
const doc = this.parse(file);
|
|
58
|
+
if (doc) {
|
|
59
|
+
results.push(doc);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return results;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Recursively find .md files in docs directory */
|
|
67
|
+
private findDocFiles(dir: string): string[] {
|
|
68
|
+
const files: string[] = [];
|
|
69
|
+
|
|
70
|
+
if (!fs.existsSync(dir)) return files;
|
|
71
|
+
|
|
72
|
+
const walk = (currentDir: string) => {
|
|
73
|
+
let entries: fs.Dirent[];
|
|
74
|
+
try {
|
|
75
|
+
entries = fs.readdirSync(currentDir, { withFileTypes: true });
|
|
76
|
+
} catch {
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
for (const entry of entries) {
|
|
81
|
+
const fullPath = path.join(currentDir, entry.name);
|
|
82
|
+
if (entry.isDirectory() && !entry.name.startsWith(".")) {
|
|
83
|
+
walk(fullPath);
|
|
84
|
+
} else if (entry.isFile() && entry.name.endsWith(".md")) {
|
|
85
|
+
files.push(fullPath);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
walk(dir);
|
|
91
|
+
return files;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Create a registry with all default parsers registered */
|
|
96
|
+
export async function createDefaultRegistry(): Promise<ParserRegistry> {
|
|
97
|
+
const registry = new ParserRegistry();
|
|
98
|
+
|
|
99
|
+
// Import and register all parsers
|
|
100
|
+
const { SpecParser } = await import("./specs.js");
|
|
101
|
+
const { PlanParser } = await import("./plans.js");
|
|
102
|
+
const { MilestoneParser } = await import("./milestones.js");
|
|
103
|
+
const {
|
|
104
|
+
QuickWorkParser,
|
|
105
|
+
DebugParser,
|
|
106
|
+
FixParser,
|
|
107
|
+
ChoreParser,
|
|
108
|
+
ReviewParser,
|
|
109
|
+
} = await import("./remaining.js");
|
|
110
|
+
|
|
111
|
+
registry.register(new SpecParser());
|
|
112
|
+
registry.register(new PlanParser());
|
|
113
|
+
registry.register(new MilestoneParser());
|
|
114
|
+
registry.register(new QuickWorkParser());
|
|
115
|
+
registry.register(new DebugParser());
|
|
116
|
+
registry.register(new FixParser());
|
|
117
|
+
registry.register(new ChoreParser());
|
|
118
|
+
registry.register(new ReviewParser());
|
|
119
|
+
|
|
120
|
+
return registry;
|
|
121
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @pi-unipi/kanboard — Milestone Parser
|
|
3
|
+
*
|
|
4
|
+
* Parses MILESTONES.md. Imports `parseMilestones` from `@pi-unipi/milestone`
|
|
5
|
+
* and converts MilestoneDoc to ParsedDoc format.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import * as fs from "node:fs";
|
|
9
|
+
import * as path from "node:path";
|
|
10
|
+
import type { DocParser, ParsedDoc, ParsedItem } from "../types.js";
|
|
11
|
+
|
|
12
|
+
/** Milestone parser — converts MilestoneDoc to ParsedDoc */
|
|
13
|
+
export class MilestoneParser implements DocParser {
|
|
14
|
+
canParse(filePath: string): boolean {
|
|
15
|
+
return /MILESTONES\.md$/i.test(filePath);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
parse(filePath: string): ParsedDoc {
|
|
19
|
+
const warnings: string[] = [];
|
|
20
|
+
const items: ParsedItem[] = [];
|
|
21
|
+
let content: string;
|
|
22
|
+
|
|
23
|
+
try {
|
|
24
|
+
content = fs.readFileSync(filePath, "utf-8");
|
|
25
|
+
} catch (err: unknown) {
|
|
26
|
+
warnings.push(`Could not read file: ${err instanceof Error ? err.message : String(err)}`);
|
|
27
|
+
return this.emptyDoc(filePath, warnings);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const lines = content.split("\n");
|
|
31
|
+
const fileName = path.basename(filePath);
|
|
32
|
+
|
|
33
|
+
// Inline parsing — extract phases and items directly
|
|
34
|
+
// This avoids requiring @pi-unipi/milestone as a runtime dependency
|
|
35
|
+
let currentPhase = "";
|
|
36
|
+
let inFrontmatter = false;
|
|
37
|
+
let frontmatterDone = false;
|
|
38
|
+
let title = "Project Milestones";
|
|
39
|
+
|
|
40
|
+
for (let i = 0; i < lines.length; i++) {
|
|
41
|
+
const line = lines[i];
|
|
42
|
+
const lineNum = i + 1;
|
|
43
|
+
|
|
44
|
+
// Frontmatter parsing
|
|
45
|
+
if (lineNum === 1 && line.trim() === "---") {
|
|
46
|
+
inFrontmatter = true;
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
if (inFrontmatter && line.trim() === "---") {
|
|
50
|
+
inFrontmatter = false;
|
|
51
|
+
frontmatterDone = true;
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
if (inFrontmatter) {
|
|
55
|
+
const titleMatch = line.match(/^title:\s*(.+)$/);
|
|
56
|
+
if (titleMatch) {
|
|
57
|
+
title = titleMatch[1].trim().replace(/^["']|["']$/g, "");
|
|
58
|
+
}
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Phase headers (## Phase N: Name)
|
|
63
|
+
const phaseMatch = line.match(/^##\s+(.+)$/);
|
|
64
|
+
if (phaseMatch) {
|
|
65
|
+
currentPhase = phaseMatch[1].trim();
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Checklist items (- [ ] or - [x])
|
|
70
|
+
const checkboxMatch = line.match(/^\s*-\s*\[([ xX])\]\s*(.*)$/);
|
|
71
|
+
if (checkboxMatch) {
|
|
72
|
+
const checked = checkboxMatch[1].toLowerCase() === "x";
|
|
73
|
+
const text = checkboxMatch[2].trim();
|
|
74
|
+
|
|
75
|
+
if (!text) {
|
|
76
|
+
warnings.push(`Line ${lineNum}: Empty checkbox text`);
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
items.push({
|
|
81
|
+
text: currentPhase ? `[${currentPhase}] ${text}` : text,
|
|
82
|
+
status: checked ? "done" : "todo",
|
|
83
|
+
lineNumber: lineNum,
|
|
84
|
+
sourceFile: fileName,
|
|
85
|
+
command: `/unipi:milestone-update`,
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return {
|
|
91
|
+
type: "milestone",
|
|
92
|
+
title,
|
|
93
|
+
filePath,
|
|
94
|
+
items,
|
|
95
|
+
metadata: {},
|
|
96
|
+
warnings,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
private emptyDoc(filePath: string, warnings: string[]): ParsedDoc {
|
|
101
|
+
return {
|
|
102
|
+
type: "milestone",
|
|
103
|
+
title: "Project Milestones",
|
|
104
|
+
filePath,
|
|
105
|
+
items: [],
|
|
106
|
+
metadata: {},
|
|
107
|
+
warnings,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
}
|
package/parser/plans.ts
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @pi-unipi/kanboard — Plan Parser
|
|
3
|
+
*
|
|
4
|
+
* Parses plans for task statuses. Handles the format:
|
|
5
|
+
* ### Task N — Name
|
|
6
|
+
* - **Status:** unstarted|in-progress|completed|failed|awaiting_user|blocked|skipped
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import * as fs from "node:fs";
|
|
10
|
+
import * as path from "node:path";
|
|
11
|
+
import type { DocParser, ParsedDoc, ParsedItem, ItemStatus } from "../types.js";
|
|
12
|
+
|
|
13
|
+
/** Parse frontmatter from markdown file */
|
|
14
|
+
function parseFrontmatter(content: string): {
|
|
15
|
+
metadata: Record<string, string>;
|
|
16
|
+
bodyStart: number;
|
|
17
|
+
} {
|
|
18
|
+
const metadata: Record<string, string> = {};
|
|
19
|
+
const lines = content.split("\n");
|
|
20
|
+
|
|
21
|
+
if (lines[0]?.trim() !== "---") return { metadata, bodyStart: 0 };
|
|
22
|
+
|
|
23
|
+
for (let i = 1; i < lines.length; i++) {
|
|
24
|
+
const line = lines[i];
|
|
25
|
+
if (line.trim() === "---") {
|
|
26
|
+
return { metadata, bodyStart: i + 1 };
|
|
27
|
+
}
|
|
28
|
+
const match = line.match(/^(\w[\w-]*):\s*(.*)$/);
|
|
29
|
+
if (match) {
|
|
30
|
+
metadata[match[1]] = match[2].trim();
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return { metadata, bodyStart: 0 };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Status keyword to ItemStatus mapping */
|
|
38
|
+
const STATUS_MAP: Record<string, ItemStatus> = {
|
|
39
|
+
unstarted: "todo",
|
|
40
|
+
"in-progress": "in-progress",
|
|
41
|
+
completed: "done",
|
|
42
|
+
failed: "todo",
|
|
43
|
+
awaiting_user: "in-progress",
|
|
44
|
+
blocked: "in-progress",
|
|
45
|
+
skipped: "done",
|
|
46
|
+
reviewed: "reviewed",
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
/** Task header pattern */
|
|
50
|
+
const TASK_HEADER_PATTERN = /^###\s+Task\s+\d+\s*[—–-]\s*(.+)$/;
|
|
51
|
+
|
|
52
|
+
/** Status line pattern: - **Status:** keyword */
|
|
53
|
+
const STATUS_LINE_PATTERN =
|
|
54
|
+
/^\s*-\s*\*\*Status:\*\*\s*(unstarted|in-progress|completed|failed|awaiting_user|blocked|skipped|reviewed)\s*$/;
|
|
55
|
+
|
|
56
|
+
/** Plan parser — extracts task statuses from plans */
|
|
57
|
+
export class PlanParser implements DocParser {
|
|
58
|
+
canParse(filePath: string): boolean {
|
|
59
|
+
return /\/plans\//.test(filePath) && filePath.endsWith(".md");
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
parse(filePath: string): ParsedDoc {
|
|
63
|
+
const warnings: string[] = [];
|
|
64
|
+
const items: ParsedItem[] = [];
|
|
65
|
+
let content: string;
|
|
66
|
+
|
|
67
|
+
try {
|
|
68
|
+
content = fs.readFileSync(filePath, "utf-8");
|
|
69
|
+
} catch (err: unknown) {
|
|
70
|
+
warnings.push(`Could not read file: ${err instanceof Error ? err.message : String(err)}`);
|
|
71
|
+
return this.emptyDoc(filePath, warnings);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const { metadata, bodyStart } = parseFrontmatter(content);
|
|
75
|
+
const lines = content.split("\n");
|
|
76
|
+
const fileName = path.basename(filePath);
|
|
77
|
+
|
|
78
|
+
let currentTaskName: string | null = null;
|
|
79
|
+
let currentTaskLine: number | null = null;
|
|
80
|
+
|
|
81
|
+
for (let i = bodyStart; i < lines.length; i++) {
|
|
82
|
+
const line = lines[i];
|
|
83
|
+
const lineNum = i + 1; // 1-indexed
|
|
84
|
+
|
|
85
|
+
// Match task headers: ### Task N — Name
|
|
86
|
+
const headerMatch = line.match(TASK_HEADER_PATTERN);
|
|
87
|
+
if (headerMatch) {
|
|
88
|
+
currentTaskName = headerMatch[1].trim();
|
|
89
|
+
currentTaskLine = lineNum;
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Match status lines: - **Status:** keyword
|
|
94
|
+
const statusMatch = line.match(STATUS_LINE_PATTERN);
|
|
95
|
+
if (statusMatch && currentTaskName) {
|
|
96
|
+
const statusKey = statusMatch[1];
|
|
97
|
+
const status = STATUS_MAP[statusKey] ?? "todo";
|
|
98
|
+
|
|
99
|
+
items.push({
|
|
100
|
+
text: currentTaskName,
|
|
101
|
+
status,
|
|
102
|
+
lineNumber: currentTaskLine ?? lineNum,
|
|
103
|
+
sourceFile: fileName,
|
|
104
|
+
command: `/unipi:work plan:${fileName}`,
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
// Reset current task
|
|
108
|
+
currentTaskName = null;
|
|
109
|
+
currentTaskLine = null;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return {
|
|
114
|
+
type: "plan",
|
|
115
|
+
title: metadata.title ?? fileName.replace(/\.md$/, ""),
|
|
116
|
+
filePath,
|
|
117
|
+
items,
|
|
118
|
+
metadata,
|
|
119
|
+
warnings,
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
private emptyDoc(filePath: string, warnings: string[]): ParsedDoc {
|
|
124
|
+
return {
|
|
125
|
+
type: "plan",
|
|
126
|
+
title: path.basename(filePath).replace(/\.md$/, ""),
|
|
127
|
+
filePath,
|
|
128
|
+
items: [],
|
|
129
|
+
metadata: {},
|
|
130
|
+
warnings,
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
}
|