@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/src/monitor.ts ADDED
@@ -0,0 +1,330 @@
1
+ /**
2
+ * @pi-unipi/kanboard — the turn arbiter's kanboard nudge provider.
3
+ *
4
+ * Replaces the runner and the R2 reminder: when the lead settles with claims
5
+ * of its own still In Progress, the monitor proposes ONE continuation nudge
6
+ * (priority 50, claims; priority 40, autowork-next). The arbiter delivers at
7
+ * most one nudge per settle, so the monitor never fights the long-horizon
8
+ * owner (priority 100) or a pending event. Lead only — children never
9
+ * register it.
10
+ */
11
+
12
+ import type { Nudge, SettleInfo } from "@pi-unipi/core";
13
+ import { harnessMetadata, kanboardGlanceLabel } from "@pi-unipi/core";
14
+
15
+ import { ANTI_POISONING_SUFFIX, taskIdsIn } from "./reminders.js";
16
+ import type { KanboardTask } from "./shapes.js";
17
+
18
+ export const CLAIMS_NUDGE_CUSTOM_TYPE = "unipi:kanboard-continue";
19
+ export const AUTOWORK_NUDGE_CUSTOM_TYPE = "unipi:kanboard-next";
20
+ export const CLAIMS_PRIORITY = 50;
21
+ export const AUTOWORK_PRIORITY = 40;
22
+ /** Hard cap of continuation nudges per task. */
23
+ export const MAX_NUDGES_PER_TASK = 5;
24
+ /** Consecutive nudged runs with zero tool calls before the monitor gives up. */
25
+ export const STALL_LIMIT = 2;
26
+ /** Offers of the SAME ready task before autowork gives up on it. */
27
+ export const MAX_OFFERS_PER_TASK = 3;
28
+
29
+ export interface MonitorDeps {
30
+ /** `list --json` on this session's project (board order). */
31
+ list(): Promise<KanboardTask[]>;
32
+ /** `list --ready --json` (todo tasks whose deps are met), board order. */
33
+ listReady(): Promise<KanboardTask[]>;
34
+ /** This session's id (UNIPI_KANBOARD_SESSION). */
35
+ session(): string;
36
+ /** Shared long-horizon owner status holder. */
37
+ ownerStatus(): { owner?: { kind: string; status: string }; lastStop?: { kind: string; at: number } } | undefined;
38
+ /** Wall clock (injectable in tests). */
39
+ now(): number;
40
+ /** User-only notices (never LLM context) — implementations must only queue. */
41
+ notify(text: string, level?: "info" | "warning"): void;
42
+ /** The monitor itself turned autowork off (done / stalled) — guard + holder follow. */
43
+ onAutoworkOff?(): void;
44
+ /** `<binary> --actor agent --project <slug>` for the nudge text, or null. */
45
+ cliPrefix(): string | null;
46
+ debug?(line: string): void;
47
+ }
48
+
49
+ export interface KanboardMonitor {
50
+ /** agent_start: the run that is about to start; stamps runStartedAt. */
51
+ onAgentStart(): void;
52
+ /** A user prompt: re-arm when it names one of our claimed ids. */
53
+ onUserPrompt(text: string): Promise<void>;
54
+ /** agent_end: an aborted or errored run disarms (kanboard never re-nudges after Esc). */
55
+ onAgentEnd(messages: unknown[] | undefined): void;
56
+ /** Arm the monitor (from -do, autowork start, or a successful lead `start`). */
57
+ arm(reason?: string): void;
58
+ disarm(): void;
59
+ setAutowork(on: boolean): void;
60
+ /** The nudge provider body (throws are caught by the arbiter, not here). */
61
+ propose(info: SettleInfo): Promise<Nudge | null>;
62
+ /** For tests / the status holder. */
63
+ state(): {
64
+ armed: boolean;
65
+ autowork: boolean;
66
+ nudgesPerTask: Record<string, number>;
67
+ noProgressRuns: number;
68
+ };
69
+ }
70
+
71
+ /** Last assistant message aborted or errored (Step 0 Q1d: stopReason). */
72
+ function runFailed(messages: unknown[] | undefined): boolean {
73
+ if (!Array.isArray(messages)) return false;
74
+ for (let index = messages.length - 1; index >= 0; index -= 1) {
75
+ const message = messages[index] as { role?: string; stopReason?: string };
76
+ if (message?.role === "assistant") return message.stopReason === "aborted" || message.stopReason === "error";
77
+ }
78
+ return false;
79
+ }
80
+
81
+ /** The final non-empty paragraph of a text, trimmed. */
82
+ function finalParagraph(text: string): string {
83
+ const paragraphs = text.trim().split(/\n\s*\n/).filter((part) => part.trim().length > 0);
84
+ return (paragraphs.at(-1) ?? "").trim();
85
+ }
86
+
87
+ function ownClaim(task: KanboardTask, session: string): boolean {
88
+ const run = task.run as { session?: string } | null | undefined;
89
+ return task.status === "in_progress" && run?.session === session;
90
+ }
91
+
92
+ /** Our own runtime texts never count as user prompts naming claimed ids. */
93
+ function isOwnRuntimeText(text: string): boolean {
94
+ return (
95
+ text.includes(ANTI_POISONING_SUFFIX) ||
96
+ text.includes(CLAIMS_NUDGE_CUSTOM_TYPE) ||
97
+ text.includes(AUTOWORK_NUDGE_CUSTOM_TYPE)
98
+ );
99
+ }
100
+
101
+ export function createKanboardMonitor(deps: MonitorDeps): KanboardMonitor {
102
+ const debug = (line: string): void => deps.debug?.(`monitor: ${line}`);
103
+ let armed = false;
104
+ let autowork = false;
105
+ let runStartedAt = 0;
106
+ let lastRunWasNudge = false;
107
+ let nudgeDelivered = false;
108
+ let noProgressRuns = 0;
109
+ /** Continuation nudges per task id (delivered, not proposed). */
110
+ const nudgesPerTask = new Map<string, number>();
111
+ /** Autowork offers per ready task id (delivered; reset when it leaves ready). */
112
+ const offersPerTask = new Map<string, number>();
113
+ /** Notice dedupe per condition; cleared on re-arm. */
114
+ const notified = new Set<string>();
115
+ const notifyOnce = (key: string, text: string, level: "info" | "warning" = "info"): void => {
116
+ if (notified.has(key)) return;
117
+ notified.add(key);
118
+ deps.notify(text, level);
119
+ };
120
+ const autoworkOff = (why: string): void => {
121
+ autowork = false;
122
+ try {
123
+ deps.onAutoworkOff?.();
124
+ } catch {
125
+ // A broken callback must never break settlement.
126
+ }
127
+ debug(`autowork off (${why})`);
128
+ };
129
+ const disarmNow = (): void => {
130
+ if (armed) debug("disarmed");
131
+ armed = false;
132
+ };
133
+
134
+ const claimedFrom = (tasks: KanboardTask[]): KanboardTask[] => {
135
+ const session = deps.session();
136
+ return tasks.filter((task) => ownClaim(task, session));
137
+ };
138
+
139
+ return {
140
+ onAgentStart() {
141
+ // The run that just started was nudged iff the previous settle delivered.
142
+ lastRunWasNudge = nudgeDelivered;
143
+ nudgeDelivered = false;
144
+ runStartedAt = deps.now();
145
+ },
146
+
147
+ async onUserPrompt(text) {
148
+ if (isOwnRuntimeText(text)) return;
149
+ const named = taskIdsIn(text);
150
+ if (named.length === 0) return;
151
+ try {
152
+ const claimed = claimedFrom(await deps.list()).map((task) => task.id);
153
+ if (named.some((id) => claimed.includes(id))) {
154
+ armed = true;
155
+ notified.clear();
156
+ debug(`re-armed by user prompt naming ${claimed.join(", ")}`);
157
+ }
158
+ } catch (error) {
159
+ debug(`prompt re-arm list failed: ${error instanceof Error ? error.message : String(error)}`);
160
+ }
161
+ },
162
+
163
+ onAgentEnd(messages) {
164
+ if (runFailed(messages)) {
165
+ // Esc (or a provider error): kanboard never re-nudges over the user.
166
+ disarmNow();
167
+ }
168
+ },
169
+
170
+ arm(reason) {
171
+ armed = true;
172
+ notified.clear();
173
+ debug(`armed${reason ? ` (${reason})` : ""}`);
174
+ },
175
+
176
+ disarm() {
177
+ disarmNow();
178
+ },
179
+
180
+ setAutowork(on) {
181
+ autowork = on;
182
+ },
183
+
184
+ async propose(info) {
185
+ if (!armed) return null;
186
+ let tasks: KanboardTask[];
187
+ try {
188
+ tasks = await deps.list();
189
+ } catch (error) {
190
+ debug(`list failed: ${error instanceof Error ? error.message : String(error)}`);
191
+ return null;
192
+ }
193
+ const claims = claimedFrom(tasks);
194
+
195
+ // A long-horizon owner drives continuation: kanboard stays quiet.
196
+ // An owner that STOPPED this run matters: complete → kanboard takes
197
+ // over again; paused/budget/other → tell the user what was left.
198
+ const owner = deps.ownerStatus();
199
+ if (owner?.owner?.status === "active") return null;
200
+ const stop = owner?.lastStop;
201
+ if (stop && stop.at >= runStartedAt && stop.kind !== "complete") {
202
+ if (claims.length > 0) {
203
+ const ids = claims.map((task) => task.id).join(", ");
204
+ const kindWord = stop.kind === "paused" ? "paused" : stop.kind === "budget" ? "hit its budget" : "stopped";
205
+ notifyOnce(
206
+ `goal-stop:${ids}`,
207
+ `kanboard: goal ${kindWord} — ${ids} left In Progress`,
208
+ );
209
+ }
210
+ disarmNow();
211
+ return null;
212
+ }
213
+
214
+ if (claims.length > 0) {
215
+ // Question heuristic: the agent ended by asking the user something —
216
+ // a nudge would talk over the question. Notice only, no count.
217
+ if (!info.lastAssistantHadToolCalls && finalParagraph(info.lastAssistantText).endsWith("?")) {
218
+ const ids = claims.map((task) => task.id).join(", ");
219
+ notifyOnce(`question:${ids}`, `kanboard: ${ids} left In Progress — the agent asked you a question`);
220
+ return null;
221
+ }
222
+ // Stall guard: nudged runs that did nothing.
223
+ if (lastRunWasNudge && info.toolCallsThisRun === 0) noProgressRuns += 1;
224
+ else if (info.toolCallsThisRun > 0) noProgressRuns = 0;
225
+ if (noProgressRuns >= STALL_LIMIT) {
226
+ const ids = claims.map((task) => task.id).join(", ");
227
+ notifyOnce(`stall:${ids}`, `kanboard: ⚠ ${ids} stalled — no progress after ${String(STALL_LIMIT)} nudges`, "warning");
228
+ disarmNow();
229
+ return null;
230
+ }
231
+ const rest = claims.slice(1).map((task) => task.id);
232
+ const target = claims.find((task) => (nudgesPerTask.get(task.id) ?? 0) < MAX_NUDGES_PER_TASK);
233
+ if (!target) {
234
+ const ids = claims.map((task) => task.id).join(", ");
235
+ notifyOnce(`cap:${ids}`, `kanboard: ⚠ ${ids} stalled — nudge cap (${String(MAX_NUDGES_PER_TASK)}) reached`, "warning");
236
+ disarmNow();
237
+ return null;
238
+ }
239
+ const n = (nudgesPerTask.get(target.id) ?? 0) + 1;
240
+ const prefix = deps.cliPrefix() ?? "unipi-kanboard";
241
+ const also = rest.length > 0 ? ` · also open: ${rest.join(", ")}` : "";
242
+ const content =
243
+ `↻ ${target.id} still In Progress — continue, or finish/block it (${String(n)}/${String(MAX_NUDGES_PER_TASK)})${also}\n` +
244
+ `\`${prefix} finish ${target.id} --comment "<summary>"\` / \`${prefix} move ${target.id} blocked --comment "<what you need>"\` ` +
245
+ ANTI_POISONING_SUFFIX;
246
+ return {
247
+ source: "kanboard",
248
+ priority: CLAIMS_PRIORITY,
249
+ customType: CLAIMS_NUDGE_CUSTOM_TYPE,
250
+ details: { unipiHarness: harnessMetadata({ source: "Kanboard", title: "Unfinished task", synopsis: String(target.id), severity: "warning" }, "boundary") },
251
+ content,
252
+ display: true,
253
+ onDelivered: () => {
254
+ nudgesPerTask.set(target.id, n); // counts on DELIVERY, not proposal
255
+ nudgeDelivered = true;
256
+ },
257
+ };
258
+ }
259
+
260
+ if (autowork) {
261
+ // The same stall rule as claims: an offered run that did nothing.
262
+ if (lastRunWasNudge && info.toolCallsThisRun === 0) noProgressRuns += 1;
263
+ else if (info.toolCallsThisRun > 0) noProgressRuns = 0;
264
+ if (noProgressRuns >= STALL_LIMIT) {
265
+ notifyOnce(
266
+ "autowork-stall",
267
+ `kanboard: ⚠ autowork stalled — no progress after ${String(STALL_LIMIT)} offers`,
268
+ "warning",
269
+ );
270
+ autoworkOff("stalled");
271
+ disarmNow();
272
+ return null;
273
+ }
274
+ let ready: KanboardTask[];
275
+ try {
276
+ ready = (await deps.listReady()).filter((task) => task.status === "todo");
277
+ } catch (error) {
278
+ debug(`listReady failed: ${error instanceof Error ? error.message : String(error)}`);
279
+ return null;
280
+ }
281
+ const next = ready[0];
282
+ if (next) {
283
+ // A task that left the ready list gets a fresh offer budget.
284
+ for (const id of [...offersPerTask.keys()]) {
285
+ if (!ready.some((task) => task.id === id)) offersPerTask.delete(id);
286
+ }
287
+ const offers = (offersPerTask.get(next.id) ?? 0) + 1;
288
+ if (offers > MAX_OFFERS_PER_TASK) {
289
+ notifyOnce(`autowork-cap:${next.id}`, `kanboard: ⚠ autowork stalled on ${next.id}`, "warning");
290
+ autoworkOff(`cap:${next.id}`);
291
+ disarmNow();
292
+ return null;
293
+ }
294
+ return {
295
+ source: "kanboard",
296
+ priority: AUTOWORK_PRIORITY,
297
+ customType: AUTOWORK_NUDGE_CUSTOM_TYPE,
298
+ details: { unipiHarness: harnessMetadata({ source: "Kanboard", title: "Next task", synopsis: String(next.id) }, "boundary") },
299
+ content: `↻ next ready: ${next.id} ${next.displayTitle || next.title || "(untitled)"} — show it, start it, work it (autowork) ${ANTI_POISONING_SUFFIX}`,
300
+ display: true,
301
+ onDelivered: () => {
302
+ offersPerTask.set(next.id, offers); // counts on DELIVERY, not proposal
303
+ nudgeDelivered = true;
304
+ },
305
+ };
306
+ }
307
+ const inReview = tasks.filter((task) => task.status === "in_review").length;
308
+ const blocked = tasks.filter((task) => task.status === "blocked").length;
309
+ notifyOnce("autowork-done", `kanboard: autowork done · ${String(inReview)} in review · ${String(blocked)} blocked`);
310
+ autoworkOff("done");
311
+ disarmNow();
312
+ return null;
313
+ }
314
+
315
+ return null;
316
+ },
317
+
318
+ state() {
319
+ return {
320
+ armed,
321
+ autowork,
322
+ nudgesPerTask: Object.fromEntries(nudgesPerTask),
323
+ noProgressRuns,
324
+ };
325
+ },
326
+ };
327
+ }
328
+
329
+ /** Glance label for the current claims/autowork snapshot (re-exported read). */
330
+ export const monitorGlanceLabel = kanboardGlanceLabel;
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Kanboard monitor notices — user-only, never LLM context.
3
+ *
4
+ * The monitor proposes during the settle boundary, where `appendEntry` is
5
+ * dropped and a `sendMessage` custom message would be converted to a
6
+ * user-role LLM message (dist/core/messages.js `case "custom"`). So the
7
+ * monitor only QUEUES text here; kanboard's `agent_settled` handler (post
8
+ * boundary) drains it via `flushNotices`: an `appendEntry` custom entry
9
+ * (rendered by the entry renderer, transcript-only) plus a UI toast.
10
+ */
11
+
12
+ export type NoticeLevel = "info" | "warning";
13
+
14
+ export interface NoticeItem {
15
+ text: string;
16
+ level: NoticeLevel;
17
+ }
18
+
19
+ export const NOTICE_ENTRY = "unipi:kanboard-notice";
20
+
21
+ export class NoticeBuffer {
22
+ private items: NoticeItem[] = [];
23
+
24
+ queue(text: string, level: NoticeLevel = "info"): void {
25
+ this.items.push({ text, level });
26
+ }
27
+
28
+ /** Return everything queued (newest last) and clear the buffer. */
29
+ drain(): NoticeItem[] {
30
+ return this.items.splice(0);
31
+ }
32
+
33
+ get pending(): number {
34
+ return this.items.length;
35
+ }
36
+ }
37
+
38
+ export interface NoticeFlushPi {
39
+ /** Custom entries are transcript-only — never serialized into LLM context. */
40
+ appendEntry(customType: string, data?: unknown): void;
41
+ }
42
+
43
+ export interface NoticeFlushUi {
44
+ hasUI?: boolean;
45
+ notify?(text: string, level?: NoticeLevel): void;
46
+ }
47
+
48
+ /**
49
+ * Drain the buffer: one appendEntry per notice (user-only entry) and, when a
50
+ * dialog-capable UI is attached, a toast. Never sends a message. appendEntry
51
+ * failures are reported to `debug` (the toast still fires, so a notice is
52
+ * never lost even if entries are dropped in odd phases).
53
+ */
54
+ export function flushNotices(
55
+ pi: NoticeFlushPi,
56
+ buffer: NoticeBuffer,
57
+ ui?: NoticeFlushUi,
58
+ debug?: (line: string) => void,
59
+ ): void {
60
+ for (const item of buffer.drain()) {
61
+ try {
62
+ pi.appendEntry(NOTICE_ENTRY, { text: item.text, level: item.level });
63
+ } catch (error) {
64
+ debug?.(`appendEntry dropped: ${error instanceof Error ? error.message : String(error)}`);
65
+ }
66
+ if (ui?.hasUI !== false) {
67
+ try {
68
+ ui?.notify?.(item.text, item.level);
69
+ } catch {
70
+ // A failed toast must never break settlement.
71
+ }
72
+ }
73
+ }
74
+ }
@@ -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
+ }
@@ -0,0 +1,234 @@
1
+ /**
2
+ * @pi-unipi/kanboard — progress reminders (no LLM, never blocking).
3
+ *
4
+ * When the agent works board tasks by hand ("do UNI-5 and UNI-8"), the board
5
+ * should show it: `start <ID>` moves a task to In Progress for this session.
6
+ * Continuation after that is the arbiter's job (src/monitor.ts); what stays
7
+ * here is:
8
+ *
9
+ * R1 every file-changing tool call of an agent turn, for each mentioned
10
+ * task that is still Todo and has not been reminded this turn (one
11
+ * steer per task per turn, at most twice per task).
12
+ *
13
+ * "Mentioned" = task ids in the user's prompts plus ids the agent `show`ed;
14
+ * only ids that exist on the board count. R1 is silent in child sessions
15
+ * (children only read the board) and when the setting `kanboard.reminders`
16
+ * is off.
17
+ */
18
+
19
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
20
+ import { isChildProcess, harnessToolResultDetails } from "@pi-unipi/core";
21
+
22
+ import { kanboardInvocations, shellSegments } from "./guard.js";
23
+ import type { KanboardTask } from "./shapes.js";
24
+
25
+ export const REMINDER_CUSTOM_TYPE = "unipi:kanboard-reminder";
26
+ /** R1 reminders per task id. */
27
+ export const MAX_REMINDERS_PER_TASK = 2;
28
+
29
+ /** Same wording long-horizon's runaway guard uses: the reminder must not stick. */
30
+ export const ANTI_POISONING_SUFFIX =
31
+ "This is a temporary runtime reminder for the current Turn only, not a user preference " +
32
+ "or a durable rule; do not save this reminder or generalize it into Memory, Skills, or " +
33
+ "other persistent instruction files.";
34
+
35
+ /** Board task ids: PREFIX-123 (the prefix is letters/digits, starting with a letter). */
36
+ const TASK_ID = /\b[A-Z][A-Z0-9]{0,15}-\d+\b/g;
37
+
38
+ /** Tools that change files. bash/powershell count unless the command only reads. */
39
+ const EDIT_TOOLS = new Set(["edit", "write", "multi_edit", "multiedit", "apply_patch", "notebook_edit"]);
40
+ const SHELL_TOOLS = new Set(["bash", "powershell"]);
41
+
42
+ /** First words of shell segments that never change files. */
43
+ const READ_COMMANDS = new Set([
44
+ "ls", "pwd", "cat", "head", "tail", "wc", "echo", "printf", "which", "type", "rg", "grep", "egrep",
45
+ "fgrep", "find", "fd", "stat", "file", "du", "df", "env", "printenv", "date", "whoami", "tree", "less",
46
+ "more", "true", "false", "test", "[", "diff", "cmp", "jq", "realpath", "dirname", "basename", "uname",
47
+ "hostname", "id", "ps", "cd",
48
+ ]);
49
+ /** Interpreters/package managers whose version-check arms never write. */
50
+ const VERSION_CHECKED = new Set(["node", "npm", "npx", "python", "python3"]);
51
+ const isVersionFlag = (word: string): boolean => word === "--version" || word === "-v" || word === "-V";
52
+ const GIT_READS = new Set(["status", "log", "diff", "show", "branch", "remote", "rev-parse", "blame", "ls-files", "describe", "tag"]);
53
+
54
+ export function taskIdsIn(text: string): string[] {
55
+ return [...new Set(text.match(TASK_ID) ?? [])];
56
+ }
57
+
58
+ /**
59
+ * Whether a shell command may change files. Conservative toward "yes": any
60
+ * segment that is not a known read (or a kanboard CLI call) counts, and so
61
+ * does an output redirection. Quoted spans are masked before both the
62
+ * redirect test and the segment split, so `grep -c "a;b" f` stays one
63
+ * read-only segment.
64
+ */
65
+ export function shellChangesFiles(command: string): boolean {
66
+ const trimmed = command.trim();
67
+ if (!trimmed) return false;
68
+ // `>`/`>>` into a file (but not `2>&1` / `>/dev/null`).
69
+ if (/(^|[^0-9&>])>{1,2}\s*(?!&|\/dev\/null)[^\s|;&]/.test(trimmed.replace(/(["'])(?:\\.|(?!\1).)*\1/g, '""'))) return true;
70
+ const segments = shellSegments(trimmed).map((part) => part.trim()).filter(Boolean);
71
+ return segments.some((segment) => {
72
+ const words = segment.split(/\s+/).filter((word) => !/^[A-Za-z_][A-Za-z0-9_]*=/.test(word));
73
+ const first = (words[0] ?? "").replace(/^.*\//, "");
74
+ if (!first) return false;
75
+ // Board writes go through the binary, not files.
76
+ if (/^unipi-kanboard(\.exe)?$/.test(first)) return false;
77
+ if (READ_COMMANDS.has(first)) return false;
78
+ if (first === "git") return !GIT_READS.has(words[1] ?? "");
79
+ if (first === "sed") return words.includes("-i") || words.some((word) => word.startsWith("-i"));
80
+ // `sort` writes only through -o/--output (a `>` redirect is caught above).
81
+ if (first === "sort") return words.some((word) => /^-[^-]*o/.test(word) || word.startsWith("--output"));
82
+ // node/npm/npx/python are reads only as version checks (`node --version`,
83
+ // `npx tsx --version`); scripts, installs and builds still count.
84
+ if (VERSION_CHECKED.has(first)) return !words.slice(1).some(isVersionFlag);
85
+ return true;
86
+ });
87
+ }
88
+
89
+ export function isFileChangingCall(toolName: string, input: Record<string, unknown> | undefined): boolean {
90
+ if (EDIT_TOOLS.has(toolName)) return true;
91
+ if (!SHELL_TOOLS.has(toolName)) return false;
92
+ return shellChangesFiles(String(input?.command ?? ""));
93
+ }
94
+
95
+ export function r1Text(todo: string[], cli: string | null): string {
96
+ const ids = todo.join(", ");
97
+ const verb = todo.length === 1 ? "is" : "are";
98
+ const how = cli ? `\`${cli} start <ID>\`` : "`start <ID>`";
99
+ return (
100
+ `[kanboard] ${ids} ${verb} still Todo. ${how} the one you're on before changing files ` +
101
+ `(it moves it to In Progress for this session), and \`finish <ID> --comment "<summary>"\` when done. ` +
102
+ ANTI_POISONING_SUFFIX
103
+ );
104
+ }
105
+
106
+ export interface TrackerDeps {
107
+ /** Reminders on (setting `kanboard.reminders`). */
108
+ enabled(): boolean;
109
+ /** This session's id (UNIPI_KANBOARD_SESSION). */
110
+ session(): string;
111
+ /** `list` on the current project; [] when unavailable. */
112
+ list(): Promise<KanboardTask[]>;
113
+ /** `<binary> --actor agent --project <slug>` for the reminder text, or null. */
114
+ cliPrefix(): string | null;
115
+ debug?(message: string): void;
116
+ }
117
+
118
+ type ToolResultContent = Array<{ type: string; text?: string; [key: string]: unknown }>;
119
+
120
+ export interface ToolResultLike {
121
+ toolName?: string;
122
+ input?: Record<string, unknown>;
123
+ content?: ToolResultContent;
124
+ isError?: boolean;
125
+ }
126
+
127
+ export interface ProgressTracker {
128
+ /** A user prompt (or -do request): remember the task ids it names. */
129
+ onPrompt(text: string): void;
130
+ /** A new agent turn (agent_start): re-arm R1. */
131
+ onTurnStart(): void;
132
+ /** tool_result: record `show`/`start`, and return R1 content when due. */
133
+ onToolResult(event: ToolResultLike): Promise<{ content: ToolResultContent } | undefined>;
134
+ /** For tests / status. */
135
+ state(): { mentioned: string[]; started: string[]; r1: Record<string, number> };
136
+ }
137
+
138
+ export function createProgressTracker(deps: TrackerDeps): ProgressTracker {
139
+ const mentioned = new Set<string>();
140
+ /** Ids this session ran `start` on (bookkeeping; the board is the truth). */
141
+ const started = new Set<string>();
142
+ const r1Count = new Map<string, number>();
143
+ /** Task ids already reminded this turn (per-task re-arm, not one shot). */
144
+ const remindedThisTurn = new Set<string>();
145
+ const debug = (message: string): void => deps.debug?.(`reminders: ${message}`);
146
+ const silent = (): boolean => !deps.enabled() || isChildProcess() || Boolean(process.env.UNIPI_KANBOARD_CHILD);
147
+
148
+ const safeList = async (): Promise<KanboardTask[] | null> => {
149
+ try {
150
+ return await deps.list();
151
+ } catch (error) {
152
+ debug(`list failed: ${error instanceof Error ? error.message : String(error)}`);
153
+ return null;
154
+ }
155
+ };
156
+
157
+ return {
158
+ onPrompt(text) {
159
+ // Our own reminders name started tasks; they are not new mentions. (A
160
+ // -do request counts: its text carries the user's request verbatim.)
161
+ if (text.includes(ANTI_POISONING_SUFFIX)) return;
162
+ for (const id of taskIdsIn(text)) mentioned.add(id);
163
+ },
164
+
165
+ onTurnStart() {
166
+ remindedThisTurn.clear();
167
+ },
168
+
169
+ async onToolResult(event) {
170
+ const toolName = String(event.toolName ?? "");
171
+ if (SHELL_TOOLS.has(toolName)) {
172
+ for (const invocation of kanboardInvocations(String(event.input?.command ?? ""))) {
173
+ const id = invocation.args.find((arg) => !arg.startsWith("-"));
174
+ if (!id) continue;
175
+ if (invocation.sub === "show") mentioned.add(id);
176
+ if (invocation.sub === "start" && !event.isError) started.add(id);
177
+
178
+ }
179
+ }
180
+ if (!isFileChangingCall(toolName, event.input)) return undefined;
181
+ if (silent() || mentioned.size === 0) return undefined;
182
+ // Per task, at most once per turn: candidates are mentioned ids not yet
183
+ // reminded this turn and under the per-task cap (a started task leaves
184
+ // Todo and is never re-reminded).
185
+ const candidates = [...mentioned].filter(
186
+ (id) => !remindedThisTurn.has(id) && (r1Count.get(id) ?? 0) < MAX_REMINDERS_PER_TASK,
187
+ );
188
+ if (candidates.length === 0) return undefined;
189
+ const tasks = await safeList();
190
+ if (!tasks) return undefined;
191
+ const byId = new Map(tasks.map((task) => [task.id, task]));
192
+ const todo = candidates.filter((id) => byId.get(id)?.status === "todo");
193
+ if (todo.length === 0) return undefined;
194
+ for (const id of todo) {
195
+ remindedThisTurn.add(id);
196
+ r1Count.set(id, (r1Count.get(id) ?? 0) + 1);
197
+ }
198
+ debug(`R1 for ${todo.join(", ")}`);
199
+ const annotation = `\n\n${r1Text(todo, deps.cliPrefix())}`;
200
+ return {
201
+ content: [...(event.content ?? []), { type: "text", text: annotation }],
202
+ details: harnessToolResultDetails(
203
+ (event as { details?: unknown }).details,
204
+ { source: "Kanboard", title: "R1 progress reminder", synopsis: `${todo.join(", ")} still Todo`, severity: "warning" },
205
+ "boundary",
206
+ annotation,
207
+ ),
208
+ };
209
+ },
210
+
211
+ state() {
212
+ return {
213
+ mentioned: [...mentioned],
214
+ started: [...started],
215
+ r1: Object.fromEntries(r1Count),
216
+ };
217
+ },
218
+ };
219
+ }
220
+
221
+ /** Wire the tracker into pi's events. */
222
+ export function registerProgressReminders(pi: ExtensionAPI, tracker: ProgressTracker): void {
223
+ pi.on("before_agent_start", (event) => {
224
+ tracker.onPrompt(String((event as { prompt?: unknown }).prompt ?? ""));
225
+ return undefined;
226
+ });
227
+ pi.on("agent_start", () => {
228
+ tracker.onTurnStart();
229
+ });
230
+ pi.on("tool_result", async (event) => {
231
+ const result = await tracker.onToolResult(event as unknown as ToolResultLike);
232
+ return result as never;
233
+ });
234
+ }