@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/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 { getSettings } from "@pi-unipi/core";
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, claims and queue
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> let the agent use the board for this turn
39
- /unipi:kanboard-autowork start|stop work ready tasks chain by chain; stop finishes the current one first
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, queueMax = 10): string {
43
- const limit = queueMax === 0 ? "Todo tasks" : `Todo tasks, at most ${queueMax}`;
44
- return `[kanboard] For this request you may use the kanboard skill on project ${slug} (CLI: \`${cli} --actor agent --project ${slug} …\`). Board writes are allowed until this turn ends. You can add tasks, move them between backlog and todo, link, order, note, and edit tasks you created. To have tasks worked, queue them in order with \`queue <IDs>\` (${limit}); the runner starts them one by one after this turn, so do not start the work yourself. If the request is unclear, ask me instead of guessing.
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: claim-and-run loop (queue first, then claim-next). */
188
- work: (ctx: ExtensionContext) => Promise<void>;
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
- if (!parsed.title) {
577
- ctx.ui.notify("kanboard: add needs a title — /unipi:kanboard-add <title>", "warning");
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
- const argv = ["add", parsed.title, ...parsed.flags];
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
- const { tasks, problems } = asTaskList(await client!.run<unknown>(["list"], {}));
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
- // Active claims and this session's queue.
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.title,
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.title,
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
- // visible length so far, then truncate the title to fit.
980
- const prefixLen = 2 + row.branch.length + (row.number ? row.number.length + 1 : 0) + row.id.length + 2;
981
- const budget = Math.max(10, width - prefixLen - (row.extra ? row.extra.length + 2 : 0));
982
- const title = row.title.length > budget ? `${row.title.slice(0, Math.max(1, budget - 1))}…` : row.title;
983
- const extra = row.extra ? ` ${row.tone === "error" ? theme.fg("error", row.extra) : theme.fg("dim", row.extra)}` : "";
984
- lines.push(`${head}${title}${extra}`);
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
- return lines;
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
- const payload = (await client.run(["list", "--json"], { cwd: ctx.cwd })) as { tasks?: ShowTask[] };
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, claims, queue and the runner state",
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 async function taskCompletions(deps: CommandDeps, prefix: string, before: string): Promise<CompletionItem[] | null> {
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.title.toLowerCase().includes(needle))
1175
+ .filter((task) => task.id.toLowerCase().startsWith(needle) || (titleSearch && showTitle(task).toLowerCase().includes(needle)))
1108
1176
  .slice(0, 12)
1109
- .map((task) => ({
1110
- value: fullArgs(before, task.id),
1111
- label: task.id,
1112
- description: `${task.status} — ${task.title}`,
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 prefix. */
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 match = /^(.*?)(\S*)$/.exec(raw)!;
1193
- const before = match[1]!;
1194
- const token = match[2]!;
1195
- // Only when the last token looks like a task id prefix (ABC-…).
1196
- if (!/^[A-Za-z]{2,6}-?[0-9]*$/.test(token) || token.length < 2) return null;
1197
- return taskCompletions(deps, token, before);
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
- const hidden = new Set([HELP_CUSTOM_TYPE, DOCTOR_CUSTOM_TYPE, SHOW_CUSTOM_TYPE]);
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 the queue", "info");
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 finish the current task and stop", "info");
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: "Let the agent use the kanboard for this turn — writes are allowed until the turn ends",
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
- pi.sendUserMessage(doText(slug, client.binary.path, request, deps.settings().queueMax), busy ? { deliverAs: "followUp" } : undefined);
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 window open (${slug})`);
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
- await deps.work(ctx as unknown as ExtensionContext);
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.stop(ctx as unknown as ExtensionContext);
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
+ }