@pi-unipi/kanboard 3.0.0-alpha.2 → 3.0.0-alpha.21
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 +51 -47
- package/index.ts +293 -55
- package/package.json +8 -8
- package/skills/kanboard/SKILL.md +61 -27
- package/src/badges.ts +93 -0
- package/src/bin.ts +4 -2
- package/src/commands.ts +218 -84
- package/src/debug.ts +20 -0
- package/src/guard.ts +267 -109
- package/src/monitor.ts +330 -0
- package/src/notice-buffer.ts +74 -0
- package/src/progress.ts +27 -0
- package/src/reminders.ts +234 -0
- package/src/settings.ts +69 -15
- package/src/shapes.ts +5 -0
- package/src/runner.ts +0 -817
package/src/commands.ts
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
* `/unipi:kanboard-add`, `/unipi:kanboard-do`, `/unipi:kanboard-autowork`.
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
+
import { boardProgressData } from "./progress.js";
|
|
6
7
|
import { execFile, spawn } from "node:child_process";
|
|
7
8
|
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
8
9
|
import { homedir, hostname as osHostname, networkInterfaces, tmpdir, type NetworkInterfaceInfo } from "node:os";
|
|
@@ -10,9 +11,11 @@ import { join } from "node:path";
|
|
|
10
11
|
import { promisify } from "node:util";
|
|
11
12
|
|
|
12
13
|
import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
|
|
13
|
-
import {
|
|
14
|
+
import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
15
|
+
import { getSettings, sendHarnessUserMessage } from "@pi-unipi/core";
|
|
14
16
|
|
|
15
17
|
import { KanboardCliError, type KanboardCli } from "./bin.js";
|
|
18
|
+
import { DEFAULT_DO_TASKS, DEFAULT_DO_WRITES } from "./guard.js";
|
|
16
19
|
import { asProject, asStopResult, asTask, asTaskList, type KanboardTask } from "./shapes.js";
|
|
17
20
|
import { applyLimitEnv, readKanboardSettings, type KanboardSettings } from "./settings.js";
|
|
18
21
|
|
|
@@ -31,21 +34,26 @@ export const HELP = `Kanboard commands
|
|
|
31
34
|
start the board and print its link
|
|
32
35
|
/unipi:kanboard close shut the board down
|
|
33
36
|
/unipi:kanboard onboard register this project
|
|
34
|
-
/unipi:kanboard status board
|
|
37
|
+
/unipi:kanboard status board and claims
|
|
35
38
|
/unipi:kanboard show [--all] the board, in chat (lanes + claim order)
|
|
36
39
|
/unipi:kanboard doctor check the setup
|
|
37
40
|
/unipi:kanboard-add [-p 1-5] <title> add a task; lines below the title are the description (paste images there)
|
|
38
|
-
/unipi:kanboard-do <request>
|
|
39
|
-
/unipi:kanboard-autowork start|stop work ready
|
|
41
|
+
/unipi:kanboard-do <request> grant the agent task slots + a write budget
|
|
42
|
+
/unipi:kanboard-autowork start|stop work every ready task in this session; stop just turns the offers off
|
|
40
43
|
Priority -p: 1 none · 2 low · 3 medium · 4 high · 5 urgent`;
|
|
41
44
|
|
|
42
|
-
export function doText(slug: string, cli: string, request: string,
|
|
43
|
-
const
|
|
44
|
-
|
|
45
|
+
export function doText(slug: string, cli: string, request: string, slots = DEFAULT_DO_TASKS, writes = DEFAULT_DO_WRITES): string {
|
|
46
|
+
const s = slots === 1 ? "" : "s";
|
|
47
|
+
const ws = writes === 1 ? "" : "s";
|
|
48
|
+
return `[kanboard] For this request you may use the kanboard skill on project ${slug} (CLI: \`${cli} --actor agent --project ${slug} …\`). Budget this session: ${String(slots)} task slot${s} — each \`start\` uses one — and ${String(writes)} board write${ws} (add, edit, link, order, move backlog↔todo, note on tasks you don't hold); /unipi:kanboard-do tops both back up. Always free: reads, and \`finish\`, \`move <ID> blocked --comment\`, \`note\` and \`attach\` on tasks you started. Work the tasks yourself in this session, in whatever mode fits: \`start <ID>\` right before you work it, then \`finish <ID> --comment "<summary>"\` or \`move <ID> blocked --comment "<what you need>"\` — never leave a task you started In Progress. Before starting anything, count the tasks this request needs; if that is more than ${String(slots)}, start nothing — tell me you can do ${String(slots)} now and ask whether to raise the limit (setting kanboard.doTasks) or work in batches. Read a task with \`show <ID>\` before editing or starting it — \`list\` only shows titles and a one-line excerpt. Sidekicks and subagents can read the board but not write it: brief them with the task, then update the board yourself from their report. If the request is unclear, ask me instead of guessing.
|
|
45
49
|
|
|
46
50
|
Request: ${request}`;
|
|
47
51
|
}
|
|
48
52
|
|
|
53
|
+
export function autoworkText(slug: string, cli: string): string {
|
|
54
|
+
return `[kanboard] Autowork on project ${slug} (CLI: \`${cli} --actor agent --project ${slug} …\`): work every ready task on the board, one at a time, in this session — choose any mode yourself (regular, goal, ralph, swarm, graph). No budget limits. Pick with \`next\` or \`list --ready\`; for each: \`show <ID>\`, \`start <ID>\`, work it, then \`finish <ID> --comment "<summary>"\` or \`move <ID> blocked --comment "<what you need>"\`. When you finish one, I'll offer the next ready task. Sidekicks and subagents can read the board but not write it — update the board yourself from their reports.`;
|
|
55
|
+
}
|
|
56
|
+
|
|
49
57
|
/**
|
|
50
58
|
* Split a command line into words the way a shell would: whitespace separates,
|
|
51
59
|
* a token may start with a quoted span, and a quote inside a word is literal
|
|
@@ -184,18 +192,15 @@ export interface CommandDeps {
|
|
|
184
192
|
settings: () => KanboardSettings;
|
|
185
193
|
/** Reveal the kanboard skill for this session (append-only). */
|
|
186
194
|
revealSkill: (ctx: ExtensionContext | ExtensionCommandContext) => void;
|
|
187
|
-
/** Autowork
|
|
188
|
-
|
|
189
|
-
/** Finish the current task, then stop the loop. */
|
|
190
|
-
stop: (ctx: ExtensionContext) => void;
|
|
191
|
-
/** Drain this session's queue after a -do turn (no-op when empty/running). */
|
|
192
|
-
drainQueue: (ctx: ExtensionContext) => Promise<void>;
|
|
193
|
-
status: () => { taskId: string | null; mode: string | null; phase: string };
|
|
195
|
+
/** Autowork on/off (guard budget, monitor, status holder). */
|
|
196
|
+
setAutowork(on: boolean): void;
|
|
194
197
|
/** The write window opened by -do (read by the tool_call gate). */
|
|
195
198
|
guard: import("./guard.js").WriteGuard;
|
|
196
199
|
/** This session's id (UNIPI_KANBOARD_SESSION / pi-<pid>). */
|
|
197
200
|
session: () => string;
|
|
198
201
|
debug: (line: string) => void;
|
|
202
|
+
/** Post a user-only progress bar (optional: absent in tests). */
|
|
203
|
+
progress?: (data: import("@pi-unipi/core").ProgressData) => void;
|
|
199
204
|
}
|
|
200
205
|
|
|
201
206
|
export interface DaemonInfo {
|
|
@@ -407,7 +412,6 @@ export async function ensureDaemon(
|
|
|
407
412
|
...process.env,
|
|
408
413
|
UNIPI_KANBOARD_ACTOR: "user",
|
|
409
414
|
UNIPI_KANBOARD_CHAIN_GATE: settings.chainGate,
|
|
410
|
-
UNIPI_KANBOARD_QUEUE_MAX: String(settings.queueMax),
|
|
411
415
|
UNIPI_KANBOARD_MAX_SESSIONS: String(settings.maxSessions),
|
|
412
416
|
},
|
|
413
417
|
});
|
|
@@ -573,15 +577,18 @@ export async function runAdd(deps: CommandDeps, ctx: ExtensionCommandContext, te
|
|
|
573
577
|
ctx.ui.notify(`kanboard: ${parsed.error} — /unipi:kanboard-add [-p 1-5] [--after ID] [--status backlog|todo] <title>`, "warning");
|
|
574
578
|
return;
|
|
575
579
|
}
|
|
576
|
-
|
|
577
|
-
|
|
580
|
+
const hasDescription = parsed.description.trim().length > 0;
|
|
581
|
+
if (!parsed.title && !hasDescription) {
|
|
582
|
+
ctx.ui.notify("kanboard: add needs a title or a description — /unipi:kanboard-add <title> (lines below are the description)", "warning");
|
|
578
583
|
return;
|
|
579
584
|
}
|
|
580
585
|
if (!(await ensureOnboarded(deps, ctx))) return;
|
|
581
586
|
|
|
582
587
|
let bodyFile: string | null = null;
|
|
583
588
|
try {
|
|
584
|
-
|
|
589
|
+
// A description-only add omits the title positional entirely (the binary
|
|
590
|
+
// stores an empty title and derives the display title from the body).
|
|
591
|
+
const argv = ["add", ...(parsed.title ? [parsed.title] : []), ...parsed.flags];
|
|
585
592
|
if (parsed.description) {
|
|
586
593
|
bodyFile = join(mkdtempSync(join(tmpdir(), "kb-add-")), "body.md");
|
|
587
594
|
writeFileSync(bodyFile, parsed.description);
|
|
@@ -603,6 +610,55 @@ export async function runAdd(deps: CommandDeps, ctx: ExtensionCommandContext, te
|
|
|
603
610
|
}
|
|
604
611
|
}
|
|
605
612
|
|
|
613
|
+
export type CaptureToBacklogResult =
|
|
614
|
+
| { ok: true; id: string; attachments: number }
|
|
615
|
+
| { ok: false; reason: string };
|
|
616
|
+
|
|
617
|
+
/**
|
|
618
|
+
* Capture editor text as a Backlog task body — the entry point the
|
|
619
|
+
* input-shortcuts K chord calls through `globalThis.__unipi_kanboard_api`.
|
|
620
|
+
* No title: the body carries the point (the binary derives the display
|
|
621
|
+
* title). No prompts: when the folder is not onboarded the capture is
|
|
622
|
+
* refused with the reason instead.
|
|
623
|
+
*/
|
|
624
|
+
export async function captureToBacklog(
|
|
625
|
+
deps: CommandDeps,
|
|
626
|
+
opts: { cwd: string; text: string },
|
|
627
|
+
): Promise<CaptureToBacklogResult> {
|
|
628
|
+
const client = deps.cli;
|
|
629
|
+
if (!client) return { ok: false, reason: deps.unavailable ?? "kanboard binary unavailable" };
|
|
630
|
+
let slug: string | null = null;
|
|
631
|
+
try {
|
|
632
|
+
const settings = getSettings("kanboard", opts.cwd) as { slug?: string };
|
|
633
|
+
slug = typeof settings.slug === "string" && settings.slug.length > 0 ? settings.slug : null;
|
|
634
|
+
} catch {
|
|
635
|
+
slug = null;
|
|
636
|
+
}
|
|
637
|
+
if (!slug) {
|
|
638
|
+
return { ok: false, reason: "kanboard is not set up here — run /unipi:kanboard" };
|
|
639
|
+
}
|
|
640
|
+
const text = opts.text.trim();
|
|
641
|
+
if (!text) return { ok: false, reason: "nothing to add" };
|
|
642
|
+
const attaches = detectFilePaths(text);
|
|
643
|
+
let bodyFile: string | null = null;
|
|
644
|
+
try {
|
|
645
|
+
bodyFile = join(mkdtempSync(join(tmpdir(), "kb-capture-")), "body.md");
|
|
646
|
+
writeFileSync(bodyFile, text);
|
|
647
|
+
const argv = ["--project", slug, "add", "--status", "backlog", "--body-file", bodyFile];
|
|
648
|
+
for (const file of attaches) argv.push("--attach", file);
|
|
649
|
+
const task = asTask("add", await client.run<unknown>(argv, { cwd: opts.cwd }));
|
|
650
|
+
deps.debug(`captureToBacklog ${task.id} (${attaches.length} attachments)`);
|
|
651
|
+
return { ok: true, id: task.id, attachments: attaches.length };
|
|
652
|
+
} catch (error) {
|
|
653
|
+
return {
|
|
654
|
+
ok: false,
|
|
655
|
+
reason: error instanceof KanboardCliError ? error.message : String(error),
|
|
656
|
+
};
|
|
657
|
+
} finally {
|
|
658
|
+
if (bodyFile) rmSync(join(bodyFile, ".."), { recursive: true, force: true });
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
|
|
606
662
|
export async function runStatus(deps: CommandDeps, ctx: ExtensionCommandContext | ExtensionContext): Promise<void> {
|
|
607
663
|
const client = deps.cli;
|
|
608
664
|
const lines: string[] = [];
|
|
@@ -615,7 +671,10 @@ export async function runStatus(deps: CommandDeps, ctx: ExtensionCommandContext
|
|
|
615
671
|
const slug = currentSlug();
|
|
616
672
|
if (slug) {
|
|
617
673
|
try {
|
|
618
|
-
|
|
674
|
+
// --all: the status view counts every lane; the CLI's bare `list`
|
|
675
|
+
// default (todo/in_progress/blocked/in_review/done) would hide backlog
|
|
676
|
+
// and cancelled here (UNI-62).
|
|
677
|
+
const { tasks, problems } = asTaskList(await client!.run<unknown>(["list", "--all"], {}));
|
|
619
678
|
const counts = tasks.reduce<Record<string, number>>((acc, task) => {
|
|
620
679
|
acc[task.status] = (acc[task.status] ?? 0) + 1;
|
|
621
680
|
return acc;
|
|
@@ -628,7 +687,9 @@ export async function runStatus(deps: CommandDeps, ctx: ExtensionCommandContext
|
|
|
628
687
|
if (problems && problems.length > 0) {
|
|
629
688
|
lines.push(`⚠ ${problems.length} task file(s) need repair — unipi-kanboard validate --fix`);
|
|
630
689
|
}
|
|
631
|
-
|
|
690
|
+
const bar = boardProgressData(tasks, slug);
|
|
691
|
+
if (bar) deps.progress?.(bar);
|
|
692
|
+
// Active claims of this session.
|
|
632
693
|
const running = tasks.filter((task) => task.status === "in_progress");
|
|
633
694
|
if (running.length > 0) {
|
|
634
695
|
lines.push("claims:");
|
|
@@ -640,44 +701,15 @@ export async function runStatus(deps: CommandDeps, ctx: ExtensionCommandContext
|
|
|
640
701
|
);
|
|
641
702
|
}
|
|
642
703
|
}
|
|
643
|
-
try {
|
|
644
|
-
const queue = (await client!.run<unknown>(["queue", "--list"], {
|
|
645
|
-
extraEnv: { UNIPI_KANBOARD_SESSION: deps.session() },
|
|
646
|
-
})) as { queue?: string[] };
|
|
647
|
-
lines.push(`queue (${deps.session()}): ${(queue.queue ?? []).join(" ") || "empty"}`);
|
|
648
|
-
} catch {
|
|
649
|
-
lines.push("queue: (unreadable)");
|
|
650
|
-
}
|
|
651
704
|
} catch {
|
|
652
705
|
lines.push(`project: ${slug} (unreadable board)`);
|
|
653
706
|
}
|
|
654
707
|
} else {
|
|
655
708
|
lines.push("project: not registered here — /unipi:kanboard onboard");
|
|
656
709
|
}
|
|
657
|
-
const run = deps.status();
|
|
658
|
-
lines.push(run.taskId ? `runner: ${run.taskId} · ${run.mode} · ${run.phase}` : "runner: idle");
|
|
659
710
|
ctx.ui.notify(lines.join("\n"), "info");
|
|
660
711
|
}
|
|
661
712
|
|
|
662
|
-
/**
|
|
663
|
-
* After a -do window closed (the first real agent_end), start draining this
|
|
664
|
-
* session's queue when it is non-empty. Returns true when draining started.
|
|
665
|
-
*/
|
|
666
|
-
export async function drainQueueAfterDo(deps: CommandDeps, ctx: ExtensionContext): Promise<boolean> {
|
|
667
|
-
const client = deps.cli;
|
|
668
|
-
const slug = currentSlug();
|
|
669
|
-
if (!client || !slug) return false;
|
|
670
|
-
const queued = await client
|
|
671
|
-
.run<{ queue?: string[] }>(["queue", "--list"], {
|
|
672
|
-
extraEnv: { UNIPI_KANBOARD_PROJECT: slug, UNIPI_KANBOARD_SESSION: deps.session() },
|
|
673
|
-
})
|
|
674
|
-
.then((payload) => payload.queue ?? [])
|
|
675
|
-
.catch(() => [] as string[]);
|
|
676
|
-
if (queued.length === 0) return false;
|
|
677
|
-
await deps.drainQueue(ctx);
|
|
678
|
-
return true;
|
|
679
|
-
}
|
|
680
|
-
|
|
681
713
|
|
|
682
714
|
/** `doctor`: ✓/✗ lines for the whole setup, posted as a display-only message. */
|
|
683
715
|
export async function runDoctor(deps: CommandDeps, ctx: ExtensionCommandContext, pi: ExtensionAPI): Promise<void> {
|
|
@@ -830,6 +862,8 @@ export async function runRotateTokenAction(
|
|
|
830
862
|
interface ShowTask {
|
|
831
863
|
id: string;
|
|
832
864
|
title: string;
|
|
865
|
+
/** Derived title (Rust `display_title`) — shown when `title` is empty. */
|
|
866
|
+
displayTitle?: string;
|
|
833
867
|
status: string;
|
|
834
868
|
priority?: string;
|
|
835
869
|
order?: number;
|
|
@@ -855,6 +889,11 @@ const SHOW_EXTRA_LANES: Array<{ id: string; label: string }> = [
|
|
|
855
889
|
const PRIO_GLYPH: Record<string, string> = { urgent: "⇈", high: "↑", medium: "·", low: "↓", none: " " };
|
|
856
890
|
const PRIO_RANK: Record<string, number> = { urgent: 0, high: 1, medium: 2, low: 3, none: 4 };
|
|
857
891
|
|
|
892
|
+
/** The title a row shows: the derived one when the raw title is empty. */
|
|
893
|
+
function showTitle(task: ShowTask): string {
|
|
894
|
+
return task.displayTitle || task.title || "(untitled)";
|
|
895
|
+
}
|
|
896
|
+
|
|
858
897
|
/** Same ordering as the CLI's claim_sort: priority, then lane order, then id. */
|
|
859
898
|
export function claimOrder(tasks: ShowTask[]): ShowTask[] {
|
|
860
899
|
return [...tasks].sort(
|
|
@@ -899,7 +938,7 @@ function laneRows(tasks: ShowTask[], lane: string): ShowRow[] {
|
|
|
899
938
|
number: `${number}.`,
|
|
900
939
|
id: task.id,
|
|
901
940
|
prio: PRIO_GLYPH[task.priority ?? "none"] ?? " ",
|
|
902
|
-
title: task
|
|
941
|
+
title: showTitle(task),
|
|
903
942
|
extra:
|
|
904
943
|
task.ready === false || waits.length > 0
|
|
905
944
|
? `waits for ${waits.join(", ")}`
|
|
@@ -922,7 +961,7 @@ function laneRows(tasks: ShowTask[], lane: string): ShowRow[] {
|
|
|
922
961
|
branch: "",
|
|
923
962
|
id: task.id,
|
|
924
963
|
prio: PRIO_GLYPH[task.priority ?? "none"] ?? " ",
|
|
925
|
-
title: task
|
|
964
|
+
title: showTitle(task),
|
|
926
965
|
extra:
|
|
927
966
|
lane === "in_progress" && task.run?.session
|
|
928
967
|
? `· ${task.run.session}`
|
|
@@ -976,15 +1015,29 @@ export function showRenderer(
|
|
|
976
1015
|
lines.push(theme.fg("accent", lane.label));
|
|
977
1016
|
for (const row of laneRows(inLane, lane.id)) {
|
|
978
1017
|
const head = ` ${row.branch}${row.number ? `${row.number} ` : ""}${theme.fg("muted", row.id)} ${row.prio} `;
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
1018
|
+
const styledExtra = row.extra
|
|
1019
|
+
? ` ${row.tone === "error" ? theme.fg("error", row.extra) : theme.fg("dim", row.extra)}`
|
|
1020
|
+
: "";
|
|
1021
|
+
// Budgets in visible cells (CJK ≈ 2 cells, ANSI = 0 — .length lies on both).
|
|
1022
|
+
// Title takes all the room left after the extra when both fit comfortably,
|
|
1023
|
+
// else it is capped at 10 cells; the extra then takes whatever remains, so a
|
|
1024
|
+
// huge blockedReason is truncated instead of overflowing the terminal (which
|
|
1025
|
+
// hard-crashes pi). Final truncate is the belt-and-braces guarantee.
|
|
1026
|
+
const avail = Math.max(0, width - visibleWidth(head));
|
|
1027
|
+
const extraW = styledExtra ? visibleWidth(styledExtra) : 0;
|
|
1028
|
+
const titleBudget = Math.max(0, avail - extraW >= 10 ? avail - extraW : Math.min(avail, 10));
|
|
1029
|
+
const title = truncateToWidth(row.title, titleBudget, "…");
|
|
1030
|
+
let line = `${head}${title}`;
|
|
1031
|
+
if (styledExtra) {
|
|
1032
|
+
const extraBudget = avail - visibleWidth(title) - 2;
|
|
1033
|
+
if (extraBudget > 0) line += truncateToWidth(styledExtra, extraBudget + 2, "…");
|
|
1034
|
+
}
|
|
1035
|
+
lines.push(line);
|
|
985
1036
|
}
|
|
986
1037
|
}
|
|
987
|
-
|
|
1038
|
+
// Hard guarantee: a line wider than the terminal hard-crashes pi, so every
|
|
1039
|
+
// emitted line — header and lane labels included — is truncated to `width`.
|
|
1040
|
+
return lines.map((line) => truncateToWidth(line, width));
|
|
988
1041
|
},
|
|
989
1042
|
invalidate: () => undefined,
|
|
990
1043
|
};
|
|
@@ -1000,7 +1053,8 @@ async function runShow(deps: CommandDeps, ctx: ExtensionCommandContext, pi: Exte
|
|
|
1000
1053
|
const slug = currentSlug()!;
|
|
1001
1054
|
let tasks: ShowTask[] = [];
|
|
1002
1055
|
try {
|
|
1003
|
-
|
|
1056
|
+
// --all: this display lists every lane; the CLI bare default hides backlog/cancelled (UNI-62).
|
|
1057
|
+
const payload = (await client.run(["list", "--all", "--json"], { cwd: ctx.cwd })) as { tasks?: ShowTask[] };
|
|
1004
1058
|
tasks = payload.tasks ?? [];
|
|
1005
1059
|
} catch (error) {
|
|
1006
1060
|
ctx.ui.notify(`kanboard: show failed — ${error instanceof KanboardCliError ? error.message : String(error)}`, "error");
|
|
@@ -1039,7 +1093,7 @@ const SUB_DESCRIPTIONS: Record<Subcommand, string> = {
|
|
|
1039
1093
|
open: "Start the daemon and print the board URL (--host 0.0.0.0|tailscale, --port N)",
|
|
1040
1094
|
close: "Shut down the board daemon (the web UI goes offline)",
|
|
1041
1095
|
onboard: "Register this project on the board",
|
|
1042
|
-
status: "Daemon, project counts
|
|
1096
|
+
status: "Daemon, project counts and claims",
|
|
1043
1097
|
doctor: "Check the whole setup (binary, daemon, project, agent, claims)",
|
|
1044
1098
|
show: "The board in chat: lanes + claim order (--all adds cancelled/archived)",
|
|
1045
1099
|
};
|
|
@@ -1088,13 +1142,27 @@ export function kanboardCompletions(prefix: string): CompletionItem[] | null {
|
|
|
1088
1142
|
/** Live task list for `--after` / `-do` completions, cached ~5s per client. */
|
|
1089
1143
|
const taskCache = new WeakMap<KanboardCli, { at: number; tasks: ShowTask[] }>();
|
|
1090
1144
|
|
|
1091
|
-
export
|
|
1145
|
+
export interface TaskCompletionOptions {
|
|
1146
|
+
/** Also match task TITLES (default true); false = id prefixes only. */
|
|
1147
|
+
titleSearch?: boolean;
|
|
1148
|
+
/** Row style: "status" = label id, description "<status> — <title>";
|
|
1149
|
+
* "id-title" = label "<id> <title>", description "<status>". */
|
|
1150
|
+
rowStyle?: "status" | "id-title";
|
|
1151
|
+
}
|
|
1152
|
+
|
|
1153
|
+
export async function taskCompletions(
|
|
1154
|
+
deps: CommandDeps,
|
|
1155
|
+
prefix: string,
|
|
1156
|
+
before: string,
|
|
1157
|
+
opts: TaskCompletionOptions = {},
|
|
1158
|
+
): Promise<CompletionItem[] | null> {
|
|
1159
|
+
const { titleSearch = true, rowStyle = "status" } = opts;
|
|
1092
1160
|
const client = deps.cli;
|
|
1093
1161
|
if (!client) return null;
|
|
1094
1162
|
let cache = taskCache.get(client);
|
|
1095
1163
|
if (!cache || Date.now() - cache.at > 5_000) {
|
|
1096
1164
|
try {
|
|
1097
|
-
const payload = (await client.run(["list", "--json"], { cwd: process.cwd() })) as { tasks?: ShowTask[] };
|
|
1165
|
+
const payload = (await client.run(["list", "--all", "--json"], { cwd: process.cwd() })) as { tasks?: ShowTask[] };
|
|
1098
1166
|
cache = { at: Date.now(), tasks: payload.tasks ?? [] };
|
|
1099
1167
|
taskCache.set(client, cache);
|
|
1100
1168
|
} catch {
|
|
@@ -1104,13 +1172,21 @@ export async function taskCompletions(deps: CommandDeps, prefix: string, before:
|
|
|
1104
1172
|
const needle = prefix.toLowerCase();
|
|
1105
1173
|
const items = cache.tasks
|
|
1106
1174
|
.filter((task) => !["cancelled", "archived"].includes(task.status))
|
|
1107
|
-
.filter((task) => task.id.toLowerCase().startsWith(needle) || task.
|
|
1175
|
+
.filter((task) => task.id.toLowerCase().startsWith(needle) || (titleSearch && showTitle(task).toLowerCase().includes(needle)))
|
|
1108
1176
|
.slice(0, 12)
|
|
1109
|
-
.map((task) =>
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1177
|
+
.map((task) =>
|
|
1178
|
+
rowStyle === "id-title"
|
|
1179
|
+
? {
|
|
1180
|
+
value: fullArgs(before, task.id),
|
|
1181
|
+
label: `${task.id} ${showTitle(task).slice(0, 64)}`,
|
|
1182
|
+
description: task.status,
|
|
1183
|
+
}
|
|
1184
|
+
: {
|
|
1185
|
+
value: fullArgs(before, task.id),
|
|
1186
|
+
label: task.id,
|
|
1187
|
+
description: `${task.status} — ${showTitle(task)}`,
|
|
1188
|
+
},
|
|
1189
|
+
);
|
|
1114
1190
|
return items.length > 0 ? items : null;
|
|
1115
1191
|
}
|
|
1116
1192
|
|
|
@@ -1186,15 +1262,19 @@ export async function kanboardAddCompletions(deps: CommandDeps, prefix: string):
|
|
|
1186
1262
|
return null;
|
|
1187
1263
|
}
|
|
1188
1264
|
|
|
1189
|
-
/** /unipi:kanboard-do … — complete a trailing task-id
|
|
1265
|
+
/** /unipi:kanboard-do … — complete a trailing task-id token; plain prose never matches. */
|
|
1190
1266
|
export async function kanboardDoCompletions(deps: CommandDeps, prefix: string): Promise<CompletionItem[] | null> {
|
|
1191
1267
|
const raw = prefix ?? "";
|
|
1192
|
-
const
|
|
1193
|
-
const before =
|
|
1194
|
-
const token =
|
|
1195
|
-
// Only
|
|
1196
|
-
|
|
1197
|
-
|
|
1268
|
+
const at = raw.lastIndexOf(" ");
|
|
1269
|
+
const before = at === -1 ? "" : raw.slice(0, at + 1);
|
|
1270
|
+
const token = raw.slice(at + 1);
|
|
1271
|
+
// Only a task-id-like tail ("UNI-1", "uni", "UNI-") completes; words that
|
|
1272
|
+
// match no id shape or prefix ("note", "set") yield nothing.
|
|
1273
|
+
if (!/^[A-Za-z]{2,10}-?\d*$/.test(token)) return null;
|
|
1274
|
+
// Title search only when the token looks like a real id prefix (has a digit
|
|
1275
|
+
// or ends with the dash); otherwise id prefixes only.
|
|
1276
|
+
const idLike = /\d/.test(token) || token.endsWith("-");
|
|
1277
|
+
return taskCompletions(deps, token, before, { titleSearch: idLike, rowStyle: "id-title" });
|
|
1198
1278
|
}
|
|
1199
1279
|
|
|
1200
1280
|
export function registerKanboardCommands(pi: ExtensionAPI, deps: CommandDeps): void {
|
|
@@ -1209,7 +1289,14 @@ export function registerKanboardCommands(pi: ExtensionAPI, deps: CommandDeps): v
|
|
|
1209
1289
|
}
|
|
1210
1290
|
|
|
1211
1291
|
pi.on("context", (event) => {
|
|
1212
|
-
|
|
1292
|
+
// Monitor notices ride custom ENTRIES (never messages) — this is defense
|
|
1293
|
+
// in depth in case one ever becomes a message.
|
|
1294
|
+
const hidden = new Set([
|
|
1295
|
+
HELP_CUSTOM_TYPE,
|
|
1296
|
+
DOCTOR_CUSTOM_TYPE,
|
|
1297
|
+
SHOW_CUSTOM_TYPE,
|
|
1298
|
+
"unipi:kanboard-notice",
|
|
1299
|
+
]);
|
|
1213
1300
|
const filtered = event.messages.filter(
|
|
1214
1301
|
(message) => !hidden.has((message as { customType?: string }).customType ?? ""),
|
|
1215
1302
|
);
|
|
@@ -1226,7 +1313,18 @@ export function registerKanboardCommands(pi: ExtensionAPI, deps: CommandDeps): v
|
|
|
1226
1313
|
// Limits live in the environment; refresh so this bash call inherits the
|
|
1227
1314
|
// current settings (the spawned CLI reads them).
|
|
1228
1315
|
applyLimitEnv(deps.settings());
|
|
1229
|
-
const reason = deps.guard.check(command
|
|
1316
|
+
const reason = await deps.guard.check(command, {
|
|
1317
|
+
ownsClaim: async (id) => {
|
|
1318
|
+
const client = deps.cli;
|
|
1319
|
+
const slug = currentSlug();
|
|
1320
|
+
if (!client || !slug) return false;
|
|
1321
|
+
const task = asTask("show", await client.run([ "show", id, "--json" ], {
|
|
1322
|
+
extraEnv: { UNIPI_KANBOARD_PROJECT: slug, UNIPI_KANBOARD_SESSION: deps.session() },
|
|
1323
|
+
}));
|
|
1324
|
+
const run = task.run as { session?: string } | null | undefined;
|
|
1325
|
+
return task.status === "in_progress" && run?.session === deps.session();
|
|
1326
|
+
},
|
|
1327
|
+
});
|
|
1230
1328
|
return reason ? { block: true, reason } : undefined;
|
|
1231
1329
|
});
|
|
1232
1330
|
|
|
@@ -1250,11 +1348,11 @@ export function registerKanboardCommands(pi: ExtensionAPI, deps: CommandDeps): v
|
|
|
1250
1348
|
return;
|
|
1251
1349
|
}
|
|
1252
1350
|
if (sub === "work") {
|
|
1253
|
-
ctx.ui.notify("kanboard: use /unipi:kanboard-autowork start to work
|
|
1351
|
+
ctx.ui.notify("kanboard: use /unipi:kanboard-autowork start to work every ready task in this session", "info");
|
|
1254
1352
|
return;
|
|
1255
1353
|
}
|
|
1256
1354
|
if (sub === "stop") {
|
|
1257
|
-
ctx.ui.notify("kanboard: use /unipi:kanboard-autowork stop to
|
|
1355
|
+
ctx.ui.notify("kanboard: use /unipi:kanboard-autowork stop to turn autowork offers off", "info");
|
|
1258
1356
|
return;
|
|
1259
1357
|
}
|
|
1260
1358
|
|
|
@@ -1303,10 +1401,15 @@ export function registerKanboardCommands(pi: ExtensionAPI, deps: CommandDeps): v
|
|
|
1303
1401
|
});
|
|
1304
1402
|
|
|
1305
1403
|
pi.registerCommand("unipi:kanboard-do", {
|
|
1306
|
-
description: "
|
|
1404
|
+
description: "Grant the agent kanboard task slots and a write budget (/unipi:kanboard-do <request>; off revokes)",
|
|
1307
1405
|
getArgumentCompletions: (prefix: string) => kanboardDoCompletions(deps, prefix ?? ""),
|
|
1308
1406
|
handler: async (args: string, ctx: ExtensionCommandContext) => {
|
|
1309
1407
|
const request = (args ?? "").trim();
|
|
1408
|
+
if (request.toLowerCase() === "off") {
|
|
1409
|
+
deps.guard.revoke();
|
|
1410
|
+
ctx.ui.notify("kanboard: -do budget revoked — reads stay free", "info");
|
|
1411
|
+
return;
|
|
1412
|
+
}
|
|
1310
1413
|
if (!request) {
|
|
1311
1414
|
ctx.ui.notify("kanboard: -do needs a request — /unipi:kanboard-do <what the agent may do on the board>", "warning");
|
|
1312
1415
|
return;
|
|
@@ -1323,9 +1426,19 @@ export function registerKanboardCommands(pi: ExtensionAPI, deps: CommandDeps): v
|
|
|
1323
1426
|
const busy = typeof (ctx as unknown as { isIdle?: () => boolean }).isIdle === "function"
|
|
1324
1427
|
? !(ctx as unknown as { isIdle: () => boolean }).isIdle!()
|
|
1325
1428
|
: false;
|
|
1326
|
-
|
|
1429
|
+
const budget = deps.guard.remaining();
|
|
1430
|
+
sendHarnessUserMessage(
|
|
1431
|
+
pi,
|
|
1432
|
+
doText(slug, client.binary.path, request, budget.slots, budget.writes),
|
|
1433
|
+
{
|
|
1434
|
+
source: "Kanboard",
|
|
1435
|
+
title: "Task budget",
|
|
1436
|
+
synopsis: `slots ${String(budget.slots)} · writes ${String(budget.writes)} · user request included`,
|
|
1437
|
+
},
|
|
1438
|
+
busy ? { deliverAs: "followUp" } : undefined,
|
|
1439
|
+
);
|
|
1327
1440
|
deps.guard.noteSent();
|
|
1328
|
-
deps.debug(`do
|
|
1441
|
+
deps.debug(`do open (${slug}): ${JSON.stringify(deps.guard.remaining())}`);
|
|
1329
1442
|
},
|
|
1330
1443
|
});
|
|
1331
1444
|
|
|
@@ -1343,11 +1456,32 @@ export function registerKanboardCommands(pi: ExtensionAPI, deps: CommandDeps): v
|
|
|
1343
1456
|
const sub = (args ?? "").trim().toLowerCase();
|
|
1344
1457
|
if (sub === "start") {
|
|
1345
1458
|
if (!(await ensureOnboarded(deps, ctx))) return;
|
|
1346
|
-
|
|
1459
|
+
const client = deps.cli;
|
|
1460
|
+
if (!client) {
|
|
1461
|
+
ctx.ui.notify(`kanboard: ${deps.unavailable}`, "warning");
|
|
1462
|
+
return;
|
|
1463
|
+
}
|
|
1464
|
+
const slug = currentSlug()!;
|
|
1465
|
+
deps.revealSkill(ctx);
|
|
1466
|
+
deps.setAutowork(true);
|
|
1467
|
+
deps.guard.setAutowork(true);
|
|
1468
|
+
deps.debug(`autowork on (${slug})`);
|
|
1469
|
+
const busy = typeof (ctx as unknown as { isIdle?: () => boolean }).isIdle === "function"
|
|
1470
|
+
? !(ctx as unknown as { isIdle: () => boolean }).isIdle!()
|
|
1471
|
+
: false;
|
|
1472
|
+
sendHarnessUserMessage(
|
|
1473
|
+
pi,
|
|
1474
|
+
autoworkText(slug, client.binary.path),
|
|
1475
|
+
{ source: "Kanboard", title: "Autowork", synopsis: "no budget limits · user request included" },
|
|
1476
|
+
busy ? { deliverAs: "followUp" } : undefined,
|
|
1477
|
+
);
|
|
1347
1478
|
return;
|
|
1348
1479
|
}
|
|
1349
1480
|
if (sub === "stop") {
|
|
1350
|
-
deps.
|
|
1481
|
+
deps.setAutowork(false);
|
|
1482
|
+
deps.guard.setAutowork(false);
|
|
1483
|
+
deps.debug("autowork off");
|
|
1484
|
+
ctx.ui.notify("kanboard: autowork off", "info");
|
|
1351
1485
|
return;
|
|
1352
1486
|
}
|
|
1353
1487
|
ctx.ui.notify("kanboard: /unipi:kanboard-autowork start|stop", "warning");
|
package/src/debug.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @pi-unipi/kanboard — debug log (~/.unipi/logs/kanboard.log when
|
|
3
|
+
* UNIPI_DEBUG_KANBOARD=1, silent otherwise).
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export function createDebugLog(env: NodeJS.ProcessEnv = process.env): (line: string) => void {
|
|
7
|
+
if (env.UNIPI_DEBUG_KANBOARD !== "1") return () => undefined;
|
|
8
|
+
return (line: string) => {
|
|
9
|
+
try {
|
|
10
|
+
// Lazy require: keeps node:fs/node:os out of the module graph.
|
|
11
|
+
const { appendFileSync, mkdirSync } = require("node:fs") as typeof import("node:fs");
|
|
12
|
+
const { homedir } = require("node:os") as typeof import("node:os");
|
|
13
|
+
const dir = `${homedir()}/.unipi/logs`;
|
|
14
|
+
mkdirSync(dir, { recursive: true });
|
|
15
|
+
appendFileSync(`${dir}/kanboard.log`, `${new Date().toISOString()} ${line}\n`);
|
|
16
|
+
} catch {
|
|
17
|
+
// best-effort
|
|
18
|
+
}
|
|
19
|
+
};
|
|
20
|
+
}
|