kanbango 3.2.0 → 3.3.0

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
@@ -3,3 +3,5 @@
3
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
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}
5
5
  {"id":"260731-o6g","ts":"2026-07-31","scope":"proj","tags":["kanban","epics","mcp"],"rule":"DO Default list/list_epics hide done+archived; hard-delete cascades epic children; archive is reversible flag","when":"kanban cleanup context","sev":2,"hits":1}
6
+ {"id":"260803-b27","ts":"2026-08-03","scope":"proj","tags":["tests","race","fs"],"rule":"DO Serialize board file mutations with an in-process lock and atomic write (temp+rename/link); race tests should assert…","when":"kanban concurrent IO","sev":2,"hits":1}
7
+ {"id":"260813-9t0","ts":"2026-08-13","scope":"proj","tags":["mcp","errors","dx"],"rule":"DO MCP missing-field errors: put Valid list + Example JSON + sent keys in message so the agent can retry without guessi…","when":"MCP tool validation / agent-facing erro…","sev":2,"hits":1}
@@ -1 +1 @@
1
- {"ts":"2026-07-31T13:58:00.629Z","sessionID":"ses_04792cf77ffec14LhSNoSJReg9","added":["260731-o6g"],"bumped":[]}
1
+ {"ts":"2026-08-13T06:43:28.413Z","sessionID":"ses_038ddd435ffeginZz7W4t3X27v","added":["260813-9t0"],"bumped":[]}
@@ -0,0 +1,121 @@
1
+ # Action error recipe (implement)
2
+
3
+ Status: approved (user said go). Plan mode blocked code edits — switch to **build** and apply.
4
+
5
+ ## Goal
6
+ Missing/empty/unknown `action` on `kanban_manage` / `kanban_gui`: **message** is a full retry recipe (Valid + Example JSON + sent keys).
7
+
8
+ ## Files
9
+ 1. `mcp-server.js` — replace `requireToolAction`; add helpers; unknown-action defaults
10
+ 2. `tests/mcp-server.test.js` — assert recipe content
11
+ 3. `CHANGELOG.md` — one-line unreleased note
12
+
13
+ ## mcp-server.js
14
+
15
+ Replace current `requireToolAction` block (after `GUI_ACTIONS`) with:
16
+
17
+ ```js
18
+ const ACTION_EXAMPLES = {
19
+ kanban_manage: '{"action":"create","title":"Ship image","col":"planned"}',
20
+ kanban_gui: '{"action":"status"}'
21
+ };
22
+
23
+ function receivedKeys(args) {
24
+ return Object.keys(args && typeof args === 'object' ? args : {});
25
+ }
26
+
27
+ function actionRecipeMessage(toolName, allowed, opts = {}) {
28
+ const keys = opts.received_keys || [];
29
+ const example = ACTION_EXAMPLES[toolName] || `{"action":"${allowed[0]}"}`;
30
+ const lines = [];
31
+ if (opts.kind === 'unknown') {
32
+ lines.push(`Unknown action "${opts.action}" on ${toolName}.`);
33
+ } else {
34
+ lines.push(`Missing required top-level field "action" on ${toolName}.`);
35
+ }
36
+ lines.push('Pass action next to other args (not nested under params).');
37
+ lines.push(`Valid: ${allowed.join(', ')}.`);
38
+ lines.push(`Example: ${example}`);
39
+ lines.push(keys.length > 0 ? `You sent keys: ${keys.join(', ')}` : 'You sent keys: (none)');
40
+ return lines.join(' ');
41
+ }
42
+
43
+ function actionRecipeHint() {
44
+ return [
45
+ 'Retry the same tool with top-level action set to one Valid value.',
46
+ 'Common mistake: omitting action, or putting it under params (jira/gitlab style) — kanban uses top-level action.'
47
+ ].join(' ');
48
+ }
49
+
50
+ function requireToolAction(args, allowed, toolName) {
51
+ const action = args && args.action;
52
+ const keys = receivedKeys(args);
53
+ if (action === undefined || action === null || action === '') {
54
+ throw kanban.createKanbanError(
55
+ 'MISSING_REQUIRED_FIELD',
56
+ actionRecipeMessage(toolName, allowed, {
57
+ kind: 'missing',
58
+ received_keys: keys,
59
+ action: action === undefined ? null : action
60
+ }),
61
+ actionRecipeHint(),
62
+ {
63
+ field: 'action',
64
+ tool: toolName,
65
+ received_keys: keys,
66
+ allowed_actions: allowed,
67
+ action: action === undefined ? null : action
68
+ },
69
+ false,
70
+ 400
71
+ );
72
+ }
73
+ return action;
74
+ }
75
+
76
+ function unknownToolAction(action, args, allowed, toolName) {
77
+ const keys = receivedKeys(args);
78
+ return invalidRequest(
79
+ actionRecipeMessage(toolName, allowed, { kind: 'unknown', action, received_keys: keys }),
80
+ actionRecipeHint(),
81
+ {
82
+ field: 'action',
83
+ tool: toolName,
84
+ action,
85
+ received_keys: keys,
86
+ allowed_actions: allowed
87
+ }
88
+ );
89
+ }
90
+ ```
91
+
92
+ Manage default: `throw unknownToolAction(action, args, MANAGE_ACTIONS, 'kanban_manage');`
93
+ GUI default: `throw unknownToolAction(action, args, GUI_ACTIONS, 'kanban_gui');`
94
+
95
+ ## tests/mcp-server.test.js
96
+
97
+ Assert on missing/empty/unknown/gui:
98
+ - message includes `Missing required top-level field "action"` (or `Unknown action "nope"`)
99
+ - `Valid:` + key actions + `Example:` + `"action":`
100
+ - sent keys line
101
+ - `details.allowed_actions`
102
+ - no `undefined` in message
103
+ - codes unchanged: MISSING_REQUIRED_FIELD / VALIDATION_ERROR
104
+
105
+ ## CHANGELOG
106
+
107
+ ```md
108
+ ### Fixed
109
+ - MCP `kanban_manage` / `kanban_gui`: missing/empty/unknown `action` errors put a full retry recipe in `message` (Valid list + Example JSON + sent keys); missing/empty use `MISSING_REQUIRED_FIELD` instead of `Unknown action: undefined`
110
+ ```
111
+
112
+ ## Verify
113
+ ```bash
114
+ node tests/mcp-server.test.js
115
+ echo '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"kanban_manage","arguments":{"title":"x"}}}' | node mcp-server.js
116
+ ```
117
+
118
+ ## Acceptance
119
+ - [ ] Missing action message is copy-paste retry recipe
120
+ - [ ] Unknown action same clarity
121
+ - [ ] Tests green
package/CHANGELOG.md CHANGED
@@ -7,6 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [3.3.0] - 2026-08-13
11
+
12
+ ### Added
13
+ - Task comments: append-only `comments[]` on tasks; MCP `comment_add` (`task_id` + `text`, optional `author`); CLI `kanban comment <ID> <text> [--author NAME]`
14
+ - Comments included in `execution` / `full` task views (omitted from `summary`)
15
+
16
+ ### Fixed
17
+ - MCP `kanban_manage` / `kanban_gui`: missing/empty/unknown `action` errors put a full retry recipe in `message` (Valid list + Example JSON + sent keys); missing/empty use `MISSING_REQUIRED_FIELD` instead of `Unknown action: undefined`
18
+
10
19
  ## [3.2.0] - 2026-07-31
