@pi-unipi/kanboard 3.0.0-alpha.2 → 3.0.0-alpha.21
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +51 -47
- package/index.ts +293 -55
- package/package.json +8 -8
- package/skills/kanboard/SKILL.md +61 -27
- package/src/badges.ts +93 -0
- package/src/bin.ts +4 -2
- package/src/commands.ts +218 -84
- package/src/debug.ts +20 -0
- package/src/guard.ts +267 -109
- package/src/monitor.ts +330 -0
- package/src/notice-buffer.ts +74 -0
- package/src/progress.ts +27 -0
- package/src/reminders.ts +234 -0
- package/src/settings.ts +69 -15
- package/src/shapes.ts +5 -0
- package/src/runner.ts +0 -817
package/src/runner.ts
DELETED
|
@@ -1,817 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @pi-unipi/kanboard — the runner (`/unipi:kanboard-autowork`).
|
|
3
|
-
*
|
|
4
|
-
* One job per session: claim the next ready task, let jev choose how to run it,
|
|
5
|
-
* hand it to the agent, then write the lifecycle transition the agent is not
|
|
6
|
-
* allowed to write. The runner owns claim → In Progress and run-end → In Review
|
|
7
|
-
* (spec principle 2).
|
|
8
|
-
*/
|
|
9
|
-
|
|
10
|
-
import { hostname as osHostname } from "node:os";
|
|
11
|
-
import type { ExtensionAPI, ExtensionContext, SessionEntry } from "@earendil-works/pi-coding-agent";
|
|
12
|
-
import { callCommandRunner, readJudgeJevSettings, askJev, UNIPI_EVENTS } from "@pi-unipi/core";
|
|
13
|
-
|
|
14
|
-
import { KanboardCliError, type KanboardCli } from "./bin.js";
|
|
15
|
-
import {
|
|
16
|
-
asClaimResult,
|
|
17
|
-
asTask,
|
|
18
|
-
asTaskList,
|
|
19
|
-
KanboardShapeError,
|
|
20
|
-
type KanboardActivity,
|
|
21
|
-
type KanboardRun,
|
|
22
|
-
type KanboardClaim,
|
|
23
|
-
type KanboardTask,
|
|
24
|
-
} from "./shapes.js";
|
|
25
|
-
import { applyLimitEnv, type KanboardSettings } from "./settings.js";
|
|
26
|
-
|
|
27
|
-
export const RUNNER_ENTRY = "unipi:kanboard-runner";
|
|
28
|
-
|
|
29
|
-
export type RunMode = "direct" | "plan" | "goal";
|
|
30
|
-
|
|
31
|
-
export type { KanboardTask, KanboardActivity, KanboardRun };
|
|
32
|
-
|
|
33
|
-
interface RunnerState {
|
|
34
|
-
phase: "idle" | "running" | "releasing";
|
|
35
|
-
task: KanboardTask | null;
|
|
36
|
-
mode: RunMode;
|
|
37
|
-
goalId: string | null;
|
|
38
|
-
endsSinceSend: number;
|
|
39
|
-
stopAfterCurrent: boolean;
|
|
40
|
-
/** Autowork: keep claiming ready tasks after each finish (queue first). */
|
|
41
|
-
autowork: boolean;
|
|
42
|
-
settleTimer: NodeJS.Timeout | null;
|
|
43
|
-
planOutcome: "approved" | "discarded" | null;
|
|
44
|
-
/** Last assistant text seen on agent_end — the run summary source. */
|
|
45
|
-
lastText: string;
|
|
46
|
-
/** Text already consumed as a release summary (guards duplicate/late ends). */
|
|
47
|
-
consumedText: string;
|
|
48
|
-
/** When the current task's prompt was sent (ms). */
|
|
49
|
-
sentAt: number;
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
export interface RunnerDeps {
|
|
53
|
-
pi: ExtensionAPI;
|
|
54
|
-
cli: KanboardCli;
|
|
55
|
-
/** Board project slug (kanboard settings `slug`, or the env override). */
|
|
56
|
-
project: () => string;
|
|
57
|
-
cwd: string;
|
|
58
|
-
settings: () => KanboardSettings;
|
|
59
|
-
debug: (line: string) => void;
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
export interface Runner {
|
|
63
|
-
/** Autowork start: the loop pulls the session queue first, then claim-next. */
|
|
64
|
-
work(ctx: ExtensionContext): Promise<void>;
|
|
65
|
-
/** Drain this session's queue without touching the autowork flag. */
|
|
66
|
-
drain(ctx: ExtensionContext): Promise<void>;
|
|
67
|
-
stop(ctx: ExtensionContext): void;
|
|
68
|
-
status(): { taskId: string | null; mode: RunMode | null; phase: string };
|
|
69
|
-
onAgentEnd(event: { messages?: unknown[] }, ctx: ExtensionContext): void;
|
|
70
|
-
onSessionStart(ctx: ExtensionContext): Promise<void>;
|
|
71
|
-
onSessionShutdown(ctx: ExtensionContext): Promise<void>;
|
|
72
|
-
onPlanModeChanged(payload: unknown): void;
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
/**
|
|
76
|
-
* The askJev question the runner asks for mode choice, as a standalone export
|
|
77
|
-
* so probes and tests exercise exactly what chooseMode sends.
|
|
78
|
-
*/
|
|
79
|
-
export function modeQuestion(task: KanboardTask): {
|
|
80
|
-
state: string;
|
|
81
|
-
questions: { mode: { type: "choice"; instructions: string; criteria: Record<string, string> } };
|
|
82
|
-
} {
|
|
83
|
-
return {
|
|
84
|
-
state: `${task.title}\n\n${(task.body ?? "").slice(0, 2000)}`,
|
|
85
|
-
questions: {
|
|
86
|
-
mode: {
|
|
87
|
-
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
|
-
},
|
|
94
|
-
},
|
|
95
|
-
},
|
|
96
|
-
};
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
export function createRunner(deps: RunnerDeps): Runner {
|
|
100
|
-
const { pi, cli, cwd } = deps;
|
|
101
|
-
const project = (): string => deps.project();
|
|
102
|
-
const state: RunnerState = {
|
|
103
|
-
phase: "idle",
|
|
104
|
-
task: null,
|
|
105
|
-
mode: "direct",
|
|
106
|
-
goalId: null,
|
|
107
|
-
endsSinceSend: 0,
|
|
108
|
-
stopAfterCurrent: false,
|
|
109
|
-
autowork: false,
|
|
110
|
-
settleTimer: null,
|
|
111
|
-
planOutcome: null,
|
|
112
|
-
lastText: "",
|
|
113
|
-
consumedText: "",
|
|
114
|
-
sentAt: 0,
|
|
115
|
-
};
|
|
116
|
-
|
|
117
|
-
const setStatus = (ctx: ExtensionContext, text?: string): void => {
|
|
118
|
-
try {
|
|
119
|
-
ctx.ui.setStatus("kanboard", text);
|
|
120
|
-
} catch {
|
|
121
|
-
// status is best-effort
|
|
122
|
-
}
|
|
123
|
-
};
|
|
124
|
-
|
|
125
|
-
const describe = (): string => (state.task ? `▣ ${state.task.id} · ${state.mode}` : "▣ idle");
|
|
126
|
-
|
|
127
|
-
const persist = (phase: string): void => {
|
|
128
|
-
pi.appendEntry(RUNNER_ENTRY, {
|
|
129
|
-
phase,
|
|
130
|
-
taskId: state.task?.id ?? null,
|
|
131
|
-
mode: state.mode,
|
|
132
|
-
goalId: state.goalId,
|
|
133
|
-
session: process.pid,
|
|
134
|
-
});
|
|
135
|
-
};
|
|
136
|
-
|
|
137
|
-
// ── claiming ──────────────────────────────────────────────────────────────
|
|
138
|
-
|
|
139
|
-
async function claimNext(): Promise<KanboardClaim> {
|
|
140
|
-
const gate = deps.settings().chainGate;
|
|
141
|
-
const raw = await runCli<unknown>(
|
|
142
|
-
[
|
|
143
|
-
"claim-next",
|
|
144
|
-
"--session",
|
|
145
|
-
sessionId(),
|
|
146
|
-
"--pid",
|
|
147
|
-
String(process.pid),
|
|
148
|
-
"--host",
|
|
149
|
-
hostname(),
|
|
150
|
-
"--gate",
|
|
151
|
-
gate,
|
|
152
|
-
],
|
|
153
|
-
{ extraEnv: { UNIPI_KANBOARD_PROJECT: project() } },
|
|
154
|
-
);
|
|
155
|
-
return asClaimResult(raw);
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
async function claimById(id: string): Promise<KanboardClaim> {
|
|
159
|
-
const gate = deps.settings().chainGate;
|
|
160
|
-
const raw = await runCli<unknown>(
|
|
161
|
-
["claim-next", "--id", id, "--session", sessionId(), "--pid", String(process.pid), "--host", hostname(), "--gate", gate],
|
|
162
|
-
{ extraEnv: { UNIPI_KANBOARD_PROJECT: project() } },
|
|
163
|
-
);
|
|
164
|
-
return asClaimResult(raw);
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
async function queueList(): Promise<string[]> {
|
|
168
|
-
try {
|
|
169
|
-
const raw = (await runCli<unknown>(["queue", "--list"], {
|
|
170
|
-
extraEnv: { UNIPI_KANBOARD_PROJECT: project(), UNIPI_KANBOARD_SESSION: sessionId() },
|
|
171
|
-
})) as { queue?: string[] };
|
|
172
|
-
return Array.isArray(raw.queue) ? raw.queue : [];
|
|
173
|
-
} catch (error) {
|
|
174
|
-
deps.debug(`queue --list failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
175
|
-
return [];
|
|
176
|
-
}
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
async function unqueue(id: string): Promise<void> {
|
|
180
|
-
await cli
|
|
181
|
-
.run(["unqueue", id], { extraEnv: { UNIPI_KANBOARD_PROJECT: project(), UNIPI_KANBOARD_SESSION: sessionId() } })
|
|
182
|
-
.catch((error) => deps.debug(`unqueue ${id} failed: ${error instanceof Error ? error.message : String(error)}`));
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
function sessionId(): string {
|
|
186
|
-
return process.env.UNIPI_KANBOARD_SESSION ?? `pi-${process.pid}`;
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
/** Every runner CLI call refreshes the env-carried limits first. */
|
|
190
|
-
async function runCli<T>(argv: string[], options: Parameters<KanboardCli["run"]>[1] = {}): Promise<T> {
|
|
191
|
-
applyLimitEnv(deps.settings());
|
|
192
|
-
return cli.run<T>(argv, options);
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
function hostname(): string {
|
|
196
|
-
// `process.env.HOSTNAME` is absent in some launches (tmux/systemd), and a
|
|
197
|
-
// wrong host makes a stale run look like it belongs to another machine —
|
|
198
|
-
// then nobody can release it. Ask the OS.
|
|
199
|
-
const fromOs = (() => {
|
|
200
|
-
try {
|
|
201
|
-
return osHostname();
|
|
202
|
-
} catch {
|
|
203
|
-
return "";
|
|
204
|
-
}
|
|
205
|
-
})();
|
|
206
|
-
return fromOs.trim() || process.env.HOSTNAME?.trim() || "unknown";
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
async function countWaiting(): Promise<{ waiting: number; blocked: number }> {
|
|
210
|
-
try {
|
|
211
|
-
const { tasks } = asTaskList(
|
|
212
|
-
await runCli<unknown>(["list"], { extraEnv: { UNIPI_KANBOARD_PROJECT: project() } }),
|
|
213
|
-
);
|
|
214
|
-
return {
|
|
215
|
-
waiting: tasks.filter((task) => task.status === "todo").length,
|
|
216
|
-
blocked: tasks.filter((task) => task.status === "blocked").length,
|
|
217
|
-
};
|
|
218
|
-
} catch {
|
|
219
|
-
return { waiting: 0, blocked: 0 };
|
|
220
|
-
}
|
|
221
|
-
}
|
|
222
|
-
|
|
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;
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
// ── prompting ─────────────────────────────────────────────────────────────
|
|
235
|
-
|
|
236
|
-
function renderPrompt(task: KanboardTask, all: KanboardTask[]): string {
|
|
237
|
-
const binary = cli.binary.path;
|
|
238
|
-
const activity = (task.activity ?? [])
|
|
239
|
-
.slice(-10)
|
|
240
|
-
.map((entry) => `- ${entry.at} [${entry.actor}] ${entry.text}`)
|
|
241
|
-
.join("\n");
|
|
242
|
-
const byId = new Map(all.map((candidate) => [candidate.id, candidate]));
|
|
243
|
-
const deps = (task.deps ?? [])
|
|
244
|
-
.map((id) => {
|
|
245
|
-
const dep = byId.get(id);
|
|
246
|
-
if (!dep) return `- ${id}: (missing)`;
|
|
247
|
-
const note = (dep.activity ?? []).slice(-1)[0]?.text ?? "(no notes)";
|
|
248
|
-
return `- ${id}: ${dep.title} — ${dep.status} · ${note}`;
|
|
249
|
-
})
|
|
250
|
-
.join("\n");
|
|
251
|
-
|
|
252
|
-
return [
|
|
253
|
-
`[kanboard ${task.id}] ${task.title}`,
|
|
254
|
-
"",
|
|
255
|
-
task.body?.trim() || "(no body)",
|
|
256
|
-
"",
|
|
257
|
-
"## Activity (latest 10)",
|
|
258
|
-
activity || "(none)",
|
|
259
|
-
"",
|
|
260
|
-
"## Dependencies",
|
|
261
|
-
deps || "(none)",
|
|
262
|
-
"",
|
|
263
|
-
...attachmentSection(task),
|
|
264
|
-
"## Rules",
|
|
265
|
-
`Work only on this task (${task.id}).`,
|
|
266
|
-
`Use the board CLI through its absolute path and always pass the actor and project:`,
|
|
267
|
-
` ${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.",
|
|
271
|
-
`Do not move the task to in_review or done and do not cancel it: the runner writes those when your turn ends.`,
|
|
272
|
-
`You may add notes with \`note ${task.id} "<text>"\`, link follow-up work with \`add\`, and reorder with \`order\`.`,
|
|
273
|
-
`To show evidence (a screenshot, a log, a report), attach it: \`attach ${task.id} <file> --note "<what it shows>"\`.`,
|
|
274
|
-
].join("\n");
|
|
275
|
-
}
|
|
276
|
-
|
|
277
|
-
// ── the loop ──────────────────────────────────────────────────────────────
|
|
278
|
-
|
|
279
|
-
async function startTask(ctx: ExtensionContext): Promise<boolean> {
|
|
280
|
-
let claimed: KanboardTask | null;
|
|
281
|
-
let claim: KanboardClaim;
|
|
282
|
-
try {
|
|
283
|
-
claim = await claimNext();
|
|
284
|
-
claimed = claim.task;
|
|
285
|
-
} catch (error) {
|
|
286
|
-
const message = error instanceof KanboardCliError ? error.message : String(error);
|
|
287
|
-
ctx.ui.notify(`kanboard: ${message}`, "error");
|
|
288
|
-
return false;
|
|
289
|
-
}
|
|
290
|
-
if (!claimed) {
|
|
291
|
-
const counts = await countWaiting();
|
|
292
|
-
ctx.ui.notify(nothingReadyMessage(counts, claim.waiting ?? []), "info");
|
|
293
|
-
state.phase = "idle";
|
|
294
|
-
setStatus(ctx, undefined);
|
|
295
|
-
return false;
|
|
296
|
-
}
|
|
297
|
-
|
|
298
|
-
state.task = claimed;
|
|
299
|
-
state.endsSinceSend = 0;
|
|
300
|
-
state.planOutcome = null;
|
|
301
|
-
state.lastText = "";
|
|
302
|
-
try {
|
|
303
|
-
return await runClaimedTask(ctx, claimed);
|
|
304
|
-
} catch (error) {
|
|
305
|
-
// Anything thrown after the claim would otherwise leave the task
|
|
306
|
-
// in_progress forever (K6 live bug: "all.map is not a function").
|
|
307
|
-
const detail = error instanceof Error ? error.message : String(error);
|
|
308
|
-
deps.debug(`task ${claimed.id} failed before the agent ran: ${detail}`);
|
|
309
|
-
await releaseAfterFailure(ctx, claimed, detail);
|
|
310
|
-
throw error;
|
|
311
|
-
}
|
|
312
|
-
}
|
|
313
|
-
|
|
314
|
-
async function releaseAfterFailure(ctx: ExtensionContext, task: KanboardTask, detail: string): Promise<void> {
|
|
315
|
-
state.phase = "releasing";
|
|
316
|
-
try {
|
|
317
|
-
const note = `runner error before handing the task over: ${detail}`.slice(0, 400);
|
|
318
|
-
await runCli(["release", task.id, "--to", "todo", "--comment", note], {
|
|
319
|
-
extraEnv: { UNIPI_KANBOARD_PROJECT: project() },
|
|
320
|
-
});
|
|
321
|
-
ctx.ui.notify(`kanboard: ${task.id} released to Todo — ${note}`, "error");
|
|
322
|
-
} catch (releaseError) {
|
|
323
|
-
ctx.ui.notify(
|
|
324
|
-
`kanboard: could not release ${task.id} after an error — ${
|
|
325
|
-
releaseError instanceof Error ? releaseError.message : String(releaseError)
|
|
326
|
-
}`,
|
|
327
|
-
"error",
|
|
328
|
-
);
|
|
329
|
-
}
|
|
330
|
-
state.task = null;
|
|
331
|
-
state.goalId = null;
|
|
332
|
-
state.phase = "idle";
|
|
333
|
-
persist("idle");
|
|
334
|
-
setStatus(ctx, undefined);
|
|
335
|
-
}
|
|
336
|
-
|
|
337
|
-
async function runClaimedTask(ctx: ExtensionContext, claimed: KanboardTask): Promise<boolean> {
|
|
338
|
-
state.goalId = null;
|
|
339
|
-
state.phase = "running";
|
|
340
|
-
|
|
341
|
-
let mode: RunMode = "direct";
|
|
342
|
-
try {
|
|
343
|
-
mode = await chooseMode(claimed);
|
|
344
|
-
} catch (error) {
|
|
345
|
-
deps.debug(`mode choice failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
346
|
-
}
|
|
347
|
-
state.mode = mode;
|
|
348
|
-
|
|
349
|
-
const all = await cli
|
|
350
|
-
.run<unknown>(["list"], { extraEnv: { UNIPI_KANBOARD_PROJECT: project() } })
|
|
351
|
-
.then((raw) => asTaskList(raw).tasks)
|
|
352
|
-
.catch((error) => {
|
|
353
|
-
deps.debug(`list failed while building the prompt: ${error instanceof Error ? error.message : String(error)}`);
|
|
354
|
-
return [] as KanboardTask[];
|
|
355
|
-
});
|
|
356
|
-
|
|
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") {
|
|
363
|
-
const started = await callCommandRunner<{ ok?: boolean; reason?: string }>("unipi:plan-enter", ctx);
|
|
364
|
-
if (!started.found || !started.result?.ok) {
|
|
365
|
-
ctx.ui.notify(
|
|
366
|
-
`kanboard: plan mode unavailable (${started.result?.reason ?? "workflow not loaded"}) — running direct`,
|
|
367
|
-
"warning",
|
|
368
|
-
);
|
|
369
|
-
state.mode = "direct";
|
|
370
|
-
}
|
|
371
|
-
}
|
|
372
|
-
if (state.mode === "goal") {
|
|
373
|
-
const started = await callCommandRunner<{ ok?: boolean; goalId?: string; reason?: string }>(
|
|
374
|
-
"unipi:goal-start",
|
|
375
|
-
ctx,
|
|
376
|
-
{ objective: `${claimed.title} — ${(claimed.body ?? "").slice(0, 400)}`.trim() },
|
|
377
|
-
);
|
|
378
|
-
if (started.found && started.result?.ok && started.result.goalId) {
|
|
379
|
-
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 {
|
|
386
|
-
ctx.ui.notify(
|
|
387
|
-
`kanboard: goal mode unavailable (${started.result?.reason ?? "long-horizon not loaded"}) — running direct`,
|
|
388
|
-
"warning",
|
|
389
|
-
);
|
|
390
|
-
state.mode = "direct";
|
|
391
|
-
}
|
|
392
|
-
}
|
|
393
|
-
|
|
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;
|
|
403
|
-
}
|
|
404
|
-
|
|
405
|
-
/** 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 }>(
|
|
408
|
-
"unipi:goal-status",
|
|
409
|
-
undefined,
|
|
410
|
-
{ goalId: state.goalId },
|
|
411
|
-
);
|
|
412
|
-
if (!looked.found || !looked.result?.found || !looked.result.status) return null;
|
|
413
|
-
return { status: looked.result.status, objective: looked.result.objective };
|
|
414
|
-
}
|
|
415
|
-
|
|
416
|
-
function planStillActive(): boolean {
|
|
417
|
-
return state.mode === "plan" && state.planOutcome === null;
|
|
418
|
-
}
|
|
419
|
-
|
|
420
|
-
async function finishTask(ctx: ExtensionContext, outcome: "in_review" | "todo" | "blocked", comment: string): Promise<void> {
|
|
421
|
-
const task = state.task;
|
|
422
|
-
if (!task) return;
|
|
423
|
-
state.phase = "releasing";
|
|
424
|
-
try {
|
|
425
|
-
const value = asTask(
|
|
426
|
-
"show",
|
|
427
|
-
await runCli<unknown>(["show", task.id], { extraEnv: { UNIPI_KANBOARD_PROJECT: project() } }),
|
|
428
|
-
);
|
|
429
|
-
if (value.status === "blocked") {
|
|
430
|
-
const note = (value.activity ?? []).slice(-1)[0]?.text ?? "blocked";
|
|
431
|
-
ctx.ui.notify(`▣ ${task.id} blocked: ${note}`, "warning");
|
|
432
|
-
} else if (outcome === "in_review") {
|
|
433
|
-
await runCli(["release", task.id, "--to", "in_review", "--comment", comment], {
|
|
434
|
-
extraEnv: { UNIPI_KANBOARD_PROJECT: project() },
|
|
435
|
-
});
|
|
436
|
-
const first = comment.split("\n")[0]?.slice(0, 120) ?? "";
|
|
437
|
-
ctx.ui.notify(`✓ ${task.id} → In Review: ${first}`, "info");
|
|
438
|
-
} else {
|
|
439
|
-
await runCli(["release", task.id, "--to", outcome, "--comment", comment], {
|
|
440
|
-
extraEnv: { UNIPI_KANBOARD_PROJECT: project() },
|
|
441
|
-
});
|
|
442
|
-
ctx.ui.notify(`↩ ${task.id} → ${outcome === "todo" ? "Todo" : "Blocked"}: ${comment.slice(0, 120)}`, "warning");
|
|
443
|
-
}
|
|
444
|
-
} catch (error) {
|
|
445
|
-
ctx.ui.notify(
|
|
446
|
-
`kanboard: could not release ${task.id} — ${error instanceof KanboardCliError ? error.message : String(error)}`,
|
|
447
|
-
"error",
|
|
448
|
-
);
|
|
449
|
-
}
|
|
450
|
-
state.consumedText = state.lastText.trim();
|
|
451
|
-
state.task = null;
|
|
452
|
-
state.goalId = null;
|
|
453
|
-
state.endsSinceSend = 0;
|
|
454
|
-
state.phase = "idle";
|
|
455
|
-
persist("idle");
|
|
456
|
-
setStatus(ctx, state.stopAfterCurrent ? undefined : describe());
|
|
457
|
-
|
|
458
|
-
await continueLoop(ctx);
|
|
459
|
-
}
|
|
460
|
-
|
|
461
|
-
/**
|
|
462
|
-
* After a task — or when -do closes with a non-empty queue: stop wins, then
|
|
463
|
-
* the session queue (`claim-next --id`, skipping entries that went stale),
|
|
464
|
-
* then autowork's `claim-next`. Nothing ready stops autowork.
|
|
465
|
-
*/
|
|
466
|
-
async function continueLoop(ctx: ExtensionContext): Promise<void> {
|
|
467
|
-
if (state.stopAfterCurrent) {
|
|
468
|
-
state.stopAfterCurrent = false;
|
|
469
|
-
state.autowork = false;
|
|
470
|
-
state.phase = "idle";
|
|
471
|
-
setStatus(ctx, undefined);
|
|
472
|
-
ctx.ui.notify("kanboard: stopped", "info");
|
|
473
|
-
return;
|
|
474
|
-
}
|
|
475
|
-
const queued = await queueList();
|
|
476
|
-
if (queued.length > 0) {
|
|
477
|
-
// Scan the queue in order: drop entries that can never run (gone, final,
|
|
478
|
-
// no longer schedulable, or blocked by a cancelled dep), skip — but keep —
|
|
479
|
-
// entries that merely wait on dependencies, and claim the first ready one.
|
|
480
|
-
const { tasks } = asTaskList(
|
|
481
|
-
await runCli<unknown>(["list"], { extraEnv: { UNIPI_KANBOARD_PROJECT: project() } }),
|
|
482
|
-
);
|
|
483
|
-
const byId = new Map(tasks.map((task) => [task.id, task]));
|
|
484
|
-
let waitingNote: string | null = null;
|
|
485
|
-
let claimed: KanboardTask | null = null;
|
|
486
|
-
for (const id of queued) {
|
|
487
|
-
const task = byId.get(id);
|
|
488
|
-
if (!task) {
|
|
489
|
-
await unqueue(id);
|
|
490
|
-
ctx.ui.notify(`kanboard: queued ${id} dropped — no longer on the board`, "warning");
|
|
491
|
-
continue;
|
|
492
|
-
}
|
|
493
|
-
if (task.status !== "todo" && task.status !== "backlog") {
|
|
494
|
-
await unqueue(id);
|
|
495
|
-
ctx.ui.notify(`kanboard: queued ${id} dropped — now ${task.status}`, "warning");
|
|
496
|
-
continue;
|
|
497
|
-
}
|
|
498
|
-
if (task.status !== "todo" || !task.ready) {
|
|
499
|
-
const waitingFor = (task.waitingFor ?? []).join(", ");
|
|
500
|
-
const cancelledDep = (task.waitingFor ?? []).find((dep) => byId.get(dep)?.status === "cancelled");
|
|
501
|
-
if (cancelledDep) {
|
|
502
|
-
await unqueue(id);
|
|
503
|
-
ctx.ui.notify(`kanboard: queued ${id} dropped — waits on cancelled ${cancelledDep}`, "warning");
|
|
504
|
-
continue;
|
|
505
|
-
}
|
|
506
|
-
waitingNote ??= task.status === "backlog"
|
|
507
|
-
? `${id} is still in Backlog (move it to Todo)`
|
|
508
|
-
: waitingFor ? `${id} waits for ${waitingFor}` : `${id} is not ready`;
|
|
509
|
-
continue;
|
|
510
|
-
}
|
|
511
|
-
await unqueue(id);
|
|
512
|
-
let claim: KanboardClaim;
|
|
513
|
-
try {
|
|
514
|
-
claim = await claimById(id);
|
|
515
|
-
} catch (error) {
|
|
516
|
-
ctx.ui.notify(
|
|
517
|
-
`kanboard: queued ${id} skipped — ${error instanceof KanboardCliError ? error.message : String(error)}`,
|
|
518
|
-
"warning",
|
|
519
|
-
);
|
|
520
|
-
continue;
|
|
521
|
-
}
|
|
522
|
-
if (claim.task) {
|
|
523
|
-
claimed = claim.task;
|
|
524
|
-
break;
|
|
525
|
-
}
|
|
526
|
-
}
|
|
527
|
-
if (claimed) {
|
|
528
|
-
state.task = claimed;
|
|
529
|
-
state.endsSinceSend = 0;
|
|
530
|
-
state.planOutcome = null;
|
|
531
|
-
state.lastText = "";
|
|
532
|
-
try {
|
|
533
|
-
await runClaimedTask(ctx, claimed);
|
|
534
|
-
} catch (error) {
|
|
535
|
-
const detail = error instanceof Error ? error.message : String(error);
|
|
536
|
-
deps.debug(`queued task ${claimed.id} failed before the agent ran: ${detail}`);
|
|
537
|
-
await releaseAfterFailure(ctx, claimed, detail);
|
|
538
|
-
}
|
|
539
|
-
return;
|
|
540
|
-
}
|
|
541
|
-
if (waitingNote) ctx.ui.notify(`kanboard: queue waiting: ${waitingNote}`, "info");
|
|
542
|
-
}
|
|
543
|
-
if (state.autowork) {
|
|
544
|
-
const started = await startTask(ctx);
|
|
545
|
-
if (!started) state.autowork = false;
|
|
546
|
-
return;
|
|
547
|
-
}
|
|
548
|
-
state.phase = "idle";
|
|
549
|
-
setStatus(ctx, undefined);
|
|
550
|
-
if (queued.length === 0) ctx.ui.notify("kanboard: queue done", "info");
|
|
551
|
-
}
|
|
552
|
-
|
|
553
|
-
function lastAssistantText(messages: unknown[] | undefined): string {
|
|
554
|
-
if (!Array.isArray(messages)) return "";
|
|
555
|
-
for (let index = messages.length - 1; index >= 0; index -= 1) {
|
|
556
|
-
const message = messages[index] as { role?: string; content?: unknown };
|
|
557
|
-
if (message?.role !== "assistant") continue;
|
|
558
|
-
const content = message.content;
|
|
559
|
-
if (typeof content === "string") return content;
|
|
560
|
-
if (Array.isArray(content)) {
|
|
561
|
-
const text = content
|
|
562
|
-
.map((part) => (part && typeof part === "object" && (part as { type?: string }).type === "text" ? String((part as { text?: string }).text ?? "") : ""))
|
|
563
|
-
.join("\n")
|
|
564
|
-
.trim();
|
|
565
|
-
if (text) return text;
|
|
566
|
-
}
|
|
567
|
-
}
|
|
568
|
-
return "";
|
|
569
|
-
}
|
|
570
|
-
|
|
571
|
-
function isAbort(messages: unknown[] | undefined): boolean {
|
|
572
|
-
if (!Array.isArray(messages)) return false;
|
|
573
|
-
for (let index = messages.length - 1; index >= 0; index -= 1) {
|
|
574
|
-
const message = messages[index] as { role?: string; stopReason?: string };
|
|
575
|
-
if (message?.role === "assistant") return message.stopReason === "aborted";
|
|
576
|
-
}
|
|
577
|
-
return false;
|
|
578
|
-
}
|
|
579
|
-
|
|
580
|
-
return {
|
|
581
|
-
async work(ctx: ExtensionContext): Promise<void> {
|
|
582
|
-
if (state.phase !== "idle") {
|
|
583
|
-
state.autowork = true;
|
|
584
|
-
ctx.ui.notify("kanboard: a task is already running — autowork continues after it", "info");
|
|
585
|
-
return;
|
|
586
|
-
}
|
|
587
|
-
state.autowork = true;
|
|
588
|
-
state.stopAfterCurrent = false;
|
|
589
|
-
await continueLoop(ctx);
|
|
590
|
-
},
|
|
591
|
-
|
|
592
|
-
async drain(ctx: ExtensionContext): Promise<void> {
|
|
593
|
-
if (state.phase !== "idle") return;
|
|
594
|
-
await continueLoop(ctx);
|
|
595
|
-
},
|
|
596
|
-
|
|
597
|
-
stop(ctx: ExtensionContext): void {
|
|
598
|
-
if (state.phase === "idle") {
|
|
599
|
-
ctx.ui.notify("kanboard: not running", "info");
|
|
600
|
-
return;
|
|
601
|
-
}
|
|
602
|
-
state.stopAfterCurrent = true;
|
|
603
|
-
ctx.ui.notify("kanboard: finishing the current task, then stopping", "info");
|
|
604
|
-
},
|
|
605
|
-
|
|
606
|
-
status() {
|
|
607
|
-
return { taskId: state.task?.id ?? null, mode: state.task ? state.mode : null, phase: state.phase };
|
|
608
|
-
},
|
|
609
|
-
|
|
610
|
-
onAgentEnd(event: { messages?: unknown[] }, ctx: ExtensionContext): void {
|
|
611
|
-
if (state.phase !== "running" || !state.task) return;
|
|
612
|
-
if (isAbort(event.messages)) {
|
|
613
|
-
if (state.settleTimer) clearTimeout(state.settleTimer);
|
|
614
|
-
state.settleTimer = null;
|
|
615
|
-
const task = state.task;
|
|
616
|
-
state.stopAfterCurrent = true;
|
|
617
|
-
void finishTask(ctx, "todo", "interrupted by user").then(() => {
|
|
618
|
-
deps.debug(`aborted ${task.id} → todo`);
|
|
619
|
-
});
|
|
620
|
-
return;
|
|
621
|
-
}
|
|
622
|
-
// A late agent_end from the PREVIOUS task can arrive just after we claimed
|
|
623
|
-
// the next one; settling on it would release the new task with the old
|
|
624
|
-
// summary (seen live: PIT-3 released with PIT-2's text).
|
|
625
|
-
const text = lastAssistantText(event.messages);
|
|
626
|
-
if (text && text === state.consumedText) {
|
|
627
|
-
deps.debug(`ignored late agent_end (same turn text as the previous release)`);
|
|
628
|
-
return;
|
|
629
|
-
}
|
|
630
|
-
if (!text && Date.now() - state.sentAt < 150) {
|
|
631
|
-
deps.debug("ignored agent_end right after sending the prompt");
|
|
632
|
-
return;
|
|
633
|
-
}
|
|
634
|
-
state.endsSinceSend += 1;
|
|
635
|
-
if (text) state.lastText = text;
|
|
636
|
-
// A turnover can take several agent_end events (bg wakeups, extra turns):
|
|
637
|
-
// settle only once nothing new arrives and the agent reports idle.
|
|
638
|
-
if (state.settleTimer) clearTimeout(state.settleTimer);
|
|
639
|
-
state.settleTimer = setTimeout(() => {
|
|
640
|
-
state.settleTimer = null;
|
|
641
|
-
void settle(ctx);
|
|
642
|
-
}, 1200);
|
|
643
|
-
state.settleTimer.unref?.();
|
|
644
|
-
},
|
|
645
|
-
|
|
646
|
-
onPlanModeChanged(payload: unknown): void {
|
|
647
|
-
const active = (payload as { active?: boolean } | undefined)?.active;
|
|
648
|
-
const reason = (payload as { reason?: string } | undefined)?.reason;
|
|
649
|
-
if (active === false && state.mode === "plan") {
|
|
650
|
-
state.planOutcome = reason === "discarded" ? "discarded" : "approved";
|
|
651
|
-
deps.debug(`plan mode left: ${state.planOutcome}`);
|
|
652
|
-
}
|
|
653
|
-
},
|
|
654
|
-
|
|
655
|
-
async onSessionStart(ctx: ExtensionContext): Promise<void> {
|
|
656
|
-
// Resume (or release) a task this session claimed before a reload.
|
|
657
|
-
const entries: SessionEntry[] = ctx.sessionManager.getEntries();
|
|
658
|
-
let claimed: { taskId?: string; mode?: RunMode; goalId?: string } | null = null;
|
|
659
|
-
for (const entry of entries) {
|
|
660
|
-
const custom = (entry as { customType?: string; data?: unknown }).customType;
|
|
661
|
-
if (custom !== RUNNER_ENTRY) continue;
|
|
662
|
-
const data = (entry as { data?: { phase?: string; taskId?: string; mode?: RunMode; goalId?: string } }).data;
|
|
663
|
-
if (!data?.taskId) continue;
|
|
664
|
-
claimed = data.phase === "idle" ? null : { taskId: data.taskId, mode: data.mode, goalId: data.goalId ?? undefined };
|
|
665
|
-
}
|
|
666
|
-
if (!claimed?.taskId) return;
|
|
667
|
-
let task: KanboardTask;
|
|
668
|
-
try {
|
|
669
|
-
task = asTask(
|
|
670
|
-
"show",
|
|
671
|
-
await runCli<unknown>(["show", claimed.taskId], { extraEnv: { UNIPI_KANBOARD_PROJECT: project() } }),
|
|
672
|
-
);
|
|
673
|
-
} catch {
|
|
674
|
-
return;
|
|
675
|
-
}
|
|
676
|
-
if (task.status !== "in_progress") {
|
|
677
|
-
persist("idle");
|
|
678
|
-
return;
|
|
679
|
-
}
|
|
680
|
-
const resume = await ctx.ui.confirm(
|
|
681
|
-
"Kanboard task in progress",
|
|
682
|
-
`${task.id} "${task.title}" was claimed by this session — resume it (keep working), or release it to Todo?`,
|
|
683
|
-
);
|
|
684
|
-
if (resume) {
|
|
685
|
-
state.task = task;
|
|
686
|
-
state.mode = claimed.mode ?? "direct";
|
|
687
|
-
state.goalId = claimed.goalId ?? null;
|
|
688
|
-
state.phase = "running";
|
|
689
|
-
state.endsSinceSend = 0;
|
|
690
|
-
setStatus(ctx, describe());
|
|
691
|
-
pi.sendUserMessage(
|
|
692
|
-
`[kanboard ${task.id}] Resuming "${task.title}" after a reload. Continue where you left off, following the same rules (block with a comment if you need the user; the runner releases the task when your turn ends).`,
|
|
693
|
-
);
|
|
694
|
-
return;
|
|
695
|
-
}
|
|
696
|
-
await runCli(["release", task.id, "--to", "todo", "--comment", "session ended"], {
|
|
697
|
-
extraEnv: { UNIPI_KANBOARD_PROJECT: project() },
|
|
698
|
-
});
|
|
699
|
-
persist("idle");
|
|
700
|
-
ctx.ui.notify(`kanboard: ${task.id} released to Todo`, "info");
|
|
701
|
-
},
|
|
702
|
-
|
|
703
|
-
async onSessionShutdown(ctx: ExtensionContext): Promise<void> {
|
|
704
|
-
if (!state.task || state.phase === "idle") return;
|
|
705
|
-
try {
|
|
706
|
-
await runCli(["release", state.task.id, "--to", "todo", "--comment", "session ended"], {
|
|
707
|
-
extraEnv: { UNIPI_KANBOARD_PROJECT: project() },
|
|
708
|
-
});
|
|
709
|
-
} catch (error) {
|
|
710
|
-
deps.debug(`shutdown release failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
711
|
-
}
|
|
712
|
-
persist("idle");
|
|
713
|
-
state.task = null;
|
|
714
|
-
setStatus(ctx, undefined);
|
|
715
|
-
},
|
|
716
|
-
};
|
|
717
|
-
|
|
718
|
-
async function settle(ctx: ExtensionContext): Promise<void> {
|
|
719
|
-
if (state.phase !== "running" || !state.task) return;
|
|
720
|
-
const idle = typeof ctx.isIdle === "function" ? ctx.isIdle() : true;
|
|
721
|
-
const pending = typeof ctx.hasPendingMessages === "function" ? ctx.hasPendingMessages() : false;
|
|
722
|
-
if (!idle || pending) {
|
|
723
|
-
// Something is still queued: wait for the next agent_end to re-arm.
|
|
724
|
-
deps.debug("settle deferred: agent not idle");
|
|
725
|
-
return;
|
|
726
|
-
}
|
|
727
|
-
|
|
728
|
-
if (planStillActive()) {
|
|
729
|
-
deps.debug("settle deferred: plan mode still active");
|
|
730
|
-
return;
|
|
731
|
-
}
|
|
732
|
-
if (state.mode === "plan" && state.planOutcome === "discarded") {
|
|
733
|
-
await finishTask(ctx, "todo", "plan discarded");
|
|
734
|
-
return;
|
|
735
|
-
}
|
|
736
|
-
if (state.mode === "goal" && state.goalId) {
|
|
737
|
-
const goal = await goalStatus();
|
|
738
|
-
if (goal && goal.status !== "complete") {
|
|
739
|
-
if (["failed", "blocked", "stalled", "abandoned", "budget_exhausted"].includes(goal.status)) {
|
|
740
|
-
await finishTask(ctx, "todo", `goal ${goal.status}`);
|
|
741
|
-
return;
|
|
742
|
-
}
|
|
743
|
-
deps.debug(`settle deferred: goal ${goal.status}`);
|
|
744
|
-
return;
|
|
745
|
-
}
|
|
746
|
-
}
|
|
747
|
-
|
|
748
|
-
const summary = state.lastText.trim() || lastAssistantText(collectMessages(ctx)) || "finished; see the activity log";
|
|
749
|
-
const clipped = summary.length > 500 ? `${summary.slice(0, 499)}…` : summary;
|
|
750
|
-
await finishTask(ctx, "in_review", clipped);
|
|
751
|
-
}
|
|
752
|
-
|
|
753
|
-
/** Best-effort read of the current transcript for the run summary. */
|
|
754
|
-
function collectMessages(ctx: ExtensionContext): unknown[] {
|
|
755
|
-
try {
|
|
756
|
-
const branch: SessionEntry[] = ctx.sessionManager.getBranch();
|
|
757
|
-
return branch
|
|
758
|
-
.map((entry) => (entry as { message?: unknown }).message)
|
|
759
|
-
.filter((message): message is object => typeof message === "object" && message !== null);
|
|
760
|
-
} catch {
|
|
761
|
-
return [];
|
|
762
|
-
}
|
|
763
|
-
}
|
|
764
|
-
}
|
|
765
|
-
|
|
766
|
-
export function createDebugLog(env: NodeJS.ProcessEnv = process.env): (line: string) => void {
|
|
767
|
-
if (env.UNIPI_DEBUG_KANBOARD !== "1") return () => undefined;
|
|
768
|
-
return (line: string) => {
|
|
769
|
-
try {
|
|
770
|
-
const { appendFileSync, mkdirSync } = require("node:fs") as typeof import("node:fs");
|
|
771
|
-
const { homedir } = require("node:os") as typeof import("node:os");
|
|
772
|
-
const dir = `${homedir()}/.unipi/logs`;
|
|
773
|
-
mkdirSync(dir, { recursive: true });
|
|
774
|
-
appendFileSync(`${dir}/kanboard.log`, `${new Date().toISOString()} ${line}\n`);
|
|
775
|
-
} catch {
|
|
776
|
-
// best-effort
|
|
777
|
-
}
|
|
778
|
-
};
|
|
779
|
-
}
|
|
780
|
-
|
|
781
|
-
export function registerPlanEventListener(pi: ExtensionAPI, runner: Runner): void {
|
|
782
|
-
pi.events.on(UNIPI_EVENTS.PLAN_MODE_CHANGED, (payload: unknown) => runner.onPlanModeChanged(payload));
|
|
783
|
-
}
|
|
784
|
-
|
|
785
|
-
/**
|
|
786
|
-
* "Nothing ready" with the reason spelled out: tasks locked behind a Backlog
|
|
787
|
-
* dependency need a human to schedule that dependency — the runner never claims
|
|
788
|
-
* Backlog — so name them instead of leaving a bare count.
|
|
789
|
-
*/
|
|
790
|
-
export function nothingReadyMessage(
|
|
791
|
-
counts: { waiting: number; blocked: number },
|
|
792
|
-
waiting: Array<{ id: string; waitingFor?: string[]; lockedBy?: string[] }>,
|
|
793
|
-
): string {
|
|
794
|
-
const head = `Nothing ready (${counts.waiting} waiting on deps, ${counts.blocked} blocked)`;
|
|
795
|
-
const locked = waiting.filter((entry) => (entry.lockedBy ?? []).length > 0);
|
|
796
|
-
if (locked.length === 0) return head;
|
|
797
|
-
const lines = locked
|
|
798
|
-
.slice(0, 5)
|
|
799
|
-
.map((entry) => ` ${entry.id} waits on ${entry.lockedBy!.join(", ")}, still in Backlog — move it to Todo to unlock`);
|
|
800
|
-
if (locked.length > 5) lines.push(` …and ${locked.length - 5} more`);
|
|
801
|
-
return [head, ...lines].join("\n");
|
|
802
|
-
}
|
|
803
|
-
|
|
804
|
-
/**
|
|
805
|
-
* Attachments the user added (screenshots, logs, documents). `att:` references in
|
|
806
|
-
* the body/activity point at these; the agent reads them straight from disk.
|
|
807
|
-
*/
|
|
808
|
-
export function attachmentSection(task: KanboardTask): string[] {
|
|
809
|
-
const items = Array.isArray(task.attachments) ? (task.attachments as Array<{ ref?: string; path?: string; kind?: string; original?: string; size?: number }>) : [];
|
|
810
|
-
if (items.length === 0) return [];
|
|
811
|
-
return [
|
|
812
|
-
"## Attachments",
|
|
813
|
-
"References like `att:ID/name` in the text above are these files — read them from disk (images with your image-reading tool):",
|
|
814
|
-
...items.map((item) => `- ${item.ref ?? "?"} → ${item.path ?? "?"} (${item.kind ?? "file"}, ${item.size ?? 0} bytes)`),
|
|
815
|
-
"",
|
|
816
|
-
];
|
|
817
|
-
}
|