kanbango 2.5.0 → 3.0.2

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/.ai/lessons.jsonl CHANGED
@@ -0,0 +1,4 @@
1
+ {"id":"260729-3h4","ts":"2026-07-29","scope":"proj","tags":["tests","mcp","cli","review"],"rule":"DO Add automated tests for every new public CLI/MCP workflow before shipping","when":"api-review","sev":2,"hits":1}
2
+ {"id":"260729-1ur","ts":"2026-07-29","scope":"proj","tags":["javascript","objects"],"rule":"DON'T Use empty object fallbacks in spreads like `...(obj || {})` since spreading falsy is safe","when":"object spread","sev":2,"hits":1}
3
+ {"id":"260729-5eo","ts":"2026-07-29","scope":"proj","tags":["processes","mcp","ownership"],"rule":"DO Track process ownership before stopping or cleaning up discovered services","when":"gui-process management","sev":2,"hits":1}
4
+ {"id":"260729-1ch","ts":"2026-07-29","scope":"proj","tags":["gui","process","mcp"],"rule":"DO GUI/MCP stop must only SIGTERM processes spawned by the current process; treat port-file PIDs as external_running","when":"gui-process management","sev":2,"hits":1}
@@ -1 +1 @@
1
- {"ts":"2026-07-29T09:29:19.070Z","sessionID":"ses_05beb2173ffe1rCJQhZBUHuLgs","added":[],"bumped":[],"error":"llm-parse-fallback"}
1
+ {"ts":"2026-07-29T10:38:42.184Z","sessionID":"ses_052c1be3fffeNPoV7VO6z40X2I","added":["260729-1ch"],"bumped":[]}
package/AGENTS.md CHANGED
@@ -2,6 +2,17 @@
2
2
 
3
3
  This document provides build commands, testing procedures, and code style guidelines for working with the kanbango codebase.
4
4
 
5
+ ## Kanbango MCP — token rules (when using this board)
6
+
7
+ Source of truth: `agent-playbook.js` (also MCP tool descriptions + `kanban_read` `operation=help`).
8
+
9
+ - list: `col` filter, `view=summary`; keep `task_id`s; no full-board re-list after every write
10
+ - show: `view=execution` while coding; `full` only if needed
11
+ - create once with `description`, `specs`, `in_scope`, `out_of_scope`, `acceptance_criteria`
12
+ - move/update: `return=none`; subtasks = full array replace (no toggle)
13
+ - non-trivial work: `plan_create` → `plan_advance` → `plan_evidence` (real tests, truncated logs) → `plan_done`
14
+ - gui: `status` before `start`; `stop` only owned; `external_running` = do not kill
15
+
5
16
  ## Build & Development Commands
6
17
 
7
18
  ### Available Scripts
package/CHANGELOG.md CHANGED
@@ -5,6 +5,42 @@ All notable changes to kanbango will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [3.0.2] - 2026-07-29
9
+
10
+ ### Added
11
+ - `agent-playbook.js` — single source of truth for agent token rules
12
+ - `kanban_read` `operation=help` returns the playbook (no board I/O)
13
+ - Test that `tools/list` descriptions match the playbook module
14
+
15
+ ### Changed
16
+ - MCP tool descriptions are loaded from `agent-playbook.js` (not duplicated prose)
17
+ - `LLM_AGENTS.md` clarified as human setup docs; agents rely on tool descriptions
18
+
19
+ ## [3.0.1] - 2026-07-29
20
+
21
+ ### Changed
22
+ - MCP tool descriptions encode a token-efficient agent playbook (cheap list/show, fat create once, `return=none`, plan_* only when needed, GUI ownership)
23
+ - `LLM_AGENTS.md`, `AGENTS.md`, and `README.md` document the same rules for project-scoped agents
24
+
25
+ ## [3.0.0] - 2026-07-29
26
+
27
+ ### Breaking
28
+ - New task IDs are zero-padded numeric strings (`001`), not `PI-001-slug`
29
+ - MCP/CLI `toggle` removed; update the full `subtasks` list via `update`
30
+ - Legacy task field alias `tasks` removed; use `subtasks` only
31
+ - `kanban_gui` `stop` only stops a GUI **spawned by the current MCP process**. An external GUI returns `status: "external_running"` and is **not** sent SIGTERM (PID-reuse safety)
32
+ - `kanban_gui` `status` may return `running` (owned), `external_running` (discovered), or `not_running`
33
+
34
+ ### Added
35
+ - Accepted-plan workflow: `plan.js` + CLI `kanban plan <action> --json` + MCP `plan_*` actions on `kanban_manage`
36
+ - `gui-registry.js` — GUI port file, preferred port, discover helpers (extracted from `kanban.js`)
37
+ - Regression tests for plan workflow and GUI process ownership
38
+
39
+ ### Changed
40
+ - Create field policy: only `title` is hard-required; `description`, `specs`, `in_scope`, `out_of_scope`, and `acceptance_criteria` are strongly recommended
41
+ - MCP `kanban_manage` create/plan_create returns `warnings` + `missing_recommended` when recommended fields are omitted (still succeeds)
42
+ - MCP tool descriptions and LLM agent docs push agents to always fill planning boundaries
43
+
8
44
  ## [2.5.0] - 2026-07-27
9
45
 
10
46
  ### Changed
package/LLM_AGENTS.md CHANGED
@@ -1,6 +1,12 @@
1
1
  # MCP Server Guide for LLM Agents
2
2
 
3
- Complete guide for integrating kanbango MCP server into LLM agents and AI assistants.
3
+ Human-oriented setup guide. **Agents do not load this file automatically.**
4
+
5
+ Canonical usage rules live in code (`agent-playbook.js`) and are injected into MCP
6
+ `tools/list` descriptions. After install, the agent only needs the MCP server —
7
+ not this markdown.
8
+
9
+ Optional refresh in-session: `kanban_read` with `operation: "help"`.
4
10
 
5
11
  ## Quick Integration
6
12
 
@@ -63,11 +69,12 @@ Read tasks from kanban board. Can list all tasks, filter by column/epic, or get
63
69
  **Operations:**
64
70
  - `list` - Get all tasks (with optional filters)
65
71
  - `show` - Get specific task details
72
+ - `help` - Return token playbook from `agent-playbook.js` (no board I/O)
66
73
 
67
74
  **Parameters:**
