@pi-unipi/kanboard 3.0.0-alpha.12 → 3.0.0-alpha.14
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/package.json +7 -7
- package/src/guard.ts +66 -10
- package/src/reminders.ts +30 -15
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.14",
|
|
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.14"
|
|
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.14",
|
|
52
|
+
"@pi-unipi/kanboard-linux-arm64": "3.0.0-alpha.14",
|
|
53
|
+
"@pi-unipi/kanboard-darwin-x64": "3.0.0-alpha.14",
|
|
54
|
+
"@pi-unipi/kanboard-darwin-arm64": "3.0.0-alpha.14",
|
|
55
|
+
"@pi-unipi/kanboard-win32-x64": "3.0.0-alpha.14"
|
|
56
56
|
}
|
|
57
57
|
}
|
package/src/guard.ts
CHANGED
|
@@ -10,7 +10,8 @@
|
|
|
10
10
|
import { tokenizeArgs } from "./commands.js";
|
|
11
11
|
|
|
12
12
|
export const DEFAULT_DO_CREDITS = 10;
|
|
13
|
-
export const WRITE_CREDITS_USED_UP =
|
|
13
|
+
export const WRITE_CREDITS_USED_UP =
|
|
14
|
+
"kanboard write credits used up — run /unipi:kanboard-do to reload (board reads, `start` and `finish` are always free)";
|
|
14
15
|
export const addCapReason = (limit: number): string => `at most ${limit} new tasks per turn`;
|
|
15
16
|
/** @deprecated tests should read the limit through the guard's getter instead. */
|
|
16
17
|
export const ADD_CAP = 20;
|
|
@@ -29,6 +30,45 @@ const FREE_WRITES = new Set(["start", "finish"]);
|
|
|
29
30
|
/** Global flags that take a value; `--json` is the only valueless one. */
|
|
30
31
|
const GLOBAL_VALUE_FLAGS = new Set(["--actor", "--project", "--gate", "--session"]);
|
|
31
32
|
|
|
33
|
+
/** Quoted spans (single or double quotes), masked before splitting segments. */
|
|
34
|
+
const QUOTED_SPAN = /(["'])(?:\\.|(?!\1).)*\1/g;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Split a command line into shell segments on `&&`, `||`, `;`, `|` and
|
|
38
|
+
* newlines — ignoring separators inside quoted spans (the mask keeps the
|
|
39
|
+
* original length, so the cut positions map back onto the input exactly).
|
|
40
|
+
*/
|
|
41
|
+
export function shellSegments(command: string): string[] {
|
|
42
|
+
const masked = command.replace(QUOTED_SPAN, (span) => " ".repeat(span.length));
|
|
43
|
+
const out: string[] = [];
|
|
44
|
+
let at = 0;
|
|
45
|
+
for (const match of masked.matchAll(/&&|\|\||[;|\n]/g)) {
|
|
46
|
+
out.push(command.slice(at, match.index));
|
|
47
|
+
at = match.index + match[0].length;
|
|
48
|
+
}
|
|
49
|
+
out.push(command.slice(at));
|
|
50
|
+
return out;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** `VAR=value` prefixes that may sit in front of the command word. */
|
|
54
|
+
const ASSIGNMENT = /^[A-Za-z_][A-Za-z0-9_]*=/;
|
|
55
|
+
/** Wrappers that keep the wrapped word in command position. */
|
|
56
|
+
const COMMAND_PREFIXES = new Set(["exec", "command", "env"]);
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Every subcommand the CLI understands (crates/kanboard/src/cli.rs `Command`,
|
|
60
|
+
* kebab-case as clap spells it). Anything else — a typo like `done`, a bare
|
|
61
|
+
* binary with no subcommand — is the binary's own usage error; the guard
|
|
62
|
+
* neither charges nor blocks it.
|
|
63
|
+
*/
|
|
64
|
+
export const KNOWN_SUBCOMMANDS = new Set([
|
|
65
|
+
"project", "add", "list", "show", "move", "note", "attach", "attachments",
|
|
66
|
+
"edit", "link", "unlink", "order", "claim-next", "start", "finish", "next",
|
|
67
|
+
"reap", "queue", "unqueue", "chain", "search", "release", "set-run",
|
|
68
|
+
"duplicate", "archive-sweep", "serve", "settings", "rotate-token", "status",
|
|
69
|
+
"stop", "validate",
|
|
70
|
+
]);
|
|
71
|
+
|
|
32
72
|
export interface KanboardInvocation {
|
|
33
73
|
/** First positional after the binary name ("" when absent). */
|
|
34
74
|
sub: string;
|
|
@@ -36,15 +76,25 @@ export interface KanboardInvocation {
|
|
|
36
76
|
args: string[];
|
|
37
77
|
}
|
|
38
78
|
|
|
39
|
-
/**
|
|
79
|
+
/**
|
|
80
|
+
* Every `unipi-kanboard` invocation inside a shell command line. A token
|
|
81
|
+
* counts only when it is positioned like a command: the first word of its
|
|
82
|
+
* segment (segments split on `&&`/`||`/`;`/`|`/newlines, quotes masked),
|
|
83
|
+
* past leading `VAR=value` assignments and the `exec`/`command`/`env`
|
|
84
|
+
* wrappers. `which unipi-kanboard` or `find -name "unipi-kanboard"` merely
|
|
85
|
+
* mention the binary and are not invocations.
|
|
86
|
+
*/
|
|
40
87
|
export function kanboardInvocations(command: string): KanboardInvocation[] {
|
|
41
|
-
const tokens = tokenizeArgs(command);
|
|
42
88
|
const out: KanboardInvocation[] = [];
|
|
43
|
-
for (
|
|
44
|
-
const
|
|
89
|
+
for (const segment of shellSegments(command)) {
|
|
90
|
+
const tokens = tokenizeArgs(segment);
|
|
91
|
+
let head = 0;
|
|
92
|
+
while (head < tokens.length && (ASSIGNMENT.test(tokens[head]!) || COMMAND_PREFIXES.has(tokens[head]!))) {
|
|
93
|
+
head += 1;
|
|
94
|
+
}
|
|
45
95
|
// The binary may be a bare name or an absolute path (and .exe on Windows).
|
|
46
|
-
if (!/unipi-kanboard(\.exe)?$/.test(
|
|
47
|
-
const rest = tokens.slice(
|
|
96
|
+
if (head >= tokens.length || !/unipi-kanboard(\.exe)?$/.test(tokens[head]!)) continue;
|
|
97
|
+
const rest = tokens.slice(head + 1);
|
|
48
98
|
let cursor = 0;
|
|
49
99
|
while (cursor < rest.length) {
|
|
50
100
|
const arg = rest[cursor]!;
|
|
@@ -108,9 +158,12 @@ export interface WriteGuard {
|
|
|
108
158
|
|
|
109
159
|
/**
|
|
110
160
|
* Writes cost session credits (one per write subcommand in the command line);
|
|
111
|
-
* reads and `start`/`finish` are free and never blocked.
|
|
112
|
-
*
|
|
113
|
-
*
|
|
161
|
+
* reads and `start`/`finish` are free and never blocked. Invocations whose
|
|
162
|
+
* subcommand does not exist are skipped: the binary itself rejects them with
|
|
163
|
+
* a usage error, and one typo must not block the rest of a compound call. A
|
|
164
|
+
* runner task keeps unlimited access (still add-capped). Credits persist
|
|
165
|
+
* across turns and follow-up questions until spent; /unipi:kanboard-do tops
|
|
166
|
+
* up to N without stacking past N.
|
|
114
167
|
*/
|
|
115
168
|
export function createWriteGuard(
|
|
116
169
|
runnerTask: () => string | null,
|
|
@@ -161,6 +214,9 @@ export function createWriteGuard(
|
|
|
161
214
|
lastTask = task;
|
|
162
215
|
}
|
|
163
216
|
for (const invocation of invocations) {
|
|
217
|
+
// An unknown subcommand (a typo, a bare binary) is the binary's own
|
|
218
|
+
// usage error — never a reason to block or charge the whole call.
|
|
219
|
+
if (!KNOWN_SUBCOMMANDS.has(invocation.sub)) continue;
|
|
164
220
|
if (isReadonly(invocation) || isFreeWrite(invocation)) continue;
|
|
165
221
|
if (task !== null) {
|
|
166
222
|
const cap = countAdd(invocation);
|
package/src/reminders.ts
CHANGED
|
@@ -6,9 +6,9 @@
|
|
|
6
6
|
* `finish <ID> --comment` hands it to In Review. Two text-only reminders keep
|
|
7
7
|
* that honest:
|
|
8
8
|
*
|
|
9
|
-
* R1
|
|
10
|
-
* task is still Todo and
|
|
11
|
-
*
|
|
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
12
|
* R2 agent_end with a task this session started still In Progress → one
|
|
13
13
|
* follow-up message (at most twice per task).
|
|
14
14
|
*
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
|
|
21
21
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
22
22
|
|
|
23
|
-
import { kanboardInvocations } from "./guard.js";
|
|
23
|
+
import { kanboardInvocations, shellSegments } from "./guard.js";
|
|
24
24
|
import type { KanboardTask } from "./shapes.js";
|
|
25
25
|
|
|
26
26
|
export const REMINDER_CUSTOM_TYPE = "unipi:kanboard-reminder";
|
|
@@ -47,6 +47,9 @@ const READ_COMMANDS = new Set([
|
|
|
47
47
|
"more", "true", "false", "test", "[", "diff", "cmp", "jq", "realpath", "dirname", "basename", "uname",
|
|
48
48
|
"hostname", "id", "ps", "cd",
|
|
49
49
|
]);
|
|
50
|
+
/** Interpreters/package managers whose version-check arms never write. */
|
|
51
|
+
const VERSION_CHECKED = new Set(["node", "npm", "npx", "python", "python3"]);
|
|
52
|
+
const isVersionFlag = (word: string): boolean => word === "--version" || word === "-v" || word === "-V";
|
|
50
53
|
const GIT_READS = new Set(["status", "log", "diff", "show", "branch", "remote", "rev-parse", "blame", "ls-files", "describe", "tag"]);
|
|
51
54
|
|
|
52
55
|
export function taskIdsIn(text: string): string[] {
|
|
@@ -56,14 +59,16 @@ export function taskIdsIn(text: string): string[] {
|
|
|
56
59
|
/**
|
|
57
60
|
* Whether a shell command may change files. Conservative toward "yes": any
|
|
58
61
|
* segment that is not a known read (or a kanboard CLI call) counts, and so
|
|
59
|
-
* does an output redirection.
|
|
62
|
+
* does an output redirection. Quoted spans are masked before both the
|
|
63
|
+
* redirect test and the segment split, so `grep -c "a;b" f` stays one
|
|
64
|
+
* read-only segment.
|
|
60
65
|
*/
|
|
61
66
|
export function shellChangesFiles(command: string): boolean {
|
|
62
67
|
const trimmed = command.trim();
|
|
63
68
|
if (!trimmed) return false;
|
|
64
69
|
// `>`/`>>` into a file (but not `2>&1` / `>/dev/null`).
|
|
65
70
|
if (/(^|[^0-9&>])>{1,2}\s*(?!&|\/dev\/null)[^\s|;&]/.test(trimmed.replace(/(["'])(?:\\.|(?!\1).)*\1/g, '""'))) return true;
|
|
66
|
-
const segments = trimmed
|
|
71
|
+
const segments = shellSegments(trimmed).map((part) => part.trim()).filter(Boolean);
|
|
67
72
|
return segments.some((segment) => {
|
|
68
73
|
const words = segment.split(/\s+/).filter((word) => !/^[A-Za-z_][A-Za-z0-9_]*=/.test(word));
|
|
69
74
|
const first = (words[0] ?? "").replace(/^.*\//, "");
|
|
@@ -73,6 +78,11 @@ export function shellChangesFiles(command: string): boolean {
|
|
|
73
78
|
if (READ_COMMANDS.has(first)) return false;
|
|
74
79
|
if (first === "git") return !GIT_READS.has(words[1] ?? "");
|
|
75
80
|
if (first === "sed") return words.includes("-i") || words.some((word) => word.startsWith("-i"));
|
|
81
|
+
// `sort` writes only through -o/--output (a `>` redirect is caught above).
|
|
82
|
+
if (first === "sort") return words.some((word) => /^-[^-]*o/.test(word) || word.startsWith("--output"));
|
|
83
|
+
// node/npm/npx/python are reads only as version checks (`node --version`,
|
|
84
|
+
// `npx tsx --version`); scripts, installs and builds still count.
|
|
85
|
+
if (VERSION_CHECKED.has(first)) return !words.slice(1).some(isVersionFlag);
|
|
76
86
|
return true;
|
|
77
87
|
});
|
|
78
88
|
}
|
|
@@ -160,7 +170,8 @@ export function createProgressTracker(deps: TrackerDeps): ProgressTracker {
|
|
|
160
170
|
const started = new Set<string>();
|
|
161
171
|
const r1Count = new Map<string, number>();
|
|
162
172
|
const r2Count = new Map<string, number>();
|
|
163
|
-
|
|
173
|
+
/** Task ids already reminded this turn (per-task re-arm, not one shot). */
|
|
174
|
+
const remindedThisTurn = new Set<string>();
|
|
164
175
|
const debug = (message: string): void => deps.debug?.(`reminders: ${message}`);
|
|
165
176
|
const silent = (): boolean => !deps.enabled() || deps.runnerOwned() || Boolean(process.env.UNIPI_KANBOARD_CHILD);
|
|
166
177
|
|
|
@@ -183,7 +194,7 @@ export function createProgressTracker(deps: TrackerDeps): ProgressTracker {
|
|
|
183
194
|
},
|
|
184
195
|
|
|
185
196
|
onTurnStart() {
|
|
186
|
-
|
|
197
|
+
remindedThisTurn.clear();
|
|
187
198
|
},
|
|
188
199
|
|
|
189
200
|
async onToolResult(event) {
|
|
@@ -196,20 +207,24 @@ export function createProgressTracker(deps: TrackerDeps): ProgressTracker {
|
|
|
196
207
|
if (invocation.sub === "start" && !event.isError) started.add(id);
|
|
197
208
|
}
|
|
198
209
|
}
|
|
199
|
-
if (
|
|
200
|
-
// Only the first file-changing call of the turn is considered.
|
|
201
|
-
checkedThisTurn = true;
|
|
210
|
+
if (!isFileChangingCall(toolName, event.input)) return undefined;
|
|
202
211
|
if (silent() || mentioned.size === 0) return undefined;
|
|
203
|
-
|
|
212
|
+
// Per task, at most once per turn: candidates are mentioned ids not yet
|
|
213
|
+
// reminded this turn and under the per-task cap (a started task leaves
|
|
214
|
+
// Todo and is never re-reminded).
|
|
215
|
+
const candidates = [...mentioned].filter(
|
|
216
|
+
(id) => !remindedThisTurn.has(id) && (r1Count.get(id) ?? 0) < MAX_REMINDERS_PER_TASK,
|
|
217
|
+
);
|
|
204
218
|
if (candidates.length === 0) return undefined;
|
|
205
219
|
const tasks = await safeList();
|
|
206
220
|
if (!tasks) return undefined;
|
|
207
|
-
const session = deps.session();
|
|
208
|
-
if (tasks.some((task) => ownedStart(task, session))) return undefined;
|
|
209
221
|
const byId = new Map(tasks.map((task) => [task.id, task]));
|
|
210
222
|
const todo = candidates.filter((id) => byId.get(id)?.status === "todo");
|
|
211
223
|
if (todo.length === 0) return undefined;
|
|
212
|
-
for (const id of todo)
|
|
224
|
+
for (const id of todo) {
|
|
225
|
+
remindedThisTurn.add(id);
|
|
226
|
+
r1Count.set(id, (r1Count.get(id) ?? 0) + 1);
|
|
227
|
+
}
|
|
213
228
|
debug(`R1 for ${todo.join(", ")}`);
|
|
214
229
|
return { content: [...(event.content ?? []), { type: "text", text: `\n\n${r1Text(todo, deps.cliPrefix())}` }] };
|
|
215
230
|
},
|