@pi-unipi/kanboard 3.0.0-alpha.0 → 3.0.0-alpha.10

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 CHANGED
@@ -39,20 +39,28 @@ not allowed to write:
39
39
 
40
40
  1. `claim-next --session <sid> --pid <ppid> --host <host> --gate <chainGate>` —
41
41
  nothing ready → `Nothing ready (N waiting on deps, M blocked)`.
42
- 2. **jev picks the mode** (one `choice` call over title + body ≤2000 chars):
43
- `direct` (small clear change) · `plan` (needs an approved plan) · `goal`
44
- (multi-turn objective with verification). jev null → `direct`; the choice is
45
- logged to `~/.unipi/logs/kanboard.log` with `UNIPI_DEBUG_KANBOARD=1`.
42
+ 2. **The task's labels decide how it runs** (`edit <ID> --strategy
43
+ none|goal|ralph|swarm|graph` and `--plan yes|no`; `auto`/unset = jev picks).
44
+ jev gets one `choice` call with only the unset questions (title + body
45
+ ≤2000 chars): strategy criteria none/goal/ralph/swarm/graph + plan yes/no.
46
+ jev null → `none` + no plan; the decision is logged to
47
+ `~/.unipi/logs/kanboard.log` with `UNIPI_DEBUG_KANBOARD=1`.
46
48
  3. `set-run --mode`, then the task goes to the agent as a user message: title,
47
49
  body, last 10 activity entries, each dependency with its status and last note,
48
50
  and the rules (block with a comment to ask a question; never write
49
51
  `in_review`/`done`/`cancelled`; work only on this task).
50
- - **plan** → plan mode is entered through workflow's `unipi:plan-enter` runner
51
- first; approval stays interactive; a discarded plan releases the task to Todo
52
- with `plan discarded`.
52
+ - **plan=yes** → plan mode is entered through workflow's `unipi:plan-enter`
53
+ runner first; approval stays interactive; a discarded plan releases the
54
+ task to Todo with `plan discarded`. The chosen strategy applies to the
55
+ work turn after approval (deferred until `planModeChanged`).
53
56
  - **goal** → long-horizon's `unipi:goal-start` runner starts a goal with the
54
57
  task as the objective; the goal id is recorded with `set-run --goal` and
55
58
  completion is read back with `unipi:goal-status`.
59
+ - **swarm / graph** → long-horizon's `unipi:lh-explicit` runner sets the
60
+ explicit mode for the work turn.
61
+ - **ralph** → `unipi:ralph-start` starts the checklist loop (task title +
62
+ body as the checklist file); with no `- [ ]` items or a failed start it
63
+ falls back to goal, then none.
56
64
  4. Run end (a `/plan` settle after the last `agent_end`, once the agent reports
57
65
  idle with no queued messages): the task is re-read — if the agent blocked it,
58
66
  that is respected and reported (`▣ UNI-12 blocked: <comment>`) and the loop
@@ -65,7 +73,7 @@ not allowed to write:
65
73
  stale are dropped with a notice), falls back to `claim-next` while autowork
66
74
  is on, and stops when nothing is ready. The event loop is never blocked.
67
75
 
68
- Footer: `▣ UNI-12 · direct` while a task runs. The claimed task is persisted with
76
+ Footer: `▣ UNI-12 · <strategy>[ +plan]` while a task runs. The claimed task is persisted with
69
77
  `pi.appendEntry("unipi:kanboard-runner", …)`, so `/reload` or a resume offers to
70
78
  resume it or releases it to Todo.
71
79
 
package/index.ts CHANGED
@@ -22,6 +22,8 @@ import {
22
22
  initUnipiDirs,
23
23
  registerCommandRunner,
24
24
  getSettings,
25
+ appendProgress,
26
+ registerProgressRenderer,
25
27
  } from "@pi-unipi/core";
26
28
 
27
29
  import { openCli, type KanboardCli } from "./src/bin.js";
@@ -69,6 +71,7 @@ export default function (pi: ExtensionAPI) {
69
71
  return status?.phase === "running" ? status.taskId : null;
70
72
  },
71
73
  () => readKanboardSettings().turnAddLimit,
74
+ () => readKanboardSettings().doCredits,
72
75
  );
73
76
  const sessionId = (): string => process.env.UNIPI_KANBOARD_SESSION ?? `pi-${process.pid}`;
74
77
 
@@ -101,6 +104,7 @@ export default function (pi: ExtensionAPI) {
101
104
  guard,
102
105
  session: sessionId,
103
106
  debug,
107
+ progress: (data) => appendProgress(pi, data),
104
108
  }) as CommandDeps;
105
109
 
106
110
  const revealSkill = (ctx: ExtensionContext | { cwd?: string }): void => {
@@ -133,6 +137,7 @@ export default function (pi: ExtensionAPI) {
133
137
  return true;
134
138
  };
135
139
 
140
+ registerProgressRenderer(pi);
136
141
  registerKanboardCommands(pi, buildDeps());
137
142
 
138
143
  registerCommandRunner(ACTION_OPEN, async (ctx) => {
@@ -158,6 +163,9 @@ export default function (pi: ExtensionAPI) {
158
163
  });
159
164
 
160
165
  pi.on("session_start", async (_event, ctx) => {
166
+ // Kanboard's own ambient children (summaries, --list-models) get this env:
167
+ // they must not re-sync the runtime or spawn a daemon.
168
+ if (process.env.UNIPI_KANBOARD_CHILD) return;
161
169
  initUnipiDirs();
162
170
  if (attach(ctx as unknown as ExtensionContext)) {
163
171
  const deps = buildDeps();
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@pi-unipi/kanboard",
3
- "version": "3.0.0-alpha.0",
4
- "description": "Kanboard v3 \u2014 deferred-work board: CLI/daemon bridge, task runner and skill",
3
+ "version": "3.0.0-alpha.10",
4
+ "description": "Kanboard v3 — deferred-work board: CLI/daemon bridge, task runner and skill",
5
5
  "type": "module",
6
6
  "main": "index.ts",
7
7
  "license": "MIT",
@@ -31,10 +31,10 @@
31
31
  "access": "public"
32
32
  },
33
33
  "dependencies": {
34
- "@pi-unipi/core": "3.0.0-alpha.0"
34
+ "@pi-unipi/core": "3.0.0-alpha.10"
35
35
  },
36
36
  "peerDependencies": {
37
- "@earendil-works/pi-coding-agent": "^0.86.0",
37
+ "@earendil-works/pi-coding-agent": "^0.87.1",
38
38
  "typebox": "^1.1.38"
39
39
  },
40
40
  "pi": {
@@ -48,10 +48,10 @@
48
48
  "themes": []
49
49
  },
50
50
  "optionalDependencies": {
51
- "@pi-unipi/kanboard-linux-x64": "3.0.0-alpha.0",
52
- "@pi-unipi/kanboard-linux-arm64": "3.0.0-alpha.0",
53
- "@pi-unipi/kanboard-darwin-x64": "3.0.0-alpha.0",
54
- "@pi-unipi/kanboard-darwin-arm64": "3.0.0-alpha.0",
55
- "@pi-unipi/kanboard-win32-x64": "3.0.0-alpha.0"
51
+ "@pi-unipi/kanboard-linux-x64": "3.0.0-alpha.10",
52
+ "@pi-unipi/kanboard-linux-arm64": "3.0.0-alpha.10",
53
+ "@pi-unipi/kanboard-darwin-x64": "3.0.0-alpha.10",
54
+ "@pi-unipi/kanboard-darwin-arm64": "3.0.0-alpha.10",
55
+ "@pi-unipi/kanboard-win32-x64": "3.0.0-alpha.10"
56
56
  }
57
57
  }
@@ -26,6 +26,8 @@ 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> edit <ID> --strategy none|goal|ralph|swarm|graph|auto # how the runner works it (auto/unset = jev decides)
30
+ <binary> --actor agent --project <slug> edit <ID> --plan yes|no|auto # plan+approve first (auto/unset = jev decides)
29
31
  <binary> --actor agent --project <slug> move <ID> blocked --comment "<what you need>"
30
32
  <binary> --actor agent --project <slug> link <ID> --after <DEP>
31
33
  <binary> --actor agent --project <slug> unlink <ID> --after <DEP>
@@ -35,7 +37,10 @@ be on `PATH`):
35
37
  ```
36
38
 