68
75
  ```json
69
76
  {
70
- "operation": "list", // "list" or "show"
77
+ "operation": "list", // "list" | "show" | "help"
71
78
  "task_id": "014", // Required for "show"
72
79
  "col": "planned", // Optional: "active" | "planned" | "icebox" | "done"
73
80
  "epic": "Phase 1" // Optional: filter by epic group
@@ -232,6 +239,11 @@ Patch-update task fields:
232
239
 
233
240
  Control the web GUI server: start, stop, or check status.
234
241
 
242
+ **Ownership (important):**
243
+ - `stop` only sends SIGTERM to a GUI **spawned by this MCP process** (`owned: true`).
244
+ - If the GUI was started elsewhere (CLI `kanban serve`, another MCP), `stop` returns `external_running` and does **not** kill that PID.
245
+ - `status` distinguishes `running` (owned), `external_running` (discovered via port file), and `not_running`.
246
+
235
247
  **Port resolution (start):**
236
248
  1. Explicit `port` argument, if provided
237
249
  2. Else `KANBANGO_GUI_PORT` env
@@ -242,9 +254,9 @@ If the preferred port is busy, the server picks the next free port. Always trust
242
254
  **Auto-start with MCP:** set `KANBANGO_AUTO_GUI=1` in the MCP server env. GUI starts when MCP starts; use `status` to read the URL.
243
255
 
244
256
  **Actions:**
245
- - `start` - Launch the GUI server
246
- - `stop` - Kill the GUI server
247
- - `status` - Check if the GUI is running (reads live process or port file)
257
+ - `start` - Launch the GUI server (or report already_running owned/external)
258
+ - `stop` - Stop only MCP-owned GUI
259
+ - `status` - Check GUI state
248
260
 
249
261
  **Parameters:**
250
262
  ```json
@@ -289,31 +301,57 @@ Check status:
289
301
  ```json
290
302
  {
291
303
  "status": "started",
304
+ "owned": true,
292
305
  "port": 5623,
293
306
  "pid": 12345,
294
307
  "url": "http://localhost:5623"
295
308
  }
296
309
  ```
297
310
 
298
- **Response (stop):**
311
+ **Response (stop - owned):**
299
312
  ```json
300
313
  {
301
314
  "status": "stopping",
315
+ "owned": true,
302
316
  "port": 5623,
303
317
  "pid": 12345
304
318
  }
305
319
  ```
306
320
 
307
- **Response (status - running):**
321
+ **Response (stop - external refused):**
322
+ ```json
323
+ {
324
+ "status": "external_running",
325
+ "owned": false,
326
+ "port": 5623,
327
+ "pid": 12345,
328
+ "url": "http://localhost:5623",
329
+ "hint": "GUI was not started by this MCP process; stop refused."
330
+ }
331
+ ```
332
+
333
+ **Response (status - running owned):**
308
334
  ```json
309
335
  {
310
336
  "status": "running",
337
+ "owned": true,
311
338
  "port": 5623,
312
339
  "pid": 12345,
313
340
  "url": "http://localhost:5623"
314
341
  }
315
342
  ```
316
343
 
344
+ **Response (status - external):**
345
+ ```json
346
+ {
347
+ "status": "external_running",
348
+ "owned": false,
349
+ "port": 5623,
350
+ "pid": 99999,
351
+ "url": "http://localhost:5623"
352
+ }
353
+ ```
354
+
317
355
  **Response (status - not running):**