11
20
 
12
21
  ### Added
package/README.md CHANGED
@@ -152,7 +152,7 @@ Once connected, your agent gets access to these tools:
152
152
  | Tool | What it does |
153
153
  |------|-------------|
154
154
  | `kanban_read` | List/show tasks (`view=summary` by default — cheap) |
155
- | `kanban_manage` | Create, move, update, plan_* workflow |
155
+ | `kanban_manage` | Create, move, update, comment_add, plan_* workflow |
156
156
  | `kanban_gui` | Start / status / stop (stop only kills GUI this MCP started) |
157
157
 
158
158
  **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).
@@ -170,6 +170,7 @@ Your agent stays in sync with your real board — every change is persisted as J
170
170
  | `kanban add <TITLE>` | Add a new task |
171
171
  | `kanban move <ID> <COL>` | Move task |
172
172
  | `kanban mcp-init` | Generate MCP config files |
173
+ | `kanban comment <ID> <text> [--author NAME]` | Append a comment to a task |
173
174
  | `kanban plan <action> --json '{...}'` | Accepted-plan workflow (create/advance/evidence/done/status) |
174
175
 
175
176
  ## Web GUI
package/agent-playbook.js CHANGED
@@ -10,10 +10,10 @@ const DROP_IN_RULE = [
10
10
  '- list: col filter, view=summary; keep task_ids; no full-board re-list after writes',
11
11
  '- list_epics default = live only (hides done + archived); include_archived / status to widen',
12
12
  '- list tasks default hides tasks under done/archived epics; show/show_epic by id always works',
13
- '- show: view=execution while coding; full only if needed',
13
+ '- show: view=execution while coding (includes comments); full only if needed',
14
14
  '- epic_create with description/goals; create tasks with epic=E001 (prefer id)',
15
15
  '- create once with description,specs,in_scope,out_of_scope,acceptance_criteria',
16
- '- move/update/delete: return=none; subtasks=full array replace',
16
+ '- move/update/delete: return=none; subtasks=full array replace; comment_add: task_id+text',
17
17
  '- cleanup: delete (task), epic_delete (cascade tasks), epic_archive / epic_unarchive',
18
18
  '- non-trivial: plan_create → plan_advance → plan_evidence (real tests, truncated logs) → plan_done',
19
19
  '- gui: status before start; stop only owned; external_running = do not kill'
@@ -23,7 +23,7 @@ const TOOL_DESCRIPTIONS = {
23
23
  kanban_read: [
24
24
  'Read board. TOKEN RULES: list defaults to view=summary (id/title/col/progress only).',
25
25
  'Hierarchy: epic (container/context) → task (work) → subtasks (steps).',
26
- 'Always pass col when possible. Prefer show+view=execution over full.',
26
+ 'Always pass col when possible. Prefer show+view=execution over full (includes comments).',
27
27
  'list_epics default live only (empty|planned|active; hides done+archived).',
28
28
  'Pass include_archived=true or status=done|archived to see closed initiatives.',
29
29
  'list hides tasks under done/archived epics unless include_archived/include_done; show/show_epic by id always works.',
@@ -36,13 +36,13 @@ const TOOL_DESCRIPTIONS = {
36
36
  'Write board / plan. TOKEN RULES: one create with all planning fields beats many updates;',
37
37
  'after write use return=none (or summary). Do not dump full task unless needed.',
38
38
  'Actions: create|move|update|delete (daily); epic_create|epic_update|epic_archive|epic_unarchive|epic_delete;',
39
- 'plan_create→plan_advance→plan_evidence→plan_done (non-trivial only).',
39
+ 'comment_add (append-only history); plan_create→plan_advance→plan_evidence→plan_done (non-trivial only).',
40
40
  'epic_create: title + description/goals/in_scope/out_of_scope. Link tasks via epic=E001.',
41
41
  'delete: task_id (hard remove). epic_delete: epic_id, always cascades child tasks.',
42
42
  'epic_archive/epic_unarchive: epic_id — hide/restore initiative without deleting history.',
43
43
  'create/plan_create: title required; also send description,specs,in_scope,out_of_scope,acceptance_criteria',
44
44
  '(missing → warnings, not failure). move: task_id+column. update: task_id + fields or subtasks[] full list',
45
- '(no toggle). plan_evidence needs real test run: diff,test_command,stdout,stderr,exit_code — truncate logs.',
45
+ '(no toggle). comment_add: task_id+text, optional author. plan_evidence needs real test run: diff,test_command,stdout,stderr,exit_code — truncate logs.',
46
46
  'Example create: {"action":"create","title":"Ship image","epic":"E001","description":"...","specs":"...",',
47
47
  '"in_scope":["CLI"],"out_of_scope":["GUI"],"acceptance_criteria":["npm test passes"],"col":"planned"}'
48
48
  ].join(' '),
@@ -63,7 +63,8 @@ const MUST_CONTAIN = [
63
63
  'subtasks',
64
64
  'epic_create',
65
65
  'epic_archive',
66
- 'epic_delete'
66
+ 'epic_delete',
67
+ 'comment_add'
67
68
  ];
68
69
 
69
70
  function playbookHelpPayload() {
package/bin/kanban.js CHANGED
@@ -789,6 +789,18 @@ async function main() {
789
789
  await cliAdd(args[1], column, epicGroup);
790
790
  } else if (cmd === 'plan' && args[1] && args[2] === '--json') {
791
791
  await cliPlan(args[1], parseJsonPayload(args[3]));
792
+ } else if (cmd === 'comment' && args[1] && args[2]) {
793
+ let author = 'user';
794
+ for (let i = 3; i < args.length; i++) {
795
+ if (args[i] === '--author' && args[i + 1]) author = args[++i];
796
+ }
797
+ try {
798
+ const added = await kanban.addComment(args[1], args[2], author);
799
+ console.log(JSON.stringify({ ok: true, task_id: added.task_id, comment_id: added.comment.id, count: added.comments.length }));
800
+ } catch (error) {
801
+ console.error(`✗ ${error.message}`);
802
+ process.exit(1);
803
+ }
792
804
  } else {
793
805
  console.error('Unknown command:', cmd);
794
806
  process.exit(1);
package/kanban.js CHANGED
@@ -41,7 +41,8 @@ const VIEW_FIELDS = {
41
41
  'out_of_scope',
42
42
  'acceptance_criteria',
43
43
  'test_cases',
44
- 'subtasks'
44
+ 'subtasks',
45
+ 'comments'
45
46
  ],
46
47
  full: [
47
48
  'task_number',
@@ -58,7 +59,8 @@ const VIEW_FIELDS = {
58
59
  'acceptance_criteria',
59
60
  'test_cases',
60
61
  'subtasks',
61
- 'notes'
62
+ 'notes',
63
+ 'comments'
62
64
  ]
63
65
  };
64
66
 
@@ -121,6 +123,43 @@ function createKanbanError(code, message, hint, details = {}, retryable = false,
121
123
  return error;
122
124
  }
123
125
 
126
+ // Serialize board mutations so concurrent create/move/update cannot race on ids or paths.
127
+ let mutationTail = Promise.resolve();
128
+
129
+ function withBoardLock(fn) {
130
+ const run = mutationTail.then(() => fn());
131
+ mutationTail = run.then(() => undefined, () => undefined);
132
+ return run;
133
+ }
134
+
135
+ function isTaskOrEpicDataFile(file) {
136
+ if (!file || file.startsWith('.')) return false;
137
+ return file.endsWith('.json') || file.endsWith('.md');
138
+ }
139
+
140
+ async function writeFileAtomic(filePath, payload, { exclusive = false } = {}) {
141
+ const dir = path.dirname(filePath);
142
+ const base = path.basename(filePath);
143
+ const tempPath = path.join(
144
+ dir,
145
+ `.${base}.${process.pid}.${Date.now()}.${Math.random().toString(16).slice(2)}.tmp`
146
+ );
147
+
148
+ await fs.writeFile(tempPath, payload, 'utf-8');
149
+ try {
150
+ if (exclusive) {
151
+ // Atomic create-if-absent: never exposes an empty final path to readers.
152
+ await fs.link(tempPath, filePath);
153
+ await fs.unlink(tempPath).catch(() => undefined);
154
+ } else {
155
+ await fs.rename(tempPath, filePath);
156
+ }
157
+ } catch (error) {
158
+ await fs.unlink(tempPath).catch(() => undefined);
159
+ throw error;
160
+ }
161
+ }
162
+
124
163
  function todayIso() {
125
164
  return new Date().toISOString().split('T')[0];
126
165
  }
@@ -216,6 +255,28 @@ function normalizeSubtasks(value) {
216
255
  })).filter((subtask) => subtask.text);
217
256
  }
218
257
 
258
+ function nowIso() {
259
+ return new Date().toISOString();
260
+ }
261
+
262
+ function nextCommentId(comments) {
263
+ const max = comments.reduce((highest, comment) => {
264
+ const match = String(comment && comment.id || '').match(/^c-(\d+)$/i);
265
+ return match ? Math.max(highest, parseInt(match[1], 10)) : highest;
266
+ }, 0);
267
+ return `c-${max + 1}`;
268
+ }
269
+
270
+ function normalizeComments(value) {
271
+ if (!Array.isArray(value)) return [];
272
+ return value.map((item, idx) => ({
273
+ id: normalizeString(item && item.id, `c-${idx + 1}`),
274
+ created: normalizeString(item && item.created) || nowIso(),
275
+ author: normalizeString(item && item.author, 'user') || 'user',
276
+ text: normalizeString(item && item.text)
277
+ })).filter((item) => item.text);
278
+ }
279
+
219
280
  function normalizeEvidence(value) {
220
281
  if (!Array.isArray(value)) return [];
221
282
  return value.map((item) => ({
@@ -255,6 +316,7 @@ function normalizeTask(task) {
255
316
  test_cases: normalizeStringArray(task.test_cases),
256
317
  subtasks: normalizeSubtasks(task.subtasks),
257
318
  notes: normalizeString(task.notes),
319
+ comments: normalizeComments(task.comments),
258
320
  plan: normalizePlan(task.plan),
259
321
  evidence: normalizeEvidence(task.evidence),
260
322
  task_number: extractTaskNumber(id)
@@ -280,6 +342,7 @@ function serializeTask(task) {
280
342
  test_cases: normalized.test_cases,
281
343
  subtasks: normalized.subtasks,
282
344
  notes: normalized.notes,
345
+ comments: normalized.comments,
283
346
  plan: normalized.plan,
284
347
  evidence: normalized.evidence,
285
348
  task_number: normalized.task_number
@@ -536,9 +599,24 @@ async function parseMarkdownTask(filePath, column) {
536
599
  }
537
600
 
538
601
  async function parseJsonTask(filePath, column) {
602
+ let raw;
603
+ try {
604
+ raw = await fs.readFile(filePath, 'utf-8');
605
+ } catch (error) {
606
+ if (error.code === 'ENOENT') throw error;
607
+ throw createKanbanError(
608
+ 'PARSE_ERROR',
609
+ `Task file ${path.basename(filePath)} could not be read`,
610
+ 'Fix permissions or restore the file from version control',
611
+ { file: filePath, reason: error.message },
612
+ false,
613
+ 500
614
+ );
615
+ }
616
+
539
617
  let data;
540
618
  try {
541
- data = JSON.parse(await fs.readFile(filePath, 'utf-8'));
619
+ data = JSON.parse(raw);
542
620
  } catch (error) {
543
621
  throw createKanbanError(
544
622
  'PARSE_ERROR',
@@ -572,7 +650,7 @@ async function allEpics() {
572
650
  try {
573
651
  const files = await fs.readdir(colDir);
574
652
  const taskFiles = files
575
- .filter((file) => file.endsWith('.json') || file.endsWith('.md'))
653
+ .filter((file) => isTaskOrEpicDataFile(file))
576
654
  .sort((left, right) => {
577
655
  const leftBase = path.basename(left, path.extname(left));
578
656
  const rightBase = path.basename(right, path.extname(right));
@@ -589,6 +667,8 @@ async function allEpics() {
589
667
  try {
590
668
  epics.push(await parseEpic(path.join(colDir, file), col));
591
669
  } catch (error) {
670
+ // File may vanish between readdir and read under concurrent delete.
671
+ if (error.code === 'ENOENT') continue;
592
672
  console.error(` parse error ${file}: ${error.message}`);
593
673
  }
594
674
  }
@@ -614,6 +694,7 @@ async function nextEpicNumber() {
614
694
  try {
615
695
  const files = await fs.readdir(EPICS_DIR);
616
696
  for (const file of files) {
697
+ if (!isTaskOrEpicDataFile(file)) continue;
617
698
  const match = file.match(/^E0*(\d+)\.json$/i);
618
699
  if (match) ids.push(parseInt(match[1], 10));
619
700
  }
@@ -662,7 +743,7 @@ async function listEpicEntities() {
662
743
  const epics = [];
663
744
  try {
664
745
  const files = (await fs.readdir(EPICS_DIR))
665
- .filter((file) => file.endsWith('.json'))
746
+ .filter((file) => isTaskOrEpicDataFile(file) && file.endsWith('.json'))
666
747
  .sort((left, right) => left.localeCompare(right));
667
748
  for (const file of files) {
668
749
  try {
@@ -677,7 +758,7 @@ async function listEpicEntities() {
677
758
  return epics;
678
759
  }
679
760
 
680
- async function writeEpic(epic) {
761
+ async function writeEpic(epic, { exclusive = false } = {}) {
681
762
  const normalized = normalizeEpic(epic);
682
763
  if (!normalizeEpicId(normalized.id)) {
683
764
  throw createKanbanError(
@@ -691,7 +772,8 @@ async function writeEpic(epic) {
691
772
  }
692
773
  await ensureBacklogDir();
693
774
  const filePath = epicFilePath(normalized.id);
694
- await fs.writeFile(filePath, JSON.stringify(serializeEpic(normalized), null, 2) + '\n', 'utf-8');
775
+ const payload = JSON.stringify(serializeEpic(normalized), null, 2) + '\n';
776
+ await writeFileAtomic(filePath, payload, { exclusive });
695
777
  return parseJsonEpic(filePath);
696
778
  }
697
779
 
@@ -760,7 +842,9 @@ async function resolveEpicRef(ref, options = {}) {
760
842
  }
761
843
 
762
844
  if (options.createIfMissing) {
763
- const created = await doCreateEpic(normalizeString(ref), {});
845
+ const created = options.skipLock
846
+ ? await createEpicRecord(normalizeString(ref), {})
847
+ : await doCreateEpic(normalizeString(ref), {});
764
848
  return { epic_id: created.id, epic_group: created.title };
765
849
  }
766
850
 
@@ -774,7 +858,7 @@ async function resolveEpicRef(ref, options = {}) {
774
858
  );
775
859
  }
776
860
 
777
- async function doCreateEpic(title, extra = {}) {
861
+ async function createEpicRecord(title, extra = {}) {
778
862
  if (!normalizeString(title)) {
779
863
  throw createKanbanError(
780
864
  'MISSING_REQUIRED_FIELD',
@@ -786,82 +870,102 @@ async function doCreateEpic(title, extra = {}) {
786
870
  );
787
871
  }
788
872
 
789
- const nextId = await nextEpicNumber();
790
- const epic = normalizeEpic({
791
- id: `E${String(nextId).padStart(3, '0')}`,
792
- title,
793
- created: todayIso(),
794
- description: extra.description,
795
- goals: extra.goals,
796
- in_scope: extra.in_scope,
797
- out_of_scope: extra.out_of_scope,
798
- notes: extra.notes
799
- });
800
- return writeEpic(epic);
873
+ for (let attempt = 0; attempt < 32; attempt++) {
874
+ const nextId = await nextEpicNumber();
875
+ const epic = normalizeEpic({
876
+ id: `E${String(nextId).padStart(3, '0')}`,
877
+ title,
878
+ created: todayIso(),
879
+ description: extra.description,
880
+ goals: extra.goals,
881
+ in_scope: extra.in_scope,
882
+ out_of_scope: extra.out_of_scope,
883
+ notes: extra.notes
884
+ });
885
+ try {
886
+ return await writeEpic(epic, { exclusive: true });
887
+ } catch (error) {
888
+ if (error.code !== 'EEXIST') throw error;
889
+ }
890
+ }
891
+ throw createKanbanError(
892
+ 'CREATE_CONFLICT',
893
+ 'Could not allocate a unique epic id',
894
+ 'Retry the create operation',
895
+ { title },
896
+ true,
897
+ 409
898
+ );
899
+ }
900
+
901
+ async function doCreateEpic(title, extra = {}) {
902
+ return withBoardLock(() => createEpicRecord(title, extra));
801
903
  }
802
904
 
803
905
  async function updateEpicEntity(epicId, patch) {
804
906
  validatePatch(patch);
805
- const current = await getEpicEntity(epicId);
806
- const next = { ...current };
807
-
808
- if (patch.title !== undefined) {
809
- const title = normalizeString(patch.title);
810
- if (!title) {
811
- throw createKanbanError(
812
- 'VALIDATION_ERROR',
813
- 'title must be a non-empty string',
814
- 'Send a non-empty title or omit the field',
815
- { field: 'title' },
816
- false,
817
- 400
818
- );
907
+ return withBoardLock(async () => {
908
+ const current = await getEpicEntity(epicId);
909
+ const next = { ...current };
910
+
911
+ if (patch.title !== undefined) {
912
+ const title = normalizeString(patch.title);
913
+ if (!title) {
914
+ throw createKanbanError(
915
+ 'VALIDATION_ERROR',
916
+ 'title must be a non-empty string',
917
+ 'Send a non-empty title or omit the field',
918
+ { field: 'title' },
919
+ false,
920
+ 400
921
+ );
922
+ }
923
+ next.title = title;
819
924
  }
820
- next.title = title;
821
- }
822
- if (patch.description !== undefined) next.description = normalizeString(patch.description);
823
- if (patch.goals !== undefined) next.goals = normalizeString(patch.goals);
824
- if (patch.in_scope !== undefined) {
825
- if (!Array.isArray(patch.in_scope)) {
826
- throw createKanbanError(
827
- 'VALIDATION_ERROR',
828
- 'in_scope must be an array of strings',
829
- 'Send in_scope as an array',
830
- { field: 'in_scope' },
831
- false,
832
- 400
833
- );
925
+ if (patch.description !== undefined) next.description = normalizeString(patch.description);
926
+ if (patch.goals !== undefined) next.goals = normalizeString(patch.goals);
927
+ if (patch.in_scope !== undefined) {
928
+ if (!Array.isArray(patch.in_scope)) {
929
+ throw createKanbanError(
930
+ 'VALIDATION_ERROR',
931
+ 'in_scope must be an array of strings',
932
+ 'Send in_scope as an array',
933
+ { field: 'in_scope' },
934
+ false,
935
+ 400
936
+ );
937
+ }
938
+ next.in_scope = normalizeStringArray(patch.in_scope);
834
939
  }
835
- next.in_scope = normalizeStringArray(patch.in_scope);
836
- }
837
- if (patch.out_of_scope !== undefined) {
838
- if (!Array.isArray(patch.out_of_scope)) {
839
- throw createKanbanError(
840
- 'VALIDATION_ERROR',
841
- 'out_of_scope must be an array of strings',
842
- 'Send out_of_scope as an array',
843
- { field: 'out_of_scope' },
844
- false,
845
- 400
846
- );
940
+ if (patch.out_of_scope !== undefined) {
941
+ if (!Array.isArray(patch.out_of_scope)) {
942
+ throw createKanbanError(
943
+ 'VALIDATION_ERROR',
944
+ 'out_of_scope must be an array of strings',
945
+ 'Send out_of_scope as an array',
946
+ { field: 'out_of_scope' },
947
+ false,
948
+ 400
949
+ );
950
+ }
951
+ next.out_of_scope = normalizeStringArray(patch.out_of_scope);
847
952
  }
848
- next.out_of_scope = normalizeStringArray(patch.out_of_scope);
849
- }
850
- if (patch.notes !== undefined) next.notes = normalizeString(patch.notes);
851
- if (patch.archived !== undefined) next.archived = Boolean(patch.archived);
953
+ if (patch.notes !== undefined) next.notes = normalizeString(patch.notes);
954
+ if (patch.archived !== undefined) next.archived = Boolean(patch.archived);
852
955
 
853
- const saved = await writeEpic(next);
956
+ const saved = await writeEpic(next);
854
957
 
855
- if (patch.title !== undefined && saved.title !== current.title) {
856
- const tasks = await allTasks();
857
- for (const task of tasks) {
858
- if (task.epic_id === saved.id && task.epic_group !== saved.title) {
859
- await updateTask(task.id, { epic_group: saved.title, _skipEpicResolve: true });
958
+ if (patch.title !== undefined && saved.title !== current.title) {
959
+ const tasks = await allTasks();
960
+ for (const task of tasks) {
961
+ if (task.epic_id === saved.id && task.epic_group !== saved.title) {
962
+ await updateTaskRecord(task.id, { epic_group: saved.title, _skipEpicResolve: true });
963
+ }
860
964
  }
861
965
  }
862
- }
863
966
 
864
- return saved;
967
+ return saved;
968
+ });
865
969
  }
866
970
 
867
971
  async function archiveEpic(epicId) {
@@ -872,7 +976,7 @@ async function unarchiveEpic(epicId) {
872
976
  return updateEpicEntity(epicId, { archived: false });
873
977
  }
874
978
 
875
- async function deleteTask(taskId) {
979
+ async function deleteTaskRecord(taskId) {
876
980
  const resolvedId = await resolveTaskId(taskId);
877
981
  const filePath = await findFile(resolvedId);
878
982
  if (!filePath) {
@@ -891,6 +995,7 @@ async function deleteTask(taskId) {
891
995
  await fs.unlink(filePath).catch((error) => {
892
996
  if (error.code !== 'ENOENT') throw error;
893
997
  });
998
+ await removeOtherTaskCopies(task.id, path.join(BACKLOG, '__none__', `${task.id}.json`));
894
999
 
895
1000
  return {
896
1001
  ok: true,
@@ -901,14 +1006,19 @@ async function deleteTask(taskId) {
901
1006
  };
902
1007
  }
903
1008
 
1009
+ async function deleteTask(taskId) {
1010
+ return withBoardLock(() => deleteTaskRecord(taskId));
1011
+ }
1012
+
904
1013
  async function deleteEpic(epicId) {
1014
+ return withBoardLock(async () => {
905
1015
  const epic = await getEpicEntity(epicId);
906
1016
  const tasks = await allTasks();
907
1017
  const children = tasks.filter((task) => task.epic_id === epic.id);
908
1018
  const deletedTasks = [];
909
1019
 
910
1020
  for (const child of children) {
911
- const result = await deleteTask(child.id);
1021
+ const result = await deleteTaskRecord(child.id);
912
1022
  deletedTasks.push({
913
1023
  task_id: result.task_id,
914
1024
  title: result.title,
@@ -928,6 +1038,7 @@ async function deleteEpic(epicId) {
928
1038
  deleted_tasks: deletedTasks,
929
1039
  deleted_task_count: deletedTasks.length
930
1040
  };
1041
+ });
931
1042
  }
932
1043
 
933
1044
  async function migrateEpicGroups(options = {}) {
@@ -998,7 +1109,7 @@ async function findFile(epicId) {
998
1109
  try {
999
1110
  const files = await fs.readdir(colDir);
1000
1111
  const candidates = files
1001
- .filter((file) => (file.endsWith('.json') || file.endsWith('.md'))
1112
+ .filter((file) => isTaskOrEpicDataFile(file)
1002
1113
  && path.basename(file, path.extname(file)) === epicId)
1003
1114
  .sort((left, _right) => (left.endsWith('.json') ? -1 : 1));
1004
1115
  if (candidates[0]) {
@@ -1057,12 +1168,34 @@ async function resolveTaskId(input) {
1057
1168
  return String(input);
1058
1169
  }
1059
1170
 
1060
- async function writeTask(task, previousFilePath = null) {
1171
+ async function removeOtherTaskCopies(taskId, keepPath) {
1172
+ const keep = path.resolve(keepPath);
1173
+ for (const col of COLS) {
1174
+ const colDir = path.join(BACKLOG, col);
1175
+ try {
1176
+ const files = await fs.readdir(colDir);
1177
+ for (const file of files) {
1178
+ if (!isTaskOrEpicDataFile(file)) continue;
1179
+ if (path.basename(file, path.extname(file)) !== taskId) continue;
1180
+ const candidate = path.join(colDir, file);
1181
+ if (path.resolve(candidate) === keep) continue;
1182
+ await fs.unlink(candidate).catch((error) => {
1183
+ if (error.code !== 'ENOENT') throw error;
1184
+ });
1185
+ }
1186
+ } catch (error) {
1187
+ if (error.code !== 'ENOENT') throw error;
1188
+ }
1189
+ }
1190
+ }
1191
+
1192
+ async function writeTask(task, previousFilePath = null, { exclusive = false } = {}) {
1061
1193
  const normalized = normalizeTask(task);
1062
1194
  await ensureBacklogDir();
1063
1195
 
1064
1196
  const nextFilePath = path.join(BACKLOG, normalized.column, `${normalized.id}.json`);
1065
- await fs.writeFile(nextFilePath, JSON.stringify(serializeTask(normalized), null, 2) + '\n', 'utf-8');
1197
+ const payload = JSON.stringify(serializeTask(normalized), null, 2) + '\n';
1198
+ await writeFileAtomic(nextFilePath, payload, { exclusive });
1066
1199
 
1067
1200
  if (previousFilePath && path.resolve(previousFilePath) !== path.resolve(nextFilePath)) {
1068
1201
  await fs.unlink(previousFilePath).catch((error) => {
@@ -1070,6 +1203,8 @@ async function writeTask(task, previousFilePath = null) {
1070
1203
  });
1071
1204
  }
1072
1205
 
1206
+ await removeOtherTaskCopies(normalized.id, nextFilePath);
1207
+
1073
1208
  return parseJsonTask(nextFilePath, normalized.column);
1074
1209
  }
1075
1210
 
@@ -1089,7 +1224,7 @@ async function migrateAll(options = {}) {
1089
1224
  }
1090
1225
 
1091
1226
  const mdFiles = files
1092
- .filter((file) => file.endsWith('.md'))
1227
+ .filter((file) => isTaskOrEpicDataFile(file) && file.endsWith('.md'))
1093
1228
  .map((file) => ({
1094
1229
  name: file,
1095
1230
  taskId: path.basename(file, '.md')
@@ -1129,6 +1264,7 @@ async function nextTaskNumber() {
1129
1264
  try {
1130
1265
  const files = await fs.readdir(colDir);
1131
1266
  for (const file of files) {
1267
+ if (!isTaskOrEpicDataFile(file)) continue;
1132
1268
  const match = file.match(/^(?:[A-Z]+-)?(\d+)/);
1133
1269
  if (match) ids.push(parseInt(match[1], 10));
1134
1270
  }
@@ -1180,39 +1316,53 @@ async function doCreate(title, column = 'planned', epicRef = '—', extra = {})
1180
1316
 
1181
1317
  validateColumn(column, 'col');
1182
1318
 
1183
- let epicLink = { epic_id: null, epic_group: '—' };
1184
- if (!isBlankEpicRef(epicRef)) {
1185
- epicLink = await resolveEpicRef(epicRef, { createIfMissing: true });
1186
- } else if (extra.epic_id) {
1187
- epicLink = await resolveEpicRef(extra.epic_id, { createIfMissing: false });
1188
- }
1319
+ return withBoardLock(async () => {
1320
+ let epicLink = { epic_id: null, epic_group: '—' };
1321
+ if (!isBlankEpicRef(epicRef)) {
1322
+ epicLink = await resolveEpicRef(epicRef, { createIfMissing: true, skipLock: true });
1323
+ } else if (extra.epic_id) {
1324
+ epicLink = await resolveEpicRef(extra.epic_id, { createIfMissing: false, skipLock: true });
1325
+ }
1189
1326
 
1190
- const nextId = await nextTaskNumber();
1191
- const task = normalizeTask({
1192
- id: String(nextId).padStart(3, '0'),
1193
- title,
1194
- column,
1195
- epic_id: epicLink.epic_id,
1196
- epic_group: epicLink.epic_group,
1197
- created: todayIso(),
1198
- description: extra.description,
1199
- specs: extra.specs,
1200
- in_scope: extra.in_scope,
1201
- out_of_scope: extra.out_of_scope,
1202
- acceptance_criteria: extra.acceptance_criteria,
1203
- test_cases: extra.test_cases,
1204
- subtasks: extra.subtasks,
1205
- notes: extra.notes,
1206
- plan: extra.plan,
1207
- evidence: extra.evidence
1327
+ for (let attempt = 0; attempt < 32; attempt++) {
1328
+ const nextId = await nextTaskNumber();
1329
+ const task = normalizeTask({
1330
+ id: String(nextId).padStart(3, '0'),
1331
+ title,
1332
+ column,
1333
+ epic_id: epicLink.epic_id,
1334
+ epic_group: epicLink.epic_group,
1335
+ created: todayIso(),
1336
+ description: extra.description,
1337
+ specs: extra.specs,
1338
+ in_scope: extra.in_scope,
1339
+ out_of_scope: extra.out_of_scope,
1340
+ acceptance_criteria: extra.acceptance_criteria,
1341
+ test_cases: extra.test_cases,
1342
+ subtasks: extra.subtasks,
1343
+ notes: extra.notes,
1344
+ comments: extra.comments,
1345
+ plan: extra.plan,
1346
+ evidence: extra.evidence
1347
+ });
1348
+ try {
1349
+ return await writeTask(task, null, { exclusive: true });
1350
+ } catch (error) {
1351
+ if (error.code !== 'EEXIST') throw error;
1352
+ }
1353
+ }
1354
+ throw createKanbanError(
1355
+ 'CREATE_CONFLICT',
1356
+ 'Could not allocate a unique task id',
1357
+ 'Retry the create operation',
1358
+ { title },
1359
+ true,
1360
+ 409
1361
+ );
1208
1362
  });
1209
-
1210
- return writeTask(task);
1211
1363
  }
1212
1364
 
1213
- async function updateTask(taskId, patch) {
1214
- validatePatch(patch);
1215
-
1365
+ async function updateTaskRecord(taskId, patch) {
1216
1366
  const resolvedId = await resolveTaskId(taskId);
1217
1367
  const previousFilePath = await findFile(resolvedId);
1218
1368
  if (!previousFilePath) {
@@ -1256,7 +1406,12 @@ async function updateTask(taskId, patch) {
1256
1406
  next.epic_id = null;
1257
1407
  next.epic_group = '—';
1258
1408
  } else {
1259
- const link = await resolveEpicRef(ref, { createIfMissing: Boolean(patch.epic_group !== undefined && patch.epic_id === undefined && patch.epic === undefined) });
1409
+ const link = await resolveEpicRef(ref, {
1410
+ createIfMissing: Boolean(
1411
+ patch.epic_group !== undefined && patch.epic_id === undefined && patch.epic === undefined
1412
+ ),
1413
+ skipLock: true
1414
+ });
1260
1415
  next.epic_id = link.epic_id;
1261
1416
  next.epic_group = link.epic_group;
1262
1417
  }
@@ -1333,6 +1488,19 @@ async function updateTask(taskId, patch) {
1333
1488
  next.subtasks = patch.subtasks;
1334
1489
  }
1335
1490
  if (patch.notes !== undefined) next.notes = normalizeString(patch.notes);
1491
+ if (patch.comments !== undefined) {
1492
+ if (!Array.isArray(patch.comments)) {
1493
+ throw createKanbanError(
1494
+ 'VALIDATION_ERROR',
1495
+ 'comments must be an array',
1496
+ 'Send comments as an array of comment objects',
1497
+ { field: 'comments' },
1498
+ false,
1499
+ 400
1500
+ );
1501
+ }
1502
+ next.comments = patch.comments;
1503
+ }
1336
1504
  if (patch.plan !== undefined) next.plan = patch.plan;
1337
1505
  if (patch.evidence !== undefined) {
1338
1506
  if (!Array.isArray(patch.evidence)) {
@@ -1351,6 +1519,49 @@ async function updateTask(taskId, patch) {
1351
1519
  return writeTask(next, previousFilePath);
1352
1520
  }
1353
1521
 
1522
+ async function updateTask(taskId, patch) {
1523
+ validatePatch(patch);
1524
+ return withBoardLock(() => updateTaskRecord(taskId, patch));
1525
+ }
1526
+
1527
+ async function addComment(taskId, text, author = 'user') {
1528
+ const body = normalizeString(text);
1529
+ if (!body) {
1530
+ throw createKanbanError(
1531
+ 'MISSING_REQUIRED_FIELD',
1532
+ 'text is required',
1533
+ 'Provide a non-empty comment text',
1534
+ { field: 'text' },
1535
+ false,
1536
+ 400
1537
+ );
1538
+ }
1539
+ return withBoardLock(async () => {
1540
+ const resolvedId = await resolveTaskId(taskId);
1541
+ const previousFilePath = await findFile(resolvedId);
1542
+ if (!previousFilePath) {
1543
+ throw createKanbanError(
1544
+ 'TASK_NOT_FOUND',
1545
+ `Task ${taskId} was not found`,
1546
+ 'Call kanban_read with operation=list to discover valid task ids',
1547
+ { task_id: taskId },
1548
+ false,
1549
+ 404
1550
+ );
1551
+ }
1552
+ const current = await parseEpic(previousFilePath, path.basename(path.dirname(previousFilePath)));
1553
+ const comments = normalizeComments(current.comments);
1554
+ const comment = {
1555
+ id: nextCommentId(comments),
1556
+ created: nowIso(),
1557
+ author: normalizeString(author, 'user') || 'user',
1558
+ text: body
1559
+ };
1560
+ const saved = await writeTask({ ...current, comments: [...comments, comment] }, previousFilePath);
1561
+ return { comment, comments: saved.comments, task_id: saved.id };
1562
+ });
1563
+ }
1564
+
1354
1565
  async function doMove(epicId, target) {
1355
1566
  try {
1356
1567
  await updateTask(epicId, { column: target });
@@ -1416,6 +1627,7 @@ module.exports = {
1416
1627
  shapeTask,
1417
1628
  shapeEpic,
1418
1629
  updateTask,
1630
+ addComment,
1419
1631
  migrateAll,
1420
1632
  migrateEpicGroups,
1421
1633
  doMove,
package/mcp-server.js CHANGED
@@ -76,6 +76,101 @@ function invalidRequest(message, hint, details) {
76
76
  return kanban.createKanbanError('VALIDATION_ERROR', message, hint, details, false, 400);
77
77
  }
78
78
 
79
+ const MANAGE_ACTIONS = [
80
+ 'create',
81
+ 'move',
82
+ 'update',
83
+ 'delete',
84
+ 'epic_create',
85
+ 'epic_update',
86
+ 'epic_archive',
87
+ 'epic_unarchive',
88
+ 'epic_delete',
89
+ 'plan_create',
90
+ 'plan_advance',
91
+ 'plan_evidence',
92
+ 'plan_done',
93
+ 'plan_status',
94
+ 'comment_add'
95
+ ];
96
+
97
+ const GUI_ACTIONS = ['start', 'stop', 'status'];
98
+
99
+ const ACTION_EXAMPLES = {
100
+ kanban_manage: '{"action":"create","title":"Ship image","col":"planned"}',
101
+ kanban_gui: '{"action":"status"}'
102
+ };
103
+
104
+ function receivedKeys(args) {
105
+ return Object.keys(args && typeof args === 'object' ? args : {});
106
+ }
107
+
108
+ function actionRecipeMessage(toolName, allowed, opts = {}) {
109
+ const keys = opts.received_keys || [];
110
+ const example = ACTION_EXAMPLES[toolName] || `{"action":"${allowed[0]}"}`;
111
+ const lines = [];
112
+
113
+ if (opts.kind === 'unknown') {
114
+ lines.push(`Unknown action "${opts.action}" on ${toolName}.`);
115
+ } else {
116
+ lines.push(`Missing required top-level field "action" on ${toolName}.`);
117
+ }
118
+
119
+ lines.push('Pass action next to other args (not nested under params).');
120
+ lines.push(`Valid: ${allowed.join(', ')}.`);
121
+ lines.push(`Example: ${example}`);
122
+ lines.push(keys.length > 0 ? `You sent keys: ${keys.join(', ')}` : 'You sent keys: (none)');
123
+ return lines.join(' ');
124
+ }
125
+
126
+ function actionRecipeHint() {
127
+ return [
128
+ 'Retry the same tool with top-level action set to one Valid value.',
129
+ 'Common mistake: omitting action, or putting it under params (jira/gitlab style) — kanban uses top-level action.'
130
+ ].join(' ');
131
+ }
132
+
133
+ function requireToolAction(args, allowed, toolName) {
134
+ const action = args && args.action;
135
+ const keys = receivedKeys(args);
136
+ if (action === undefined || action === null || action === '') {
137
+ throw kanban.createKanbanError(
138
+ 'MISSING_REQUIRED_FIELD',
139
+ actionRecipeMessage(toolName, allowed, {
140
+ kind: 'missing',
141
+ received_keys: keys,
142
+ action: action === undefined ? null : action
143
+ }),
144
+ actionRecipeHint(),
145
+ {
146
+ field: 'action',
147
+ tool: toolName,
148
+ received_keys: keys,
149
+ allowed_actions: allowed,
150
+ action: action === undefined ? null : action
151
+ },
152
+ false,
153
+ 400
154
+ );
155
+ }
156
+ return action;
157
+ }
158
+
159
+ function unknownToolAction(action, args, allowed, toolName) {
160
+ const keys = receivedKeys(args);
161
+ return invalidRequest(
162
+ actionRecipeMessage(toolName, allowed, { kind: 'unknown', action, received_keys: keys }),
163
+ actionRecipeHint(),
164
+ {
165
+ field: 'action',
166
+ tool: toolName,
167
+ action,
168
+ received_keys: keys,
169
+ allowed_actions: allowed
170
+ }
171
+ );
172
+ }
173
+
79
174
  function serializeResult(result) {
80
175
  if (typeof result === 'string') return result;
81
176
 
@@ -370,23 +465,8 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
370
465
  properties: {
371
466
  action: {
372
467
  type: 'string',
373
- enum: [
374
- 'create',
375
- 'move',
376
- 'update',
377
- 'delete',
378
- 'epic_create',
379
- 'epic_update',
380
- 'epic_archive',
381
- 'epic_unarchive',
382
- 'epic_delete',
383
- 'plan_create',
384
- 'plan_advance',
385
- 'plan_evidence',
386
- 'plan_done',
387
- 'plan_status'
388
- ],
389
- description: 'create|move|update|delete daily; epic_create|epic_update|epic_archive|epic_unarchive|epic_delete; plan_* multi-step'
468
+ enum: MANAGE_ACTIONS,
469
+ description: 'create|move|update|delete daily; epic_*; plan_*; comment_add'
390
470
  },
391
471
  title: {
392
472
  type: 'string',
@@ -456,9 +536,17 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
456
536
  type: 'string',
457
537
  description: 'Freeform notes'
458
538
  },
539
+ text: {
540
+ type: 'string',
541
+ description: 'Required for comment_add. Non-empty comment body.'
542
+ },
543
+ author: {
544
+ type: 'string',
545
+ description: 'Optional comment author (default user). e.g. kocur-reviewer, worker'
546
+ },
459
547
  task_id: {
460
548
  type: 'string',
461
- description: "Required for move/update/delete/plan_* except plan_create. '014' or '14'."
549
+ description: "Required for move/update/delete/comment_add/plan_* except plan_create. '014' or '14'."
462
550
  },
463
551
  column: {
464
552
  type: 'string',
@@ -520,7 +608,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
520
608
  properties: {
521
609
  action: {
522
610
  type: 'string',
523
- enum: ['start', 'stop', 'status'],
611
+ enum: GUI_ACTIONS,
524
612
  description: 'start | stop (owned only) | status'
525
613
  },
526
614
  port: {
@@ -626,7 +714,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
626
714
  }
627
715
 
628
716
  case 'kanban_manage': {
629
- const action = args.action;
717
+ const action = requireToolAction(args, MANAGE_ACTIONS, 'kanban_manage');
630
718
  const returnShape = normalizeReturnShape(args.return);
631
719
 
632
720
  switch (action) {
@@ -824,18 +912,38 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
824
912
  result = await plan.status(args.task_id);
825
913
  break;
826
914
 
915
+ case 'comment_add': {
916
+ if (!args.task_id) {
917
+ throw invalidRequest(
918
+ "task_id is required for 'comment_add'",
919
+ 'Provide a task ID',
920
+ { action }
921
+ );
922
+ }
923
+ if (!args.text) {
924
+ throw invalidRequest(
925
+ "text is required for 'comment_add'",
926
+ 'Provide a non-empty comment body',
927
+ { action }
928
+ );
929
+ }
930
+ const added = await kanban.addComment(args.task_id, args.text, args.author);
931
+ result = returnShape === 'none'
932
+ ? { ok: true, comment_id: added.comment.id }
933
+ : returnShape === 'full'
934
+ ? added
935
+ : { ok: true, task_id: added.task_id, comment_id: added.comment.id, count: added.comments.length };
936
+ break;
937
+ }
938
+
827
939
  default:
828
- throw invalidRequest(
829
- `Unknown action: ${action}`,
830
- 'Use create, move, update, delete, epic_create, epic_update, epic_archive, epic_unarchive, epic_delete, plan_create, plan_advance, plan_evidence, plan_done, or plan_status',
831
- { action }
832
- );
940
+ throw unknownToolAction(action, args, MANAGE_ACTIONS, 'kanban_manage');
833
941
  }
834
942
  break;
835
943
  }
836
944
 
837
945
  case 'kanban_gui': {
838
- const action = args.action;
946
+ const action = requireToolAction(args, GUI_ACTIONS, 'kanban_gui');
839
947
 
840
948
  switch (action) {
841
949
  case 'start': {
@@ -851,11 +959,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
851
959
  break;
852
960
  }
853
961
  default:
854
- throw invalidRequest(
855
- `Unknown action: ${action}`,
856
- 'Use one of: start, stop, status',
857
- { action }
858
- );
962
+ throw unknownToolAction(action, args, GUI_ACTIONS, 'kanban_gui');
859
963
  }
860
964
  break;
861
965
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kanbango",
3
- "version": "3.2.0",
3
+ "version": "3.3.0",
4
4
  "description": "JSON-first local Kanban board with web GUI, CLI, and MCP server",
5
5
  "main": "index.js",
6
6
  "bin": {
package/tests/run.js CHANGED
@@ -15,6 +15,7 @@ function runNode(scriptPath, args, label) {
15
15
 
16
16
  runNode(path.join('bin', 'kanban.js'), ['list', '--json'], 'CLI list');
17
17
  runNode(path.join('tests', 'update-tasks.test.js'), [], 'Update tasks test');
18
+ runNode(path.join('tests', 'comments.test.js'), [], 'Comments test');
18
19
  runNode(path.join('tests', 'read-views.test.js'), [], 'Read views test');
19
20
  runNode(path.join('tests', 'mcp-server.test.js'), [], 'MCP server test');
20
21
  runNode(path.join('tests', 'gui-port.test.js'), [], 'GUI port test');
@@ -22,3 +23,4 @@ runNode(path.join('tests', 'plan-workflow.test.js'), [], 'Plan workflow test');
22
23
  runNode(path.join('tests', 'agent-playbook.test.js'), [], 'Agent playbook test');
23
24
  runNode(path.join('tests', 'epics.test.js'), [], 'Epics test');
24
25
  runNode(path.join('tests', 'delete-archive.test.js'), [], 'Delete/archive test');
26
+ runNode(path.join('tests', 'race-conditions.test.js'), [], 'Race conditions test');