@pi-unipi/kanboard 3.0.0-alpha.11 → 3.0.0-alpha.13
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 +1 -0
- package/index.ts +35 -4
- package/package.json +7 -7
- package/skills/kanboard/SKILL.md +21 -10
- package/src/commands.ts +1 -1
- package/src/guard.ts +14 -2
- package/src/reminders.ts +284 -0
- package/src/settings.ts +11 -0
- package/src/shapes.ts +2 -0
package/README.md
CHANGED
|
@@ -91,6 +91,7 @@ resume it or releases it to Todo.
|
|
|
91
91
|
| `queueMax` | `10` | Tasks a session may queue (0 = unlimited); passed to the CLI as `UNIPI_KANBOARD_QUEUE_MAX` |
|
|
92
92
|
| `maxSessions` | `2` | Distinct sessions running tasks per project (`UNIPI_KANBOARD_MAX_SESSIONS`) |
|
|
93
93
|
| `turnAddLimit` | `20` | `add` calls allowed per -do turn or runner task (0 = unlimited) |
|
|
94
|
+
| `reminders` | `true` | Progress reminders for hand-worked tasks: a steer on the first file-changing call while a mentioned task is still Todo (R1), and a follow-up at turn end while a started task is still In Progress (R2, max 2 per task). Text only, silent in runner runs |
|
|
94
95
|
| *actions* | | `Open board…`, `Stop daemon`, `Summary agent command…`, `Rotate access token` |
|
|
95
96
|
|
|
96
97
|
`Summary agent command…` writes through `settings set agent-command` and
|
package/index.ts
CHANGED
|
@@ -5,7 +5,9 @@
|
|
|
5
5
|
* status/doctor — bare lists the commands), `/unipi:kanboard-add`,
|
|
6
6
|
* `/unipi:kanboard-do` (opens the board-write window for one turn),
|
|
7
7
|
* `/unipi:kanboard-autowork` (the runner loop: queue first, then claim-next).
|
|
8
|
-
* The runner owns claim → In Progress and run-end → In Review
|
|
8
|
+
* The runner owns claim → In Progress and run-end → In Review for queued work;
|
|
9
|
+
* an agent working a task by hand uses `start`/`finish` (free), nudged by the
|
|
10
|
+
* progress reminders (src/reminders.ts). The board itself
|
|
9
11
|
* is written by the Rust binary (`crates/kanboard`); this extension never edits
|
|
10
12
|
* task files. Bash calls into the binary are gated by the write window
|
|
11
13
|
* (src/guard.ts): reads always pass, writes need a -do turn or a running task.
|
|
@@ -38,6 +40,8 @@ import {
|
|
|
38
40
|
type CommandDeps,
|
|
39
41
|
} from "./src/commands.js";
|
|
40
42
|
import { createDebugLog, createRunner, registerPlanEventListener, type Runner } from "./src/runner.js";
|
|
43
|
+
import { createProgressTracker, registerProgressReminders, sendReminder } from "./src/reminders.js";
|
|
44
|
+
import { asTaskList } from "./src/shapes.js";
|
|
41
45
|
import {
|
|
42
46
|
ACTION_OPEN,
|
|
43
47
|
ACTION_STOP_DAEMON,
|
|
@@ -56,6 +60,9 @@ export const KANBOARD_SKILL = "kanboard";
|
|
|
56
60
|
export default function (pi: ExtensionAPI) {
|
|
57
61
|
// One session id shared by the runner and the agent's bash calls.
|
|
58
62
|
process.env.UNIPI_KANBOARD_SESSION ??= `pi-${process.pid}`;
|
|
63
|
+
// `start` claims belong to this pi process: the stale-claim reaper releases
|
|
64
|
+
// them to Todo when it dies (the agent's bash inherits the env).
|
|
65
|
+
process.env.UNIPI_KANBOARD_PID ??= String(process.pid);
|
|
59
66
|
// Limits travel through the environment; refresh on load and before every
|
|
60
67
|
// tool_call (see the guard registration in commands.ts).
|
|
61
68
|
applyLimitEnv(readKanboardSettings());
|
|
@@ -140,6 +147,26 @@ export default function (pi: ExtensionAPI) {
|
|
|
140
147
|
registerProgressRenderer(pi);
|
|
141
148
|
registerKanboardCommands(pi, buildDeps());
|
|
142
149
|
|
|
150
|
+
// R1/R2 progress reminders for hand-worked tasks (silent in runner runs).
|
|
151
|
+
const reminders = registerProgressReminders(
|
|
152
|
+
pi,
|
|
153
|
+
createProgressTracker({
|
|
154
|
+
enabled: () => readKanboardSettings().reminders,
|
|
155
|
+
runnerOwned: () => (runner?.status().phase ?? "idle") !== "idle",
|
|
156
|
+
session: sessionId,
|
|
157
|
+
list: async () => {
|
|
158
|
+
const slug = projectSlug();
|
|
159
|
+
if (!cli || !slug) return [];
|
|
160
|
+
return asTaskList(await cli.run<unknown>(["list"], { extraEnv: { UNIPI_KANBOARD_PROJECT: slug } })).tasks;
|
|
161
|
+
},
|
|
162
|
+
cliPrefix: () => {
|
|
163
|
+
const slug = projectSlug();
|
|
164
|
+
return cli && slug ? `${cli.binary.path} --actor agent --project ${slug}` : null;
|
|
165
|
+
},
|
|
166
|
+
debug,
|
|
167
|
+
}),
|
|
168
|
+
);
|
|
169
|
+
|
|
143
170
|
registerCommandRunner(ACTION_OPEN, async (ctx) => {
|
|
144
171
|
const context = ctx as ExtensionContext | undefined;
|
|
145
172
|
if (!context?.ui) return;
|
|
@@ -211,9 +238,13 @@ export default function (pi: ExtensionAPI) {
|
|
|
211
238
|
});
|
|
212
239
|
|
|
213
240
|
pi.on("agent_settled", async (_event, ctx) => {
|
|
214
|
-
|
|
215
|
-
drainPending
|
|
216
|
-
|
|
241
|
+
const reminder = reminders.takePending();
|
|
242
|
+
if (drainPending) {
|
|
243
|
+
drainPending = false;
|
|
244
|
+
await drainQueueAfterDo(buildDeps(), ctx as unknown as ExtensionContext);
|
|
245
|
+
}
|
|
246
|
+
// A drained queue hands the session to the runner: the reminder is moot.
|
|
247
|
+
if (reminder && (runner?.status().phase ?? "idle") === "idle") sendReminder(pi, reminder);
|
|
217
248
|
});
|
|
218
249
|
|
|
219
250
|
pi.on("session_shutdown", async (_event, ctx) => {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pi-unipi/kanboard",
|
|
3
|
-
"version": "3.0.0-alpha.
|
|
3
|
+
"version": "3.0.0-alpha.13",
|
|
4
4
|
"description": "Kanboard v3 — deferred-work board: CLI/daemon bridge, task runner and skill",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "index.ts",
|
|
@@ -31,7 +31,7 @@
|
|
|
31
31
|
"access": "public"
|
|
32
32
|
},
|
|
33
33
|
"dependencies": {
|
|
34
|
-
"@pi-unipi/core": "3.0.0-alpha.
|
|
34
|
+
"@pi-unipi/core": "3.0.0-alpha.13"
|
|
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.13",
|
|
52
|
+
"@pi-unipi/kanboard-linux-arm64": "3.0.0-alpha.13",
|
|
53
|
+
"@pi-unipi/kanboard-darwin-x64": "3.0.0-alpha.13",
|
|
54
|
+
"@pi-unipi/kanboard-darwin-arm64": "3.0.0-alpha.13",
|
|
55
|
+
"@pi-unipi/kanboard-win32-x64": "3.0.0-alpha.13"
|
|
56
56
|
}
|
|
57
57
|
}
|
package/skills/kanboard/SKILL.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: kanboard
|
|
3
|
-
description: "Kanboard — the project's deferred-work board. Use when the user asks to note a task for later, to see what is on the board, or while working a board task: read it with `unipi-kanboard show`, add notes, block with a question, or file follow-up work."
|
|
3
|
+
description: "Kanboard — the project's deferred-work board. Use when the user asks to note a task for later, to see what is on the board, or to work board tasks (e.g. do UNI-5): `start` a task before working on it and `finish` it with a summary when done; while working a board task: read it with `unipi-kanboard show`, add notes, block with a question, or file follow-up work."
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# Kanboard
|
|
@@ -28,6 +28,8 @@ be on `PATH`):
|
|
|
28
28
|
<binary> --actor agent --project <slug> edit <ID> --title|--body|--labels … # only tasks you created, while in backlog/todo
|
|
29
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
30
|
<binary> --actor agent --project <slug> edit <ID> --plan yes|no|auto # plan+approve first (auto/unset = jev decides)
|
|
31
|
+
<binary> --actor agent --project <slug> start <ID> # todo → in progress, claimed for your session (free)
|
|
32
|
+
<binary> --actor agent --project <slug> finish <ID> --comment "<summary>" # in progress → in review, only a task you started (free)
|
|
31
33
|
<binary> --actor agent --project <slug> move <ID> blocked --comment "<what you need>"
|
|
32
34
|
<binary> --actor agent --project <slug> link <ID> --after <DEP>
|
|
33
35
|
<binary> --actor agent --project <slug> unlink <ID> --after <DEP>
|
|
@@ -50,9 +52,9 @@ and `rotate-token` are user-only — the agent is refused.
|
|
|
50
52
|
| Move | Who |
|
|
51
53
|
|---|---|
|
|
52
54
|
| backlog ↔ todo | user, agent |
|
|
53
|
-
| todo → in progress |
|
|
55
|
+
| todo → in progress | the runner (`claim-next`), or the agent with `start <ID>` (claims it for its session) |
|
|
54
56
|
| edit a task | agent — only tasks it created, and only in backlog/todo |
|
|
55
|
-
| in progress → in review |
|
|
57
|
+
| in progress → in review | the runner (when a runner task's turn ends), or the agent with `finish <ID> --comment` — only a task its own session `start`ed |
|
|
56
58
|
| in progress → blocked | agent, system — **comment required** (what you need) |
|
|
57
59
|
| blocked → todo | user only — comment required (the answer) |
|
|
58
60
|
| in review → done | user only |
|
|
@@ -70,8 +72,15 @@ lists them — read the files there directly if you need one.
|
|
|
70
72
|
|
|
71
73
|
1. **Never pass `--actor user`.** That is an honour system: pretending to be the
|
|
72
74
|
user to cancel a task or mark it done breaks the board's whole contract.
|
|
73
|
-
2. **
|
|
74
|
-
|
|
75
|
+
2. **Working a task by hand** (the user says "do UNI-5"): **`start` a task
|
|
76
|
+
before working on it, `finish` it with a summary when done** —
|
|
77
|
+
`start <ID>` before your first edit, `finish <ID> --comment "<what you did>"`
|
|
78
|
+
before your turn ends (or `move <ID> blocked --comment "<what you need>"` if
|
|
79
|
+
you cannot). Both are free and need no `/unipi:kanboard-do`. In Review means
|
|
80
|
+
"the agent did the work, a human reviews" — never leave a task you worked in
|
|
81
|
+
Todo or In Progress. You may have several tasks started. **Never move a task
|
|
82
|
+
to `done`**, and never `finish` a task the runner gave you: the runner writes
|
|
83
|
+
in_review for its own tasks when your turn ends (`finish` refuses them).
|
|
75
84
|
3. **Never cancel.** If a task should be dropped, block it with
|
|
76
85
|
`move <ID> blocked --comment "suggest cancel: <why>"` and let the user decide.
|
|
77
86
|
4. **Follow the blocking rule in your task prompt**: by default work
|
|
@@ -85,9 +94,10 @@ lists them — read the files there directly if you need one.
|
|
|
85
94
|
why. You may
|
|
86
95
|
block only the task your own session is running (`move <ID> blocked` checks
|
|
87
96
|
`--session`/`UNIPI_KANBOARD_SESSION` against the claim).
|
|
88
|
-
6. **To have tasks worked
|
|
89
|
-
`queue <IDs>` (Todo tasks, up to the queue limit — setting
|
|
90
|
-
|
|
97
|
+
6. **To have tasks worked by the runner after a `/unipi:kanboard-do` turn, queue
|
|
98
|
+
them** with `queue <IDs>` (Todo tasks, up to the queue limit — setting
|
|
99
|
+
`queueMax`, default 10) — the runner starts them one by one after the turn
|
|
100
|
+
ends. Never `claim-next` yourself, and don't `start` a task you queued.
|
|
91
101
|
7. Use `note <ID> "<text>"` for progress worth remembering (decisions, what you
|
|
92
102
|
verified, what you left undone) — it is the activity log the next reader sees.
|
|
93
103
|
8. Dependencies form a DAG: a task is ready only when every dep reached the chain
|
|
@@ -98,8 +108,9 @@ lists them — read the files there directly if you need one.
|
|
|
98
108
|
## Terminal-only execution
|
|
99
109
|
|
|
100
110
|
The web UI can create, edit, reorder, link and move tasks, but it **never runs a
|
|
101
|
-
task** — there is no run button. Work starts only from a terminal
|
|
102
|
-
`/unipi:kanboard-autowork start
|
|
111
|
+
task** — there is no run button. Work starts only from a terminal: the runner
|
|
112
|
+
(`/unipi:kanboard-autowork start`, or the queue after a `/unipi:kanboard-do`), or
|
|
113
|
+
an agent that `start`s a task the user asked it to do. (The Done column's "Summarize & archive" does call the
|
|
103
114
|
agent command set in the board's Settings, but only to write a summary.)
|
|
104
115
|
|
|
105
116
|
## Attachments
|
package/src/commands.ts
CHANGED
|
@@ -42,7 +42,7 @@ Priority -p: 1 none · 2 low · 3 medium · 4 high · 5 urgent`;
|
|
|
42
42
|
|
|
43
43
|
export function doText(slug: string, cli: string, request: string, queueMax = 10, credits = 0): string {
|
|
44
44
|
const limit = queueMax === 0 ? "Todo tasks" : `Todo tasks, at most ${queueMax}`;
|
|
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
|
|
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 by the runner, queue them in order with \`queue <IDs>\` (${limit}); the runner starts them one by one after this turn. If you work a task yourself in this turn instead, \`start <ID>\` it before working on it and \`finish <ID> --comment "<summary>"\` it when done (both free) — never leave a task you worked in Todo or In Progress. 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.
|
|
46
46
|
|
|
47
47
|
Request: ${request}`;
|
|
48
48
|
}
|
package/src/guard.ts
CHANGED
|
@@ -19,6 +19,13 @@ export const ADD_CAP_REASON = addCapReason(ADD_CAP);
|
|
|
19
19
|
/** Subcommands that never write to the board. */
|
|
20
20
|
const READONLY = new Set(["list", "show", "attachments", "next", "chain", "search", "status"]);
|
|
21
21
|
|
|
22
|
+
/**
|
|
23
|
+
* Writes that cost nothing and need no -do window: `start`/`finish` only touch
|
|
24
|
+
* tasks the session claims itself (the binary enforces ownership), and the
|
|
25
|
+
* board should always show what is being worked.
|
|
26
|
+
*/
|
|
27
|
+
const FREE_WRITES = new Set(["start", "finish"]);
|
|
28
|
+
|
|
22
29
|
/** Global flags that take a value; `--json` is the only valueless one. */
|
|
23
30
|
const GLOBAL_VALUE_FLAGS = new Set(["--actor", "--project", "--gate", "--session"]);
|
|
24
31
|
|
|
@@ -60,6 +67,11 @@ export function kanboardInvocations(command: string): KanboardInvocation[] {
|
|
|
60
67
|
return out;
|
|
61
68
|
}
|
|
62
69
|
|
|
70
|
+
/** A write that is free and allowed without a -do window (`start`, `finish`). */
|
|
71
|
+
export function isFreeWrite(invocation: KanboardInvocation): boolean {
|
|
72
|
+
return FREE_WRITES.has(invocation.sub);
|
|
73
|
+
}
|
|
74
|
+
|
|
63
75
|
/** Read-only means: no writes, and the board does not change. */
|
|
64
76
|
export function isReadonly(invocation: KanboardInvocation): boolean {
|
|
65
77
|
if (READONLY.has(invocation.sub)) return true;
|
|
@@ -96,7 +108,7 @@ export interface WriteGuard {
|
|
|
96
108
|
|
|
97
109
|
/**
|
|
98
110
|
* 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
|
|
111
|
+
* reads and `start`/`finish` are free and never blocked. A runner task keeps unlimited access
|
|
100
112
|
* (still add-capped). Credits persist across turns and follow-up questions
|
|
101
113
|
* until spent; /unipi:kanboard-do tops up to N without stacking past N.
|
|
102
114
|
*/
|
|
@@ -149,7 +161,7 @@ export function createWriteGuard(
|
|
|
149
161
|
lastTask = task;
|
|
150
162
|
}
|
|
151
163
|
for (const invocation of invocations) {
|
|
152
|
-
if (isReadonly(invocation)) continue;
|
|
164
|
+
if (isReadonly(invocation) || isFreeWrite(invocation)) continue;
|
|
153
165
|
if (task !== null) {
|
|
154
166
|
const cap = countAdd(invocation);
|
|
155
167
|
if (cap) return cap;
|
package/src/reminders.ts
ADDED
|
@@ -0,0 +1,284 @@
|
|
|
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
|
+
* `finish <ID> --comment` hands it to In Review. Two text-only reminders keep
|
|
7
|
+
* that honest:
|
|
8
|
+
*
|
|
9
|
+
* R1 the first file-changing tool call of an agent turn, while a mentioned
|
|
10
|
+
* task is still Todo and this session has nothing started → a steer is
|
|
11
|
+
* appended to that tool result (once per turn, at most twice per task).
|
|
12
|
+
* R2 agent_end with a task this session started still In Progress → one
|
|
13
|
+
* follow-up message (at most twice per task).
|
|
14
|
+
*
|
|
15
|
+
* "Mentioned" = task ids in the user's prompts plus ids the agent `show`ed;
|
|
16
|
+
* only ids that exist on the board count. Both reminders are silent in
|
|
17
|
+
* runner-owned runs, in kanboard's own child sessions and when the setting
|
|
18
|
+
* `kanboard.reminders` is off.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
22
|
+
|
|
23
|
+
import { kanboardInvocations } from "./guard.js";
|
|
24
|
+
import type { KanboardTask } from "./shapes.js";
|
|
25
|
+
|
|
26
|
+
export const REMINDER_CUSTOM_TYPE = "unipi:kanboard-reminder";
|
|
27
|
+
/** Reminders per task id (R1 and R2 each). */
|
|
28
|
+
export const MAX_REMINDERS_PER_TASK = 2;
|
|
29
|
+
|
|
30
|
+
/** Same wording long-horizon's runaway guard uses: the reminder must not stick. */
|
|
31
|
+
export const ANTI_POISONING_SUFFIX =
|
|
32
|
+
"This is a temporary runtime reminder for the current Turn only, not a user preference " +
|
|
33
|
+
"or a durable rule; do not save this reminder or generalize it into Memory, Skills, or " +
|
|
34
|
+
"other persistent instruction files.";
|
|
35
|
+
|
|
36
|
+
/** Board task ids: PREFIX-123 (the prefix is letters/digits, starting with a letter). */
|
|
37
|
+
const TASK_ID = /\b[A-Z][A-Z0-9]{0,15}-\d+\b/g;
|
|
38
|
+
|
|
39
|
+
/** Tools that change files. bash/powershell count unless the command only reads. */
|
|
40
|
+
const EDIT_TOOLS = new Set(["edit", "write", "multi_edit", "multiedit", "apply_patch", "notebook_edit"]);
|
|
41
|
+
const SHELL_TOOLS = new Set(["bash", "powershell"]);
|
|
42
|
+
|
|
43
|
+
/** First words of shell segments that never change files. */
|
|
44
|
+
const READ_COMMANDS = new Set([
|
|
45
|
+
"ls", "pwd", "cat", "head", "tail", "wc", "echo", "printf", "which", "type", "rg", "grep", "egrep",
|
|
46
|
+
"fgrep", "find", "fd", "stat", "file", "du", "df", "env", "printenv", "date", "whoami", "tree", "less",
|
|
47
|
+
"more", "true", "false", "test", "[", "diff", "cmp", "jq", "realpath", "dirname", "basename", "uname",
|
|
48
|
+
"hostname", "id", "ps", "cd",
|
|
49
|
+
]);
|
|
50
|
+
const GIT_READS = new Set(["status", "log", "diff", "show", "branch", "remote", "rev-parse", "blame", "ls-files", "describe", "tag"]);
|
|
51
|
+
|
|
52
|
+
export function taskIdsIn(text: string): string[] {
|
|
53
|
+
return [...new Set(text.match(TASK_ID) ?? [])];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Whether a shell command may change files. Conservative toward "yes": any
|
|
58
|
+
* segment that is not a known read (or a kanboard CLI call) counts, and so
|
|
59
|
+
* does an output redirection.
|
|
60
|
+
*/
|
|
61
|
+
export function shellChangesFiles(command: string): boolean {
|
|
62
|
+
const trimmed = command.trim();
|
|
63
|
+
if (!trimmed) return false;
|
|
64
|
+
// `>`/`>>` into a file (but not `2>&1` / `>/dev/null`).
|
|
65
|
+
if (/(^|[^0-9&>])>{1,2}\s*(?!&|\/dev\/null)[^\s|;&]/.test(trimmed.replace(/(["'])(?:\\.|(?!\1).)*\1/g, '""'))) return true;
|
|
66
|
+
const segments = trimmed.split(/&&|\|\||[;|\n]/).map((part) => part.trim()).filter(Boolean);
|
|
67
|
+
return segments.some((segment) => {
|
|
68
|
+
const words = segment.split(/\s+/).filter((word) => !/^[A-Za-z_][A-Za-z0-9_]*=/.test(word));
|
|
69
|
+
const first = (words[0] ?? "").replace(/^.*\//, "");
|
|
70
|
+
if (!first) return false;
|
|
71
|
+
// Board writes go through the binary, not files.
|
|
72
|
+
if (/^unipi-kanboard(\.exe)?$/.test(first)) return false;
|
|
73
|
+
if (READ_COMMANDS.has(first)) return false;
|
|
74
|
+
if (first === "git") return !GIT_READS.has(words[1] ?? "");
|
|
75
|
+
if (first === "sed") return words.includes("-i") || words.some((word) => word.startsWith("-i"));
|
|
76
|
+
return true;
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function isFileChangingCall(toolName: string, input: Record<string, unknown> | undefined): boolean {
|
|
81
|
+
if (EDIT_TOOLS.has(toolName)) return true;
|
|
82
|
+
if (!SHELL_TOOLS.has(toolName)) return false;
|
|
83
|
+
return shellChangesFiles(String(input?.command ?? ""));
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function r1Text(todo: string[], cli: string | null): string {
|
|
87
|
+
const ids = todo.join(", ");
|
|
88
|
+
const verb = todo.length === 1 ? "is" : "are";
|
|
89
|
+
const how = cli ? `\`${cli} start <ID>\`` : "`start <ID>`";
|
|
90
|
+
return (
|
|
91
|
+
`[kanboard] ${ids} ${verb} still Todo. ${how} the one you're on before changing files ` +
|
|
92
|
+
`(it moves it to In Progress for this session), and \`finish <ID> --comment "<summary>"\` when done. ` +
|
|
93
|
+
ANTI_POISONING_SUFFIX
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function r2Text(open: string[], cli: string | null): string {
|
|
98
|
+
const prefix = cli ? `${cli} ` : "";
|
|
99
|
+
const lines = open.map(
|
|
100
|
+
(id) =>
|
|
101
|
+
`${id} is still In Progress. If done: \`${prefix}finish ${id} --comment "<summary>"\`. ` +
|
|
102
|
+
`If not: say what remains, or \`${prefix}move ${id} blocked --comment "<what you need>"\`.`,
|
|
103
|
+
);
|
|
104
|
+
return `[kanboard] ${lines.join("\n")}\n${ANTI_POISONING_SUFFIX}`;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export interface TrackerDeps {
|
|
108
|
+
/** Reminders on (setting `kanboard.reminders`). */
|
|
109
|
+
enabled(): boolean;
|
|
110
|
+
/** The runner owns this turn (a queued/claimed task is running) → silent. */
|
|
111
|
+
runnerOwned(): boolean;
|
|
112
|
+
/** This session's id (UNIPI_KANBOARD_SESSION). */
|
|
113
|
+
session(): string;
|
|
114
|
+
/** `list` on the current project; [] when unavailable. */
|
|
115
|
+
list(): Promise<KanboardTask[]>;
|
|
116
|
+
/** `<binary> --actor agent --project <slug>` for the reminder text, or null. */
|
|
117
|
+
cliPrefix(): string | null;
|
|
118
|
+
debug?(message: string): void;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
type ToolResultContent = Array<{ type: string; text?: string; [key: string]: unknown }>;
|
|
122
|
+
|
|
123
|
+
export interface ToolResultLike {
|
|
124
|
+
toolName?: string;
|
|
125
|
+
input?: Record<string, unknown>;
|
|
126
|
+
content?: ToolResultContent;
|
|
127
|
+
isError?: boolean;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export interface ProgressTracker {
|
|
131
|
+
/** A user prompt (or -do request): remember the task ids it names. */
|
|
132
|
+
onPrompt(text: string): void;
|
|
133
|
+
/** A new agent turn (agent_start): re-arm R1. */
|
|
134
|
+
onTurnStart(): void;
|
|
135
|
+
/** tool_result: record `show`/`start`, and return R1 content when due. */
|
|
136
|
+
onToolResult(event: ToolResultLike): Promise<{ content: ToolResultContent } | undefined>;
|
|
137
|
+
/** agent_end: the R2 text when due (the caller delivers it). */
|
|
138
|
+
onAgentEnd(event: { messages?: unknown[] }): Promise<string | null>;
|
|
139
|
+
/** For tests / status. */
|
|
140
|
+
state(): { mentioned: string[]; started: string[]; r1: Record<string, number>; r2: Record<string, number> };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function aborted(messages: unknown[] | undefined): boolean {
|
|
144
|
+
if (!Array.isArray(messages)) return false;
|
|
145
|
+
for (let index = messages.length - 1; index >= 0; index -= 1) {
|
|
146
|
+
const message = messages[index] as { role?: string; stopReason?: string };
|
|
147
|
+
if (message?.role === "assistant") return message.stopReason === "aborted";
|
|
148
|
+
}
|
|
149
|
+
return false;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function ownedStart(task: KanboardTask, session: string): boolean {
|
|
153
|
+
const run = task.run as { session?: string; owner?: string } | null | undefined;
|
|
154
|
+
return task.status === "in_progress" && run?.session === session && run?.owner === "agent";
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export function createProgressTracker(deps: TrackerDeps): ProgressTracker {
|
|
158
|
+
const mentioned = new Set<string>();
|
|
159
|
+
/** Ids this session ran `start` on (candidates; the board is the truth). */
|
|
160
|
+
const started = new Set<string>();
|
|
161
|
+
const r1Count = new Map<string, number>();
|
|
162
|
+
const r2Count = new Map<string, number>();
|
|
163
|
+
let checkedThisTurn = false;
|
|
164
|
+
const debug = (message: string): void => deps.debug?.(`reminders: ${message}`);
|
|
165
|
+
const silent = (): boolean => !deps.enabled() || deps.runnerOwned() || Boolean(process.env.UNIPI_KANBOARD_CHILD);
|
|
166
|
+
|
|
167
|
+
const safeList = async (): Promise<KanboardTask[] | null> => {
|
|
168
|
+
try {
|
|
169
|
+
return await deps.list();
|
|
170
|
+
} catch (error) {
|
|
171
|
+
debug(`list failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
172
|
+
return null;
|
|
173
|
+
}
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
return {
|
|
177
|
+
onPrompt(text) {
|
|
178
|
+
// Our own reminders name started tasks; they are not new mentions, and a
|
|
179
|
+
// runner task prompt belongs to the runner. (A -do request counts: its
|
|
180
|
+
// text carries the user's request verbatim.)
|
|
181
|
+
if (text.includes(ANTI_POISONING_SUFFIX) || deps.runnerOwned()) return;
|
|
182
|
+
for (const id of taskIdsIn(text)) mentioned.add(id);
|
|
183
|
+
},
|
|
184
|
+
|
|
185
|
+
onTurnStart() {
|
|
186
|
+
checkedThisTurn = false;
|
|
187
|
+
},
|
|
188
|
+
|
|
189
|
+
async onToolResult(event) {
|
|
190
|
+
const toolName = String(event.toolName ?? "");
|
|
191
|
+
if (SHELL_TOOLS.has(toolName)) {
|
|
192
|
+
for (const invocation of kanboardInvocations(String(event.input?.command ?? ""))) {
|
|
193
|
+
const id = invocation.args.find((arg) => !arg.startsWith("-"));
|
|
194
|
+
if (!id) continue;
|
|
195
|
+
if (invocation.sub === "show") mentioned.add(id);
|
|
196
|
+
if (invocation.sub === "start" && !event.isError) started.add(id);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
if (checkedThisTurn || !isFileChangingCall(toolName, event.input)) return undefined;
|
|
200
|
+
// Only the first file-changing call of the turn is considered.
|
|
201
|
+
checkedThisTurn = true;
|
|
202
|
+
if (silent() || mentioned.size === 0) return undefined;
|
|
203
|
+
const candidates = [...mentioned].filter((id) => (r1Count.get(id) ?? 0) < MAX_REMINDERS_PER_TASK);
|
|
204
|
+
if (candidates.length === 0) return undefined;
|
|
205
|
+
const tasks = await safeList();
|
|
206
|
+
if (!tasks) return undefined;
|
|
207
|
+
const session = deps.session();
|
|
208
|
+
if (tasks.some((task) => ownedStart(task, session))) return undefined;
|
|
209
|
+
const byId = new Map(tasks.map((task) => [task.id, task]));
|
|
210
|
+
const todo = candidates.filter((id) => byId.get(id)?.status === "todo");
|
|
211
|
+
if (todo.length === 0) return undefined;
|
|
212
|
+
for (const id of todo) r1Count.set(id, (r1Count.get(id) ?? 0) + 1);
|
|
213
|
+
debug(`R1 for ${todo.join(", ")}`);
|
|
214
|
+
return { content: [...(event.content ?? []), { type: "text", text: `\n\n${r1Text(todo, deps.cliPrefix())}` }] };
|
|
215
|
+
},
|
|
216
|
+
|
|
217
|
+
async onAgentEnd(event) {
|
|
218
|
+
if (silent() || started.size === 0 || aborted(event.messages)) return null;
|
|
219
|
+
const tasks = await safeList();
|
|
220
|
+
if (!tasks) return null;
|
|
221
|
+
const session = deps.session();
|
|
222
|
+
const byId = new Map(tasks.map((task) => [task.id, task]));
|
|
223
|
+
const open: string[] = [];
|
|
224
|
+
for (const id of [...started]) {
|
|
225
|
+
const task = byId.get(id);
|
|
226
|
+
if (!task || !ownedStart(task, session)) {
|
|
227
|
+
started.delete(id); // finished, blocked, reaped or someone else's now
|
|
228
|
+
continue;
|
|
229
|
+
}
|
|
230
|
+
if ((r2Count.get(id) ?? 0) >= MAX_REMINDERS_PER_TASK) continue;
|
|
231
|
+
r2Count.set(id, (r2Count.get(id) ?? 0) + 1);
|
|
232
|
+
open.push(id);
|
|
233
|
+
}
|
|
234
|
+
if (open.length === 0) return null;
|
|
235
|
+
debug(`R2 for ${open.join(", ")}`);
|
|
236
|
+
return r2Text(open, deps.cliPrefix());
|
|
237
|
+
},
|
|
238
|
+
|
|
239
|
+
state() {
|
|
240
|
+
return {
|
|
241
|
+
mentioned: [...mentioned],
|
|
242
|
+
started: [...started],
|
|
243
|
+
r1: Object.fromEntries(r1Count),
|
|
244
|
+
r2: Object.fromEntries(r2Count),
|
|
245
|
+
};
|
|
246
|
+
},
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Wire the tracker into pi's events. R2 is computed at agent_end and handed
|
|
252
|
+
* back through `takePending()`: the caller sends it at agent_settled — a
|
|
253
|
+
* message sent while the turn is still finalizing is queued as a follow-up
|
|
254
|
+
* that never gets delivered (same reason the -do queue drains there).
|
|
255
|
+
*/
|
|
256
|
+
export function registerProgressReminders(pi: ExtensionAPI, tracker: ProgressTracker): { takePending(): string | null } {
|
|
257
|
+
let pending: string | null = null;
|
|
258
|
+
pi.on("before_agent_start", (event) => {
|
|
259
|
+
tracker.onPrompt(String((event as { prompt?: unknown }).prompt ?? ""));
|
|
260
|
+
return undefined;
|
|
261
|
+
});
|
|
262
|
+
pi.on("agent_start", () => {
|
|
263
|
+
tracker.onTurnStart();
|
|
264
|
+
});
|
|
265
|
+
pi.on("tool_result", async (event) => {
|
|
266
|
+
const result = await tracker.onToolResult(event as unknown as ToolResultLike);
|
|
267
|
+
return result as never;
|
|
268
|
+
});
|
|
269
|
+
pi.on("agent_end", async (event) => {
|
|
270
|
+
pending = await tracker.onAgentEnd(event as { messages?: unknown[] });
|
|
271
|
+
});
|
|
272
|
+
return {
|
|
273
|
+
takePending() {
|
|
274
|
+
const text = pending;
|
|
275
|
+
pending = null;
|
|
276
|
+
return text;
|
|
277
|
+
},
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/** Deliver an R2 reminder as a visible custom message that starts a turn. */
|
|
282
|
+
export function sendReminder(pi: ExtensionAPI, text: string): void {
|
|
283
|
+
pi.sendMessage({ customType: REMINDER_CUSTOM_TYPE, content: text, display: true }, { triggerTurn: true });
|
|
284
|
+
}
|
package/src/settings.ts
CHANGED
|
@@ -42,6 +42,9 @@ export interface KanboardSettings {
|
|
|
42
42
|
/** Whether a blocked-by-confusion task may ask the user (ask) or must
|
|
43
43
|
* assume-and-note (avoid, the default). */
|
|
44
44
|
blocking: "avoid" | "ask";
|
|
45
|
+
/** Progress reminders when the agent works board tasks by hand: `start`
|
|
46
|
+
* before the first edit, `finish` (or block) before the turn ends. */
|
|
47
|
+
reminders: boolean;
|
|
45
48
|
}
|
|
46
49
|
|
|
47
50
|
export const DEFAULT_SETTINGS: KanboardSettings = {
|
|
@@ -62,6 +65,7 @@ export const DEFAULT_SETTINGS: KanboardSettings = {
|
|
|
62
65
|
jevThreshold: 0.8,
|
|
63
66
|
defaultPlan: false,
|
|
64
67
|
blocking: "avoid",
|
|
68
|
+
reminders: true,
|
|
65
69
|
};
|
|
66
70
|
|
|
67
71
|
export const KANBOARD_NAMESPACE = "kanboard";
|
|
@@ -119,6 +123,12 @@ export function registerKanboardSettings(): void {
|
|
|
119
123
|
{ key: "queueMax", type: "number", label: "-do queue limit", min: 0, zeroLabel: "unlimited", description: "Tasks a session may queue for the runner" },
|
|
120
124
|
{ key: "maxSessions", type: "number", label: "Sessions working at once (per project)", min: 1, description: "Distinct sessions holding in_progress tasks" },
|
|
121
125
|
{ key: "turnAddLimit", type: "number", label: "New tasks per turn", min: 0, zeroLabel: "unlimited", description: "`add` calls allowed per -do turn or runner task" },
|
|
126
|
+
{
|
|
127
|
+
key: "reminders",
|
|
128
|
+
type: "boolean",
|
|
129
|
+
label: "Progress reminders",
|
|
130
|
+
description: "Remind the agent to `start` a mentioned Todo task before editing and to `finish` it before the turn ends (text only, never blocks; off in runner runs)",
|
|
131
|
+
},
|
|
122
132
|
{ 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
133
|
],
|
|
124
134
|
},
|
|
@@ -210,6 +220,7 @@ export function readKanboardSettings(cwd: string = process.cwd()): KanboardSetti
|
|
|
210
220
|
: DEFAULT_SETTINGS.jevThreshold,
|
|
211
221
|
defaultPlan: raw.defaultPlan === true,
|
|
212
222
|
blocking: raw.blocking === "ask" ? "ask" : "avoid",
|
|
223
|
+
reminders: raw.reminders !== false,
|
|
213
224
|
};
|
|
214
225
|
}
|
|
215
226
|
|