318
356
  ```json
319
357
  {
@@ -426,17 +464,34 @@ Check status:
426
464
 
427
465
  ---
428
466
 
429
- ## Best Practices for LLM Agents
430
-
431
- 1. **Always use `kanban_read` first** - Discover existing tasks before creating new ones
432
- 2. **Numeric task lookup** - Task IDs are zero-padded numbers (e.g. `"035"`). Unpadded numbers such as `"35"` also work in any `task_id` parameter.
433
- 3. **Use `col` filter** - Narrow down to relevant column when listing
434
- 4. **Use `epic` grouping** - Organize tasks by features/phases
435
- 5. **Create with planning fields** - Always include description, specs, in_scope, out_of_scope, acceptance_criteria; treat `warnings`/`missing_recommended` as a signal to fill gaps
436
- 6. **Work through subtasks** - Toggle each subtask as you complete them
437
- 7. **Move tasks through workflow** - planned → active → done progression
438
- 8. **Use `show` operation** - Get full task details including subtasks
439
- 9. **Handle task IDs** - Always use the full task ID returned from create/show
467
+ ## Token-efficient playbook (source of truth)
468
+
469
+ **Edit `agent-playbook.js` only.** MCP tool descriptions and `operation=help` are built from it.
470
+
471
+ At runtime agents already receive the rules via tool descriptions. Humans can also call:
472
+
473
+ ```json
474
+ { "operation": "help" }
475
+ ```
476
+
477
+ on `kanban_read` (no board I/O).
478
+
479
+ ### Drop-in for project `AGENTS.md` (optional)
480
+
481
+ Same bullets as `DROP_IN_RULE` in `agent-playbook.js` — copy if you want them outside MCP:
482
+
483
+ ```text
484
+ Kanbango MCP — token rules:
485
+ - list: col filter, view=summary; keep task_ids; no full-board re-list after writes
486
+ - show: view=execution while coding; full only if needed
487
+ - create once with description,specs,in_scope,out_of_scope,acceptance_criteria
488
+ - move/update: return=none; subtasks=full array replace
489
+ - non-trivial: plan_create → plan_advance → plan_evidence (real tests, truncated logs) → plan_done
490
+ - gui: status before start; stop only owned; external_running = do not kill
491
+ ```
492
+
493
+ ### Columns
494
+ `planned` → `active` (few at a time) → `done`. Use `icebox` for parked work.
440
495
 
441
496
  ---
442
497
 
package/README.md CHANGED
@@ -129,9 +129,11 @@ Once connected, your agent gets access to these tools:
129
129
 
130
130
  | Tool | What it does |
131
131
  |------|-------------|
132
- | `kanban_read` | List tasks, filter by column/epic, show details |
133
- | `kanban_manage` | Create, move, patch-update tasks |
134
- | `kanban_gui` | Start, stop, or check web GUI status (returns the real URL/port) |
132
+ | `kanban_read` | List/show tasks (`view=summary` by default cheap) |
133
+ | `kanban_manage` | Create, move, update, plan_* workflow |
134
+ | `kanban_gui` | Start / status / stop (stop only kills GUI this MCP started) |
135
+
136
+ **Token tip for agents:** rules ship inside MCP tool descriptions (`agent-playbook.js`). Optional: `kanban_read` → `operation: "help"`. Human setup notes: [LLM_AGENTS.md](./LLM_AGENTS.md).
135
137
 
136
138
  Your agent stays in sync with your real board — every change is persisted as JSON files.
137
139
 
@@ -146,6 +148,7 @@ Your agent stays in sync with your real board — every change is persisted as J
146
148
  | `kanban add <TITLE>` | Add a new task |
147
149
  | `kanban move <ID> <COL>` | Move task |
148
150
  | `kanban mcp-init` | Generate MCP config files |
151
+ | `kanban plan <action> --json '{...}'` | Accepted-plan workflow (create/advance/evidence/done/status) |
149
152
 
150
153
  ## Web GUI
151
154
 
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Single source of truth for agent-facing kanbango usage rules.
3
+ * MCP tool descriptions are built from this module — not from LLM_AGENTS.md.
4
+ * Docs may quote DROP_IN_RULE; keep them in sync by editing here only.
5
+ */
6
+
7
+ const DROP_IN_RULE = [
8
+ 'Kanbango MCP — token rules:',
9
+ '- list: col filter, view=summary; keep task_ids; no full-board re-list after writes',
10
+ '- show: view=execution while coding; full only if needed',
11
+ '- create once with description,specs,in_scope,out_of_scope,acceptance_criteria',
12
+ '- move/update: return=none; subtasks=full array replace',
13
+ '- non-trivial: plan_create → plan_advance → plan_evidence (real tests, truncated logs) → plan_done',
14
+ '- gui: status before start; stop only owned; external_running = do not kill'
15
+ ].join('\n');
16
+
17
+ const TOOL_DESCRIPTIONS = {
18
+ kanban_read: [
19
+ 'Read board. TOKEN RULES: list defaults to view=summary (id/title/col/progress only).',
20
+ 'Always pass col when possible. Prefer show+view=execution over full.',
21
+ 'Do not re-list the whole board after every write — keep task_id from create/move.',
22
+ 'IDs are numeric ("014" or "14"). views: summary|planning|execution|full; fields[] overrides view.',
23
+ 'operation=help returns this playbook as short text (no board I/O).'
24
+ ].join(' '),
25
+
26
+ kanban_manage: [
27
+ 'Write board / plan. TOKEN RULES: one create with all planning fields beats many updates;',
28
+ 'after write use return=none (or summary). Do not dump full task unless needed.',
29
+ 'Actions: create|move|update (daily); plan_create→plan_advance→plan_evidence→plan_done (non-trivial only).',
30
+ 'create/plan_create: title required; also send description,specs,in_scope,out_of_scope,acceptance_criteria',
31
+ '(missing → warnings, not failure). move: task_id+column. update: task_id + fields or subtasks[] full list',
32
+ '(no toggle). plan_evidence needs real test run: diff,test_command,stdout,stderr,exit_code — truncate logs.',
33
+ 'Example create: {"action":"create","title":"Ship image","description":"...","specs":"...","in_scope":["CLI"],',
34
+ '"out_of_scope":["GUI"],"acceptance_criteria":["npm test passes"],"col":"planned"}'
35
+ ].join(' '),
36
+
37
+ kanban_gui: [
38
+ 'Web GUI control. Prefer status before start. stop only kills GUI this MCP spawned;',
39
+ 'external_running = do not retry kill — use the returned url or leave it.',
40
+ 'status: running|external_running|not_running. Rarely needed mid-task — open once if user wants UI.'
41
+ ].join(' ')
42
+ };
43
+
44
+ const MUST_CONTAIN = [
45
+ 'TOKEN RULES',
46
+ 'view=summary',
47
+ 'return=none',
48
+ 'plan_create',
49
+ 'external_running',
50
+ 'subtasks'
51
+ ];
52
+
53
+ function playbookHelpPayload() {
54
+ return {
55
+ source: 'agent-playbook.js',
56
+ note: 'Canonical rules for agents. Same text drives MCP tool descriptions. LLM_AGENTS.md is human docs only.',
57
+ drop_in_rule: DROP_IN_RULE,
58
+ tools: {
59
+ kanban_read: TOOL_DESCRIPTIONS.kanban_read,
60
+ kanban_manage: TOOL_DESCRIPTIONS.kanban_manage,
61
+ kanban_gui: TOOL_DESCRIPTIONS.kanban_gui
62
+ }
63
+ };
64
+ }
65
+
66
+ module.exports = {
67
+ DROP_IN_RULE,
68
+ TOOL_DESCRIPTIONS,
69
+ MUST_CONTAIN,
70
+ playbookHelpPayload
71
+ };
package/bin/kanban-cmd.js CHANGED
@@ -13,7 +13,7 @@ function findPython() {
13
13
  if (result.status === 0) {
14
14
  return cmd;
15
15
  }
16
- } catch (e) {
16
+ } catch {
17
17
  continue;
18
18
  }
19
19
  }
package/bin/kanban.js CHANGED
@@ -2,12 +2,12 @@
2
2
 
3
3
  const kanban = require('../kanban.js');
4
4
  const plan = require('../plan.js');
5
+ const guiRegistry = require('../gui-registry.js');
5
6
  const http = require('http');
6
7
  const fs = require('fs');
7
8
  const path = require('path');
8
9
 
9
10
  const BACKLOG = path.join(process.cwd(), 'backlog');
10
- const COLS = kanban.COLS;
11
11
 
12
12
  function shortId(taskId) {
13
13
  const match = taskId.match(/^(?:[A-Z]+-)?(\d+)/);
@@ -357,11 +357,11 @@ async function serveWeb(port) {
357
357
  }
358
358
 
359
359
  const actualPort = server.address().port;
360
- const portInfo = await kanban.writeGuiPortFile({ port: actualPort, pid: process.pid });
360
+ const portInfo = await guiRegistry.writeGuiPortFile({ port: actualPort, pid: process.pid });
361
361
 
362
362
  async function cleanupGuiPortFile() {
363
363
  try {
364
- await kanban.clearGuiPortFile({ pid: process.pid });
364
+ await guiRegistry.clearGuiPortFile({ pid: process.pid });
365
365
  } catch {
366
366
  // best-effort cleanup
367
367
  }
@@ -458,9 +458,9 @@ async function main() {
458
458
  const args = process.argv.slice(2);
459
459
  const cmd = args[0];
460
460
 
461
- if (!cmd || cmd === 'serve') {
462
- const port = kanban.resolvePreferredGuiPort(args[1]);
463
- await serveWeb(port);
461
+ if (!cmd || cmd === 'serve') {
462
+ const port = guiRegistry.resolvePreferredGuiPort(args[1]);
463
+ await serveWeb(port);
464
464
  } else if (cmd === 'init') {
465
465
  await cliInit();
466
466
  } else if (cmd === 'mcp-init') {
@@ -0,0 +1,148 @@
1
+ const fs = require('fs').promises;
2
+ const path = require('path');
3
+
4
+ const BACKLOG = path.join(process.cwd(), 'backlog');
5
+ const GUI_PORT_FILE = '.kanbango-gui.json';
6
+ const GUI_PORT_MIN = 5510;
7
+ const GUI_PORT_MAX = 5999;
8
+ const GUI_PORT_SPAN = GUI_PORT_MAX - GUI_PORT_MIN + 1;
9
+
10
+ function createGuiError(code, message, hint, details = {}, retryable = false, status = 400) {
11
+ const error = new Error(message);
12
+ error.code = code;
13
+ error.hint = hint;
14
+ error.details = details;
15
+ error.retryable = retryable;
16
+ error.status = status;
17
+ return error;
18
+ }
19
+
20
+ function guiPortFilePath() {
21
+ return path.join(BACKLOG, GUI_PORT_FILE);
22
+ }
23
+
24
+ function hashCwdToPort(cwd = process.cwd()) {
25
+ let hash = 0;
26
+ const input = String(cwd);
27
+ for (let i = 0; i < input.length; i++) {
28
+ hash = ((hash << 5) - hash + input.charCodeAt(i)) | 0;
29
+ }
30
+ return GUI_PORT_MIN + (Math.abs(hash) % GUI_PORT_SPAN);
31
+ }
32
+
33
+ function normalizeGuiPort(value) {
34
+ const parsed = Number.parseInt(value, 10);
35
+ if (!Number.isFinite(parsed) || parsed < 1 || parsed > 65535) {
36
+ return null;
37
+ }
38
+ return parsed;
39
+ }
40
+
41
+ function resolvePreferredGuiPort(explicitPort) {
42
+ if (explicitPort !== undefined && explicitPort !== null && explicitPort !== '') {
43
+ const fromArg = normalizeGuiPort(explicitPort);
44
+ if (fromArg) return fromArg;
45
+ }
46
+
47
+ const fromEnv = normalizeGuiPort(process.env.KANBANGO_GUI_PORT);
48
+ if (fromEnv) return fromEnv;
49
+
50
+ return hashCwdToPort(process.cwd());
51
+ }
52
+
53
+ function isPidAlive(pid) {
54
+ const n = Number.parseInt(pid, 10);
55
+ if (!Number.isFinite(n) || n <= 0) return false;
56
+ try {
57
+ process.kill(n, 0);
58
+ return true;
59
+ } catch {
60
+ return false;
61
+ }
62
+ }
63
+
64
+ async function ensureBacklogDir() {
65
+ await fs.mkdir(BACKLOG, { recursive: true });
66
+ }
67
+
68
+ async function writeGuiPortFile({ port, pid = process.pid } = {}) {
69
+ const normalizedPort = normalizeGuiPort(port);
70
+ if (!normalizedPort) {
71
+ throw createGuiError(
72
+ 'VALIDATION_ERROR',
73
+ 'Invalid GUI port',
74
+ 'Use an integer between 1 and 65535',
75
+ { port },
76
+ false,
77
+ 400
78
+ );
79
+ }
80
+
81
+ await ensureBacklogDir();
82
+ const data = {
83
+ port: normalizedPort,
84
+ pid,
85
+ url: `http://localhost:${normalizedPort}`,
86
+ cwd: process.cwd(),
87
+ started_at: new Date().toISOString()
88
+ };
89
+ await fs.writeFile(guiPortFilePath(), JSON.stringify(data, null, 2), 'utf-8');
90
+ return data;
91
+ }
92
+
93
+ async function readGuiPortFile() {
94
+ try {
95
+ const raw = await fs.readFile(guiPortFilePath(), 'utf-8');
96
+ const data = JSON.parse(raw);
97
+ if (!data || !normalizeGuiPort(data.port)) return null;
98
+ return data;
99
+ } catch (error) {
100
+ if (error.code === 'ENOENT') return null;
101
+ return null;
102
+ }
103
+ }
104
+
105
+ async function clearGuiPortFile({ pid, force = false } = {}) {
106
+ const info = await readGuiPortFile();
107
+ if (!info) return false;
108
+ if (!force && pid !== undefined && info.pid !== pid) return false;
109
+ if (!force && pid === undefined && info.pid !== process.pid) return false;
110
+
111
+ try {
112
+ await fs.unlink(guiPortFilePath());
113
+ return true;
114
+ } catch (error) {
115
+ if (error.code === 'ENOENT') return false;
116
+ throw error;
117
+ }
118
+ }
119
+
120
+ async function discoverRunningGui() {
121
+ const info = await readGuiPortFile();
122
+ if (!info || !isPidAlive(info.pid)) {
123
+ if (info) await clearGuiPortFile({ force: true });
124
+ return null;
125
+ }
126
+ return {
127
+ status: 'running',
128
+ port: info.port,
129
+ pid: info.pid,
130
+ url: info.url || `http://localhost:${info.port}`,
131
+ cwd: info.cwd,
132
+ started_at: info.started_at
133
+ };
134
+ }
135
+
136
+ module.exports = {
137
+ hashCwdToPort,
138
+ normalizeGuiPort,
139
+ resolvePreferredGuiPort,
140
+ isPidAlive,
141
+ writeGuiPortFile,
142
+ readGuiPortFile,
143
+ clearGuiPortFile,
144
+ discoverRunningGui,
145
+ guiPortFilePath,
146
+ GUI_PORT_MIN,
147
+ GUI_PORT_MAX
148
+ };
package/index.js CHANGED
@@ -10,8 +10,12 @@
10
10
 