37
39
  `--json` gives machine-readable output for every subcommand (task objects carry
38
- `ready`, `waitingFor`, `depsStatus` and `staleness`).
40
+ `ready`, `waitingFor`, `depsStatus`, `staleness`, `strategy` and `plan`).
41
+
42
+ `list`/`search` show a one-line body excerpt; read a task's full description with
43
+ `show <ID>` before editing, linking or queueing it.
39
44
 
40
45
  `settings show` reads the pi runtime and effective limits; `settings set`
41
46
  and `rotate-token` are user-only — the agent is refused.
@@ -69,9 +74,10 @@ lists them — read the files there directly if you need one.
69
74
  your turn ends; claiming them yourself loses the summary and the review step.
70
75
  3. **Never cancel.** If a task should be dropped, block it with
71
76
  `move <ID> blocked --comment "suggest cancel: <why>"` and let the user decide.
72
- 4. **To ask the user something, block the task and stop**: `move <ID> blocked
73
- --comment "<exactly what you need>"`. The answer arrives as a comment the next
74
- time the task is claimed.
77
+ 4. **Follow the blocking rule in your task prompt**: by default work
78
+ autonomously and record assumptions with `note <ID> "assumed: <what/why>"`;
79
+ block (with a comment saying exactly what you need) only when you truly
80
+ cannot continue — `move <ID> blocked --comment "<what you need>"`.
75
81
  5. **Work only on the task you were given.** Follow-up work goes to the board as
76
82
  a new task in Backlog (`add "<title>"`), optionally `link <new> --after <ID>`.
77
83
  A session holds one claim at a time, and at most `maxSessions` (default 2)
package/src/bin.ts CHANGED
@@ -80,10 +80,12 @@ export function resolveBinary(env: NodeJS.ProcessEnv = process.env, from?: strin
80
80
  if (explicit) {
81
81
  return isRunnable(explicit) ? { path: explicit, source: "env" } : null;
82
82
  }
83
- const packaged = platformPackagePath(from);
84
- if (packaged && isRunnable(packaged)) return { path: packaged, source: "platform-package" };
83
+ // In a source checkout the dev build wins — the platform package can ship a
84
+ // stale prebuilt binary (seen on coffee: alpha.0 shadowed the debug build).
85
85
  const dev = devBuildPath();
86
86
  if (dev) return { path: dev, source: "dev-build" };
87
+ const packaged = platformPackagePath(from);
88
+ if (packaged && isRunnable(packaged)) return { path: packaged, source: "platform-package" };
87
89
  return null;
88
90
  }
89
91
 
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";
@@ -39,9 +40,9 @@ export const HELP = `Kanboard commands
39
40
  /unipi:kanboard-autowork start|stop work ready tasks chain by chain; stop finishes the current one first
40
41
  Priority -p: 1 none · 2 low · 3 medium · 4 high · 5 urgent`;
41
42
 
