@pi-unipi/kanboard 3.0.0-alpha.2 → 3.0.0-alpha.4

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";
@@ -101,6 +103,7 @@ export default function (pi: ExtensionAPI) {
101
103
  guard,
102
104
  session: sessionId,
103
105
  debug,
106
+ progress: (data) => appendProgress(pi, data),
104
107
  }) as CommandDeps;
105
108
 
106
109
  const revealSkill = (ctx: ExtensionContext | { cwd?: string }): void => {
@@ -133,6 +136,7 @@ export default function (pi: ExtensionAPI) {
133
136
  return true;
134
137
  };
135
138
 
139
+ registerProgressRenderer(pi);
136
140
  registerKanboardCommands(pi, buildDeps());
137
141
 
138
142
  registerCommandRunner(ACTION_OPEN, async (ctx) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-unipi/kanboard",
3
- "version": "3.0.0-alpha.2",
3
+ "version": "3.0.0-alpha.4",
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.2"
34
+ "@pi-unipi/core": "3.0.0-alpha.4"
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.2",
52
- "@pi-unipi/kanboard-linux-arm64": "3.0.0-alpha.2",
53
- "@pi-unipi/kanboard-darwin-x64": "3.0.0-alpha.2",
54
- "@pi-unipi/kanboard-darwin-arm64": "3.0.0-alpha.2",
55
- "@pi-unipi/kanboard-win32-x64": "3.0.0-alpha.2"
51
+ "@pi-unipi/kanboard-linux-x64": "3.0.0-alpha.4",
52
+ "@pi-unipi/kanboard-linux-arm64": "3.0.0-alpha.4",
53
+ "@pi-unipi/kanboard-darwin-x64": "3.0.0-alpha.4",
54
+ "@pi-unipi/kanboard-darwin-arm64": "3.0.0-alpha.4",
55
+ "@pi-unipi/kanboard-win32-x64": "3.0.0-alpha.4"
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";
@@ -41,7 +42,7 @@ Priority -p: 1 none · 2 low · 3 medium · 4 high · 5 urgent`;
41
42
 
42
43
  export function doText(slug: string, cli: string, request: string, queueMax = 10): 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} …\`). 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. 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) {
@@ -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,27 @@ export function createRunner(deps: RunnerDeps): Runner {
220
246
  }
221
247
  }
222
248
 
223
- // ── mode choice (jev) ─────────────────────────────────────────────────────
224
-
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;
249
+ // ── strategy choice (labels win; jev decides the unset) ───────────────────
250
+
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
+ let strategy: Strategy | undefined = labelled;
258
+ if (strategy === undefined && board.defaultStrategy !== "auto") {
259
+ strategy = board.defaultStrategy as Strategy;
260
+ }
261
+ let jevAnswer: string | null = null;
262
+ if (strategy === undefined) {
263
+ const settings = resolveDecisionModel(cwd, "kanboard");
264
+ const answers = await askJev({ ...strategyQuestion(task), settings, env: process.env });
265
+ jevAnswer = answers?.strategy?.choice ?? null;
266
+ strategy = jevAnswer !== null && jevAnswer in STRATEGY_CRITERIA ? (jevAnswer as Strategy) : "none";
267
+ }
268
+ deps.debug(`strategy ${task.id}: label=${labelled ?? "-"} board=${board.defaultStrategy}/${board.defaultPlan ? "plan" : "no-plan"} jev answered ${JSON.stringify(jevAnswer)} → ${strategy}${plan ? "+plan" : ""}`);
269
+ return { strategy, plan };
232
270
  }
233
271
 
234
272
  // ── prompting ─────────────────────────────────────────────────────────────
@@ -240,7 +278,7 @@ export function createRunner(deps: RunnerDeps): Runner {
240
278
  .map((entry) => `- ${entry.at} [${entry.actor}] ${entry.text}`)
241
279
  .join("\n");
242
280
  const byId = new Map(all.map((candidate) => [candidate.id, candidate]));
243
- const deps = (task.deps ?? [])
281
+ const depLines = (task.deps ?? [])
244
282
  .map((id) => {
245
283
  const dep = byId.get(id);
246
284
  if (!dep) return `- ${id}: (missing)`;
@@ -258,16 +296,25 @@ export function createRunner(deps: RunnerDeps): Runner {
258
296
  activity || "(none)",
259
297
  "",
260
298
  "## Dependencies",
261
- deps || "(none)",
299
+ depLines || "(none)",
262
300
  "",
263
301
  ...attachmentSection(task),
264
302
  "## Rules",
265
303
  `Work only on this task (${task.id}).`,
266
304
  `Use the board CLI through its absolute path and always pass the actor and project:`,
267
305
  ` ${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.",
306
+ ...(deps.settings().blocking === "ask"
307
+ ? [
308
+ `If you need information or a decision from the user, run:`,
309
+ ` ${binary} --actor agent --project ${project()} move ${task.id} blocked --comment "<what you need>"`,
310
+ "and stop — the runner will hand the task back when the user answers.",
311
+ ]
312
+ : [
313
+ "Work autonomously. If something is unclear, make the most reasonable assumption, record it with",
314
+ ` \`note ${task.id} "assumed: <what and why>"\`, and continue. Block only when you truly cannot continue —`,
315
+ "missing credentials or access, a destructive or irreversible decision, or a contradiction in the",
316
+ ` task — with \`${binary} --actor agent --project ${project()} move ${task.id} blocked --comment "<what you need>"\`, then stop.`,
317
+ ]),
271
318
  `Do not move the task to in_review or done and do not cancel it: the runner writes those when your turn ends.`,
272
319
  `You may add notes with \`note ${task.id} "<text>"\`, link follow-up work with \`add\`, and reorder with \`order\`.`,
273
320
  `To show evidence (a screenshot, a log, a report), attach it: \`attach ${task.id} <file> --note "<what it shows>"\`.`,
@@ -277,6 +324,7 @@ export function createRunner(deps: RunnerDeps): Runner {
277
324
  // ── the loop ──────────────────────────────────────────────────────────────
278
325
 
279
326
  async function startTask(ctx: ExtensionContext): Promise<boolean> {
327
+ state.lastCtx = ctx;
280
328
  let claimed: KanboardTask | null;
281
329
  let claim: KanboardClaim;
282
330
  try {
@@ -298,6 +346,8 @@ export function createRunner(deps: RunnerDeps): Runner {
298
346
  state.task = claimed;
299
347
  state.endsSinceSend = 0;
300
348
  state.planOutcome = null;
349
+ state.planning = false;
350
+ state.pendingStrategy = null;
301
351
  state.lastText = "";
302
352
  try {
303
353
  return await runClaimedTask(ctx, claimed);
@@ -338,13 +388,16 @@ export function createRunner(deps: RunnerDeps): Runner {
338
388
  state.goalId = null;
339
389
  state.phase = "running";
340
390
 
341
- let mode: RunMode = "direct";
391
+ let strategy: Strategy = "none";
392
+ let plan = false;
342
393
  try {
343
- mode = await chooseMode(claimed);
394
+ ({ strategy, plan } = await chooseStrategy(claimed));
344
395
  } catch (error) {
345
- deps.debug(`mode choice failed: ${error instanceof Error ? error.message : String(error)}`);
396
+ deps.debug(`strategy choice failed: ${error instanceof Error ? error.message : String(error)}`);
346
397
  }
347
- state.mode = mode;
398
+ // mode = the strategy that actually runs the work turn (plan rides alongside).
399
+ state.mode = strategy;
400
+ state.planning = plan;
348
401
 
349
402
  const all = await cli
350
403
  .run<unknown>(["list"], { extraEnv: { UNIPI_KANBOARD_PROJECT: project() } })
@@ -354,52 +407,126 @@ export function createRunner(deps: RunnerDeps): Runner {
354
407
  return [] as KanboardTask[];
355
408
  });
356
409
 
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") {
410
+ if (plan) {
363
411
  const started = await callCommandRunner<{ ok?: boolean; reason?: string }>("unipi:plan-enter", ctx);
364
412
  if (!started.found || !started.result?.ok) {
365
413
  ctx.ui.notify(
366
- `kanboard: plan mode unavailable (${started.result?.reason ?? "workflow not loaded"}) — running direct`,
414
+ `kanboard: plan mode unavailable (${started.result?.reason ?? "workflow not loaded"}) — running without a plan`,
367
415
  "warning",
368
416
  );
369
- state.mode = "direct";
417
+ state.planning = false;
370
418
  }
371
419
  }
372
- if (state.mode === "goal") {
420
+ if (state.planning) {
421
+ // The strategy applies to the WORK turn after approval — apply it on
422
+ // planModeChanged, not now (setExplicit would be consumed by the plan turn).
423
+ state.pendingStrategy = strategy;
424
+ } else {
425
+ await applyStrategy(ctx, claimed, strategy);
426
+ }
427
+
428
+ persist("running");
429
+ setStatus(ctx, describe());
430
+ ctx.ui.notify(`▣ ${claimed.id} · ${state.mode}${state.planning ? " +plan" : ""} — ${claimed.title}`, "info");
431
+
432
+ const prompt = renderPrompt(claimed, all);
433
+ const busy = typeof ctx.isIdle === "function" ? !ctx.isIdle() : false;
434
+ state.sentAt = Date.now();
435
+ pi.sendUserMessage(prompt, busy ? { deliverAs: "followUp" } : undefined);
436
+ return true;
437
+ }
438
+
439
+ /** Wire the chosen strategy for the work turn; falls back toward "none". */
440
+ async function applyStrategy(ctx: ExtensionContext, task: KanboardTask, strategy: Strategy): Promise<void> {
441
+ const setRun = (mode: RunMode, goal?: string): void => {
442
+ void cli
443
+ .run(
444
+ ["set-run", task.id, "--mode", mode, ...(goal ? ["--goal", goal] : [])],
445
+ { extraEnv: { UNIPI_KANBOARD_PROJECT: project() } },
446
+ )
447
+ .catch((error) => deps.debug(`set-run failed: ${error instanceof Error ? error.message : String(error)}`));
448
+ };
449
+ const goalStart = async (): Promise<boolean> => {
373
450
  const started = await callCommandRunner<{ ok?: boolean; goalId?: string; reason?: string }>(
374
451
  "unipi:goal-start",
375
452
  ctx,
376
- { objective: `${claimed.title} — ${(claimed.body ?? "").slice(0, 400)}`.trim() },
453
+ { objective: `${task.title} — ${(task.body ?? "").slice(0, 400)}`.trim() },
377
454
  );
378
455
  if (started.found && started.result?.ok && started.result.goalId) {
379
456
  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 {
457
+ state.mode = "goal";
458
+ setRun("goal", state.goalId);
459
+ return true;
460
+ }
461
+ return false;
462
+ };
463
+ switch (strategy) {
464
+ case "none":
465
+ state.mode = "none";
466
+ setRun("none");
467
+ return;
468
+ case "goal":
469
+ if (await goalStart()) return;
470
+ ctx.ui.notify(`kanboard: goal unavailable — running without a strategy`, "warning");
471
+ state.mode = "none";
472
+ setRun("none");
473
+ return;
474
+ case "swarm":
475
+ case "graph": {
476
+ const started = await callCommandRunner<{ ok?: boolean; reason?: string }>(
477
+ "unipi:lh-explicit",
478
+ ctx,
479
+ { mode: strategy },
480
+ );
481
+ if (started.found && started.result?.ok) {
482
+ state.mode = strategy;
483
+ setRun(strategy);
484
+ return;
485
+ }
486
+ ctx.ui.notify(
487
+ `kanboard: ${strategy} unavailable (${started.result?.reason ?? "long-horizon not loaded"}) — running without a strategy`,
488
+ "warning",
489
+ );
490
+ state.mode = "none";
491
+ setRun("none");
492
+ return;
493
+ }
494
+ case "ralph": {
495
+ const body = task.body ?? "";
496
+ const started = await callCommandRunner<{ ok?: boolean; reason?: string }>(
497
+ "unipi:ralph-start",
498
+ ctx,
499
+ { name: task.id, content: `${task.title}
500
+
501
+ ${body}`.trim() },
502
+ );
503
+ if (started.found && started.result?.ok) {
504
+ state.mode = "ralph";
505
+ setRun("ralph");
506
+ return;
507
+ }
386
508
  ctx.ui.notify(
387
- `kanboard: goal mode unavailable (${started.result?.reason ?? "long-horizon not loaded"}) — running direct`,
509
+ `kanboard: ralph unavailable (${started.result?.reason ?? "no checklist items"}) — falling back to goal`,
388
510
  "warning",
389
511
  );
390
- state.mode = "direct";
512
+ if (await goalStart()) return;
513
+ ctx.ui.notify(`kanboard: goal unavailable — running without a strategy`, "warning");
514
+ state.mode = "none";
515
+ setRun("none");
516
+ return;
391
517
  }
392
518
  }
519
+ }
393
520
 
394
- persist("running");
395
- setStatus(ctx, describe());
396
- ctx.ui.notify(`▣ ${claimed.id} · ${state.mode} — ${claimed.title}`, "info");
397
-
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;
521
+ /** User-only board bar after each finished task. Best-effort. */
522
+ async function postBoardProgress(): Promise<void> {
523
+ try {
524
+ const { tasks } = asTaskList(await runCli<unknown>(["list"], { extraEnv: { UNIPI_KANBOARD_PROJECT: project() } }));
525
+ const bar = boardProgressData(tasks, project());
526
+ if (bar) appendProgress(pi, bar);
527
+ } catch (error) {
528
+ deps.debug(`board progress failed: ${error instanceof Error ? error.message : String(error)}`);
529
+ }
403
530
  }
404
531
 
405
532
  /** Ask the goal engine whether the goal reached a terminal state. */
@@ -414,7 +541,7 @@ export function createRunner(deps: RunnerDeps): Runner {
414
541
  }
415
542
 
416
543
  function planStillActive(): boolean {
417
- return state.mode === "plan" && state.planOutcome === null;
544
+ return state.planning && state.planOutcome === null;
418
545
  }
419
546
 
420
547
  async function finishTask(ctx: ExtensionContext, outcome: "in_review" | "todo" | "blocked", comment: string): Promise<void> {
@@ -454,6 +581,7 @@ export function createRunner(deps: RunnerDeps): Runner {
454
581
  state.phase = "idle";
455
582
  persist("idle");
456
583
  setStatus(ctx, state.stopAfterCurrent ? undefined : describe());
584
+ await postBoardProgress();
457
585
 
458
586
  await continueLoop(ctx);
459
587
  }
@@ -577,6 +705,14 @@ export function createRunner(deps: RunnerDeps): Runner {
577
705
  return false;
578
706
  }
579
707
 
708
+ // Compaction summaries lead with the task in flight, so the agent keeps its
709
+ // task id and rules even when the original task prompt is summarized away.
710
+ registerCompactionContext("kanboard", () =>
711
+ state.phase === "running" && state.task
712
+ ? kanboardCompactionBrief(state.task, state.mode, cli.binary.path, project(), deps.settings().blocking)
713
+ : null,
714
+ );
715
+
580
716
  return {
581
717
  async work(ctx: ExtensionContext): Promise<void> {
582
718
  if (state.phase !== "idle") {
@@ -646,9 +782,14 @@ export function createRunner(deps: RunnerDeps): Runner {
646
782
  onPlanModeChanged(payload: unknown): void {
647
783
  const active = (payload as { active?: boolean } | undefined)?.active;
648
784
  const reason = (payload as { reason?: string } | undefined)?.reason;
649
- if (active === false && state.mode === "plan") {
785
+ if (active === false && state.planning && state.planOutcome === null) {
650
786
  state.planOutcome = reason === "discarded" ? "discarded" : "approved";
651
787
  deps.debug(`plan mode left: ${state.planOutcome}`);
788
+ // Strategy applies to the work turn after approval.
789
+ if (state.planOutcome === "approved" && state.pendingStrategy && state.task && state.lastCtx) {
790
+ void applyStrategy(state.lastCtx, state.task, state.pendingStrategy);
791
+ }
792
+ state.pendingStrategy = null;
652
793
  }
653
794
  },
654
795
 
@@ -683,7 +824,9 @@ export function createRunner(deps: RunnerDeps): Runner {
683
824
  );
684
825
  if (resume) {
685
826
  state.task = task;
686
- state.mode = claimed.mode ?? "direct";
827
+ // Old session entries may still say "direct" — it maps to "none".
828
+ const restored = (claimed.mode as string | undefined) === "direct" ? "none" : (claimed.mode ?? "none");
829
+ state.mode = restored;
687
830
  state.goalId = claimed.goalId ?? null;
688
831
  state.phase = "running";
689
832
  state.endsSinceSend = 0;
@@ -729,7 +872,7 @@ export function createRunner(deps: RunnerDeps): Runner {
729
872
  deps.debug("settle deferred: plan mode still active");
730
873
  return;
731
874
  }
732
- if (state.mode === "plan" && state.planOutcome === "discarded") {
875
+ if (state.planning && state.planOutcome === "discarded") {
733
876
  await finishTask(ctx, "todo", "plan discarded");
734
877
  return;
735
878
  }
@@ -778,6 +921,27 @@ export function createDebugLog(env: NodeJS.ProcessEnv = process.env): (line: str
778
921
  };
779
922
  }
780
923
 
924
+ /** Active-work text for compaction summaries (see core registerCompactionContext). */
925
+ export function kanboardCompactionBrief(
926
+ task: KanboardTask,
927
+ mode: string | null,
928
+ binary: string,
929
+ project: string,
930
+ blocking: string,
931
+ ): string {
932
+ const body = (task.body ?? "").replace(/\s+/g, " ").trim();
933
+ const cli = `${binary} --actor agent --project ${project}`;
934
+ return [
935
+ `Kanboard task ${task.id} "${task.title}" is in progress${mode && mode !== "none" ? ` (strategy: ${mode})` : ""} — work only on this task.`,
936
+ body ? `Task: ${body.length > 600 ? `${body.slice(0, 599)}…` : body}` : "",
937
+ `Board CLI: ${cli} <command> (e.g. \`show ${task.id}\` for the full task, \`note ${task.id} "<text>"\`).`,
938
+ blocking === "ask"
939
+ ? `If you need the user: \`${cli} move ${task.id} blocked --comment "<what you need>"\`, then stop.`
940
+ : `Work autonomously; block (\`move ${task.id} blocked --comment "…"\`) only when you truly cannot continue.`,
941
+ "Do not move the task to in_review or done — the runner does that when your turn ends.",
942
+ ].filter(Boolean).join("\n");
943
+ }
944
+
781
945
  export function registerPlanEventListener(pi: ExtensionAPI, runner: Runner): void {
782
946
  pi.events.on(UNIPI_EVENTS.PLAN_MODE_CHANGED, (payload: unknown) => runner.onPlanModeChanged(payload));
783
947
  }
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,13 @@ export interface KanboardSettings {
31
31
  maxSessions: number;
32
32
  /** `add` calls allowed per -do/runner window (0 = unlimited). */
33
33
  turnAddLimit: number;
34
+ /** Strategy for unlabelled tasks ("auto" = jev decides). */
35
+ defaultStrategy: "auto" | "none" | "goal" | "ralph" | "swarm" | "graph";
36
+ /** Plan-first for unlabelled tasks (jev is never asked about plan). */
37
+ defaultPlan: boolean;
38
+ /** Whether a blocked-by-confusion task may ask the user (ask) or must
39
+ * assume-and-note (avoid, the default). */
40
+ blocking: "avoid" | "ask";
34
41
  }
35
42
 
36
43
  export const DEFAULT_SETTINGS: KanboardSettings = {
@@ -46,6 +53,9 @@ export const DEFAULT_SETTINGS: KanboardSettings = {
46
53
  queueMax: 10,
47
54
  maxSessions: 2,
48
55
  turnAddLimit: 20,
56
+ defaultStrategy: "auto",
57
+ defaultPlan: false,
58
+ blocking: "avoid",
49
59
  };
50
60
 
51
61
  export const KANBOARD_NAMESPACE = "kanboard";
@@ -59,7 +69,7 @@ export function registerKanboardSettings(): void {
59
69
  registerSettings({
60
70
  namespace: KANBOARD_NAMESPACE,
61
71
  label: "Kanboard",
62
- defaults: DEFAULT_SETTINGS as unknown as Record<string, unknown>,
72
+ defaults: { ...DEFAULT_SETTINGS, decisionModel: DEFAULT_DECISION_OVERRIDE } as unknown as Record<string, unknown>,
63
73
  schema: [
64
74
  {
65
75
  title: "Kanboard",
@@ -103,11 +113,54 @@ export function registerKanboardSettings(): void {
103
113
  { key: "queueMax", type: "number", label: "-do queue limit", min: 0, zeroLabel: "unlimited", description: "Tasks a session may queue for the runner" },
104
114
  { key: "maxSessions", type: "number", label: "Sessions working at once (per project)", min: 1, description: "Distinct sessions holding in_progress tasks" },
105
115
  { key: "turnAddLimit", type: "number", label: "New tasks per turn", min: 0, zeroLabel: "unlimited", description: "`add` calls allowed per -do turn or runner task" },
116
+ ],
117
+ },
118
+ {
119
+ title: "Task defaults",
120
+ description: "What the runner does when a task carries no label",
121
+ fields: [
122
+ {
123
+ key: "defaultStrategy",
124
+ type: "enum",
125
+ label: "Default strategy",
126
+ options: [
127
+ { value: "auto", label: "Auto (jev decides)" },
128
+ { value: "none", label: "none (one-pass change)" },
129
+ { value: "goal", label: "goal (iterate until verifiably done)" },
130
+ { value: "ralph", label: "ralph (checklist of similar chores)" },
131
+ { value: "swarm", label: "swarm (parallel parts)" },
132
+ { value: "graph", label: "graph (dependent steps)" },
133
+ ],
134
+ description: "Strategy for tasks without a --strategy label",
135
+ },
136
+ {
137
+ key: "defaultPlan",
138
+ type: "boolean",
139
+ label: "Plan first by default",
140
+ description: "Unlabelled tasks get a plan+approval turn before work (jev is never asked)",
141
+ },
142
+ {
143
+ key: "blocking",
144
+ type: "enum",
145
+ label: "Blocking",
146
+ options: [
147
+ { value: "avoid", label: "Avoid — work autonomously, note assumptions" },
148
+ { value: "ask", label: "Ask — block the task to ask the user" },
149
+ ],
150
+ description: "What a confused runner task may do; avoid = assume-and-note, only block on true impossibles",
151
+ },
152
+ ],
153
+ },
154
+ {
155
+ title: "Board",
156
+ description: "Daemon, tokens and links",
157
+ fields: [
106
158
  { key: "open", type: "action", label: "Open board…", description: "Start the daemon and print the URL", command: ACTION_OPEN },
107
159
  { key: "stopDaemon", type: "action", label: "Stop daemon", description: "Terminate the running kanboard daemon", command: ACTION_STOP_DAEMON },
108
160
  { 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
161
  ],
110
162
  },
163
+ decisionModelSection({ title: "Strategy — Decision model" }),
111
164
  ],
112
165
  });
113
166
  }
@@ -130,6 +183,13 @@ export function readKanboardSettings(cwd: string = process.cwd()): KanboardSetti
130
183
  queueMax: typeof raw.queueMax === "number" && raw.queueMax >= 0 ? raw.queueMax : DEFAULT_SETTINGS.queueMax,
131
184
  maxSessions: typeof raw.maxSessions === "number" && raw.maxSessions >= 1 ? raw.maxSessions : DEFAULT_SETTINGS.maxSessions,
132
185
  turnAddLimit: typeof raw.turnAddLimit === "number" && raw.turnAddLimit >= 0 ? raw.turnAddLimit : DEFAULT_SETTINGS.turnAddLimit,
186
+ defaultStrategy:
187
+ typeof raw.defaultStrategy === "string" &&
188
+ ["auto", "none", "goal", "ralph", "swarm", "graph"].includes(raw.defaultStrategy)
189
+ ? (raw.defaultStrategy as KanboardSettings["defaultStrategy"])
190
+ : DEFAULT_SETTINGS.defaultStrategy,
191
+ defaultPlan: raw.defaultPlan === true,
192
+ blocking: raw.blocking === "ask" ? "ask" : "avoid",
133
193
  };
134
194
  }
135
195