@pi-unipi/kanboard 2.0.8 → 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 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-unipi/kanboard",
3
- "version": "2.0.8",
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
6
  "main": "index.ts",
@@ -23,7 +23,12 @@
23
23
  "workflow"
24
24
  ],
25
25
  "files": [
26
- "src/**/*.ts",
26
+ "index.ts",
27
+ "commands.ts",
28
+ "types.ts",
29
+ "parser/**/*.ts",
30
+ "server/**/*.ts",
31
+ "tui/**/*.ts",
27
32
  "ui/**/*.ts",
28
33
  "ui/**/*.css",
29
34
  "ui/**/*.js",
@@ -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
+ }
@@ -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
+ }