11
11
  const kanban = require('./kanban.js');
12
12
  const plan = require('./plan.js');
13
+ const guiRegistry = require('./gui-registry.js');
14
+ const playbook = require('./agent-playbook.js');
13
15
 
14
16
  module.exports = {
15
17
  kanban,
16
18
  plan,
19
+ guiRegistry,
20
+ playbook,
17
21
  };
package/kanban.js CHANGED
@@ -3,10 +3,6 @@ const path = require('path');
3
3
 
4
4
  const BACKLOG = path.join(process.cwd(), 'backlog');
5
5
  const COLS = ['active', 'planned', 'icebox', 'done'];
6
- const GUI_PORT_FILE = '.kanbango-gui.json';
7
- const GUI_PORT_MIN = 5510;
8
- const GUI_PORT_MAX = 5999;
9
- const GUI_PORT_SPAN = GUI_PORT_MAX - GUI_PORT_MIN + 1;
10
6
  const STATUS_MAP = {
11
7
  active: 'in_progress',
12
8
  planned: 'planned',
@@ -383,7 +379,7 @@ async function findFile(epicId) {
383
379
  const candidates = files
384
380
  .filter((file) => (file.endsWith('.json') || file.endsWith('.md'))
385
381
  && path.basename(file, path.extname(file)) === epicId)
386
- .sort((left, right) => (left.endsWith('.json') ? -1 : 1));
382
+ .sort((left, _right) => (left.endsWith('.json') ? -1 : 1));
387
383
  if (candidates[0]) {
388
384
  return path.join(colDir, candidates[0]);
389
385
  }
@@ -564,11 +560,6 @@ async function doCreate(title, column = 'planned', epicGroup = '—', extra = {}
564
560
  validateColumn(column, 'col');
565
561
 
566
562
  const nextId = await nextTaskNumber();
567
- const slug = title
568
- .toLowerCase()
569
- .replace(/[^a-z0-9]+/g, '-')
570
- .replace(/^-+|-+$/g, '')
571
- .substring(0, 25);
572
563
  const task = normalizeTask({
573
564
  id: String(nextId).padStart(3, '0'),
574
565
  title,
@@ -769,118 +760,6 @@ async function doUpdate(epicId, newTitle, newTasks) {
769
760
  }
770
761
  }
771
762
 
772
- function guiPortFilePath() {
773
- return path.join(BACKLOG, GUI_PORT_FILE);
774
- }
775
-
776
- function hashCwdToPort(cwd = process.cwd()) {
777
- let hash = 0;
778
- const input = String(cwd);
779
- for (let i = 0; i < input.length; i++) {
780
- hash = ((hash << 5) - hash + input.charCodeAt(i)) | 0;
781
- }
782
- return GUI_PORT_MIN + (Math.abs(hash) % GUI_PORT_SPAN);
783
- }
784
-
785
- function normalizeGuiPort(value) {
786
- const parsed = Number.parseInt(value, 10);
787
- if (!Number.isFinite(parsed) || parsed < 1 || parsed > 65535) {
788
- return null;
789
- }
790
- return parsed;
791
- }
792
-
793
- function resolvePreferredGuiPort(explicitPort) {
794
- if (explicitPort !== undefined && explicitPort !== null && explicitPort !== '') {
795
- const fromArg = normalizeGuiPort(explicitPort);
796
- if (fromArg) return fromArg;
797
- }
798
-
799
- const fromEnv = normalizeGuiPort(process.env.KANBANGO_GUI_PORT);
800
- if (fromEnv) return fromEnv;
801
-
802
- return hashCwdToPort(process.cwd());
803
- }
804
-
805
- function isPidAlive(pid) {
806
- const n = Number.parseInt(pid, 10);
807
- if (!Number.isFinite(n) || n <= 0) return false;
808
- try {
809
- process.kill(n, 0);
810
- return true;
811
- } catch {
812
- return false;
813
- }
814
- }
815
-
816
- async function writeGuiPortFile({ port, pid = process.pid } = {}) {
817
- const normalizedPort = normalizeGuiPort(port);
818
- if (!normalizedPort) {
819
- throw createKanbanError(
820
- 'VALIDATION_ERROR',
821
- 'Invalid GUI port',
822
- 'Use an integer between 1 and 65535',
823
- { port },
824
- false,
825
- 400
826
- );
827
- }
828
-
829
- await ensureBacklogDir();
830
- const data = {
831
- port: normalizedPort,
832
- pid,
833
- url: `http://localhost:${normalizedPort}`,
834
- cwd: process.cwd(),
835
- started_at: new Date().toISOString()
836
- };
837
- await fs.writeFile(guiPortFilePath(), JSON.stringify(data, null, 2), 'utf-8');
838
- return data;
839
- }
840
-
841
- async function readGuiPortFile() {
842
- try {
843
- const raw = await fs.readFile(guiPortFilePath(), 'utf-8');
844
- const data = JSON.parse(raw);
845
- if (!data || !normalizeGuiPort(data.port)) return null;
846
- return data;
847
- } catch (error) {
848
- if (error.code === 'ENOENT') return null;
849
- return null;
850
- }
851
- }
852
-
853
- async function clearGuiPortFile({ pid, force = false } = {}) {
854
- const info = await readGuiPortFile();
855
- if (!info) return false;
856
- if (!force && pid !== undefined && info.pid !== pid) return false;
857
- if (!force && pid === undefined && info.pid !== process.pid) return false;
858
-
859
- try {
860
- await fs.unlink(guiPortFilePath());
861
- return true;
862
- } catch (error) {
863
- if (error.code === 'ENOENT') return false;
864
- throw error;
865
- }
866
- }
867
-
868
- async function discoverRunningGui() {
869
- const info = await readGuiPortFile();
870
- if (!info || !isPidAlive(info.pid)) {
871
- if (info) await clearGuiPortFile({ force: true });
872
- return null;
873
- }
874
- return {
875
- status: 'running',
876
- port: info.port,
877
- pid: info.pid,
878
- url: info.url || `http://localhost:${info.port}`,
879
- cwd: info.cwd,
880
- started_at: info.started_at
881
- };
882
- }
883
-
884
763
  module.exports = {
885
764
  ensureBacklogDir,
886
765
  parseEpic,
@@ -899,19 +778,8 @@ module.exports = {
899
778
  missingRecommendedCreateFields,
900
779
  getProgress,
901
780
  resolveTaskId,
902
- hashCwdToPort,
903
- normalizeGuiPort,
904
- resolvePreferredGuiPort,
905
- isPidAlive,
906
- writeGuiPortFile,
907
- readGuiPortFile,
908
- clearGuiPortFile,
909
- discoverRunningGui,
910
- guiPortFilePath,
911
781
  COLS,
912
782
  STATUS_MAP,
913
783
  VIEW_FIELDS,
914
- RECOMMENDED_CREATE_FIELDS,
915
- GUI_PORT_MIN,
916
- GUI_PORT_MAX
784
+ RECOMMENDED_CREATE_FIELDS
917
785
  };
package/mcp-server.js CHANGED
@@ -8,6 +8,8 @@ const path = require('path');
8
8
  const pkg = require('./package.json');
9
9
  const kanban = require('./kanban.js');
10
10
  const plan = require('./plan.js');
11
+ const guiRegistry = require('./gui-registry.js');
12
+ const playbook = require('./agent-playbook.js');
11
13
 
12
14
  const COLS = kanban.COLS;
13
15
  const READ_VIEWS = Object.keys(kanban.VIEW_FIELDS);
@@ -17,7 +19,11 @@ let guiProcess = null;
17
19
  let guiPort = null;
18
20
 
19
21
  function normalizePort(value) {
20
- return kanban.normalizeGuiPort(value);
22
+ return guiRegistry.normalizeGuiPort(value);
23
+ }
24
+
25
+ function ownsGuiProcess() {
26
+ return Boolean(guiProcess && guiProcess.exitCode === null);
21
27
  }
22
28
 
23
29
  function sleep(ms) {
@@ -41,7 +47,7 @@ async function waitForGuiReady(pid, timeoutMs = GUI_READY_TIMEOUT_MS) {
41
47
  );
42
48
  }
43
49
 
44
- const info = await kanban.discoverRunningGui();
50
+ const info = await guiRegistry.discoverRunningGui();
45
51
  if (info && info.pid === pid) return info;
46
52
 
47
53
  await sleep(GUI_READY_POLL_MS);
@@ -145,27 +151,28 @@ async function startGuiServer(port) {
145
151
  throw invalidRequest('Invalid port', 'Use an integer between 1 and 65535', { port });
146
152
  }
147
153
 
148
- if (guiProcess && guiProcess.exitCode === null && guiPort) {
154
+ if (ownsGuiProcess() && guiPort) {
149
155
  return {
150
156
  status: 'already_running',
157
+ owned: true,
151
158
  port: guiPort,
152
159
  pid: guiProcess.pid,
153
160
  url: `http://localhost:${guiPort}`
154
161
  };
155
162
  }
156
163
 
157
- const existing = await kanban.discoverRunningGui();
164
+ const existing = await guiRegistry.discoverRunningGui();
158
165
  if (existing) {
159
- guiPort = existing.port;
160
166
  return {
161
167
  status: 'already_running',
168
+ owned: false,
162
169
  port: existing.port,
163
170
  pid: existing.pid,
164
171
  url: existing.url
165
172
  };
166
173
  }
167
174
 
168
- const desiredPort = kanban.resolvePreferredGuiPort(port);
175
+ const desiredPort = guiRegistry.resolvePreferredGuiPort(port);
169
176
  await kanban.ensureBacklogDir();
170
177
 
171
178
  const scriptPath = path.join(__dirname, 'bin', 'kanban.js');
@@ -189,6 +196,7 @@ async function startGuiServer(port) {
189
196
 
190
197
  return {
191
198
  status: 'started',
199
+ owned: true,
192
200
  port: ready.port,
193
201
  pid: ready.pid,
194
202
  url: ready.url
@@ -196,61 +204,70 @@ async function startGuiServer(port) {
196
204
  }
197
205
 
198
206
  async function stopGuiServer() {
199
- const trackedRunning = guiProcess && guiProcess.exitCode === null;
200
- const discovered = trackedRunning ? null : await kanban.discoverRunningGui();
201
-
202
- if (!trackedRunning && !discovered) {
203
- guiProcess = null;
204
- guiPort = null;
205
- return { status: 'not_running' };
206
- }
207
-
208
- const port = trackedRunning ? guiPort : discovered.port;
209
- const pid = trackedRunning ? guiProcess.pid : discovered.pid;
210
-
211
- if (trackedRunning) {
207
+ if (ownsGuiProcess()) {
208
+ const port = guiPort;
209
+ const pid = guiProcess.pid;
212
210
  guiProcess.kill();
213
- } else if (pid) {
214
- try {
215
- process.kill(pid, 'SIGTERM');
216
- } catch {
217
- // process may already be gone
211
+
212
+ const deadline = Date.now() + 2000;
213
+ while (Date.now() < deadline) {
214
+ const still = await guiRegistry.discoverRunningGui();
215
+ if (!still || still.pid !== pid) break;
216
+ await sleep(50);
218
217
  }
219
- }
220
218
 
221
- const deadline = Date.now() + 2000;
222
- while (Date.now() < deadline) {
223
- const still = await kanban.discoverRunningGui();
224
- if (!still || still.pid !== pid) break;
225
- await sleep(50);
219
+ await guiRegistry.clearGuiPortFile({ force: true });
220
+ guiProcess = null;
221
+ guiPort = null;
222
+ return { status: 'stopping', owned: true, port, pid };
226
223
  }
227
224
 
228
- await kanban.clearGuiPortFile({ force: true });
229
225
  guiProcess = null;
230
226
  guiPort = null;
231
227
 
232
- return { status: 'stopping', port, pid };
228
+ const discovered = await guiRegistry.discoverRunningGui();
229
+ if (!discovered) {
230
+ return { status: 'not_running' };
231
+ }
232
+
233
+ return {
234
+ status: 'external_running',
235
+ owned: false,
236
+ port: discovered.port,
237
+ pid: discovered.pid,
238
+ url: discovered.url,
239
+ hint: 'GUI was not started by this MCP process; stop refused. Stop it from the owning terminal or kill that PID manually.'
240
+ };
233
241
  }
234
242
 
235
243
  async function guiStatus() {
236
- if (guiProcess && guiProcess.exitCode === null && guiPort) {
244
+ if (ownsGuiProcess() && guiPort) {
237
245
  return {
238
246
  status: 'running',
247
+ owned: true,
239
248
  port: guiPort,
240
249
  pid: guiProcess.pid,
241
250
  url: `http://localhost:${guiPort}`
242
251
  };
243
252
  }
244
253
 
245
- const discovered = await kanban.discoverRunningGui();
254
+ guiProcess = null;
255
+ guiPort = null;
256
+
257
+ const discovered = await guiRegistry.discoverRunningGui();
246
258
  if (!discovered) {
247
- guiProcess = null;
248
- guiPort = null;
249
259
  return { status: 'not_running' };
250
260
  }
251
261
 
252
- guiPort = discovered.port;
253
- return discovered;
262
+ return {
263
+ status: 'external_running',
264
+ owned: false,
265
+ port: discovered.port,
266
+ pid: discovered.pid,
267
+ url: discovered.url,
268
+ cwd: discovered.cwd,
269
+ started_at: discovered.started_at
270
+ };
254
271
  }
255
272
 
256
273
  const server = new Server(
@@ -270,37 +287,37 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
270
287
  tools: [
271
288
  {
272
289
  name: 'kanban_read',
273
- description: 'Read tasks from the board. operation=list returns multiple tasks with optional col/epic filters. operation=show requires task_id. Use view for preset payload sizes or fields for exact field selection.',
290
+ description: playbook.TOOL_DESCRIPTIONS.kanban_read,
274
291
  inputSchema: {
275
292
  type: 'object',
276
293
  properties: {
277
294
  operation: {
278
295
  type: 'string',
279
- enum: ['list', 'show'],
280
- description: "Operation to perform: 'list' for all tasks, 'show' for a specific task",
296
+ enum: ['list', 'show', 'help'],
297
+ description: 'list=scan board; show=one task (needs task_id); help=token playbook (no board I/O)',
281
298
  default: 'list'
282
299
  },
283
300
  task_id: {
284
301
  type: 'string',
285
- description: "Task ID (optional for 'list', required for 'show'). Use a numeric ID like '014' or just a number like '14'."
302
+ description: "Required for show. Numeric id: '014' or '14'."
286
303
  },
287
304
  col: {
288
305
  type: 'string',
289
306
  enum: COLS,
290
- description: 'Optional column filter for list'
307
+ description: 'Filter list by column (saves tokens — prefer this)'
291
308
  },
292
309
  epic: {
293
310
  type: 'string',
294
- description: 'Optional epic group filter for list'
311
+ description: 'Filter list by epic group'
295
312
  },
296
313
  view: {
297
314
  type: 'string',
298
315
  enum: READ_VIEWS,
299
- description: 'Preset response view. Defaults to summary.'
316
+ description: 'summary=board scan (default); planning=scope/AC; execution=+subtasks; full=everything. Prefer smallest that works.'
300
317
  },
301
318
  fields: {
302
319
  type: 'array',
303
- description: 'Explicit fields to return. When provided, fields override view.',
320
+ description: 'Exact fields only (overrides view). Use when you need 1–2 fields.',
304
321
  items: { type: 'string' }
305
322
  }
306
323
  },
@@ -309,61 +326,61 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
309
326
  },
310
327
  {
311
328
  name: 'kanban_manage',
312
- description: 'Mutate tasks and accepted plans. Required by action: create/plan_create -> title only; move -> task_id + column; update -> task_id + patch/shortcuts; plan_advance/plan_done/plan_status -> task_id; plan_evidence -> task_id + diff + test_command + stdout + stderr + exit_code. Strongly recommended on create/plan_create: description, specs, in_scope, out_of_scope, acceptance_criteria (missing fields return warnings, not errors). Example: {"action":"create","title":"Ship Docker image","description":"...","specs":"...","in_scope":["CLI"],"out_of_scope":["GUI"],"acceptance_criteria":["npm test passes"],"col":"planned","epic":"Release"}.',
329
+ description: playbook.TOOL_DESCRIPTIONS.kanban_manage,
313
330
  inputSchema: {
314
331
  type: 'object',
315
332
  properties: {
316
333
  action: {
317
334
  type: 'string',
318
- enum: ['create', 'move', 'update', 'plan_create', 'plan_advance', 'plan_evidence', 'plan_done', 'plan_status'],
319
- description: 'Create, move, update, or operate the accepted-plan workflow'
335
+ enum: ['create', 'move', 'update', 'plan_create', 'plan_advance', 'plan_evidence', 'plan_done', 'plan_status'],
336
+ description: 'create|move|update daily; plan_* only for accepted multi-step work with tests'
320
337
  },
321
338
  title: {
322
339
  type: 'string',
323
- description: "Non-empty title. Required for 'create' and 'plan_create'."
340
+ description: 'Required for create and plan_create'
324
341
  },
325
342
  col: {
326
343
  type: 'string',
327
344
  enum: COLS,
328
345
  default: 'planned',
329
- description: "Column for 'create' or shortcut patch field for 'update' (default: 'planned')."
346
+ description: 'create column, or update shortcut for column'
330
347
  },
331
348
  epic: {
332
349
  type: 'string',
333
350
  default: '—',
334
- description: "Epic group for 'create', 'update', or 'plan_create' (optional)."
351
+ description: 'Epic group (create/update/plan_create)'
335
352
  },
336
353
  description: {
337
354
  type: 'string',
338
- description: "Strongly recommended. High-level context/why for 'create', 'update', or 'plan_create'."
355
+ description: 'Why/context (recommended on create)'
339
356
  },
340
357
  specs: {
341
358
  type: 'string',
342
- description: "Strongly recommended. Technical constraints, APIs, and edge cases for 'create', 'update', or 'plan_create'."
359
+ description: 'Technical constraints (recommended on create)'
343
360
  },
344
361
  in_scope: {
345
362
  type: 'array',
346
- description: "Strongly recommended. What this task includes (boundaries) for 'create', 'update', or 'plan_create'.",
363
+ description: 'In-scope bullets (recommended on create)',
347
364
  items: { type: 'string' }
348
365
  },
349
366
  out_of_scope: {
350
367
  type: 'array',
351
- description: "Strongly recommended. Explicit non-goals / exclusions for 'create', 'update', or 'plan_create'.",
368
+ description: 'Out-of-scope bullets (recommended on create)',
352
369
  items: { type: 'string' }
353
370
  },
354
371
  acceptance_criteria: {
355
372
  type: 'array',
356
- description: "Strongly recommended. Completion requirements for 'create', 'update', or 'plan_create'.",
373
+ description: 'Done criteria (recommended on create)',
357
374
  items: { type: 'string' }
358
375
  },
359
376
  test_cases: {
360
377
  type: 'array',
361
- description: "Recommended. Verification scenarios for 'create', 'update', or 'plan_create'.",
378
+ description: 'Verification scenarios',
362
379
  items: { type: 'string' }
363
380
  },
364
381
  subtasks: {
365
382
  type: 'array',
366
- description: "Optional subtask list for 'create', 'update', or internally generated by 'plan_create'.",
383
+ description: 'Full subtask list replace on update (send complete array, not a single toggle)',
367
384
  items: {
368
385
  type: 'object',
369
386
  properties: {
@@ -376,58 +393,58 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
376
393
  },
377
394
  notes: {
378
395
  type: 'string',
379
- description: "Optional freeform notes for 'create', 'update', or 'plan_create'."
396
+ description: 'Freeform notes'
380
397
  },
381
398
  task_id: {
382
399
  type: 'string',
383
- description: "Task ID required for 'move', 'update', and all plan_* actions except 'plan_create'. Use '014' or '14'."
400
+ description: "Required for move/update/plan_* except plan_create. '014' or '14'."
384
401
  },
385
402
  column: {
386
403
  type: 'string',
387
404
  enum: COLS,
388
- description: "Target column required for 'move'."
405
+ description: 'Target column for move (not col)'
389
406
  },
390
407
  patch: {
391
408
  type: 'object',
392
- description: "Patch payload for 'update'. Use this for bulk field changes; top-level shortcuts are merged into the patch."
409
+ description: 'Bulk update object; merged with top-level field shortcuts'
393
410
  },
394
411
  return: {
395
412
  type: 'string',
396
413
  enum: ['none', 'summary', 'full'],
397
- description: "Response shape for 'move' and 'update'. Defaults to summary. 'create' returns the full created task."
414
+ description: 'move/update response size. Prefer none. Default summary. create always returns full task once.'
398
415
  },
399
416
  index: {
400
417
  type: 'integer',
401
- description: "Zero-based plan subtask index for 'plan_advance'. Defaults to the first incomplete step when omitted."
418
+ description: 'plan_advance: subtask index; omit = first incomplete'
402
419
  },
403
420
  steps: {
404
421
  type: 'array',
405
422
  items: { type: 'string' },
406
- description: "Implementation steps inserted between the default plan workflow steps for 'plan_create'."
423
+ description: 'plan_create: implementation steps between red/green test steps'
407
424
  },
408
425
  project_root: {
409
426
  type: 'string',
410
- description: "Project root used for test runner detection in 'plan_create'. Defaults to the MCP server working directory."
427
+ description: 'plan_create: root for test-runner detect (default cwd)'
411
428
  },
412
429
  diff: {
413
430
  type: 'string',
414
- description: "Required for 'plan_evidence'. Include the relevant code diff or summary."
431
+ description: 'plan_evidence: short diff or summary (not whole repo)'
415
432
  },
416
433
  test_command: {
417
434
  type: 'string',
418
- description: "Required for 'plan_evidence'. The exact verification command that was run."
435
+ description: 'plan_evidence: exact command run'
419
436
  },
420
437
  stdout: {
421
438
  type: 'string',
422
- description: "Required for 'plan_evidence'. Captured standard output from the verification command."
439
+ description: 'plan_evidence: test stdout (truncate to last ~2KB if huge)'
423
440
  },
424
441
  stderr: {
425
442
  type: 'string',
426
- description: "Required for 'plan_evidence'. Captured standard error from the verification command."
443
+ description: 'plan_evidence: test stderr (truncate if huge)'
427
444
  },
428
445
  exit_code: {
429
446
  type: 'integer',
430
- description: "Required for 'plan_evidence'. Integer process exit code from the verification command."
447
+ description: 'plan_evidence: process exit code'
431
448
  }
432
449
  },
433
450
  required: ['action'],
@@ -436,18 +453,18 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
436
453
  },
437
454
  {
438
455
  name: 'kanban_gui',
439
- description: 'Control the web GUI server: start, stop, or check status.',
456
+ description: playbook.TOOL_DESCRIPTIONS.kanban_gui,
440
457
  inputSchema: {
441
458
  type: 'object',
442
459
  properties: {
443
460
  action: {
444
461
  type: 'string',
445
462
  enum: ['start', 'stop', 'status'],
446
- description: "Action to perform: 'start' launches GUI, 'stop' kills it, 'status' checks if running"
463
+ description: 'start | stop (owned only) | status'
447
464
  },
448
465
  port: {
449
466
  type: 'integer',
450
- description: "Port for the GUI server (only for 'start'). Defaults to KANBANGO_GUI_PORT or a stable hash of the project cwd (5510-5999)."
467
+ description: 'Optional start port; else KANBANGO_GUI_PORT or stable 5510-5999'
451
468
  }
452
469
  },
453
470
  required: ['action'],
@@ -467,6 +484,12 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
467
484
  switch (name) {
468
485
  case 'kanban_read': {
469
486
  const operation = args.operation || 'list';
487
+
488
+ if (operation === 'help') {
489
+ result = playbook.playbookHelpPayload();
490
+ break;
491
+ }
492
+
470
493
  const readOptions = normalizeReadOptions(args, 'summary');
471
494
 
472
495
  if (operation === 'list') {
@@ -492,7 +515,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
492
515
  } else {
493
516
  throw invalidRequest(
494
517
  `Unknown operation: ${operation}`,
495
- 'Use one of: list, show',
518
+ 'Use one of: list, show, help',
496
519
  { operation }
497
520
  );
498
521
  }
@@ -656,9 +679,10 @@ async function maybeAutoStartGui() {
656
679
  function installGuiShutdownHooks() {
657
680
  let shuttingDown = false;
658
681
 
659
- async function shutdown() {
682
+ async function shutdownOwnedGui() {
660
683
  if (shuttingDown) return;
661
684
  shuttingDown = true;
685
+ if (!ownsGuiProcess()) return;
662
686
  try {
663
687
  await stopGuiServer();
664
688
  } catch {
@@ -667,7 +691,7 @@ function installGuiShutdownHooks() {
667
691
  }
668
692
 
669
693
  process.once('exit', () => {
670
- if (guiProcess && guiProcess.exitCode === null) {
694
+ if (ownsGuiProcess()) {
671
695
  try {
672
696
  guiProcess.kill();
673
697
  } catch {
@@ -676,10 +700,10 @@ function installGuiShutdownHooks() {
676
700
  }
677
701
  });
678
702
  process.once('SIGINT', () => {
679
- shutdown().finally(() => process.exit(0));
703
+ shutdownOwnedGui().finally(() => process.exit(0));
680
704
  });
681
705
  process.once('SIGTERM', () => {
682
- shutdown().finally(() => process.exit(0));
706
+ shutdownOwnedGui().finally(() => process.exit(0));
683
707
  });
684
708
  }
685
709
 
@@ -699,7 +723,8 @@ module.exports = {
699
723
  startGuiServer,
700
724
  stopGuiServer,
701
725
  guiStatus,
702
- resolvePreferredGuiPort: kanban.resolvePreferredGuiPort,
726
+ resolvePreferredGuiPort: guiRegistry.resolvePreferredGuiPort,
727
+ playbook,
703
728
  server,
704
729
  main
705
730
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kanbango",
3
- "version": "2.5.0",
3
+ "version": "3.0.2",
4
4
  "description": "JSON-first local Kanban board with web GUI, CLI, and MCP server",
5
5
  "main": "index.js",
6
6
  "bin": {
package/plan.js CHANGED
@@ -147,7 +147,7 @@ async function done(payload = {}) {
147
147
  throw planError('PLAN_INCOMPLETE', 'Plan has incomplete subtasks',
148
148
  'Advance every plan step before marking the workflow done', { incomplete });
149
149
  }
150
- const updated = await kanban.updateTask(task.id, { column: 'done', plan: { ...(task.plan || {}), status: 'done' } });
150
+ const updated = await kanban.updateTask(task.id, { column: 'done', plan: { ...task.plan, status: 'done' } });
151
151
  return result(updated, { status: 'done' });
152
152
  }
153
153
 
package/tests/run.js CHANGED
@@ -18,3 +18,5 @@ runNode(path.join('tests', 'update-tasks.test.js'), [], 'Update tasks test');
18
18
  runNode(path.join('tests', 'read-views.test.js'), [], 'Read views test');
19
19
  runNode(path.join('tests', 'mcp-server.test.js'), [], 'MCP server test');
20
20
  runNode(path.join('tests', 'gui-port.test.js'), [], 'GUI port test');
21
+ runNode(path.join('tests', 'plan-workflow.test.js'), [], 'Plan workflow test');
22
+ runNode(path.join('tests', 'agent-playbook.test.js'), [], 'Agent playbook test');