@pi-unipi/kanboard 3.0.0-alpha.8 → 3.0.0-alpha.9

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/index.ts CHANGED
@@ -71,6 +71,7 @@ export default function (pi: ExtensionAPI) {
71
71
  return status?.phase === "running" ? status.taskId : null;
72
72
  },
73
73
  () => readKanboardSettings().turnAddLimit,
74
+ () => readKanboardSettings().doCredits,
74
75
  );
75
76
  const sessionId = (): string => process.env.UNIPI_KANBOARD_SESSION ?? `pi-${process.pid}`;
76
77
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-unipi/kanboard",
3
- "version": "3.0.0-alpha.8",
3
+ "version": "3.0.0-alpha.9",
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.8"
34
+ "@pi-unipi/core": "3.0.0-alpha.9"
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.8",
52
- "@pi-unipi/kanboard-linux-arm64": "3.0.0-alpha.8",
53
- "@pi-unipi/kanboard-darwin-x64": "3.0.0-alpha.8",
54
- "@pi-unipi/kanboard-darwin-arm64": "3.0.0-alpha.8",
55
- "@pi-unipi/kanboard-win32-x64": "3.0.0-alpha.8"
51
+ "@pi-unipi/kanboard-linux-x64": "3.0.0-alpha.9",
52
+ "@pi-unipi/kanboard-linux-arm64": "3.0.0-alpha.9",
53
+ "@pi-unipi/kanboard-darwin-x64": "3.0.0-alpha.9",
54
+ "@pi-unipi/kanboard-darwin-arm64": "3.0.0-alpha.9",
55
+ "@pi-unipi/kanboard-win32-x64": "3.0.0-alpha.9"
56
56
  }
57
57
  }
package/src/commands.ts CHANGED
@@ -40,9 +40,9 @@ export const HELP = `Kanboard commands
40
40
  /unipi:kanboard-autowork start|stop work ready tasks chain by chain; stop finishes the current one first
41
41
  Priority -p: 1 none · 2 low · 3 medium · 4 high · 5 urgent`;
42
42
 
