@pi-unipi/kanboard 2.20.4 → 3.0.0-alpha.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.
- package/README.md +184 -66
- package/index.ts +206 -63
- package/package.json +18 -23
- package/skills/kanboard/SKILL.md +106 -0
- package/src/bin.ts +210 -0
- package/src/commands.ts +1371 -0
- package/src/guard.ts +143 -0
- package/src/runner.ts +817 -0
- package/src/settings.ts +158 -0
- package/src/shapes.ts +220 -0
- package/commands.ts +0 -83
- package/parser/checkbox-parser.ts +0 -165
- package/parser/frontmatter.ts +0 -26
- package/parser/index.ts +0 -94
- package/parser/milestones.ts +0 -110
- package/parser/plans.ts +0 -111
- package/server/index.ts +0 -266
- package/server/routes/milestone.ts +0 -38
- package/server/routes/workflow.ts +0 -41
- package/skills/kanboard-doctor/SKILL.md +0 -71
- package/types.ts +0 -67
- package/ui/components/checklist.ts +0 -58
- package/ui/components/copy-button.ts +0 -23
- package/ui/components/status-badge.ts +0 -21
- package/ui/layouts/base.ts +0 -68
- package/ui/milestone/page.ts +0 -140
- package/ui/static/app.js +0 -73
- package/ui/static/style.css +0 -729
- package/ui/workflow/page.ts +0 -182
package/src/settings.ts
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @pi-unipi/kanboard — settings (hub section "Kanboard").
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { getSettings, registerCommandRunner, registerSettings, setSettings } from "@pi-unipi/core";
|
|
6
|
+
|
|
7
|
+
export type ChainGate = "in_review" | "done";
|
|
8
|
+
|
|
9
|
+
export interface KanboardSettings {
|
|
10
|
+
/** Which dependency status counts as "the chain reached this task". */
|
|
11
|
+
chainGate: ChainGate;
|
|
12
|
+
/** Passed to `serve --idle-min`. */
|
|
13
|
+
idleMin: number;
|
|
14
|
+
/** Passed to `serve --host` (127.0.0.1 = local only). */
|
|
15
|
+
host: string;
|
|
16
|
+
/** Passed to `serve --port` (0 = OS-assigned). */
|
|
17
|
+
port: number;
|
|
18
|
+
/** Auto-archive done/cancelled tasks after N days (0 = off). */
|
|
19
|
+
archiveAfterDays: number;
|
|
20
|
+
/** Move archived/cancelled tasks to cold storage after N days (0 = off). */
|
|
21
|
+
retentionDays: number;
|
|
22
|
+
/** Open the board in a browser on `open` (default off). */
|
|
23
|
+
openBrowser: boolean;
|
|
24
|
+
/** Token-gate loopback binds too (remote always needs it). */
|
|
25
|
+
requireAuth: boolean;
|
|
26
|
+
/** Reuse <home>/token so board links survive daemon restarts. */
|
|
27
|
+
keepToken: boolean;
|
|
28
|
+
/** Tasks one session may queue (0 = unlimited). */
|
|
29
|
+
queueMax: number;
|
|
30
|
+
/** Distinct sessions running tasks per project (min 1). */
|
|
31
|
+
maxSessions: number;
|
|
32
|
+
/** `add` calls allowed per -do/runner window (0 = unlimited). */
|
|
33
|
+
turnAddLimit: number;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export const DEFAULT_SETTINGS: KanboardSettings = {
|
|
37
|
+
chainGate: "in_review",
|
|
38
|
+
idleMin: 10,
|
|
39
|
+
host: "127.0.0.1",
|
|
40
|
+
port: 0,
|
|
41
|
+
archiveAfterDays: 0,
|
|
42
|
+
retentionDays: 90,
|
|
43
|
+
openBrowser: false,
|
|
44
|
+
requireAuth: false,
|
|
45
|
+
keepToken: false,
|
|
46
|
+
queueMax: 10,
|
|
47
|
+
maxSessions: 2,
|
|
48
|
+
turnAddLimit: 20,
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
export const KANBOARD_NAMESPACE = "kanboard";
|
|
52
|
+
|
|
53
|
+
/** Runners the hub's action rows call (registered by index.ts). */
|
|
54
|
+
export const ACTION_OPEN = "unipi:kanboard-open";
|
|
55
|
+
export const ACTION_STOP_DAEMON = "unipi:kanboard-stop-daemon";
|
|
56
|
+
export const ACTION_ROTATE_TOKEN = "unipi:kanboard-rotate-token";
|
|
57
|
+
|
|
58
|
+
export function registerKanboardSettings(): void {
|
|
59
|
+
registerSettings({
|
|
60
|
+
namespace: KANBOARD_NAMESPACE,
|
|
61
|
+
label: "Kanboard",
|
|
62
|
+
defaults: DEFAULT_SETTINGS as unknown as Record<string, unknown>,
|
|
63
|
+
schema: [
|
|
64
|
+
{
|
|
65
|
+
title: "Kanboard",
|
|
66
|
+
description: "Deferred-work board: task runner, daemon and skill",
|
|
67
|
+
fields: [
|
|
68
|
+
{
|
|
69
|
+
key: "chainGate",
|
|
70
|
+
type: "enum",
|
|
71
|
+
label: "Chain gate",
|
|
72
|
+
options: [
|
|
73
|
+
{ value: "in_review", label: "in_review (next task starts when this one is reviewed)" },
|
|
74
|
+
{ value: "done", label: "done (next task waits for a completed dependency)" },
|
|
75
|
+
],
|
|
76
|
+
description: "When a dependency counts as satisfied for the next task",
|
|
77
|
+
},
|
|
78
|
+
{ key: "idleMin", type: "number", label: "Daemon idle minutes", min: 1, description: "Shut the daemon down after this long with no board open" },
|
|
79
|
+
{
|
|
80
|
+
key: "host",
|
|
81
|
+
type: "string",
|
|
82
|
+
label: "Bind address",
|
|
83
|
+
emptyLabel: "127.0.0.1 (local only)",
|
|
84
|
+
hint: "127.0.0.1 · 0.0.0.0 (LAN, token-gated) · tailscale (tailnet IP)",
|
|
85
|
+
description: "Anything but a loopback address requires an access token",
|
|
86
|
+
},
|
|
87
|
+
{ key: "port", type: "number", label: "Daemon port", min: 0, max: 65535, zeroLabel: "auto", description: "0 lets the OS pick a free port" },
|
|
88
|
+
{ key: "archiveAfterDays", type: "number", label: "Archive after (days)", min: 0, zeroLabel: "off", description: "Auto-archive done/cancelled tasks on session start" },
|
|
89
|
+
{ key: "retentionDays", type: "number", label: "Cold storage after (days)", min: 0, zeroLabel: "off", description: "Move archived/cancelled tasks to cold storage — files stay readable, the board drops them" },
|
|
90
|
+
{ key: "openBrowser", type: "boolean", label: "Open the browser", description: "Open the board in a browser when /unipi:kanboard opens it" },
|
|
91
|
+
{
|
|
92
|
+
key: "requireAuth",
|
|
93
|
+
type: "boolean",
|
|
94
|
+
label: "Require the access token on localhost too",
|
|
95
|
+
description: "Remote binds always need the token; this adds it for 127.0.0.1. Applies on the next daemon start.",
|
|
96
|
+
},
|
|
97
|
+
{
|
|
98
|
+
key: "keepToken",
|
|
99
|
+
type: "boolean",
|
|
100
|
+
label: "Keep the access token across restarts",
|
|
101
|
+
description: "Reuse one token so board links stay valid; rotate it below.",
|
|
102
|
+
},
|
|
103
|
+
{ key: "queueMax", type: "number", label: "-do queue limit", min: 0, zeroLabel: "unlimited", description: "Tasks a session may queue for the runner" },
|
|
104
|
+
{ key: "maxSessions", type: "number", label: "Sessions working at once (per project)", min: 1, description: "Distinct sessions holding in_progress tasks" },
|
|
105
|
+
{ key: "turnAddLimit", type: "number", label: "New tasks per turn", min: 0, zeroLabel: "unlimited", description: "`add` calls allowed per -do turn or runner task" },
|
|
106
|
+
{ key: "open", type: "action", label: "Open board…", description: "Start the daemon and print the URL", command: ACTION_OPEN },
|
|
107
|
+
{ key: "stopDaemon", type: "action", label: "Stop daemon", description: "Terminate the running kanboard daemon", command: ACTION_STOP_DAEMON },
|
|
108
|
+
{ key: "rotateToken", type: "action", label: "Rotate access token", description: "Drop the persistent token; a fresh one mints on the next daemon start", command: ACTION_ROTATE_TOKEN },
|
|
109
|
+
],
|
|
110
|
+
},
|
|
111
|
+
],
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function readKanboardSettings(cwd: string = process.cwd()): KanboardSettings {
|
|
116
|
+
const raw = getSettings(KANBOARD_NAMESPACE, cwd);
|
|
117
|
+
return {
|
|
118
|
+
chainGate: raw.chainGate === "done" ? "done" : "in_review",
|
|
119
|
+
idleMin: typeof raw.idleMin === "number" && raw.idleMin >= 1 ? raw.idleMin : DEFAULT_SETTINGS.idleMin,
|
|
120
|
+
host:
|
|
121
|
+
typeof raw.host === "string" && raw.host.trim().length > 0 ? raw.host.trim() : DEFAULT_SETTINGS.host,
|
|
122
|
+
port: typeof raw.port === "number" && raw.port >= 0 && raw.port <= 65535 ? raw.port : 0,
|
|
123
|
+
archiveAfterDays:
|
|
124
|
+
typeof raw.archiveAfterDays === "number" && raw.archiveAfterDays > 0 ? raw.archiveAfterDays : 0,
|
|
125
|
+
retentionDays:
|
|
126
|
+
typeof raw.retentionDays === "number" && raw.retentionDays >= 0 ? raw.retentionDays : DEFAULT_SETTINGS.retentionDays,
|
|
127
|
+
openBrowser: raw.openBrowser === true,
|
|
128
|
+
requireAuth: raw.requireAuth === true,
|
|
129
|
+
keepToken: raw.keepToken === true,
|
|
130
|
+
queueMax: typeof raw.queueMax === "number" && raw.queueMax >= 0 ? raw.queueMax : DEFAULT_SETTINGS.queueMax,
|
|
131
|
+
maxSessions: typeof raw.maxSessions === "number" && raw.maxSessions >= 1 ? raw.maxSessions : DEFAULT_SETTINGS.maxSessions,
|
|
132
|
+
turnAddLimit: typeof raw.turnAddLimit === "number" && raw.turnAddLimit >= 0 ? raw.turnAddLimit : DEFAULT_SETTINGS.turnAddLimit,
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function writeKanboardSettings(patch: Partial<KanboardSettings>, cwd: string): void {
|
|
137
|
+
setSettings(KANBOARD_NAMESPACE, patch as unknown as Record<string, unknown>, "global", cwd);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Re-exported so index.ts and tests share one registration path. */
|
|
141
|
+
export function registerKanboardRunners(runners: {
|
|
142
|
+
open: (ctx: unknown) => void | Promise<void>;
|
|
143
|
+
stopDaemon: (ctx: unknown) => void | Promise<void>;
|
|
144
|
+
rotateToken?: (ctx: unknown) => void | Promise<void>;
|
|
145
|
+
}): void {
|
|
146
|
+
registerCommandRunner(ACTION_OPEN, runners.open);
|
|
147
|
+
registerCommandRunner(ACTION_STOP_DAEMON, runners.stopDaemon);
|
|
148
|
+
if (runners.rotateToken) registerCommandRunner(ACTION_ROTATE_TOKEN, runners.rotateToken);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Limits reach the Rust CLI through the environment — refresh the process-wide
|
|
153
|
+
* values so the agent's bash calls and the runner's CLI calls both see them.
|
|
154
|
+
*/
|
|
155
|
+
export function applyLimitEnv(settings: KanboardSettings): void {
|
|
156
|
+
process.env.UNIPI_KANBOARD_QUEUE_MAX = String(settings.queueMax);
|
|
157
|
+
process.env.UNIPI_KANBOARD_MAX_SESSIONS = String(settings.maxSessions);
|
|
158
|
+
}
|
package/src/shapes.ts
ADDED
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @pi-unipi/kanboard — the CLI's JSON contract.
|
|
3
|
+
*
|
|
4
|
+
* Every shape the extension consumes is validated here instead of being cast,
|
|
5
|
+
* so a change on the Rust side fails loudly (and the contract test in
|
|
6
|
+
* `tests/contract.test.ts` feeds the real binary's output through these
|
|
7
|
+
* functions). K4 changed `list --json` from an array to `{tasks, problems}` and
|
|
8
|
+
* a stale cast silently broke the runner with "all.map is not a function".
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export class KanboardShapeError extends Error {
|
|
12
|
+
readonly command: string;
|
|
13
|
+
|
|
14
|
+
constructor(command: string, detail: string) {
|
|
15
|
+
super(`unexpected ${command} output: ${detail} — the extension and the binary disagree (version mismatch?)`);
|
|
16
|
+
this.name = "KanboardShapeError";
|
|
17
|
+
this.command = command;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface KanboardProblem {
|
|
22
|
+
file: string;
|
|
23
|
+
line: number;
|
|
24
|
+
error: string;
|
|
25
|
+
fixable?: boolean;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface KanboardRun {
|
|
29
|
+
session?: string;
|
|
30
|
+
pid?: number;
|
|
31
|
+
host?: string;
|
|
32
|
+
mode?: string;
|
|
33
|
+
goal?: string | null;
|
|
34
|
+
started?: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface KanboardActivity {
|
|
38
|
+
at: string;
|
|
39
|
+
actor: string;
|
|
40
|
+
text: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface KanboardTask {
|
|
44
|
+
id: string;
|
|
45
|
+
title: string;
|
|
46
|
+
body?: string;
|
|
47
|
+
status: string;
|
|
48
|
+
priority?: string;
|
|
49
|
+
order?: number;
|
|
50
|
+
deps?: string[];
|
|
51
|
+
labels?: string[];
|
|
52
|
+
run?: KanboardRun | null;
|
|
53
|
+
activity?: KanboardActivity[];
|
|
54
|
+
ready?: boolean;
|
|
55
|
+
waitingFor?: string[];
|
|
56
|
+
/** Deps still in Backlog (or missing): only a human can schedule them. */
|
|
57
|
+
lockedBy?: string[];
|
|
58
|
+
staleness?: string;
|
|
59
|
+
allowedMoves?: string[];
|
|
60
|
+
[key: string]: unknown;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface KanboardProject {
|
|
64
|
+
slug: string;
|
|
65
|
+
name: string;
|
|
66
|
+
root?: string;
|
|
67
|
+
prefix?: string;
|
|
68
|
+
nextId?: number;
|
|
69
|
+
createdAt?: string;
|
|
70
|
+
counts?: Record<string, number>;
|
|
71
|
+
total?: number;
|
|
72
|
+
problems?: KanboardProblem[];
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export interface KanboardClaim {
|
|
76
|
+
task: KanboardTask | null;
|
|
77
|
+
waiting?: Array<{ id: string; waitingFor?: string[]; lockedBy?: string[] }>;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export interface KanboardStop {
|
|
81
|
+
stopped: boolean;
|
|
82
|
+
pid?: number;
|
|
83
|
+
reason?: string;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
87
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function requireRecord(command: string, value: unknown): Record<string, unknown> {
|
|
91
|
+
if (!isRecord(value)) {
|
|
92
|
+
throw new KanboardShapeError(command, `expected an object, got ${Array.isArray(value) ? "an array" : typeof value}`);
|
|
93
|
+
}
|
|
94
|
+
return value;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function requireString(command: string, record: Record<string, unknown>, key: string): string {
|
|
98
|
+
const value = record[key];
|
|
99
|
+
if (typeof value !== "string") {
|
|
100
|
+
throw new KanboardShapeError(command, `missing string "${key}"`);
|
|
101
|
+
}
|
|
102
|
+
return value;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function optionalArray<T>(value: unknown): T[] | undefined {
|
|
106
|
+
return Array.isArray(value) ? (value as T[]) : undefined;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** `show …, add …, move …, note …, release …` → one task. */
|
|
110
|
+
export function asTask(command: string, value: unknown): KanboardTask {
|
|
111
|
+
const record = requireRecord(command, value);
|
|
112
|
+
return {
|
|
113
|
+
...record,
|
|
114
|
+
id: requireString(command, record, "id"),
|
|
115
|
+
title: requireString(command, record, "title"),
|
|
116
|
+
status: requireString(command, record, "status"),
|
|
117
|
+
deps: optionalArray<string>(record.deps) ?? [],
|
|
118
|
+
activity: optionalArray<KanboardActivity>(record.activity) ?? [],
|
|
119
|
+
} as KanboardTask;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function asTaskList(value: unknown): { tasks: KanboardTask[]; problems: KanboardProblem[] } {
|
|
123
|
+
if (Array.isArray(value)) {
|
|
124
|
+
// Exactly the K4 regression: the runner cast the old array shape.
|
|
125
|
+
throw new KanboardShapeError("list", "an array here means the binary is older than the extension");
|
|
126
|
+
}
|
|
127
|
+
const record = requireRecord("list", value);
|
|
128
|
+
if (!Array.isArray(record.tasks)) {
|
|
129
|
+
throw new KanboardShapeError("list", 'expected {tasks: [...], problems: [...]}');
|
|
130
|
+
}
|
|
131
|
+
const tasks = record.tasks.map((entry) => asTask("list", entry));
|
|
132
|
+
const problems = (optionalArray<KanboardProblem>(record.problems) ?? []).map((problem) => ({
|
|
133
|
+
file: String(problem.file ?? ""),
|
|
134
|
+
line: Number(problem.line ?? 0),
|
|
135
|
+
error: String(problem.error ?? ""),
|
|
136
|
+
fixable: problem.fixable === true,
|
|
137
|
+
}));
|
|
138
|
+
return { tasks, problems };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** `claim-next` → `{task: <task|null>, waiting: [...]}`. */
|
|
142
|
+
export function asClaimResult(value: unknown): KanboardClaim {
|
|
143
|
+
const record = requireRecord("claim-next", value);
|
|
144
|
+
if (!("task" in record)) {
|
|
145
|
+
throw new KanboardShapeError("claim-next", 'missing "task"');
|
|
146
|
+
}
|
|
147
|
+
const waiting = (optionalArray<{ id: string; waitingFor?: unknown; lockedBy?: unknown }>(record.waiting) ?? []).map((entry) => ({
|
|
148
|
+
id: String(entry.id ?? ""),
|
|
149
|
+
waitingFor: optionalArray<string>(entry.waitingFor) ?? [],
|
|
150
|
+
lockedBy: optionalArray<string>(entry.lockedBy) ?? [],
|
|
151
|
+
}));
|
|
152
|
+
return {
|
|
153
|
+
task: record.task === null ? null : asTask("claim-next", record.task),
|
|
154
|
+
waiting,
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export function asProject(command: string, value: unknown): KanboardProject {
|
|
159
|
+
const record = requireRecord(command, value);
|
|
160
|
+
return {
|
|
161
|
+
...record,
|
|
162
|
+
slug: requireString(command, record, "slug"),
|
|
163
|
+
name: requireString(command, record, "name"),
|
|
164
|
+
} as KanboardProject;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export function asProjectList(value: unknown): KanboardProject[] {
|
|
168
|
+
if (!Array.isArray(value)) {
|
|
169
|
+
throw new KanboardShapeError("project list", `expected an array, got ${typeof value}`);
|
|
170
|
+
}
|
|
171
|
+
return value.map((entry) => asProject("project list", entry));
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** `project show` → `{project, counts, total, problems}`. */
|
|
175
|
+
export function asProjectDetail(value: unknown): KanboardProject & { total: number } {
|
|
176
|
+
const record = requireRecord("project show", value);
|
|
177
|
+
const project = asProject("project show", record.project);
|
|
178
|
+
return {
|
|
179
|
+
...project,
|
|
180
|
+
counts: isRecord(record.counts) ? (record.counts as Record<string, number>) : {},
|
|
181
|
+
total: typeof record.total === "number" ? record.total : 0,
|
|
182
|
+
problems: optionalArray<KanboardProblem>(record.problems) ?? [],
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export function asStopResult(value: unknown): KanboardStop {
|
|
187
|
+
const record = requireRecord("stop", value);
|
|
188
|
+
if (typeof record.stopped !== "boolean") {
|
|
189
|
+
throw new KanboardShapeError("stop", 'missing boolean "stopped"');
|
|
190
|
+
}
|
|
191
|
+
return {
|
|
192
|
+
stopped: record.stopped,
|
|
193
|
+
pid: typeof record.pid === "number" ? record.pid : undefined,
|
|
194
|
+
reason: typeof record.reason === "string" ? record.reason : undefined,
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** `status` → `{daemon, alive, home}` (the extension reads daemon.json itself, but tests pin the shape). */
|
|
199
|
+
export function asDaemonStatus(value: unknown): { daemon: Record<string, unknown> | null; alive: boolean; home?: string } {
|
|
200
|
+
const record = requireRecord("status", value);
|
|
201
|
+
const daemon = record.daemon;
|
|
202
|
+
if (daemon !== null && !isRecord(daemon)) {
|
|
203
|
+
throw new KanboardShapeError("status", '"daemon" must be an object or null');
|
|
204
|
+
}
|
|
205
|
+
return {
|
|
206
|
+
daemon: (daemon as Record<string, unknown> | null) ?? null,
|
|
207
|
+
alive: record.alive === true,
|
|
208
|
+
home: typeof record.home === "string" ? record.home : undefined,
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** `validate` → `{ok, problems, fixed}`. */
|
|
213
|
+
export function asValidateResult(value: unknown): { ok: boolean; problems: KanboardProblem[]; fixed: string[] } {
|
|
214
|
+
const record = requireRecord("validate", value);
|
|
215
|
+
return {
|
|
216
|
+
ok: record.ok === true,
|
|
217
|
+
problems: optionalArray<KanboardProblem>(record.problems) ?? [],
|
|
218
|
+
fixed: optionalArray<string>(record.fixed) ?? [],
|
|
219
|
+
};
|
|
220
|
+
}
|
package/commands.ts
DELETED
|
@@ -1,83 +0,0 @@
|
|
|
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
|
-
|
|
13
|
-
/** Module-level reference to running server */
|
|
14
|
-
let runningServer: KanboardServer | null = null;
|
|
15
|
-
|
|
16
|
-
/** Check if a kanboard server is running via PID file */
|
|
17
|
-
function isPidFileRunning(pidFile: string): boolean {
|
|
18
|
-
try {
|
|
19
|
-
if (!fs.existsSync(pidFile)) return false;
|
|
20
|
-
const pid = parseInt(fs.readFileSync(pidFile, "utf-8").trim(), 10);
|
|
21
|
-
if (isNaN(pid)) return false;
|
|
22
|
-
process.kill(pid, 0); // Check if process exists
|
|
23
|
-
return true;
|
|
24
|
-
} catch {
|
|
25
|
-
// Process doesn't exist or can't access PID file
|
|
26
|
-
try {
|
|
27
|
-
fs.unlinkSync(pidFile);
|
|
28
|
-
} catch {}
|
|
29
|
-
return false;
|
|
30
|
-
}
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
/** Register kanboard commands */
|
|
34
|
-
export function registerCommands(pi: ExtensionAPI): void {
|
|
35
|
-
// kanboard — Toggle server start/stop
|
|
36
|
-
pi.registerCommand(
|
|
37
|
-
`${UNIPI_PREFIX}${KANBOARD_COMMANDS.KANBOARD}`,
|
|
38
|
-
{
|
|
39
|
-
description: "Toggle kanboard visualization server",
|
|
40
|
-
handler: async (_args: string, ctx: ExtensionCommandContext) => {
|
|
41
|
-
const pidFile = path.resolve(KANBOARD_DIRS.PID_FILE);
|
|
42
|
-
|
|
43
|
-
try {
|
|
44
|
-
// Case 1: We have a live reference in this process
|
|
45
|
-
if (runningServer) {
|
|
46
|
-
runningServer.stop();
|
|
47
|
-
runningServer = null;
|
|
48
|
-
ctx.ui.notify("Kanboard stopped", "info");
|
|
49
|
-
return;
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
// Case 2: PID file shows a running instance (from previous process)
|
|
53
|
-
if (isPidFileRunning(pidFile)) {
|
|
54
|
-
// PID file exists but we don't have a reference — stale or external.
|
|
55
|
-
// Remove stale PID file and let user know.
|
|
56
|
-
try { fs.unlinkSync(pidFile); } catch {}
|
|
57
|
-
ctx.ui.notify("Kanboard was running in a previous session. PID file cleaned. Run again to start fresh.", "info");
|
|
58
|
-
return;
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
// Case 3: No running instance — start fresh
|
|
62
|
-
const { server, url } = await startServer();
|
|
63
|
-
runningServer = server;
|
|
64
|
-
ctx.ui.notify(`Kanboard running at ${url}`, "info");
|
|
65
|
-
} catch (err: unknown) {
|
|
66
|
-
ctx.ui.notify(`Kanboard error: ${err instanceof Error ? err.message : String(err)}`, "error");
|
|
67
|
-
}
|
|
68
|
-
},
|
|
69
|
-
},
|
|
70
|
-
);
|
|
71
|
-
|
|
72
|
-
// kanboard-doctor — Load doctor skill
|
|
73
|
-
pi.registerCommand(
|
|
74
|
-
`${UNIPI_PREFIX}${KANBOARD_COMMANDS.KANBOARD_DOCTOR}`,
|
|
75
|
-
{
|
|
76
|
-
description: "Diagnose and fix kanboard parser issues",
|
|
77
|
-
handler: async (_args: string, ctx: ExtensionCommandContext) => {
|
|
78
|
-
ctx.ui.notify("Loading kanboard-doctor skill...", "info");
|
|
79
|
-
// The skill will be loaded by the skill system via resources_discover
|
|
80
|
-
},
|
|
81
|
-
},
|
|
82
|
-
);
|
|
83
|
-
}
|
|
@@ -1,165 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @pi-unipi/kanboard — Config-driven checkbox document parser
|
|
3
|
-
*
|
|
4
|
-
* Replaces 7 separate parser classes (Spec, QuickWork, Debug, Fix, Chore, Review,
|
|
5
|
-
* and the checkbox-extraction half of Milestone) with one config-driven class.
|
|
6
|
-
* Each doc type is just: path regex + type label + command string + checkbox pattern.
|
|
7
|
-
*/
|
|
8
|
-
|
|
9
|
-
import * as fs from "node:fs";
|
|
10
|
-
import * as path from "node:path";
|
|
11
|
-
import type { DocParser, ParsedDoc, ParsedItem, DocType } from "../types.js";
|
|
12
|
-
import { parseFrontmatter } from "./frontmatter.js";
|
|
13
|
-
|
|
14
|
-
/** Config for a checkbox-based document type. */
|
|
15
|
-
interface CheckboxDocConfig {
|
|
16
|
-
/** Document type label */
|
|
17
|
-
type: DocType;
|
|
18
|
-
/** Path regex to match (e.g. /\/specs\//) */
|
|
19
|
-
pathRegex: RegExp;
|
|
20
|
-
/** Command string for parsed items */
|
|
21
|
-
command: string | ((fileName: string) => string);
|
|
22
|
-
/** Whether to also extract ## headers as items (default: false) */
|
|
23
|
-
extractHeaders?: boolean;
|
|
24
|
-
/** Status for header items (default: "todo") */
|
|
25
|
-
headerStatus?: "todo" | "done";
|
|
26
|
-
/** Extra metadata fields to include */
|
|
27
|
-
extraMetadata?: (metadata: Record<string, string>) => Record<string, string>;
|
|
28
|
-
/** Custom title extraction (default: metadata.title ?? fileName) */
|
|
29
|
-
titleExtractor?: (metadata: Record<string, string>, fileName: string) => string;
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
const CHECKBOX_PATTERN = /^\s*-\s*\[([ xX])\]\s*(.*)$/;
|
|
33
|
-
const HEADER_PATTERN = /^##\s+(.+)$/;
|
|
34
|
-
|
|
35
|
-
/** Config-driven parser for checkbox-style documents. */
|
|
36
|
-
export class CheckboxParser implements DocParser {
|
|
37
|
-
constructor(private config: CheckboxDocConfig) {}
|
|
38
|
-
|
|
39
|
-
canParse(filePath: string): boolean {
|
|
40
|
-
return this.config.pathRegex.test(filePath) && filePath.endsWith(".md");
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
parse(filePath: string): ParsedDoc {
|
|
44
|
-
const warnings: string[] = [];
|
|
45
|
-
const items: ParsedItem[] = [];
|
|
46
|
-
let content: string;
|
|
47
|
-
|
|
48
|
-
try {
|
|
49
|
-
content = fs.readFileSync(filePath, "utf-8");
|
|
50
|
-
} catch (err: unknown) {
|
|
51
|
-
warnings.push(`Could not read file: ${err instanceof Error ? err.message : String(err)}`);
|
|
52
|
-
return this.emptyDoc(filePath, warnings);
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
const { metadata, bodyStart } = parseFrontmatter(content);
|
|
56
|
-
const lines = content.split("\n");
|
|
57
|
-
const fileName = path.basename(filePath);
|
|
58
|
-
const cmd = typeof this.config.command === "function"
|
|
59
|
-
? this.config.command(fileName)
|
|
60
|
-
: this.config.command;
|
|
61
|
-
|
|
62
|
-
for (let i = bodyStart; i < lines.length; i++) {
|
|
63
|
-
const line = lines[i];
|
|
64
|
-
const lineNum = i + 1;
|
|
65
|
-
|
|
66
|
-
// Match ## headers if configured
|
|
67
|
-
if (this.config.extractHeaders) {
|
|
68
|
-
const headerMatch = line.match(HEADER_PATTERN);
|
|
69
|
-
if (headerMatch) {
|
|
70
|
-
items.push({
|
|
71
|
-
text: headerMatch[1].trim(),
|
|
72
|
-
status: this.config.headerStatus ?? "todo",
|
|
73
|
-
lineNumber: lineNum,
|
|
74
|
-
sourceFile: fileName,
|
|
75
|
-
command: cmd,
|
|
76
|
-
});
|
|
77
|
-
}
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
// Match checklist items
|
|
81
|
-
const checkboxMatch = line.match(CHECKBOX_PATTERN);
|
|
82
|
-
if (checkboxMatch) {
|
|
83
|
-
const checked = checkboxMatch[1].toLowerCase() === "x";
|
|
84
|
-
const text = checkboxMatch[2].trim();
|
|
85
|
-
if (text) {
|
|
86
|
-
items.push({
|
|
87
|
-
text,
|
|
88
|
-
status: checked ? "done" : "todo",
|
|
89
|
-
lineNumber: lineNum,
|
|
90
|
-
sourceFile: fileName,
|
|
91
|
-
command: cmd,
|
|
92
|
-
});
|
|
93
|
-
}
|
|
94
|
-
}
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
const title = this.config.titleExtractor
|
|
98
|
-
? this.config.titleExtractor(metadata, fileName)
|
|
99
|
-
: (metadata.title ?? fileName.replace(/\.md$/, ""));
|
|
100
|
-
|
|
101
|
-
const finalMetadata = this.config.extraMetadata
|
|
102
|
-
? this.config.extraMetadata(metadata)
|
|
103
|
-
: metadata;
|
|
104
|
-
|
|
105
|
-
return {
|
|
106
|
-
type: this.config.type,
|
|
107
|
-
title,
|
|
108
|
-
filePath,
|
|
109
|
-
items,
|
|
110
|
-
metadata: finalMetadata,
|
|
111
|
-
warnings,
|
|
112
|
-
};
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
private emptyDoc(filePath: string, warnings: string[]): ParsedDoc {
|
|
116
|
-
return {
|
|
117
|
-
type: this.config.type,
|
|
118
|
-
title: path.basename(filePath).replace(/\.md$/, ""),
|
|
119
|
-
filePath,
|
|
120
|
-
items: [],
|
|
121
|
-
metadata: {},
|
|
122
|
-
warnings,
|
|
123
|
-
};
|
|
124
|
-
}
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
/** All checkbox doc configs — drives the 6 simple parsers. */
|
|
128
|
-
export const CHECKBOX_DOC_CONFIGS: CheckboxDocConfig[] = [
|
|
129
|
-
{
|
|
130
|
-
type: "spec",
|
|
131
|
-
pathRegex: /\/specs\//,
|
|
132
|
-
command: (f) => `/unipi:plan specs:${f}`,
|
|
133
|
-
},
|
|
134
|
-
{
|
|
135
|
-
type: "quick-work",
|
|
136
|
-
pathRegex: /\/quick-work\//,
|
|
137
|
-
command: "/unipi:quick-work",
|
|
138
|
-
},
|
|
139
|
-
{
|
|
140
|
-
type: "debug",
|
|
141
|
-
pathRegex: /\/debug\//,
|
|
142
|
-
command: (f) => `/unipi:fix debug:${f}`,
|
|
143
|
-
extractHeaders: true,
|
|
144
|
-
headerStatus: "todo",
|
|
145
|
-
},
|
|
146
|
-
{
|
|
147
|
-
type: "fix",
|
|
148
|
-
pathRegex: /\/fix\//,
|
|
149
|
-
command: "/unipi:fix",
|
|
150
|
-
extractHeaders: true,
|
|
151
|
-
headerStatus: "done",
|
|
152
|
-
extraMetadata: (m) => ({ ...m, related_debug: m.related_debug ?? m.debug ?? "" }),
|
|
153
|
-
},
|
|
154
|
-
{
|
|
155
|
-
type: "chore",
|
|
156
|
-
pathRegex: /\/chore\//,
|
|
157
|
-
command: (f) => `/unipi:chore-execute chore:${f}`,
|
|
158
|
-
titleExtractor: (m, f) => m.title ?? m.name ?? f.replace(/\.md$/, ""),
|
|
159
|
-
},
|
|
160
|
-
{
|
|
161
|
-
type: "review",
|
|
162
|
-
pathRegex: /\/reviews\//,
|
|
163
|
-
command: "/unipi:review-work",
|
|
164
|
-
},
|
|
165
|
-
];
|
package/parser/frontmatter.ts
DELETED
|
@@ -1,26 +0,0 @@
|
|
|
1
|
-
/** Shared frontmatter parser for kanboard document parsers. */
|
|
2
|
-
|
|
3
|
-
/** Parse frontmatter from markdown content.
|
|
4
|
-
* Returns metadata key-value pairs and the line number where the body starts. */
|
|
5
|
-
export function parseFrontmatter(content: string): {
|
|
6
|
-
metadata: Record<string, string>;
|
|
7
|
-
bodyStart: number;
|
|
8
|
-
} {
|
|
9
|
-
const metadata: Record<string, string> = {};
|
|
10
|
-
const lines = content.split("\n");
|
|
11
|
-
|
|
12
|
-
if (lines[0]?.trim() !== "---") return { metadata, bodyStart: 0 };
|
|
13
|
-
|
|
14
|
-
for (let i = 1; i < lines.length; i++) {
|
|
15
|
-
const line = lines[i];
|
|
16
|
-
if (line.trim() === "---") {
|
|
17
|
-
return { metadata, bodyStart: i + 1 };
|
|
18
|
-
}
|
|
19
|
-
const match = line.match(/^(\w[\w-]*):\s*(.*)$/);
|
|
20
|
-
if (match) {
|
|
21
|
-
metadata[match[1]] = match[2].trim();
|
|
22
|
-
}
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
return { metadata, bodyStart: 0 };
|
|
26
|
-
}
|