@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-unipi/kanboard",
3
- "version": "3.0.0-alpha.12",
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.12"
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.12",
52
- "@pi-unipi/kanboard-linux-arm64": "3.0.0-alpha.12",
53
- "@pi-unipi/kanboard-darwin-x64": "3.0.0-alpha.12",
54
- "@pi-unipi/kanboard-darwin-arm64": "3.0.0-alpha.12",
55
- "@pi-unipi/kanboard-win32-x64": "3.0.0-alpha.12"
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 = "kanboard write credits used up — run /unipi:kanboard-do to reload";
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
- /** Every `unipi-kanboard` invocation inside a shell command line. */
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 (let index = 0; index < tokens.length; index += 1) {
44
- const token = tokens[index]!;
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(token)) continue;
47
- const rest = tokens.slice(index + 1);
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. A runner task keeps unlimited access
112
- * (still add-capped). Credits persist across turns and follow-up questions
113
- * until spent; /unipi:kanboard-do tops up to N without stacking past N.
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 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).
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.split(/&&|\|\||[;|\n]/).map((part) => part.trim()).filter(Boolean);
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
- let checkedThisTurn = false;
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
- checkedThisTurn = false;
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 (checkedThisTurn || !isFileChangingCall(toolName, event.input)) return undefined;
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
- const candidates = [...mentioned].filter((id) => (r1Count.get(id) ?? 0) < MAX_REMINDERS_PER_TASK);
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) r1Count.set(id, (r1Count.get(id) ?? 0) + 1);
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
  },