43
- export function doText(slug: string, cli: string, request: string, queueMax = 10): string {
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} …\`). Board writes are allowed until this turn ends. 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, so do not start the work yourself. 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.
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, so do not start the work yourself. 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
  }
@@ -1093,7 +1093,21 @@ export function kanboardCompletions(prefix: string): CompletionItem[] | null {
1093
1093
  /** Live task list for `--after` / `-do` completions, cached ~5s per client. */
1094
1094
  const taskCache = new WeakMap<KanboardCli, { at: number; tasks: ShowTask[] }>();
1095
1095
 
1096
- export async function taskCompletions(deps: CommandDeps, prefix: string, before: string): Promise<CompletionItem[] | null> {
1096
+ export interface TaskCompletionOptions {
1097
+ /** Also match task TITLES (default true); false = id prefixes only. */
1098
+ titleSearch?: boolean;
1099
+ /** Row style: "status" = label id, description "<status> — <title>";
1100
+ * "id-title" = label "<id> <title>", description "<status>". */
1101
+ rowStyle?: "status" | "id-title";
1102
+ }
1103
+
1104
+ export async function taskCompletions(
1105
+ deps: CommandDeps,
1106
+ prefix: string,
1107
+ before: string,
1108
+ opts: TaskCompletionOptions = {},
1109
+ ): Promise<CompletionItem[] | null> {
1110
+ const { titleSearch = true, rowStyle = "status" } = opts;
1097
1111
  const client = deps.cli;
1098
1112
  if (!client) return null;
1099
1113
  let cache = taskCache.get(client);
@@ -1109,13 +1123,21 @@ export async function taskCompletions(deps: CommandDeps, prefix: string, before:
1109
1123
  const needle = prefix.toLowerCase();
1110
1124
  const items = cache.tasks
1111
1125
  .filter((task) => !["cancelled", "archived"].includes(task.status))
1112
- .filter((task) => task.id.toLowerCase().startsWith(needle) || task.title.toLowerCase().includes(needle))
1126
+ .filter((task) => task.id.toLowerCase().startsWith(needle) || (titleSearch && task.title.toLowerCase().includes(needle)))
1113
1127
  .slice(0, 12)
1114
- .map((task) => ({
1115
- value: fullArgs(before, task.id),
1116
- label: task.id,
1117
- description: `${task.status} — ${task.title}`,
1118
- }));
1128
+ .map((task) =>
1129
+ rowStyle === "id-title"
1130
+ ? {
1131
+ value: fullArgs(before, task.id),
1132
+ label: `${task.id} ${task.title.slice(0, 64)}`,
1133
+ description: task.status,
1134
+ }
1135
+ : {
1136
+ value: fullArgs(before, task.id),
1137
+ label: task.id,
1138
+ description: `${task.status} — ${task.title}`,
1139
+ },
1140
+ );
1119
1141
  return items.length > 0 ? items : null;
1120
1142
  }
1121
1143
 
@@ -1191,15 +1213,19 @@ export async function kanboardAddCompletions(deps: CommandDeps, prefix: string):
1191
1213
  return null;
1192
1214
  }
1193
1215
 
1194
- /** /unipi:kanboard-do … — complete a trailing task-id prefix. */
1216
+ /** /unipi:kanboard-do … — complete a trailing task-id token; plain prose never matches. */
1195
1217
  export async function kanboardDoCompletions(deps: CommandDeps, prefix: string): Promise<CompletionItem[] | null> {
1196
1218
  const raw = prefix ?? "";
1197
- const match = /^(.*?)(\S*)$/.exec(raw)!;
1198
- const before = match[1]!;
1199
- const token = match[2]!;
1200
- // Only when the last token looks like a task id prefix (ABC-…).
1201
- if (!/^[A-Za-z]{2,6}-?[0-9]*$/.test(token) || token.length < 2) return null;
1202
- return taskCompletions(deps, token, before);
1219
+ const at = raw.lastIndexOf(" ");
1220
+ const before = at === -1 ? "" : raw.slice(0, at + 1);
1221
+ const token = raw.slice(at + 1);
1222
+ // Only a task-id-like tail ("UNI-1", "uni", "UNI-") completes; words that
1223
+ // match no id shape or prefix ("note", "set") yield nothing.
1224
+ if (!/^[A-Za-z]{2,10}-?\d*$/.test(token)) return null;
1225
+ // Title search only when the token looks like a real id prefix (has a digit
1226
+ // or ends with the dash); otherwise id prefixes only.
1227
+ const idLike = /\d/.test(token) || token.endsWith("-");
1228
+ return taskCompletions(deps, token, before, { titleSearch: idLike, rowStyle: "id-title" });
1203
1229
  }
1204
1230
 
1205
1231
  export function registerKanboardCommands(pi: ExtensionAPI, deps: CommandDeps): void {
@@ -1308,10 +1334,15 @@ export function registerKanboardCommands(pi: ExtensionAPI, deps: CommandDeps): v
1308
1334
  });
1309
1335
 
1310
1336
  pi.registerCommand("unipi:kanboard-do", {
1311
- description: "Let the agent use the kanboard for this turn — writes are allowed until the turn ends",
1337
+ description: "Grant kanboard write credits to the agent (/unipi:kanboard-do <request>; off revokes)",
1312
1338
  getArgumentCompletions: (prefix: string) => kanboardDoCompletions(deps, prefix ?? ""),
1313
1339
  handler: async (args: string, ctx: ExtensionCommandContext) => {
1314
1340
  const request = (args ?? "").trim();
1341
+ if (request.toLowerCase() === "off") {
1342
+ deps.guard.revoke();
1343
+ ctx.ui.notify("kanboard: write credits revoked — reads stay free", "info");
1344
+ return;
1345
+ }
1315
1346
  if (!request) {
1316
1347
  ctx.ui.notify("kanboard: -do needs a request — /unipi:kanboard-do <what the agent may do on the board>", "warning");
1317
1348
  return;
@@ -1328,9 +1359,12 @@ export function registerKanboardCommands(pi: ExtensionAPI, deps: CommandDeps): v
1328
1359
  const busy = typeof (ctx as unknown as { isIdle?: () => boolean }).isIdle === "function"
1329
1360
  ? !(ctx as unknown as { isIdle: () => boolean }).isIdle!()
1330
1361
  : false;
1331
- pi.sendUserMessage(doText(slug, client.binary.path, request, deps.settings().queueMax), busy ? { deliverAs: "followUp" } : undefined);
1362
+ pi.sendUserMessage(
1363
+ doText(slug, client.binary.path, request, deps.settings().queueMax, deps.guard.remaining()),
1364
+ busy ? { deliverAs: "followUp" } : undefined,
1365
+ );
1332
1366
  deps.guard.noteSent();
1333
- deps.debug(`do window open (${slug})`);
1367
+ deps.debug(`do credits open (${slug}): ${deps.guard.remaining()} remaining`);
1334
1368
  },
1335
1369
  });
1336
1370
 
package/src/guard.ts CHANGED
@@ -9,8 +9,8 @@
9
9
 
10
10
  import { tokenizeArgs } from "./commands.js";
11
11
 
12
- export const WRITE_BLOCK_REASON =
13
- "kanboard board writes are only allowed during /unipi:kanboard-do or a runner task";
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";
14
14
  export const addCapReason = (limit: number): string => `at most ${limit} new tasks per turn`;
15
15
  /** @deprecated tests should read the limit through the guard's getter instead. */
16
16
  export const ADD_CAP = 20;
@@ -80,35 +80,55 @@ export function isReadonly(invocation: KanboardInvocation): boolean {
80
80
  }
81
81
 
82
82
  export interface WriteGuard {
83
- /** Open the window for a `/unipi:kanboard-do` turn. */
83
+ /** Grant / top up the session's write credits to N (kanboard.doCredits). */
84
84
  open(): void;
85
+ /** Revoke remaining credits (/unipi:kanboard-do off). */
86
+ revoke(): void;
87
+ /** Credits left this session. */
88
+ remaining(): number;
85
89
  /** Arm the agent_end closer right after the -do prompt was sent. */
86
90
  noteSent(): void;
87
- /**
88
- * A window opened by -do closes on the first agent_end that isn't the
89
- * pre-send echo (>150ms after noteSent). Returns true when it just closed.
90
- */
91
+ /** Credits persist across turns; this only closes the -do window label. */
91
92
  onAgentEnd(): boolean;
92
93
  /** null when the command is allowed; otherwise the block reason. */
93
94
  check(command: string): string | null;
94
95
  }
95
96
 
96
97
  /**
97
- * The window is open during a -do turn (`doOpen`) or while the runner has a
98
- * task in phase `running`. `runnerTask` returns that task's id (null when the
99
- * runner is not running one) — the `add` counter resets whenever the running
100
- * task changes, so autowork/queue drains get a fresh 20 per task.
98
+ * 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
100
+ * (still add-capped). Credits persist across turns and follow-up questions
101
+ * until spent; /unipi:kanboard-do tops up to N without stacking past N.
101
102
  */
102
- export function createWriteGuard(runnerTask: () => string | null, addLimit: () => number = () => ADD_CAP): WriteGuard {
103
+ export function createWriteGuard(
104
+ runnerTask: () => string | null,
105
+ addLimit: () => number = () => ADD_CAP,
106
+ doCredits: () => number = () => DEFAULT_DO_CREDITS,
107
+ ): WriteGuard {
108
+ let credits = 0;
103
109
  let doOpen = false;
104
110
  let sentAt = 0;
105
111
  let adds = 0;
106
112
  let lastTask: string | null = null;
113
+ const countAdd = (invocation: KanboardInvocation): string | null => {
114
+ if (invocation.sub !== "add") return null;
115
+ adds += 1;
116
+ const limit = addLimit();
117
+ return limit > 0 && adds > limit ? addCapReason(limit) : null;
118
+ };
107
119
  return {
108
120
  open() {
109
- doOpen = true;
121
+ credits = Math.max(credits, Math.max(0, doCredits()));
110
122
  adds = 0;
111
123
  lastTask = null;
124
+ doOpen = true;
125
+ },
126
+ revoke() {
127
+ credits = 0;
128
+ doOpen = false;
129
+ },
130
+ remaining() {
131
+ return credits;
112
132
  },
113
133
  noteSent() {
114
134
  sentAt = Date.now();
@@ -130,12 +150,15 @@ export function createWriteGuard(runnerTask: () => string | null, addLimit: () =
130
150
  }
131
151
  for (const invocation of invocations) {
132
152
  if (isReadonly(invocation)) continue;
133
- if (!(doOpen || task !== null)) return WRITE_BLOCK_REASON;
134
- if (invocation.sub === "add") {
135
- adds += 1;
136
- const limit = addLimit();
137
- if (limit > 0 && adds > limit) return addCapReason(limit);
153
+ if (task !== null) {
154
+ const cap = countAdd(invocation);
155
+ if (cap) return cap;
156
+ continue;
138
157
  }
158
+ if (credits <= 0) return WRITE_CREDITS_USED_UP;
159
+ credits -= 1;
160
+ const cap = countAdd(invocation);
161
+ if (cap) return cap;
139
162
  }
140
163
  return null;
141
164
  },
package/src/runner.ts CHANGED
@@ -436,6 +436,22 @@ export function createRunner(deps: RunnerDeps): Runner {
436
436
  return true;
437
437
  }
438
438
 
439
+ /** Latest [parked goal: …] marker from the task's release comments, if any. */
440
+ async function parkedGoalFromNotes(taskId: string): Promise<string | null> {
441
+ try {
442
+ const shown = asTask(
443
+ "show",
444
+ await runCli<unknown>(["show", taskId], { extraEnv: { UNIPI_KANBOARD_PROJECT: project() } }),
445
+ );
446
+ const matches = (shown.activity ?? [])
447
+ .map((entry) => entry.text.match(/\[parked goal: ([\w-]+)\]/)?.[1])
448
+ .filter((goal): goal is string => typeof goal === "string");
449
+ return matches.at(-1) ?? null;
450
+ } catch {
451
+ return null;
452
+ }
453
+ }
454
+
439
455
  /** Wire the chosen strategy for the work turn; falls back toward "none". */
440
456
  async function applyStrategy(ctx: ExtensionContext, task: KanboardTask, strategy: Strategy): Promise<void> {
441
457
  const setRun = (mode: RunMode, goal?: string): void => {
@@ -447,6 +463,22 @@ export function createRunner(deps: RunnerDeps): Runner {
447
463
  .catch((error) => deps.debug(`set-run failed: ${error instanceof Error ? error.message : String(error)}`));
448
464
  };
449
465
  const goalStart = async (): Promise<boolean> => {
466
+ // A goal parked for THIS task (interrupted/released earlier) resumes
467
+ // instead of starting fresh; a stale id falls through to goal-start.
468
+ const priorGoal = task.run?.goal ?? (await parkedGoalFromNotes(task.id));
469
+ if (priorGoal) {
470
+ const resumed = await callCommandRunner<{ ok?: boolean; reason?: string }>(
471
+ "unipi:goal-resume",
472
+ ctx,
473
+ { goalId: priorGoal },
474
+ );
475
+ if (resumed.found && resumed.result?.ok) {
476
+ state.goalId = priorGoal;
477
+ state.mode = "goal";
478
+ setRun("goal", priorGoal);
479
+ return true;
480
+ }
481
+ }
450
482
  const started = await callCommandRunner<{ ok?: boolean; goalId?: string; reason?: string }>(
451
483
  "unipi:goal-start",
452
484
  ctx,
@@ -530,14 +562,14 @@ ${body}`.trim() },
530
562
  }
