@pi-unipi/kanboard 3.0.0-alpha.1 → 3.0.0-alpha.11
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 +16 -8
- package/index.ts +5 -0
- package/package.json +8 -8
- package/skills/kanboard/SKILL.md +10 -4
- package/src/bin.ts +4 -2
- package/src/commands.ts +58 -19
- package/src/guard.ts +41 -18
- package/src/progress.ts +27 -0
- package/src/runner.ts +321 -73
- package/src/settings.ts +82 -2
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. **
|
|
43
|
-
`
|
|
44
|
-
|
|
45
|
-
|
|
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`
|
|
51
|
-
first; approval stays interactive; a discarded plan releases the
|
|
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 ·
|
|
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) => {
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pi-unipi/kanboard",
|
|
3
|
-
"version": "3.0.0-alpha.
|
|
4
|
-
"description": "Kanboard v3
|
|
3
|
+
"version": "3.0.0-alpha.11",
|
|
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,7 +31,7 @@
|
|
|
31
31
|
"access": "public"
|
|
32
32
|
},
|
|
33
33
|
"dependencies": {
|
|
34
|
-
"@pi-unipi/core": "3.0.0-alpha.
|
|
34
|
+
"@pi-unipi/core": "3.0.0-alpha.11"
|
|
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.
|
|
52
|
-
"@pi-unipi/kanboard-linux-arm64": "3.0.0-alpha.
|
|
53
|
-
"@pi-unipi/kanboard-darwin-x64": "3.0.0-alpha.
|
|
54
|
-
"@pi-unipi/kanboard-darwin-arm64": "3.0.0-alpha.
|
|
55
|
-
"@pi-unipi/kanboard-win32-x64": "3.0.0-alpha.
|
|
51
|
+
"@pi-unipi/kanboard-linux-x64": "3.0.0-alpha.11",
|
|
52
|
+
"@pi-unipi/kanboard-linux-arm64": "3.0.0-alpha.11",
|
|
53
|
+
"@pi-unipi/kanboard-darwin-x64": "3.0.0-alpha.11",
|
|
54
|
+
"@pi-unipi/kanboard-darwin-arm64": "3.0.0-alpha.11",
|
|
55
|
+
"@pi-unipi/kanboard-win32-x64": "3.0.0-alpha.11"
|
|
56
56
|
}
|
|
57
57
|
}
|
package/skills/kanboard/SKILL.md
CHANGED
|
@@ -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 `
|
|
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. **
|
|
73
|
-
|
|
74
|
-
|
|
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
|
-
|
|
84
|
-
|
|
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} …\`).
|
|
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) {
|
|
@@ -1088,7 +1093,21 @@ export function kanboardCompletions(prefix: string): CompletionItem[] | null {
|
|
|
1088
1093
|
/** Live task list for `--after` / `-do` completions, cached ~5s per client. */
|
|
1089
1094
|
const taskCache = new WeakMap<KanboardCli, { at: number; tasks: ShowTask[] }>();
|
|
1090
1095
|
|
|
1091
|
-
export
|
|
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;
|
|
1092
1111
|
const client = deps.cli;
|
|
1093
1112
|
if (!client) return null;
|
|
1094
1113
|
let cache = taskCache.get(client);
|
|
@@ -1104,13 +1123,21 @@ export async function taskCompletions(deps: CommandDeps, prefix: string, before:
|
|
|
1104
1123
|
const needle = prefix.toLowerCase();
|
|
1105
1124
|
const items = cache.tasks
|
|
1106
1125
|
.filter((task) => !["cancelled", "archived"].includes(task.status))
|
|
1107
|
-
.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)))
|
|
1108
1127
|
.slice(0, 12)
|
|
1109
|
-
.map((task) =>
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
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
|
+
);
|
|
1114
1141
|
return items.length > 0 ? items : null;
|
|
1115
1142
|
}
|
|
1116
1143
|
|
|
@@ -1186,15 +1213,19 @@ export async function kanboardAddCompletions(deps: CommandDeps, prefix: string):
|
|
|
1186
1213
|
return null;
|
|
1187
1214
|
}
|
|
1188
1215
|
|
|
1189
|
-
/** /unipi:kanboard-do … — complete a trailing task-id
|
|
1216
|
+
/** /unipi:kanboard-do … — complete a trailing task-id token; plain prose never matches. */
|
|
1190
1217
|
export async function kanboardDoCompletions(deps: CommandDeps, prefix: string): Promise<CompletionItem[] | null> {
|
|
1191
1218
|
const raw = prefix ?? "";
|
|
1192
|
-
const
|
|
1193
|
-
const before =
|
|
1194
|
-
const token =
|
|
1195
|
-
// Only
|
|
1196
|
-
|
|
1197
|
-
|
|
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" });
|
|
1198
1229
|
}
|
|
1199
1230
|
|
|
1200
1231
|
export function registerKanboardCommands(pi: ExtensionAPI, deps: CommandDeps): void {
|
|
@@ -1303,10 +1334,15 @@ export function registerKanboardCommands(pi: ExtensionAPI, deps: CommandDeps): v
|
|
|
1303
1334
|
});
|
|
1304
1335
|
|
|
1305
1336
|
pi.registerCommand("unipi:kanboard-do", {
|
|
1306
|
-
description: "
|
|
1337
|
+
description: "Grant kanboard write credits to the agent (/unipi:kanboard-do <request>; off revokes)",
|
|
1307
1338
|
getArgumentCompletions: (prefix: string) => kanboardDoCompletions(deps, prefix ?? ""),
|
|
1308
1339
|
handler: async (args: string, ctx: ExtensionCommandContext) => {
|
|
1309
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
|
+
}
|
|
1310
1346
|
if (!request) {
|
|
1311
1347
|
ctx.ui.notify("kanboard: -do needs a request — /unipi:kanboard-do <what the agent may do on the board>", "warning");
|
|
1312
1348
|
return;
|
|
@@ -1323,9 +1359,12 @@ export function registerKanboardCommands(pi: ExtensionAPI, deps: CommandDeps): v
|
|
|
1323
1359
|
const busy = typeof (ctx as unknown as { isIdle?: () => boolean }).isIdle === "function"
|
|
1324
1360
|
? !(ctx as unknown as { isIdle: () => boolean }).isIdle!()
|
|
1325
1361
|
: false;
|
|
1326
|
-
pi.sendUserMessage(
|
|
1362
|
+
pi.sendUserMessage(
|
|
1363
|
+
doText(slug, client.binary.path, request, deps.settings().queueMax, deps.guard.remaining()),
|
|
1364
|
+
busy ? { deliverAs: "followUp" } : undefined,
|
|
1365
|
+
);
|
|
1327
1366
|
deps.guard.noteSent();
|
|
1328
|
-
deps.debug(`do
|
|
1367
|
+
deps.debug(`do credits open (${slug}): ${deps.guard.remaining()} remaining`);
|
|
1329
1368
|
},
|
|
1330
1369
|
});
|
|
1331
1370
|
|
package/src/guard.ts
CHANGED
|
@@ -9,8 +9,8 @@
|
|
|
9
9
|
|
|
10
10
|
import { tokenizeArgs } from "./commands.js";
|
|
11
11
|
|
|
12
|
-
export const
|
|
13
|
-
|
|
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
|
-
/**
|
|
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
|
-
*
|
|
98
|
-
*
|
|
99
|
-
*
|
|
100
|
-
*
|
|
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(
|
|
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
|
-
|
|
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 (
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
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
|
},
|
package/src/progress.ts
ADDED
|
@@ -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,
|
|
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 = "
|
|
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
|
|
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
|
|
105
|
+
export function strategyQuestion(task: KanboardTask): {
|
|
80
106
|
state: string;
|
|
81
|
-
questions: {
|
|
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
|
-
|
|
112
|
+
strategy: {
|
|
87
113
|
type: "choice",
|
|
88
|
-
instructions: "
|
|
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: "
|
|
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 =>
|
|
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
|
-
// ──
|
|
249
|
+
// ── strategy choice (labels win; jev decides the unset) ───────────────────
|
|
224
250
|
|
|
225
|
-
async function
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
const
|
|
229
|
-
const
|
|
230
|
-
|
|
231
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
269
|
-
|
|
270
|
-
|
|
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
|
|
400
|
+
let strategy: Strategy = "none";
|
|
401
|
+
let plan = false;
|
|
342
402
|
try {
|
|
343
|
-
|
|
403
|
+
({ strategy, plan } = await chooseStrategy(claimed));
|
|
344
404
|
} catch (error) {
|
|
345
|
-
deps.debug(`
|
|
405
|
+
deps.debug(`strategy choice failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
346
406
|
}
|
|
347
|
-
|
|
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
|
-
|
|
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
|
|
423
|
+
`kanboard: plan mode unavailable (${started.result?.reason ?? "workflow not loaded"}) — running without a plan`,
|
|
367
424
|
"warning",
|
|
368
425
|
);
|
|
369
|
-
state.
|
|
426
|
+
state.planning = false;
|
|
370
427
|
}
|
|
371
428
|
}
|
|
372
|
-
if (state.
|
|
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: `${
|
|
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
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
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:
|
|
528
|
+
`kanboard: ${strategy} unavailable (${started.result?.reason ?? "long-horizon not loaded"}) — running without a strategy`,
|
|
388
529
|
"warning",
|
|
389
530
|
);
|
|
390
|
-
state.mode = "
|
|
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
|
-
|
|
395
|
-
|
|
396
|
-
|
|
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
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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
|
|
739
|
-
|
|
740
|
-
|
|
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
|
|