@pi-unipi/kanboard 3.0.0-alpha.29 → 3.0.0-alpha.30

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/index.ts CHANGED
@@ -29,6 +29,7 @@ import {
29
29
  isChildProcess,
30
30
  registerCommandRunner,
31
31
  registerEvidenceContributor,
32
+ registerMoveHandler,
32
33
  registerNudgeProvider,
33
34
  registerProgressRenderer,
34
35
  setSharedKanboardStatus,
@@ -37,6 +38,7 @@ import {
37
38
 
38
39
  import { openCli, type KanboardCli } from "./src/bin.js";
39
40
  import { kanboardInvocations } from "./src/guard.js";
41
+ import { kanboardMoveHandler } from "./src/move.js";
40
42
  import { maybeBadgeToolResult, registerBadgeRenderer } from "./src/badges.js";
41
43
  import {
42
44
  registerKanboardCommands,
@@ -69,6 +71,7 @@ export const SKILL_REVEAL_EVENT = "unipi:skills:reveal";
69
71
  export const KANBOARD_SKILL = "kanboard";
70
72
 
71
73
  export default function (pi: ExtensionAPI) {
74
+ registerMoveHandler(kanboardMoveHandler);
72
75
  // One session id shared by the agent's bash calls (refined at session_start
73
76
  // from the pi session id; children keep the value they inherited).
74
77
  process.env.UNIPI_KANBOARD_SESSION ??= `pi-${process.pid}`;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-unipi/kanboard",
3
- "version": "3.0.0-alpha.29",
3
+ "version": "3.0.0-alpha.30",
4
4
  "description": "Kanboard v3 — deferred-work board: CLI/daemon bridge, task runner and skill",
5
5
  "type": "module",
6
6
  "main": "index.ts",
@@ -31,7 +31,7 @@
31
31
  "access": "public"
32
32
  },
33
33
  "dependencies": {
34
- "@pi-unipi/core": "3.0.0-alpha.29"
34
+ "@pi-unipi/core": "3.0.0-alpha.30"
35
35
  },
36
36
  "peerDependencies": {
37
37
  "@earendil-works/pi-coding-agent": "^0.87.1",
@@ -48,10 +48,10 @@
48
48
  "themes": []
49
49
  },
50
50
  "optionalDependencies": {
51
- "@pi-unipi/kanboard-linux-x64": "3.0.0-alpha.29",
52
- "@pi-unipi/kanboard-linux-arm64": "3.0.0-alpha.29",
53
- "@pi-unipi/kanboard-darwin-x64": "3.0.0-alpha.29",
54
- "@pi-unipi/kanboard-darwin-arm64": "3.0.0-alpha.29",
55
- "@pi-unipi/kanboard-win32-x64": "3.0.0-alpha.29"
51
+ "@pi-unipi/kanboard-linux-x64": "3.0.0-alpha.30",
52
+ "@pi-unipi/kanboard-linux-arm64": "3.0.0-alpha.30",
53
+ "@pi-unipi/kanboard-darwin-x64": "3.0.0-alpha.30",
54
+ "@pi-unipi/kanboard-darwin-arm64": "3.0.0-alpha.30",
55
+ "@pi-unipi/kanboard-win32-x64": "3.0.0-alpha.30"
56
56
  }
57
57
  }
@@ -26,9 +26,10 @@ be on `PATH`):
26
26
  <binary> --actor agent --project <slug> attach <ID> <file> --note "<what it shows>"
27
27
  <binary> --actor agent --project <slug> attachments <ID>
28
28
  <binary> --actor agent --project <slug> edit <ID> --title|--body|--labels … # only tasks you created, while in backlog/todo
29
- <binary> --actor agent --project <slug> start <ID> # todo → in progress, claimed for your session (costs a -do slot)
29
+ <binary> --actor agent --project <slug> start <ID> # todo → in progress, or blocked → in progress to resume a task you blocked (either way claims it for your session and costs a -do slot)
30
30
  <binary> --actor agent --project <slug> finish <ID> --comment "<summary>" [--attach <file>]… # in progress → in review, only a task you started (free)
31
31
  <binary> --actor agent --project <slug> move <ID> blocked --comment "<what you need>" [--attach <file>]…
32
+ <binary> --actor agent --project <slug> move <ID> todo [--comment "<why>"] # unblock your own blocked task (comment optional) — then `start <ID>` to resume it, or leave it in todo
32
33
  <binary> --actor agent --project <slug> link <ID> --after <DEP>
33
34
  <binary> --actor agent --project <slug> unlink <ID> --after <DEP>
34
35
  <binary> --actor agent --project <slug> order <ID> --top|--bottom|--before <ID>
@@ -54,13 +55,14 @@ and `rotate-token` are user-only — the agent is refused.
54
55
  |---|---|
55
56
  | backlog ↔ todo | user, agent |
56
57
  | todo → in progress | the agent with `start <ID>` (claims it for its session; costs a `-do` slot) |
58
+ | blocked → in progress | the agent with `start <ID>`, to resume a task it blocked — same claim/session-cap rules, same `-do` slot cost |
57
59
  | edit a task | agent — only tasks it created, and only in backlog/todo |
58
60
  | in progress → in review | the agent with `finish <ID> --comment` — only a task its own session `start`ed |
59
61
  | in progress → blocked | agent, system — **comment required** (what you need) |
60
62
  | in progress → todo | user or system `release` — comment required (the session died or gave the task up) |
61
- | blocked → todo | user only — comment required (the answer) |
63
+ | blocked → todo | **user or agent** — comment optional (the answer, when there is one) |
62
64
  | in review → done | user only |
63
- | in review → todo/backlog | user only — comment required (rework note) |
65
+ | in review → todo/backlog | user only — comment optional (a rework note, when there is one) |
64
66
  | anything → cancelled | user only |
65
67
  | in review → archived | user only (one-click archive) |
66
68
  | done/cancelled → archived | user (or automatically) |
@@ -91,11 +93,17 @@ lists them — read the files there directly if you need one.
91
93
  4. **Follow the blocking rule in your task prompt**: by default work
92
94
  autonomously and record assumptions with `note <ID> "assumed: <what/why>"`;
93
95
  block (with a comment saying exactly what you need) only when you truly
94
- cannot continue — `move <ID> blocked --comment "<what you need>"`.
96
+ cannot continue — `move <ID> blocked --comment "<what you need>"`. Once you
97
+ have what you needed, you may unblock it yourself — `move <ID> todo` (a
98
+ comment is optional, unlike blocking) — and resume it with `start <ID>`
99
+ (blocked → in progress; the same claim and session-cap rules as a fresh
100
+ `start`, and it still costs a `-do` slot). You do not have to wait for the
101
+ user to answer in the board if you already have the answer.
95
102
  5. **Work only on the task you were given.** Follow-up work goes to the board as
96
103
  a new task in Backlog (`add "<title>"`), optionally `link <new> --after <ID>`.
97
104
  At most `maxSessions` (default 2) sessions may hold tasks in a project at
98
- once — if the board refuses a `start`, that is why. You may
105
+ once — if the board refuses a `start`, that is why (this cap covers resuming
106
+ a blocked task too). You may
99
107
  block only the task your own session is running (`move <ID> blocked` checks
100
108
  `--session`/`UNIPI_KANBOARD_SESSION` against the claim).
101
109
  6. **Sidekicks and subagents can read the board but never write it** — every
package/src/commands.ts CHANGED
@@ -35,7 +35,7 @@ export const HELP = `Kanboard commands
35
35
  /unipi:kanboard close shut the board down
36
36
  /unipi:kanboard onboard register this project
37
37
  /unipi:kanboard status board and claims
38
- /unipi:kanboard show [--all] the board, in chat (lanes + claim order)
38
+ /unipi:kanboard show [<lane>] [--all] the board, in chat (lanes + claim order)
39
39
  /unipi:kanboard doctor check the setup
40
40
  /unipi:kanboard-add [-p 1-5] <title> add a task; lines below the title are the description (paste images there)
41
41
  /unipi:kanboard-do <request> grant the agent task slots + a write budget
@@ -886,6 +886,13 @@ const SHOW_EXTRA_LANES: Array<{ id: string; label: string }> = [
886
886
  { id: "cancelled", label: "Cancelled" },
887
887
  { id: "archived", label: "Archived" },
888
888
  ];
889
+ const SHOW_FILTERS = ["backlog", "todo", "in_progress", "blocked", "in_review", "done", "cancelled", "archived"];
890
+
891
+ function showLanes(all: boolean, filter?: string): Array<{ id: string; label: string }> {
892
+ const lanes = [...SHOW_LANES, ...SHOW_EXTRA_LANES];
893
+ return filter ? lanes.filter((lane) => lane.id === filter) : all ? lanes : SHOW_LANES;
894
+ }
895
+
889
896
  const PRIO_GLYPH: Record<string, string> = { urgent: "⇈", high: "↑", medium: "·", low: "↓", none: " " };
890
897
  const PRIO_RANK: Record<string, number> = { urgent: 0, high: 1, medium: 2, low: 3, none: 4 };
891
898
 
@@ -977,8 +984,9 @@ function laneRows(tasks: ShowTask[], lane: string): ShowRow[] {
977
984
  }
978
985
 
979
986
  /** Plain-text fallback (also what the message stores as content). */
980
- export function renderShowPlain(project: string, tasks: ShowTask[], all: boolean): string {
981
- const lanes = all ? [...SHOW_LANES, ...SHOW_EXTRA_LANES] : SHOW_LANES;
987
+ export function renderShowPlain(project: string, tasks: ShowTask[], all: boolean, filter?: string): string {
988
+ const lanes = showLanes(all, filter);
989
+ if (filter) tasks = tasks.filter((task) => task.status === filter);
982
990
  const lines = [`${project} · ${tasks.length} tasks`];
983
991
  for (const lane of lanes) {
984
992
  const inLane = tasks.filter((task) => task.status === lane.id);
@@ -996,7 +1004,7 @@ export function renderShowPlain(project: string, tasks: ShowTask[], all: boolean
996
1004
 
997
1005
  /** The themed renderer registered for SHOW_CUSTOM_TYPE. */
998
1006
  export function showRenderer(
999
- message: { content: unknown; details?: { project?: string; tasks?: ShowTask[]; all?: boolean } },
1007
+ message: { content: unknown; details?: { project?: string; tasks?: ShowTask[]; all?: boolean; lane?: string } },
1000
1008
  _options: unknown,
1001
1009
  theme: Theme,
1002
1010
  ): { render: (width: number) => string[]; invalidate: () => void } {
@@ -1004,10 +1012,11 @@ export function showRenderer(
1004
1012
  render(width: number): string[] {
1005
1013
  const details = message.details;
1006
1014
  if (!details?.tasks) return [typeof message.content === "string" ? message.content : ""];
1007
- const lanes = details.all ? [...SHOW_LANES, ...SHOW_EXTRA_LANES] : SHOW_LANES;
1008
- const lines: string[] = [theme.bold(`${details.project ?? "board"} · ${details.tasks.length} tasks`)];
1015
+ const lanes = showLanes(details.all ?? false, details.lane);
1016
+ const tasks = details.tasks.filter((task) => !details.lane || task.status === details.lane);
1017
+ const lines: string[] = [theme.bold(`${details.project ?? "board"} · ${tasks.length} tasks`)];
1009
1018
  for (const lane of lanes) {
1010
- const inLane = details.tasks.filter((task) => task.status === lane.id);
1019
+ const inLane = tasks.filter((task) => task.status === lane.id);
1011
1020
  if (inLane.length === 0) {
1012
1021
  lines.push(` ${theme.fg("dim", `${lane.label} — empty`)}`);
1013
1022
  continue;
@@ -1043,7 +1052,7 @@ export function showRenderer(
1043
1052
  };
1044
1053
  }
1045
1054
 
1046
- async function runShow(deps: CommandDeps, ctx: ExtensionCommandContext, pi: ExtensionAPI, all: boolean): Promise<void> {
1055
+ async function runShow(deps: CommandDeps, ctx: ExtensionCommandContext, pi: ExtensionAPI, all: boolean, lane?: string): Promise<void> {
1047
1056
  const client = deps.cli;
1048
1057
  if (!client) {
1049
1058
  ctx.ui.notify(`kanboard: ${deps.unavailable}`, "warning");
@@ -1055,7 +1064,7 @@ async function runShow(deps: CommandDeps, ctx: ExtensionCommandContext, pi: Exte
1055
1064
  try {
1056
1065
  // --all: this display lists every lane; the CLI bare default hides backlog/cancelled (UNI-62).
1057
1066
  const payload = (await client.run(["list", "--all", "--json"], { cwd: ctx.cwd })) as { tasks?: ShowTask[] };
1058
- tasks = payload.tasks ?? [];
1067
+ tasks = (payload.tasks ?? []).filter((task) => !lane || task.status === lane);
1059
1068
  } catch (error) {
1060
1069
  ctx.ui.notify(`kanboard: show failed — ${error instanceof KanboardCliError ? error.message : String(error)}`, "error");
1061
1070
  return;
@@ -1063,9 +1072,9 @@ async function runShow(deps: CommandDeps, ctx: ExtensionCommandContext, pi: Exte
1063
1072
  pi.sendMessage(
1064
1073
  {
1065
1074
  customType: SHOW_CUSTOM_TYPE,
1066
- content: renderShowPlain(slug, tasks, all),
1075
+ content: renderShowPlain(slug, tasks, all, lane),
1067
1076
  display: true,
1068
- details: { project: slug, tasks, all },
1077
+ details: { project: slug, tasks, all, lane },
1069
1078
  },
1070
1079
  { triggerTurn: false },
1071
1080
  );
@@ -1095,7 +1104,7 @@ const SUB_DESCRIPTIONS: Record<Subcommand, string> = {
1095
1104
  onboard: "Register this project on the board",
1096
1105
  status: "Daemon, project counts and claims",
1097
1106
  doctor: "Check the whole setup (binary, daemon, project, agent, claims)",
1098
- show: "The board in chat: lanes + claim order (--all adds cancelled/archived)",
1107
+ show: "The board in chat: show <lane> filters a lane; --all adds cancelled/archived",
1099
1108
  };
1100
1109
 
1101
1110
  /** /unipi:kanboard … */
@@ -1128,7 +1137,10 @@ export function kanboardCompletions(prefix: string): CompletionItem[] | null {
1128
1137
  const showFlags = /^(show\s+)(\S*)$/.exec(raw);
1129
1138
  if (showFlags) {
1130
1139
  const [, before, partial] = showFlags;
1131
- return [{ value: "--all", label: "--all", description: "Include cancelled and archived lanes" }]
1140
+ return [
1141
+ ...SHOW_FILTERS.map((lane) => ({ value: lane, label: lane, description: `Show only ${lane}` })),
1142
+ { value: "--all", label: "--all", description: "Include cancelled and archived lanes" },
1143
+ ]
1132
1144
  .filter((item) => item.value.startsWith(partial))
1133
1145
  .map((item) => ({ ...item, value: fullArgs(before!, item.value) }));
1134
1146
  }
@@ -1284,7 +1296,7 @@ export function registerKanboardCommands(pi: ExtensionAPI, deps: CommandDeps): v
1284
1296
  // stays plain text as a fallback and the context filter drops it.
1285
1297
  if (typeof pi.registerMessageRenderer === "function") {
1286
1298
  pi.registerMessageRenderer(SHOW_CUSTOM_TYPE, (message, options, theme) =>
1287
- showRenderer(message as { content: unknown; details?: { project?: string; tasks?: ShowTask[]; all?: boolean } }, options, theme),
1299
+ showRenderer(message as { content: unknown; details?: { project?: string; tasks?: ShowTask[]; all?: boolean; lane?: string } }, options, theme),
1288
1300
  );
1289
1301
  }
1290
1302
 
@@ -1379,7 +1391,7 @@ export function registerKanboardCommands(pi: ExtensionAPI, deps: CommandDeps): v
1379
1391
  await runDoctor(deps, ctx, pi);
1380
1392
  return;
1381
1393
  case "show":
1382
- await runShow(deps, ctx, pi, parts.includes("--all"));
1394
+ await runShow(deps, ctx, pi, parts.includes("--all"), parts.find((part) => SHOW_FILTERS.includes(part)));
1383
1395
  return;
1384
1396
  case "open":
1385
1397
  if (!deps.cli) {
package/src/move.ts ADDED
@@ -0,0 +1,63 @@
1
+ import { existsSync, readdirSync, readFileSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { projectSettingsPath, setSettings, type MoveContext, type MoveHandler } from "@pi-unipi/core";
5
+ import { createCli, resolveBinary } from "./bin.js";
6
+
7
+ export function kanboardHome(env: NodeJS.ProcessEnv = process.env): string {
8
+ return env.UNIPI_KANBOARD_HOME?.trim() || join(homedir(), ".unipi", "kanboard");
9
+ }
10
+
11
+ function projects(): Array<{ slug: string; root: string }> {
12
+ const root = join(kanboardHome(), "projects");
13
+ if (!existsSync(root)) return [];
14
+ const result: Array<{ slug: string; root: string }> = [];
15
+ for (const entry of readdirSync(root, { withFileTypes: true })) {
16
+ if (!entry.isDirectory()) continue;
17
+ try {
18
+ const parsed = JSON.parse(readFileSync(join(root, entry.name, "project.json"), "utf8"));
19
+ if (typeof parsed.slug === "string" && typeof parsed.root === "string") result.push(parsed);
20
+ } catch {}
21
+ }
22
+ return result;
23
+ }
24
+
25
+ function updateSettings(ctx: MoveContext, slug: string): void {
26
+ const file = projectSettingsPath(ctx.newRoot, "kanboard");
27
+ ctx.backup(file);
28
+ setSettings("kanboard", { slug, root: ctx.newRoot }, "project", ctx.newRoot);
29
+ ctx.log({ area: "kanboard", action: "settings-cache", from: ctx.oldRoot, to: file, result: "ok" });
30
+ }
31
+
32
+ export const kanboardMoveHandler: MoveHandler = {
33
+ id: "kanboard",
34
+ label: "Kanboard",
35
+ async discoverOrphans() {
36
+ return [...new Set(projects().filter((p) => !existsSync(p.root)).map((p) => p.root))];
37
+ },
38
+ scan(ctx) {
39
+ const match = projects().find((p) => p.root === ctx.oldRoot);
40
+ if (!match) return [];
41
+ const binary = resolveBinary();
42
+ return [{
43
+ area: "kanboard",
44
+ description: `project ${match.slug}: rebind root ${ctx.oldRoot} → ${ctx.newRoot} via unipi-kanboard project rebind; update ${projectSettingsPath(ctx.newRoot, "kanboard")}` + (!binary ? "; binary unavailable (will fail without changing project.json)" : ""),
45
+ async apply() {
46
+ if (ctx.dryRun) return;
47
+ if (!binary) throw new Error("kanboard binary unavailable — update kanboard binary or build the dev binary");
48
+ ctx.backup(join(kanboardHome(), "projects", match.slug, "project.json"));
49
+ const client = createCli(binary, { ...process.env, UNIPI_KANBOARD_HOME: kanboardHome(), UNIPI_KANBOARD_ACTOR: "user" });
50
+ try {
51
+ await client.run(["project", "rebind", match.slug, "--root", ctx.newRoot], { cwd: ctx.newRoot });
52
+ } catch (error) {
53
+ const message = error instanceof Error ? error.message : String(error);
54
+ const reason = /unrecognized subcommand|unknown.*rebind|unexpected argument.*rebind/i.test(message) ? `${message}; update kanboard binary` : message;
55
+ ctx.log({ area: "kanboard", action: "rebind", from: ctx.oldRoot, to: ctx.newRoot, result: `failed: ${reason}` });
56
+ throw new Error(reason);
57
+ }
58
+ ctx.log({ area: "kanboard", action: "rebind", from: ctx.oldRoot, to: ctx.newRoot, result: "ok" });
59
+ updateSettings(ctx, match.slug);
60
+ },
61
+ }];
62
+ },
63
+ };