531
563
 
532
564
  /** Ask the goal engine whether the goal reached a terminal state. */
533
- async function goalStatus(): Promise<{ status: string; objective?: string } | null> {
534
- const looked = await callCommandRunner<{ found?: boolean; status?: string; objective?: string }>(
565
+ async function goalStatus(): Promise<{ status: string; reason?: string; objective?: string } | null> {
566
+ const looked = await callCommandRunner<{ found?: boolean; status?: string; reason?: string; objective?: string }>(
535
567
  "unipi:goal-status",
536
568
  undefined,
537
569
  { goalId: state.goalId },
538
570
  );
539
571
  if (!looked.found || !looked.result?.found || !looked.result.status) return null;
540
- return { status: looked.result.status, objective: looked.result.objective };
572
+ return { status: looked.result.status, reason: looked.result.reason, objective: looked.result.objective };
541
573
  }
542
574
 
543
575
  function planStillActive(): boolean {
@@ -553,21 +585,57 @@ ${body}`.trim() },
553
585
  "show",
554
586
  await runCli<unknown>(["show", task.id], { extraEnv: { UNIPI_KANBOARD_PROJECT: project() } }),
555
587
  );
556
- if (value.status === "blocked") {
588
+ // The user moved the task out from under the runner (done/cancelled/
589
+ // backlog): stop its goal, leave the board alone.
590
+ const movedAway = ["done", "cancelled", "backlog"].includes(value.status);
591
+ if (movedAway && state.goalId) {
592
+ await callCommandRunner("unipi:goal-stop", ctx, { goalId: state.goalId });
593
+ }
594
+ // A still-drivable goal is parked so re-claiming this task resumes it.
595
+ // The id rides the release comment (activity survives; the run block
596
+ // does not — release clears it).
597
+ let parkedMarker = "";
598
+ if (!movedAway && state.mode === "goal" && state.goalId) {
599
+ const paused = await callCommandRunner<{ ok?: boolean; reason?: string }>(
600
+ "unipi:goal-pause",
601
+ ctx,
602
+ { goalId: state.goalId },
603
+ );
604
+ if (paused.found && paused.result?.ok) {
605
+ deps.debug(`goal ${state.goalId} parked for ${task.id}`);
606
+ parkedMarker = ` [parked goal: ${state.goalId}]`;
607
+ }
608
+ }
609
+ if (movedAway) {
610
+ // The user's move stands — no release, just reset the runner.
611
+ ctx.ui.notify(`▣ ${task.id} was moved to ${value.status} outside the runner — goal stopped`, "warning");
612
+ } else if (value.status === "blocked") {
557
613
  const note = (value.activity ?? []).slice(-1)[0]?.text ?? "blocked";
558
614
  ctx.ui.notify(`▣ ${task.id} blocked: ${note}`, "warning");
559
615
  } else if (outcome === "in_review") {
560
- await runCli(["release", task.id, "--to", "in_review", "--comment", comment], {
616
+ await runCli(["release", task.id, "--to", "in_review", "--comment", comment + parkedMarker], {
561
617
  extraEnv: { UNIPI_KANBOARD_PROJECT: project() },
562
618
  });
563
619
  const first = comment.split("\n")[0]?.slice(0, 120) ?? "";
564
620
  ctx.ui.notify(`✓ ${task.id} → In Review: ${first}`, "info");
565
621
  } else {
566
- await runCli(["release", task.id, "--to", outcome, "--comment", comment], {
622
+ await runCli(["release", task.id, "--to", outcome, "--comment", comment + parkedMarker], {
567
623
  extraEnv: { UNIPI_KANBOARD_PROJECT: project() },
568
624
  });
569
625
  ctx.ui.notify(`↩ ${task.id} → ${outcome === "todo" ? "Todo" : "Blocked"}: ${comment.slice(0, 120)}`, "warning");
570
626
  }
627
+ if (movedAway) {
628
+ state.consumedText = state.lastText.trim();
629
+ state.task = null;
630
+ state.goalId = null;
631
+ state.endsSinceSend = 0;
632
+ state.phase = "idle";
633
+ persist("idle");
634
+ setStatus(ctx, state.stopAfterCurrent ? undefined : describe());
635
+ await postBoardProgress();
636
+ await continueLoop(ctx);
637
+ return;
638
+ }
571
639
  } catch (error) {
572
640
  ctx.ui.notify(
573
641
  `kanboard: could not release ${task.id} — ${error instanceof KanboardCliError ? error.message : String(error)}`,
@@ -878,13 +946,20 @@ ${body}`.trim() },
878
946
  }
879
947
  if (state.mode === "goal" && state.goalId) {
880
948
  const goal = await goalStatus();
881
- if (goal && goal.status !== "complete") {
882
- if (["failed", "blocked", "stalled", "abandoned", "budget_exhausted"].includes(goal.status)) {
883
- await finishTask(ctx, "todo", `goal ${goal.status}`);
949
+ if (goal) {
950
+ // A user-stopped goal is NOT success: the task goes back to Todo.
951
+ if (goal.status === "complete" && goal.reason === "complete(user_requested)") {
952
+ await finishTask(ctx, "todo", "goal stopped by user");
953
+ return;
954
+ }
955
+ if (goal.status !== "complete") {
956
+ if (["failed", "blocked", "stalled", "abandoned", "budget_exhausted"].includes(goal.status)) {
957
+ await finishTask(ctx, "todo", `goal ${goal.status}`);
958
+ return;
959
+ }
960
+ deps.debug(`settle deferred: goal ${goal.status}`);
884
961
  return;
885
962
  }
886
- deps.debug(`settle deferred: goal ${goal.status}`);
887
- return;
888
963
  }
889
964
  }
890
965
 
package/src/settings.ts CHANGED
@@ -31,6 +31,8 @@ export interface KanboardSettings {
31
31
  maxSessions: number;
32
32
  /** `add` calls allowed per -do/runner window (0 = unlimited). */
33
33
  turnAddLimit: number;
34
+ /** Write credits a /unipi:kanboard-do grants (each board write costs 1). */
35
+ doCredits: number;
34
36
  /** Strategy for unlabelled tasks ("auto" = jev decides). */
35
37
  defaultStrategy: "auto" | "none" | "goal" | "ralph" | "swarm" | "graph";
36
38
  /** Plan-first for unlabelled tasks (jev is never asked about plan). */
@@ -53,6 +55,7 @@ export const DEFAULT_SETTINGS: KanboardSettings = {
53
55
  queueMax: 10,
54
56
  maxSessions: 2,
55
57
  turnAddLimit: 20,
58
+ doCredits: 10,
56
59
  defaultStrategy: "auto",
57
60
  defaultPlan: false,
58
61
  blocking: "avoid",
@@ -113,6 +116,7 @@ export function registerKanboardSettings(): void {
113
116
  { key: "queueMax", type: "number", label: "-do queue limit", min: 0, zeroLabel: "unlimited", description: "Tasks a session may queue for the runner" },
114
117
  { key: "maxSessions", type: "number", label: "Sessions working at once (per project)", min: 1, description: "Distinct sessions holding in_progress tasks" },
115
118
  { key: "turnAddLimit", type: "number", label: "New tasks per turn", min: 0, zeroLabel: "unlimited", description: "`add` calls allowed per -do turn or runner task" },
119
+ { 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" },
116
120
  ],
117
121
  },
118
122
  {
@@ -183,6 +187,7 @@ export function readKanboardSettings(cwd: string = process.cwd()): KanboardSetti
183
187
  queueMax: typeof raw.queueMax === "number" && raw.queueMax >= 0 ? raw.queueMax : DEFAULT_SETTINGS.queueMax,
184
188
  maxSessions: typeof raw.maxSessions === "number" && raw.maxSessions >= 1 ? raw.maxSessions : DEFAULT_SETTINGS.maxSessions,
185
189
  turnAddLimit: typeof raw.turnAddLimit === "number" && raw.turnAddLimit >= 0 ? raw.turnAddLimit : DEFAULT_SETTINGS.turnAddLimit,
190
+ doCredits: typeof raw.doCredits === "number" && raw.doCredits >= 0 ? raw.doCredits : DEFAULT_SETTINGS.doCredits,
186
191
  defaultStrategy:
187
192
  typeof raw.defaultStrategy === "string" &&
188
193
  ["auto", "none", "goal", "ralph", "swarm", "graph"].includes(raw.defaultStrategy)