42
- export function doText(slug: string, cli: string, request: string, queueMax = 10): string {
43
+ export function doText(slug: string, cli: string, request: string, queueMax = 10, credits = 0): string {
43
44
  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
+ return `[kanboard] For this request you may use the kanboard skill on project ${slug} (CLI: \`${cli} --actor agent --project ${slug} …\`). You have ${credits} write credit${credits === 1 ? "" : "s"} remaining this session — each board write (add, move, edit, note, link, order, queue) costs 1, reads are free; /unipi:kanboard-do tops the credits back up. 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. Read a task with \`show <ID>\` before editing, linking or queueing it — \`list\` only shows titles and a one-line excerpt. To choose how a task is worked, label it with \`edit <ID> --strategy none|goal|ralph|swarm|graph\` and \`--plan yes|no\`; leave it unset to let the runner decide. If the request is unclear, ask me instead of guessing.
45
46
 
46
47
  Request: ${request}`;
47
48
  }
@@ -196,6 +197,8 @@ export interface CommandDeps {
196
197
  /** This session's id (UNIPI_KANBOARD_SESSION / pi-<pid>). */
197
198
  session: () => string;
198
199
  debug: (line: string) => void;
200
+ /** Post a user-only progress bar (optional: absent in tests). */
201
+ progress?: (data: import("@pi-unipi/core").ProgressData) => void;
199
202
  }
200
203
 
201
204
  export interface DaemonInfo {
@@ -628,6 +631,8 @@ export async function runStatus(deps: CommandDeps, ctx: ExtensionCommandContext
628
631
  if (problems && problems.length > 0) {
629
632
  lines.push(`⚠ ${problems.length} task file(s) need repair — unipi-kanboard validate --fix`);
630
633
  }
634
+ const bar = boardProgressData(tasks, slug);
635
+ if (bar) deps.progress?.(bar);
631
636
  // Active claims and this session's queue.
632
637
  const running = tasks.filter((task) => task.status === "in_progress");
633
638
  if (running.length > 0) {
@@ -781,26 +786,23 @@ export async function runStopDaemon(deps: CommandDeps, ctx: ExtensionCommandCont
781
786
 
782
787
  /**
783
788
  * Tell the daemon how to run pi for summaries: `piCommand` (this process's
784
- * argv minus flags) and `models` (the user's available list, the whitelist the
785
- * Settings dialog may pick from). Runs on session start and on `open`.
789
+ * argv minus flags). The daemon owns the model catalog itself now — it runs
790
+ * `piCommand --list-models` with the same ambient runtime as the summarizer.
791
+ * Runs on session start and on `open`; skipped inside kanboard's own child
792
+ * sessions (UNIPI_KANBOARD_CHILD) so the summarizer never rewrites the file.
786
793
  */
787
794
  export async function syncPiRuntime(
788
795
  deps: CommandDeps,
789
796
  ctx: ExtensionCommandContext | ExtensionContext,
790
797
  ): Promise<void> {
791
798
  const client = deps.cli;
792
- if (!client) return;
799
+ if (!client || process.env.UNIPI_KANBOARD_CHILD) return;
793
800
  // argv[1] is the pi script when node runs it; a compiled binary has none.
794
801
  // Keep it when it exists on disk (the coffee bin/pi shim has no extension).
795
802
  const script = process.argv[1];
796
803
  const argv = [process.execPath, ...(script && existsSync(script) ? [script] : [])];
797
- const models =
798
- (ctx as { modelRegistry?: { getAvailable?: () => Array<{ provider: string; id: string }> } })
799
- .modelRegistry?.getAvailable?.()
800
- .map((model) => `${model.provider}/${model.id}`) ?? [];
801
804
  try {
802
805
  await client.run<unknown>(["settings", "set", "pi-command", JSON.stringify(argv)], {});
803
- await client.run<unknown>(["settings", "set", "models", JSON.stringify(models)], {});
804
806
  } catch (error) {
805
807
  deps.debug(`pi runtime sync failed: ${error instanceof Error ? error.message : String(error)}`);
806
808
  }
@@ -1091,7 +1093,21 @@ export function kanboardCompletions(prefix: string): CompletionItem[] | null {
1091
1093
  /** Live task list for `--after` / `-do` completions, cached ~5s per client. */
1092
1094
  const taskCache = new WeakMap<KanboardCli, { at: number; tasks: ShowTask[] }>();
1093
1095
 
1094
- export async function taskCompletions(deps: CommandDeps, prefix: string, before: string): Promise<CompletionItem[] | null> {
1096
+ export interface TaskCompletionOptions {
1097
+ /** Also match task TITLES (default true); false = id prefixes only. */
1098
+ titleSearch?: boolean;
1099
+ /** Row style: "status" = label id, description "<status> — <title>";
1100
+ * "id-title" = label "<id> <title>", description "<status>". */
1101
+ rowStyle?: "status" | "id-title";
1102
+ }
1103
+
1104
+ export async function taskCompletions(
1105
+ deps: CommandDeps,
1106
+ prefix: string,
1107
+ before: string,
1108
+ opts: TaskCompletionOptions = {},
1109
+ ): Promise<CompletionItem[] | null> {
1110
+ const { titleSearch = true, rowStyle = "status" } = opts;
1095
1111
  const client = deps.cli;
1096
1112
  if (!client) return null;
1097
1113
  let cache = taskCache.get(client);
@@ -1107,13 +1123,21 @@ export async function taskCompletions(deps: CommandDeps, prefix: string, before:
1107
1123
  const needle = prefix.toLowerCase();
1108
1124
  const items = cache.tasks
1109
1125
  .filter((task) => !["cancelled", "archived"].includes(task.status))
1110
- .filter((task) => task.id.toLowerCase().startsWith(needle) || task.title.toLowerCase().includes(needle))
1126
+ .filter((task) => task.id.toLowerCase().startsWith(needle) || (titleSearch && task.title.toLowerCase().includes(needle)))
1111
1127
  .slice(0, 12)
1112
- .map((task) => ({
1113
- value: fullArgs(before, task.id),
1114
- label: task.id,
1115
- description: `${task.status} — ${task.title}`,
1116
- }));
1128
+ .map((task) =>
1129
+ rowStyle === "id-title"
1130
+ ? {
1131
+ value: fullArgs(before, task.id),
1132
+ label: `${task.id} ${task.title.slice(0, 64)}`,
1133
+ description: task.status,
1134
+ }
1135
+ : {
1136
+ value: fullArgs(before, task.id),
1137
+ label: task.id,
1138
+ description: `${task.status} — ${task.title}`,
1139
+ },
1140
+ );
1117
1141
  return items.length > 0 ? items : null;
1118
1142
  }
1119
1143
 
@@ -1189,15 +1213,19 @@ export async function kanboardAddCompletions(deps: CommandDeps, prefix: string):
1189
1213
  return null;
1190
1214
  }
1191
1215
 
1192
- /** /unipi:kanboard-do … — complete a trailing task-id prefix. */
1216
+ /** /unipi:kanboard-do … — complete a trailing task-id token; plain prose never matches. */
1193
1217
  export async function kanboardDoCompletions(deps: CommandDeps, prefix: string): Promise<CompletionItem[] | null> {
1194
1218
  const raw = prefix ?? "";
1195
- const match = /^(.*?)(\S*)$/.exec(raw)!;
1196
- const before = match[1]!;
1197
- const token = match[2]!;
1198
- // Only when the last token looks like a task id prefix (ABC-…).
1199
- if (!/^[A-Za-z]{2,6}-?[0-9]*$/.test(token) || token.length < 2) return null;
1200
- return taskCompletions(deps, token, before);
1219
+ const at = raw.lastIndexOf(" ");
1220
+ const before = at === -1 ? "" : raw.slice(0, at + 1);
1221
+ const token = raw.slice(at + 1);
1222
+ // Only a task-id-like tail ("UNI-1", "uni", "UNI-") completes; words that
1223
+ // match no id shape or prefix ("note", "set") yield nothing.
1224
+ if (!/^[A-Za-z]{2,10}-?\d*$/.test(token)) return null;
1225
+ // Title search only when the token looks like a real id prefix (has a digit
1226
+ // or ends with the dash); otherwise id prefixes only.
1227
+ const idLike = /\d/.test(token) || token.endsWith("-");
1228
+ return taskCompletions(deps, token, before, { titleSearch: idLike, rowStyle: "id-title" });
1201
1229
  }
1202
1230
 
1203
1231
  export function registerKanboardCommands(pi: ExtensionAPI, deps: CommandDeps): void {
@@ -1306,10 +1334,15 @@ export function registerKanboardCommands(pi: ExtensionAPI, deps: CommandDeps): v
1306
1334
  });
1307
1335
 
1308
1336
  pi.registerCommand("unipi:kanboard-do", {
1309
- description: "Let the agent use the kanboard for this turn — writes are allowed until the turn ends",
1337
+ description: "Grant kanboard write credits to the agent (/unipi:kanboard-do <request>; off revokes)",
1310
1338
  getArgumentCompletions: (prefix: string) => kanboardDoCompletions(deps, prefix ?? ""),
1311
1339
  handler: async (args: string, ctx: ExtensionCommandContext) => {
1312
1340
  const request = (args ?? "").trim();
1341
+ if (request.toLowerCase() === "off") {
1342
+ deps.guard.revoke();
1343
+ ctx.ui.notify("kanboard: write credits revoked — reads stay free", "info");
1344
+ return;
1345
+ }
1313
1346
  if (!request) {
1314
1347
  ctx.ui.notify("kanboard: -do needs a request — /unipi:kanboard-do <what the agent may do on the board>", "warning");
1315
1348
  return;
@@ -1326,9 +1359,12 @@ export function registerKanboardCommands(pi: ExtensionAPI, deps: CommandDeps): v
1326
1359
  const busy = typeof (ctx as unknown as { isIdle?: () => boolean }).isIdle === "function"
1327
1360
  ? !(ctx as unknown as { isIdle: () => boolean }).isIdle!()
1328
1361
  : false;
1329
- pi.sendUserMessage(doText(slug, client.binary.path, request, deps.settings().queueMax), busy ? { deliverAs: "followUp" } : undefined);
1362
+ pi.sendUserMessage(
1363
+ doText(slug, client.binary.path, request, deps.settings().queueMax, deps.guard.remaining()),
1364
+ busy ? { deliverAs: "followUp" } : undefined,
1365
+ );
1330
1366
  deps.guard.noteSent();
1331
- deps.debug(`do window open (${slug})`);
1367
+ deps.debug(`do credits open (${slug}): ${deps.guard.remaining()} remaining`);
1332
1368
  },
1333
1369
  });
1334
1370
 
package/src/guard.ts CHANGED
@@ -9,8 +9,8 @@
9
9
 
10
10
  import { tokenizeArgs } from "./commands.js";
11
11
 
12
- export const WRITE_BLOCK_REASON =
13
- "kanboard board writes are only allowed during /unipi:kanboard-do or a runner task";
12
+ export const DEFAULT_DO_CREDITS = 10;
13
+ export const WRITE_CREDITS_USED_UP = "kanboard write credits used up — run /unipi:kanboard-do to reload";
14
14
  export const addCapReason = (limit: number): string => `at most ${limit} new tasks per turn`;
15
15
  /** @deprecated tests should read the limit through the guard's getter instead. */
16
16
  export const ADD_CAP = 20;
@@ -80,35 +80,55 @@ export function isReadonly(invocation: KanboardInvocation): boolean {
80
80
  }
81
81
 
82
82
  export interface WriteGuard {
83
- /** Open the window for a `/unipi:kanboard-do` turn. */
83
+ /** Grant / top up the session's write credits to N (kanboard.doCredits). */
84
84
  open(): void;
85
+ /** Revoke remaining credits (/unipi:kanboard-do off). */
86
+ revoke(): void;
87
+ /** Credits left this session. */
88
+ remaining(): number;
85
89
  /** Arm the agent_end closer right after the -do prompt was sent. */
86
90
  noteSent(): void;
87
- /**
88
- * A window opened by -do closes on the first agent_end that isn't the
89
- * pre-send echo (>150ms after noteSent). Returns true when it just closed.
90
- */
91
+ /** Credits persist across turns; this only closes the -do window label. */
91
92
  onAgentEnd(): boolean;
92
93
  /** null when the command is allowed; otherwise the block reason. */
93
94
  check(command: string): string | null;
94
95
  }
95
96
 
96
97
  /**
97
- * The window is open during a -do turn (`doOpen`) or while the runner has a
98
- * task in phase `running`. `runnerTask` returns that task's id (null when the
99
- * runner is not running one) — the `add` counter resets whenever the running
100
- * task changes, so autowork/queue drains get a fresh 20 per task.
98
+ * Writes cost session credits (one per write subcommand in the command line);
99
+ * reads are free and never blocked. A runner task keeps unlimited access
100
+ * (still add-capped). Credits persist across turns and follow-up questions
101
+ * until spent; /unipi:kanboard-do tops up to N without stacking past N.
101
102
  */
102
- export function createWriteGuard(runnerTask: () => string | null, addLimit: () => number = () => ADD_CAP): WriteGuard {
103
+ export function createWriteGuard(
104
+ runnerTask: () => string | null,
105
+ addLimit: () => number = () => ADD_CAP,
106
+ doCredits: () => number = () => DEFAULT_DO_CREDITS,
107
+ ): WriteGuard {
108
+ let credits = 0;
103
109
  let doOpen = false;
104
110
  let sentAt = 0;
105
111
  let adds = 0;
106
112
  let lastTask: string | null = null;
113
+ const countAdd = (invocation: KanboardInvocation): string | null => {
114
+ if (invocation.sub !== "add") return null;
115
+ adds += 1;
116
+ const limit = addLimit();
117
+ return limit > 0 && adds > limit ? addCapReason(limit) : null;
118
+ };
107
119
  return {
108
120
  open() {
109
- doOpen = true;
121
+ credits = Math.max(credits, Math.max(0, doCredits()));
110
122
  adds = 0;
111
123
  lastTask = null;
124
+ doOpen = true;
125
+ },
126
+ revoke() {
127
+ credits = 0;
128
+ doOpen = false;
129
+ },
130
+ remaining() {
131
+ return credits;
112
132
  },
113
133
  noteSent() {
114
134
  sentAt = Date.now();
@@ -130,12 +150,15 @@ export function createWriteGuard(runnerTask: () => string | null, addLimit: () =
130
150
  }
131
151
  for (const invocation of invocations) {
132
152
  if (isReadonly(invocation)) continue;
133
- if (!(doOpen || task !== null)) return WRITE_BLOCK_REASON;
134
- if (invocation.sub === "add") {
135
- adds += 1;
136
- const limit = addLimit();
137
- if (limit > 0 && adds > limit) return addCapReason(limit);
153
+ if (task !== null) {
154
+ const cap = countAdd(invocation);
155
+ if (cap) return cap;
156
+ continue;
138
157
  }
158
+ if (credits <= 0) return WRITE_CREDITS_USED_UP;
159
+ credits -= 1;
160
+ const cap = countAdd(invocation);
161
+ if (cap) return cap;
139
162
  }
140
163
  return null;
141
164
  },
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Board progress bar (user-only entry):
3
+ * ▣ Board · unipi ██████████▒▒░░░░░░░░ 5/10 tasks 1 blocked
4
+ * Solid = in review + done, shade = in progress, light = todo + blocked.
5
+ * Backlog, cancelled and archived tasks are outside the bar.
6
+ */
7
+
8
+ import type { ProgressData } from "@pi-unipi/core";
9
+
10
+ const COUNTED = new Set(["todo", "in_progress", "in_review", "done", "blocked"]);
11
+
12
+ export function boardProgressData(tasks: ReadonlyArray<{ status: string }>, slug: string): ProgressData | undefined {
13
+ const counted = tasks.filter((t) => COUNTED.has(t.status));
14
+ if (counted.length === 0) return undefined;
15
+ const n = (s: string) => counted.filter((t) => t.status === s).length;
16
+ const blocked = n("blocked");
17
+ return {
18
+ icon: "▣",
19
+ label: slug ? `Board · ${slug}` : "Board",
20
+ done: n("in_review") + n("done"),
21
+ active: n("in_progress"),
22
+ total: counted.length,
23
+ unit: "tasks",
24
+ detail: blocked > 0 ? `${String(blocked)} blocked` : undefined,
25
+ color: "success",
26
+ };
27
+ }
package/src/runner.ts CHANGED
@@ -7,9 +7,11 @@
7
7
  * (spec principle 2).
8
8
  */
9
9
 
10
+ import { appendProgress } from "@pi-unipi/core";
11
+ import { boardProgressData } from "./progress.js";
10
12
  import { hostname as osHostname } from "node:os";
11
13
  import type { ExtensionAPI, ExtensionContext, SessionEntry } from "@earendil-works/pi-coding-agent";
12
- import { callCommandRunner, readJudgeJevSettings, askJev, UNIPI_EVENTS } from "@pi-unipi/core";
14
+ import { callCommandRunner, resolveDecisionModel, askJev, registerCompactionContext, UNIPI_EVENTS } from "@pi-unipi/core";
13
15
 
14
16
  import { KanboardCliError, type KanboardCli } from "./bin.js";
15
17
  import {
@@ -26,7 +28,10 @@ import { applyLimitEnv, type KanboardSettings } from "./settings.js";
26
28
 
27
29
  export const RUNNER_ENTRY = "unipi:kanboard-runner";
28
30
 
29
- export type RunMode = "direct" | "plan" | "goal";
31
+ export type RunMode = "none" | "plan" | "goal" | "ralph" | "swarm" | "graph";
32
+
33
+ /** Work strategy = the long-horizon modes; `direct` maps to `none`. */
34
+ export type Strategy = "none" | "goal" | "ralph" | "swarm" | "graph";
30
35
 
31
36
  export type { KanboardTask, KanboardActivity, KanboardRun };
32
37
 
@@ -35,6 +40,10 @@ interface RunnerState {
35
40
  task: KanboardTask | null;
36
41
  mode: RunMode;
37
42
  goalId: string | null;
43
+ /** Plan-first flag for the current task (requested by label or jev). */
44
+ planning: boolean;
45
+ /** Strategy held back until plan approval (set on planModeChanged). */
46
+ pendingStrategy: Strategy | null;
38
47
  endsSinceSend: number;
39
48
  stopAfterCurrent: boolean;
40
49
  /** Autowork: keep claiming ready tasks after each finish (queue first). */
@@ -47,6 +56,8 @@ interface RunnerState {
47
56
  consumedText: string;
48
57
  /** When the current task's prompt was sent (ms). */
49
58
  sentAt: number;
59
+ /** Last ctx seen — needed to apply a deferred strategy on plan approval. */
60
+ lastCtx: ExtensionContext | undefined;
50
61
  }
51
62
 
52
63
  export interface RunnerDeps {
@@ -74,23 +85,34 @@ export interface Runner {
74
85
 
75
86
  /**
76
87
  * The askJev question the runner asks for mode choice, as a standalone export
77
- * so probes and tests exercise exactly what chooseMode sends.
88
+ * so probes and tests exercise exactly what chooseStrategy sends.
89
+ */
90
+ const STRATEGY_CRITERIA: Record<Strategy, string> = {
91
+ none: "A well-scoped change one pass can finish and check.",
92
+ goal: "One objective that needs iterating until it is verifiably done (tests, checks).",
93
+ ralph: "An enumerable checklist of similar chores worked item by item.",
94
+ swarm: "Several independent parts that can be worked in parallel, then combined.",
95
+ graph: "Dependent steps where later work needs earlier results.",
96
+ };
97
+
98
+ type StrategyQuestion = { type: "choice"; instructions: string; criteria: Record<string, string> };
99
+
100
+ /**
101
+ * The askJev questions the runner asks per claimed task — only for the fields
102
+ * the task left unset (`strategy`/`plan` labels win; `auto` = jev decides).
103
+ * Exported so probes and tests exercise exactly what chooseStrategy sends.
78
104
  */
79
- export function modeQuestion(task: KanboardTask): {
105
+ export function strategyQuestion(task: KanboardTask): {
80
106
  state: string;
81
- questions: { mode: { type: "choice"; instructions: string; criteria: Record<string, string> } };
107
+ questions: { strategy: StrategyQuestion };
82
108
  } {
83
109
  return {
84
110
  state: `${task.title}\n\n${(task.body ?? "").slice(0, 2000)}`,
85
111
  questions: {
86
- mode: {
112
+ strategy: {
87
113
  type: "choice",
88
- instructions: "How should this task be executed?",
89
- criteria: {
90
- direct: "A small, clear change — just do it",
91
- plan: "Multi-file or design choices; needs a plan approved first",
92
- goal: "A large multi-step objective that needs many turns and verification",
93
- },
114
+ instructions: "Which work strategy should run this task?",
115
+ criteria: { ...STRATEGY_CRITERIA },
94
116
  },
95
117
  },
96
118
  };
@@ -102,8 +124,10 @@ export function createRunner(deps: RunnerDeps): Runner {
102
124
  const state: RunnerState = {
103
125
  phase: "idle",
104
126
  task: null,
105
- mode: "direct",
127
+ mode: "none",
106
128
  goalId: null,
129
+ planning: false,
130
+ pendingStrategy: null,
107
131
  endsSinceSend: 0,
108
132
  stopAfterCurrent: false,
109
133
  autowork: false,
@@ -112,6 +136,7 @@ export function createRunner(deps: RunnerDeps): Runner {
112
136
  lastText: "",
113
137
  consumedText: "",
114
138
  sentAt: 0,
139
+ lastCtx: undefined,
115
140
  };
116
141
 
117
142
  const setStatus = (ctx: ExtensionContext, text?: string): void => {
@@ -122,7 +147,8 @@ export function createRunner(deps: RunnerDeps): Runner {
122
147
  }
123
148
  };
124
149
 
125
- const describe = (): string => (state.task ? `▣ ${state.task.id} · ${state.mode}` : "▣ idle");
150
+ const describe = (): string =>
151
+ state.task ? `▣ ${state.task.id} · ${state.mode}${state.planning ? " +plan" : ""}` : "▣ idle";
126
152
 
127
153
  const persist = (phase: string): void => {
128
154
  pi.appendEntry(RUNNER_ENTRY, {
@@ -220,15 +246,36 @@ export function createRunner(deps: RunnerDeps): Runner {
220
246
  }
221
247
  }
222
248
 
223
- // ── mode choice (jev) ─────────────────────────────────────────────────────
249
+ // ── strategy choice (labels win; jev decides the unset) ───────────────────
224
250
 
225
- async function chooseMode(task: KanboardTask): Promise<RunMode> {
226
- const settings = readJudgeJevSettings(cwd);
227
- const answers = await askJev({ ...modeQuestion(task), settings, env: process.env });
228
- const choice = answers?.mode?.choice;
229
- const mode: RunMode = choice === "plan" || choice === "goal" ? choice : "direct";
230
- deps.debug(`mode ${task.id}: jev answered ${JSON.stringify(choice ?? null)} → ${mode}`);
231
- return mode;
251
+ async function chooseStrategy(task: KanboardTask): Promise<{ strategy: Strategy; plan: boolean }> {
252
+ // Labels win; the board defaults fill the gaps; jev only picks a strategy
253
+ // when both leave it on auto. Plan is never a jev question.
254
+ const board = deps.settings();
255
+ const labelled = typeof task.strategy === "string" && (task.strategy as string) !== "auto" ? (task.strategy as Strategy) : undefined;
256
+ const plan: boolean = typeof task.plan === "boolean" ? task.plan : board.defaultPlan === true;
257
+ // Jev routing is enabled only by defaultStrategy "auto"; disabled = the
258
+ // board default runs, no jev call at all.
259
+ const jevRouting = board.defaultStrategy === "auto";
260
+ let strategy: Strategy | undefined = labelled;
261
+ if (strategy === undefined && !jevRouting) {
262
+ strategy = board.defaultStrategy as Strategy;
263
+ }
264
+ let jevAnswer: string | null = null;
265
+ let jevConfidence: number | null = null;
266
+ if (strategy === undefined && jevRouting) {
267
+ const settings = resolveDecisionModel(cwd, "kanboard");
268
+ const answers = await askJev({ ...strategyQuestion(task), settings, env: process.env });
269
+ jevAnswer = answers?.strategy?.choice ?? null;
270
+ jevConfidence = typeof answers?.strategy?.confidence === "number" ? answers.strategy.confidence : null;
271
+ // Same confidence gate as the long-horizon judge: a missing or low
272
+ // confidence abstains to "none".
273
+ const accepted =
274
+ jevAnswer !== null && jevAnswer in STRATEGY_CRITERIA && (jevConfidence ?? -1) >= board.jevThreshold;
275
+ strategy = accepted ? (jevAnswer as Strategy) : "none";
276
+ }
277
+ deps.debug(`strategy ${task.id}: label=${labelled ?? "-"} board=${board.defaultStrategy}/${board.defaultPlan ? "plan" : "no-plan"} jev answered ${JSON.stringify(jevAnswer)} confidence=${jevConfidence ?? "-"}/${board.jevThreshold} → ${strategy}${plan ? "+plan" : ""}`);
278
+ return { strategy: strategy ?? "none", plan };
232
279
  }
233
280
 
234
281
  // ── prompting ─────────────────────────────────────────────────────────────
@@ -240,7 +287,7 @@ export function createRunner(deps: RunnerDeps): Runner {
240
287
  .map((entry) => `- ${entry.at} [${entry.actor}] ${entry.text}`)
241
288
  .join("\n");
242
289
  const byId = new Map(all.map((candidate) => [candidate.id, candidate]));
243
- const deps = (task.deps ?? [])
290
+ const depLines = (task.deps ?? [])
244
291
  .map((id) => {
245
292
  const dep = byId.get(id);
246
293
  if (!dep) return `- ${id}: (missing)`;
@@ -258,16 +305,25 @@ export function createRunner(deps: RunnerDeps): Runner {
258
305
  activity || "(none)",
259
306
  "",
260
307
  "## Dependencies",
261
- deps || "(none)",
308
+ depLines || "(none)",
262
309
  "",
263
310
  ...attachmentSection(task),
264
311
  "## Rules",
265
312
  `Work only on this task (${task.id}).`,
266
313
  `Use the board CLI through its absolute path and always pass the actor and project:`,
267
314
  ` ${binary} --actor agent --project ${project()} <command>`,
268
- `If you need information or a decision from the user, run:`,
269
- ` ${binary} --actor agent --project ${project()} move ${task.id} blocked --comment "<what you need>"`,
270
- "and stop — the runner will hand the task back when the user answers.",
315
+ ...(deps.settings().blocking === "ask"
316
+ ? [
317
+ `If you need information or a decision from the user, run:`,
318
+ ` ${binary} --actor agent --project ${project()} move ${task.id} blocked --comment "<what you need>"`,
319
+ "and stop — the runner will hand the task back when the user answers.",
320
+ ]
321
+ : [
322
+ "Work autonomously. If something is unclear, make the most reasonable assumption, record it with",
323
+ ` \`note ${task.id} "assumed: <what and why>"\`, and continue. Block only when you truly cannot continue —`,
324
+ "missing credentials or access, a destructive or irreversible decision, or a contradiction in the",
325
+ ` task — with \`${binary} --actor agent --project ${project()} move ${task.id} blocked --comment "<what you need>"\`, then stop.`,
326
+ ]),
271
327
  `Do not move the task to in_review or done and do not cancel it: the runner writes those when your turn ends.`,
272
328
  `You may add notes with \`note ${task.id} "<text>"\`, link follow-up work with \`add\`, and reorder with \`order\`.`,
273
329
  `To show evidence (a screenshot, a log, a report), attach it: \`attach ${task.id} <file> --note "<what it shows>"\`.`,
@@ -277,6 +333,7 @@ export function createRunner(deps: RunnerDeps): Runner {
277
333
  // ── the loop ──────────────────────────────────────────────────────────────
278
334
 
279
335
  async function startTask(ctx: ExtensionContext): Promise<boolean> {
336
+ state.lastCtx = ctx;
280
337
  let claimed: KanboardTask | null;
281
338
  let claim: KanboardClaim;
282
339
  try {
@@ -298,6 +355,8 @@ export function createRunner(deps: RunnerDeps): Runner {
298
355
  state.task = claimed;
299
356
  state.endsSinceSend = 0;
300
357
  state.planOutcome = null;
358
+ state.planning = false;
359
+ state.pendingStrategy = null;
301
360
  state.lastText = "";
302
361
  try {
303
362
  return await runClaimedTask(ctx, claimed);
@@ -338,13 +397,16 @@ export function createRunner(deps: RunnerDeps): Runner {
338
397
  state.goalId = null;
339
398
  state.phase = "running";
340
399
 
341
- let mode: RunMode = "direct";
400
+ let strategy: Strategy = "none";
401
+ let plan = false;
342
402
  try {
343
- mode = await chooseMode(claimed);
403
+ ({ strategy, plan } = await chooseStrategy(claimed));
344
404
  } catch (error) {
345
- deps.debug(`mode choice failed: ${error instanceof Error ? error.message : String(error)}`);
405
+ deps.debug(`strategy choice failed: ${error instanceof Error ? error.message : String(error)}`);
346
406
  }
347
- state.mode = mode;
407
+ // mode = the strategy that actually runs the work turn (plan rides alongside).
408
+ state.mode = strategy;
409
+ state.planning = plan;
348
410
 
349
411
  const all = await cli
350
412
  .run<unknown>(["list"], { extraEnv: { UNIPI_KANBOARD_PROJECT: project() } })
@@ -354,67 +416,173 @@ export function createRunner(deps: RunnerDeps): Runner {
354
416
  return [] as KanboardTask[];
355
417
  });
356
418
 
357
- // Record the mode (and the goal id once it exists) on the task itself.
358
- await cli
359
- .run(["set-run", claimed.id, "--mode", mode], { extraEnv: { UNIPI_KANBOARD_PROJECT: project() } })
360
- .catch((error) => deps.debug(`set-run failed: ${error instanceof Error ? error.message : String(error)}`));
361
-
362
- if (mode === "plan") {
419
+ if (plan) {
363
420
  const started = await callCommandRunner<{ ok?: boolean; reason?: string }>("unipi:plan-enter", ctx);
364
421
  if (!started.found || !started.result?.ok) {
365
422
  ctx.ui.notify(
366
- `kanboard: plan mode unavailable (${started.result?.reason ?? "workflow not loaded"}) — running direct`,
423
+ `kanboard: plan mode unavailable (${started.result?.reason ?? "workflow not loaded"}) — running without a plan`,
367
424
  "warning",
368
425
  );
369
- state.mode = "direct";
426
+ state.planning = false;
370
427
  }
371
428
  }
372
- if (state.mode === "goal") {
429
+ if (state.planning) {
430
+ // The strategy applies to the WORK turn after approval — apply it on
431
+ // planModeChanged, not now (setExplicit would be consumed by the plan turn).
432
+ state.pendingStrategy = strategy;
433
+ } else {
434
+ await applyStrategy(ctx, claimed, strategy);
435
+ }
436
+
437
+ persist("running");
438
+ setStatus(ctx, describe());
439
+ ctx.ui.notify(`▣ ${claimed.id} · ${state.mode}${state.planning ? " +plan" : ""} — ${claimed.title}`, "info");
440
+
441
+ const prompt = renderPrompt(claimed, all);
442
+ const busy = typeof ctx.isIdle === "function" ? !ctx.isIdle() : false;
443
+ state.sentAt = Date.now();
444
+ pi.sendUserMessage(prompt, busy ? { deliverAs: "followUp" } : undefined);
445
+ return true;
446
+ }
447
+
448
+ /** Latest [parked goal: …] marker from the task's release comments, if any. */
449
+ async function parkedGoalFromNotes(taskId: string): Promise<string | null> {
450
+ try {
451
+ const shown = asTask(
452
+ "show",
453
+ await runCli<unknown>(["show", taskId], { extraEnv: { UNIPI_KANBOARD_PROJECT: project() } }),
454
+ );
455
+ const matches = (shown.activity ?? [])
456
+ .map((entry) => entry.text.match(/\[parked goal: ([\w-]+)\]/)?.[1])
457
+ .filter((goal): goal is string => typeof goal === "string");
458
+ return matches.at(-1) ?? null;
459
+ } catch {
460
+ return null;
461
+ }
462
+ }
463
+
464
+ /** Wire the chosen strategy for the work turn; falls back toward "none". */
465
+ async function applyStrategy(ctx: ExtensionContext, task: KanboardTask, strategy: Strategy): Promise<void> {
466
+ const setRun = (mode: RunMode, goal?: string): void => {
467
+ void cli
468
+ .run(
469
+ ["set-run", task.id, "--mode", mode, ...(goal ? ["--goal", goal] : [])],
470
+ { extraEnv: { UNIPI_KANBOARD_PROJECT: project() } },
471
+ )
472
+ .catch((error) => deps.debug(`set-run failed: ${error instanceof Error ? error.message : String(error)}`));
473
+ };
474
+ const goalStart = async (): Promise<boolean> => {
475
+ // A goal parked for THIS task (interrupted/released earlier) resumes
476
+ // instead of starting fresh; a stale id falls through to goal-start.
477
+ const priorGoal = task.run?.goal ?? (await parkedGoalFromNotes(task.id));
478
+ if (priorGoal) {
479
+ const resumed = await callCommandRunner<{ ok?: boolean; reason?: string }>(
480
+ "unipi:goal-resume",
481
+ ctx,
482
+ { goalId: priorGoal },
483
+ );
484
+ if (resumed.found && resumed.result?.ok) {
485
+ state.goalId = priorGoal;
486
+ state.mode = "goal";
487
+ setRun("goal", priorGoal);
488
+ return true;
489
+ }
490
+ }
373
491
  const started = await callCommandRunner<{ ok?: boolean; goalId?: string; reason?: string }>(
374
492
  "unipi:goal-start",
375
493
  ctx,
376
- { objective: `${claimed.title} — ${(claimed.body ?? "").slice(0, 400)}`.trim() },
494
+ { objective: `${task.title} — ${(task.body ?? "").slice(0, 400)}`.trim() },
377
495
  );
378
496
  if (started.found && started.result?.ok && started.result.goalId) {
379
497
  state.goalId = started.result.goalId;
380
- await cli
381
- .run(["set-run", claimed.id, "--mode", "goal", "--goal", state.goalId], {
382
- extraEnv: { UNIPI_KANBOARD_PROJECT: project() },
383
- })
384
- .catch(() => undefined);
385
- } else {
498
+ state.mode = "goal";
499
+ setRun("goal", state.goalId);
500
+ return true;
501
+ }
502
+ return false;
503
+ };
504
+ switch (strategy) {
505
+ case "none":
506
+ state.mode = "none";
507
+ setRun("none");
508
+ return;
509
+ case "goal":
510
+ if (await goalStart()) return;
511
+ ctx.ui.notify(`kanboard: goal unavailable — running without a strategy`, "warning");
512
+ state.mode = "none";
513
+ setRun("none");
514
+ return;
515
+ case "swarm":
516
+ case "graph": {
517
+ const started = await callCommandRunner<{ ok?: boolean; reason?: string }>(
518
+ "unipi:lh-explicit",
519
+ ctx,
520
+ { mode: strategy },
521
+ );
522
+ if (started.found && started.result?.ok) {
523
+ state.mode = strategy;
524
+ setRun(strategy);
525
+ return;
526
+ }
386
527
  ctx.ui.notify(
387
- `kanboard: goal mode unavailable (${started.result?.reason ?? "long-horizon not loaded"}) — running direct`,
528
+ `kanboard: ${strategy} unavailable (${started.result?.reason ?? "long-horizon not loaded"}) — running without a strategy`,
388
529
  "warning",
389
530
  );
390
- state.mode = "direct";
531
+ state.mode = "none";
532
+ setRun("none");
533
+ return;
391
534
  }
392
- }
535
+ case "ralph": {
536
+ const body = task.body ?? "";
537
+ const started = await callCommandRunner<{ ok?: boolean; reason?: string }>(
538
+ "unipi:ralph-start",
539
+ ctx,
540
+ { name: task.id, content: `${task.title}
393
541
 
394
- persist("running");
395
- setStatus(ctx, describe());
396
- ctx.ui.notify(`▣ ${claimed.id} · ${state.mode} — ${claimed.title}`, "info");
542
+ ${body}`.trim() },
543
+ );
544
+ if (started.found && started.result?.ok) {
545
+ state.mode = "ralph";
546
+ setRun("ralph");
547
+ return;
548
+ }
549
+ ctx.ui.notify(
550
+ `kanboard: ralph unavailable (${started.result?.reason ?? "no checklist items"}) — falling back to goal`,
551
+ "warning",
552
+ );
553
+ if (await goalStart()) return;
554
+ ctx.ui.notify(`kanboard: goal unavailable — running without a strategy`, "warning");
555
+ state.mode = "none";
556
+ setRun("none");
557
+ return;
558
+ }
559
+ }
560
+ }
397
561
 
398
- const prompt = renderPrompt(claimed, all);
399
- const busy = typeof ctx.isIdle === "function" ? !ctx.isIdle() : false;
400
- state.sentAt = Date.now();
401
- pi.sendUserMessage(prompt, busy ? { deliverAs: "followUp" } : undefined);
402
- return true;
562
+ /** User-only board bar after each finished task. Best-effort. */
563
+ async function postBoardProgress(): Promise<void> {
564
+ try {
565
+ const { tasks } = asTaskList(await runCli<unknown>(["list"], { extraEnv: { UNIPI_KANBOARD_PROJECT: project() } }));
566
+ const bar = boardProgressData(tasks, project());
567
+ if (bar) appendProgress(pi, bar);
568
+ } catch (error) {
569
+ deps.debug(`board progress failed: ${error instanceof Error ? error.message : String(error)}`);
570
+ }
403
571
  }
404
572
 
405
573
  /** Ask the goal engine whether the goal reached a terminal state. */
406
- async function goalStatus(): Promise<{ status: string; objective?: string } | null> {
407
- const looked = await callCommandRunner<{ found?: boolean; status?: string; objective?: string }>(
574
+ async function goalStatus(): Promise<{ status: string; reason?: string; objective?: string } | null> {
575
+ const looked = await callCommandRunner<{ found?: boolean; status?: string; reason?: string; objective?: string }>(
408
576
  "unipi:goal-status",
409
577
  undefined,
410
578
  { goalId: state.goalId },
411
579
  );
412
580
  if (!looked.found || !looked.result?.found || !looked.result.status) return null;
413
- return { status: looked.result.status, objective: looked.result.objective };
581
+ return { status: looked.result.status, reason: looked.result.reason, objective: looked.result.objective };
414
582
  }
415
583
 
416
584
  function planStillActive(): boolean {
417
- return state.mode === "plan" && state.planOutcome === null;
585
+ return state.planning && state.planOutcome === null;
418
586
  }
419
587
 
420
588
  async function finishTask(ctx: ExtensionContext, outcome: "in_review" | "todo" | "blocked", comment: string): Promise<void> {
@@ -426,21 +594,57 @@ export function createRunner(deps: RunnerDeps): Runner {
426
594
  "show",
427
595
  await runCli<unknown>(["show", task.id], { extraEnv: { UNIPI_KANBOARD_PROJECT: project() } }),
428
596
  );
429
- if (value.status === "blocked") {
597
+ // The user moved the task out from under the runner (done/cancelled/
598
+ // backlog): stop its goal, leave the board alone.
599
+ const movedAway = ["done", "cancelled", "backlog"].includes(value.status);
600
+ if (movedAway && state.goalId) {
601
+ await callCommandRunner("unipi:goal-stop", ctx, { goalId: state.goalId });
602
+ }
603
+ // A still-drivable goal is parked so re-claiming this task resumes it.
604
+ // The id rides the release comment (activity survives; the run block
605
+ // does not — release clears it).
606
+ let parkedMarker = "";
607
+ if (!movedAway && state.mode === "goal" && state.goalId) {
608
+ const paused = await callCommandRunner<{ ok?: boolean; reason?: string }>(
609
+ "unipi:goal-pause",
610
+ ctx,
611
+ { goalId: state.goalId },
612
+ );
613
+ if (paused.found && paused.result?.ok) {
614
+ deps.debug(`goal ${state.goalId} parked for ${task.id}`);
615
+ parkedMarker = ` [parked goal: ${state.goalId}]`;
616
+ }
617
+ }
618
+ if (movedAway) {
619
+ // The user's move stands — no release, just reset the runner.
620
+ ctx.ui.notify(`▣ ${task.id} was moved to ${value.status} outside the runner — goal stopped`, "warning");
621
+ } else if (value.status === "blocked") {
430
622
  const note = (value.activity ?? []).slice(-1)[0]?.text ?? "blocked";
431
623
  ctx.ui.notify(`▣ ${task.id} blocked: ${note}`, "warning");
432
624
  } else if (outcome === "in_review") {
433
- await runCli(["release", task.id, "--to", "in_review", "--comment", comment], {
625
+ await runCli(["release", task.id, "--to", "in_review", "--comment", comment + parkedMarker], {
434
626
  extraEnv: { UNIPI_KANBOARD_PROJECT: project() },
435
627
  });
436
628
  const first = comment.split("\n")[0]?.slice(0, 120) ?? "";
437
629
  ctx.ui.notify(`✓ ${task.id} → In Review: ${first}`, "info");
438
630
  } else {
439
- await runCli(["release", task.id, "--to", outcome, "--comment", comment], {
631
+ await runCli(["release", task.id, "--to", outcome, "--comment", comment + parkedMarker], {
440
632
  extraEnv: { UNIPI_KANBOARD_PROJECT: project() },
441
633
  });
442
634
  ctx.ui.notify(`↩ ${task.id} → ${outcome === "todo" ? "Todo" : "Blocked"}: ${comment.slice(0, 120)}`, "warning");
443
635
  }
636
+ if (movedAway) {
637
+ state.consumedText = state.lastText.trim();
638
+ state.task = null;
639
+ state.goalId = null;
640
+ state.endsSinceSend = 0;
641
+ state.phase = "idle";
642
+ persist("idle");
643
+ setStatus(ctx, state.stopAfterCurrent ? undefined : describe());
644
+ await postBoardProgress();
645
+ await continueLoop(ctx);
646
+ return;
647
+ }
444
648
  } catch (error) {
445
649
  ctx.ui.notify(
446
650
  `kanboard: could not release ${task.id} — ${error instanceof KanboardCliError ? error.message : String(error)}`,
@@ -454,6 +658,7 @@ export function createRunner(deps: RunnerDeps): Runner {
454
658
  state.phase = "idle";
455
659
  persist("idle");
456
660
  setStatus(ctx, state.stopAfterCurrent ? undefined : describe());
661
+ await postBoardProgress();
457
662
 
458
663
  await continueLoop(ctx);
459
664
  }
@@ -577,6 +782,14 @@ export function createRunner(deps: RunnerDeps): Runner {
577
782
  return false;
578
783
  }
579
784
 
785
+ // Compaction summaries lead with the task in flight, so the agent keeps its
786
+ // task id and rules even when the original task prompt is summarized away.
787
+ registerCompactionContext("kanboard", () =>
788
+ state.phase === "running" && state.task
789
+ ? kanboardCompactionBrief(state.task, state.mode, cli.binary.path, project(), deps.settings().blocking)
790
+ : null,
791
+ );
792
+
580
793
  return {
581
794
  async work(ctx: ExtensionContext): Promise<void> {
582
795
  if (state.phase !== "idle") {
@@ -646,9 +859,14 @@ export function createRunner(deps: RunnerDeps): Runner {
646
859
  onPlanModeChanged(payload: unknown): void {
647
860
  const active = (payload as { active?: boolean } | undefined)?.active;
648
861
  const reason = (payload as { reason?: string } | undefined)?.reason;
649
- if (active === false && state.mode === "plan") {
862
+ if (active === false && state.planning && state.planOutcome === null) {
650
863
  state.planOutcome = reason === "discarded" ? "discarded" : "approved";
651
864
  deps.debug(`plan mode left: ${state.planOutcome}`);
865
+ // Strategy applies to the work turn after approval.
866
+ if (state.planOutcome === "approved" && state.pendingStrategy && state.task && state.lastCtx) {
867
+ void applyStrategy(state.lastCtx, state.task, state.pendingStrategy);
868
+ }
869
+ state.pendingStrategy = null;
652
870
  }
653
871
  },
654
872
 
@@ -683,7 +901,9 @@ export function createRunner(deps: RunnerDeps): Runner {
683
901
  );
684
902
  if (resume) {
685
903
  state.task = task;
686
- state.mode = claimed.mode ?? "direct";
904
+ // Old session entries may still say "direct" — it maps to "none".
905
+ const restored = (claimed.mode as string | undefined) === "direct" ? "none" : (claimed.mode ?? "none");
906
+ state.mode = restored;
687
907
  state.goalId = claimed.goalId ?? null;
688
908
  state.phase = "running";
689
909
  state.endsSinceSend = 0;
@@ -729,19 +949,26 @@ export function createRunner(deps: RunnerDeps): Runner {
729
949
  deps.debug("settle deferred: plan mode still active");
730
950
  return;
731
951
  }
732
- if (state.mode === "plan" && state.planOutcome === "discarded") {
952
+ if (state.planning && state.planOutcome === "discarded") {
733
953
  await finishTask(ctx, "todo", "plan discarded");
734
954
  return;
735
955
  }
736
956
  if (state.mode === "goal" && state.goalId) {
737
957
  const goal = await goalStatus();
738
- if (goal && goal.status !== "complete") {
739
- if (["failed", "blocked", "stalled", "abandoned", "budget_exhausted"].includes(goal.status)) {
740
- await finishTask(ctx, "todo", `goal ${goal.status}`);
958
+ if (goal) {
959
+ // A user-stopped goal is NOT success: the task goes back to Todo.
960
+ if (goal.status === "complete" && goal.reason === "complete(user_requested)") {
961
+ await finishTask(ctx, "todo", "goal stopped by user");
962
+ return;
963
+ }
964
+ if (goal.status !== "complete") {
965
+ if (["failed", "blocked", "stalled", "abandoned", "budget_exhausted"].includes(goal.status)) {
966
+ await finishTask(ctx, "todo", `goal ${goal.status}`);
967
+ return;
968
+ }
969
+ deps.debug(`settle deferred: goal ${goal.status}`);
741
970
  return;
742
971
  }
743
- deps.debug(`settle deferred: goal ${goal.status}`);
744
- return;
745
972
  }
746
973
  }
747
974
 
@@ -778,6 +1005,27 @@ export function createDebugLog(env: NodeJS.ProcessEnv = process.env): (line: str
778
1005
  };
779
1006
  }
780
1007
 
1008
+ /** Active-work text for compaction summaries (see core registerCompactionContext). */
1009
+ export function kanboardCompactionBrief(
1010
+ task: KanboardTask,
1011
+ mode: string | null,
1012
+ binary: string,
1013
+ project: string,
1014
+ blocking: string,
1015
+ ): string {
1016
+ const body = (task.body ?? "").replace(/\s+/g, " ").trim();
1017
+ const cli = `${binary} --actor agent --project ${project}`;
1018
+ return [
1019
+ `Kanboard task ${task.id} "${task.title}" is in progress${mode && mode !== "none" ? ` (strategy: ${mode})` : ""} — work only on this task.`,
1020
+ body ? `Task: ${body.length > 600 ? `${body.slice(0, 599)}…` : body}` : "",
1021
+ `Board CLI: ${cli} <command> (e.g. \`show ${task.id}\` for the full task, \`note ${task.id} "<text>"\`).`,
1022
+ blocking === "ask"
1023
+ ? `If you need the user: \`${cli} move ${task.id} blocked --comment "<what you need>"\`, then stop.`
1024
+ : `Work autonomously; block (\`move ${task.id} blocked --comment "…"\`) only when you truly cannot continue.`,
1025
+ "Do not move the task to in_review or done — the runner does that when your turn ends.",
1026
+ ].filter(Boolean).join("\n");
1027
+ }
1028
+
781
1029
  export function registerPlanEventListener(pi: ExtensionAPI, runner: Runner): void {
782
1030
  pi.events.on(UNIPI_EVENTS.PLAN_MODE_CHANGED, (payload: unknown) => runner.onPlanModeChanged(payload));
783
1031
  }
package/src/settings.ts CHANGED
@@ -2,7 +2,7 @@
2
2
  * @pi-unipi/kanboard — settings (hub section "Kanboard").
3
3
  */
4
4
 
5
- import { getSettings, registerCommandRunner, registerSettings, setSettings } from "@pi-unipi/core";
5
+ import { getSettings, registerCommandRunner, registerSettings, setSettings, decisionModelSection, DEFAULT_DECISION_OVERRIDE } from "@pi-unipi/core";
6
6
 
7
7
  export type ChainGate = "in_review" | "done";
8
8
 
@@ -31,6 +31,17 @@ export interface KanboardSettings {
31
31
  maxSessions: number;
32
32
  /** `add` calls allowed per -do/runner window (0 = unlimited). */
33
33
  turnAddLimit: number;
34
+ /** Write credits a /unipi:kanboard-do grants (each board write costs 1). */
35
+ doCredits: number;
36
+ /** Strategy for unlabelled tasks ("auto" = jev decides, gated by `jevThreshold`). */
37
+ defaultStrategy: "auto" | "none" | "goal" | "ralph" | "swarm" | "graph";
38
+ /** Confidence floor for jev's strategy answer; below it (or missing) the task runs "none". */
39
+ jevThreshold: number;
40
+ /** Plan-first for unlabelled tasks (jev is never asked about plan). */
41
+ defaultPlan: boolean;
42
+ /** Whether a blocked-by-confusion task may ask the user (ask) or must
43
+ * assume-and-note (avoid, the default). */
44
+ blocking: "avoid" | "ask";
34
45
  }
35
46
 
36
47
  export const DEFAULT_SETTINGS: KanboardSettings = {
@@ -46,6 +57,11 @@ export const DEFAULT_SETTINGS: KanboardSettings = {
46
57
  queueMax: 10,
47
58
  maxSessions: 2,
48
59
  turnAddLimit: 20,
60
+ doCredits: 10,
61
+ defaultStrategy: "none",
62
+ jevThreshold: 0.8,
63
+ defaultPlan: false,
64
+ blocking: "avoid",
49
65
  };
50
66
 
51
67
  export const KANBOARD_NAMESPACE = "kanboard";
@@ -59,7 +75,7 @@ export function registerKanboardSettings(): void {
59
75
  registerSettings({
60
76
  namespace: KANBOARD_NAMESPACE,
61
77
  label: "Kanboard",
62
- defaults: DEFAULT_SETTINGS as unknown as Record<string, unknown>,
78
+ defaults: { ...DEFAULT_SETTINGS, decisionModel: DEFAULT_DECISION_OVERRIDE } as unknown as Record<string, unknown>,
63
79
  schema: [
64
80
  {
65
81
  title: "Kanboard",
@@ -103,11 +119,63 @@ export function registerKanboardSettings(): void {
103
119
  { key: "queueMax", type: "number", label: "-do queue limit", min: 0, zeroLabel: "unlimited", description: "Tasks a session may queue for the runner" },
104
120
  { key: "maxSessions", type: "number", label: "Sessions working at once (per project)", min: 1, description: "Distinct sessions holding in_progress tasks" },
105
121
  { key: "turnAddLimit", type: "number", label: "New tasks per turn", min: 0, zeroLabel: "unlimited", description: "`add` calls allowed per -do turn or runner task" },
122
+ { key: "doCredits", type: "number", label: "-do write credits", min: 0, zeroLabel: "off", description: "Write credits a /unipi:kanboard-do grants; each board write costs 1, reads are free" },
123
+ ],
124
+ },
125
+ {
126
+ title: "Task defaults",
127
+ description: "What the runner does when a task carries no label",
128
+ fields: [
129
+ {
130
+ key: "defaultStrategy",
131
+ type: "enum",
132
+ label: "Default strategy",
133
+ options: [
134
+ { value: "auto", label: "Auto (jev decides, gated by confidence)" },
135
+ { value: "none", label: "none (one-pass change)" },
136
+ { value: "goal", label: "goal (iterate until verifiably done)" },
137
+ { value: "ralph", label: "ralph (checklist of similar chores)" },
138
+ { value: "swarm", label: "swarm (parallel parts)" },
139
+ { value: "graph", label: "graph (dependent steps)" },
140
+ ],
141
+ description: "Strategy for tasks without a --strategy label; auto = jev decides, gated by the confidence threshold",
142
+ },
143
+ {
144
+ key: "jevThreshold",
145
+ type: "number",
146
+ label: "Jev confidence threshold",
147
+ min: 0.01,
148
+ max: 1,
149
+ description: "Below this jev's strategy answer abstains → none (0-1)",
150
+ },
151
+ {
152
+ key: "defaultPlan",
153
+ type: "boolean",
154
+ label: "Plan first by default",
155
+ description: "Unlabelled tasks get a plan+approval turn before work (jev is never asked)",
156
+ },
157
+ {
158
+ key: "blocking",
159
+ type: "enum",
160
+ label: "Blocking",
161
+ options: [
162
+ { value: "avoid", label: "Avoid — work autonomously, note assumptions" },
163
+ { value: "ask", label: "Ask — block the task to ask the user" },
164
+ ],
165
+ description: "What a confused runner task may do; avoid = assume-and-note, only block on true impossibles",
166
+ },
167
+ ],
168
+ },
169
+ {
170
+ title: "Board",
171
+ description: "Daemon, tokens and links",
172
+ fields: [
106
173
  { key: "open", type: "action", label: "Open board…", description: "Start the daemon and print the URL", command: ACTION_OPEN },
107
174
  { key: "stopDaemon", type: "action", label: "Stop daemon", description: "Terminate the running kanboard daemon", command: ACTION_STOP_DAEMON },
108
175
  { 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
176
  ],
110
177
  },
178
+ decisionModelSection({ title: "Strategy — Decision model" }),
111
179
  ],
112
180
  });
113
181
  }
@@ -130,6 +198,18 @@ export function readKanboardSettings(cwd: string = process.cwd()): KanboardSetti
130
198
  queueMax: typeof raw.queueMax === "number" && raw.queueMax >= 0 ? raw.queueMax : DEFAULT_SETTINGS.queueMax,
131
199
  maxSessions: typeof raw.maxSessions === "number" && raw.maxSessions >= 1 ? raw.maxSessions : DEFAULT_SETTINGS.maxSessions,
132
200
  turnAddLimit: typeof raw.turnAddLimit === "number" && raw.turnAddLimit >= 0 ? raw.turnAddLimit : DEFAULT_SETTINGS.turnAddLimit,
201
+ doCredits: typeof raw.doCredits === "number" && raw.doCredits >= 0 ? raw.doCredits : DEFAULT_SETTINGS.doCredits,
202
+ defaultStrategy:
203
+ typeof raw.defaultStrategy === "string" &&
204
+ ["auto", "none", "goal", "ralph", "swarm", "graph"].includes(raw.defaultStrategy)
205
+ ? (raw.defaultStrategy as KanboardSettings["defaultStrategy"])
206
+ : DEFAULT_SETTINGS.defaultStrategy,
207
+ jevThreshold:
208
+ typeof raw.jevThreshold === "number" && raw.jevThreshold > 0 && raw.jevThreshold <= 1
209
+ ? raw.jevThreshold
210
+ : DEFAULT_SETTINGS.jevThreshold,
211
+ defaultPlan: raw.defaultPlan === true,
212
+ blocking: raw.blocking === "ask" ? "ask" : "avoid",
133
213
  };
134
214
  }
135
215