kanbango 3.3.0 → 3.4.1

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
@@ -5,3 +5,4 @@
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
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
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}
8
+ {"id":"260819-ztl","ts":"2026-08-19","scope":"proj","tags":["config","kanban","validation"],"rule":"DO Field-policy config: skip only ENOENT; invalid JSON/shape must throw CONFIG_INVALID, never silently fall back to def…","when":"config load / required fields","sev":2,"hits":1}
@@ -1 +1 @@
1
- {"ts":"2026-08-13T06:43:28.413Z","sessionID":"ses_038ddd435ffeginZz7W4t3X27v","added":["260813-9t0"],"bumped":[]}
1
+ {"ts":"2026-08-19T20:04:53.425Z","sessionID":"ses_feb8269efffe9L33YfwuSnVUVa","added":["260819-ztl"],"bumped":[]}
@@ -0,0 +1,25 @@
1
+ # kanbango quality policy
2
+ # CLI/GUI entrypoints use console.log as user-facing output (not debug leftovers).
3
+ version: 1
4
+ include: []
5
+ exclude:
6
+ - node_modules/**
7
+ - backlog/**
8
+ - examples/**
9
+ languages:
10
+ - javascript
11
+ - markdown
12
+ rules:
13
+ enabled: true
14
+ debug-leftover: off
15
+ swallowed-exception: error
16
+ hidden-fallback: error
17
+ unsafe-pattern: error
18
+ placeholder-todo: warning
19
+ dead-code: warning
20
+ cognitive-complexity: warning
21
+ function-length: warning
22
+ nesting-depth: warning
23
+ duplicate-blocks: warning
24
+ gate:
25
+ fail_on: error
package/CHANGELOG.md CHANGED
@@ -7,14 +7,29 @@ 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
10
+ ## [3.4.1] - 2026-08-20
11
+
12
+ ### Fixed
13
+ - CLI `kanban mcp` / `npx kanbango mcp` starts the MCP server (was `Unknown command: mcp` → client `-32000 Connection closed`)
14
+
15
+ ## [3.4.0] - 2026-08-20
11
16
 
12
17
  ### 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`)
18
+ - GUI renders fenced ` ```mermaid ` blocks in task Details and epic info panels (flowchart, sequence, gantt, pie, …)
19
+ - Local Mermaid vendor served from `node_modules` (`/vendor/mermaid.min.js`); dual-export fence parser `fenced-text.js`
20
+ - Soft-fail per diagram: broken Mermaid shows source and does not break the board
21
+ - Dependency `mermaid`; tracked `package-lock.json`
22
+ - Tests: `tests/fenced-text.test.js`, `tests/kanban-mermaid-vendor.test.js`
23
+ - In-process lock + atomic writes for create/update/delete (race-safe board mutations)
24
+ - `.ait-quality.yml` project quality policy (CLI `console.log` treated as UX output)
15
25
 
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`
26
+ ### Changed
27
+ - Edit mode still shows raw mermaid fences (no live preview)
28
+
29
+ ## [3.3.0] - 2026-08-19
30
+
31
+ ### Note
32
+ - Version 3.3.0 was published on npm; this repo release for Mermaid GUI is **3.4.0**.
18
33
 
19
34
  ## [3.2.0] - 2026-07-31
20
35
 
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, comment_add, plan_* workflow |
155
+ | `kanban_manage` | Create, move, update, 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).
@@ -169,8 +169,8 @@ Your agent stays in sync with your real board — every change is persisted as J
169
169
  | `kanban show <ID>` | Show task details |
170
170
  | `kanban add <TITLE>` | Add a new task |
171
171
  | `kanban move <ID> <COL>` | Move task |
172
+ | `kanban mcp` | Start MCP server (stdio) |
172
173
  | `kanban mcp-init` | Generate MCP config files |
173
- | `kanban comment <ID> <text> [--author NAME]` | Append a comment to a task |
174
174
  | `kanban plan <action> --json '{...}'` | Accepted-plan workflow (create/advance/evidence/done/status) |
175
175
 
176
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 (includes comments); full only if needed',
13
+ '- show: view=execution while coding; 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; comment_add: task_id+text',
16
+ '- move/update/delete: return=none; subtasks=full array replace',
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 (includes comments).',
26
+ 'Always pass col when possible. Prefer show+view=execution over full.',
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
- 'comment_add (append-only history); plan_create→plan_advance→plan_evidence→plan_done (non-trivial only).',
39
+ '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). comment_add: task_id+text, optional author. plan_evidence needs real test run: diff,test_command,stdout,stderr,exit_code — truncate logs.',
45
+ '(no toggle). 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,8 +63,7 @@ const MUST_CONTAIN = [
63
63
  'subtasks',
64
64
  'epic_create',
65
65
  'epic_archive',
66
- 'epic_delete',
67
- 'comment_add'
66
+ 'epic_delete'
68
67
  ];
69
68
 
70
69
  function playbookHelpPayload() {
package/bin/kanban.js CHANGED
@@ -336,6 +336,46 @@ function injectProjectIntoHtml(html, project) {
336
336
  .replace(/<h1>Donna Kanban<\/h1>/, `<h1>${safe}</h1>`);
337
337
  }
338
338
 
339
+ /** Allowlisted GUI vendor files only (no open node_modules). */
340
+ const VENDOR_FILES = {
341
+ '/vendor/fenced-text.js': {
342
+ filePath: path.join(__dirname, '..', 'fenced-text.js'),
343
+ contentType: 'application/javascript; charset=utf-8'
344
+ },
345
+ '/vendor/mermaid.min.js': {
346
+ filePath: path.join(__dirname, '..', 'node_modules', 'mermaid', 'dist', 'mermaid.min.js'),
347
+ contentType: 'application/javascript; charset=utf-8'
348
+ }
349
+ };
350
+
351
+ function sendVendorFile(res, requestPath) {
352
+ const entry = VENDOR_FILES[requestPath];
353
+ if (!entry) return false;
354
+ if (!fs.existsSync(entry.filePath)) {
355
+ sendJson(res, 404, {
356
+ error: {
357
+ code: 'VENDOR_MISSING',
358
+ message: `Vendor file missing: ${requestPath}`,
359
+ hint: 'Run npm install in the kanbango package root'
360
+ }
361
+ });
362
+ return true;
363
+ }
364
+ res.writeHead(200, {
365
+ 'Content-Type': entry.contentType,
366
+ 'Cache-Control': 'public, max-age=3600'
367
+ });
368
+ fs.createReadStream(entry.filePath).pipe(res);
369
+ return true;
370
+ }
371
+
372
+ function logGuiPortCleanupError(err) {
373
+ // Port file may already be gone on shutdown — do not block exit.
374
+ if (err && err.code !== 'ENOENT') {
375
+ console.error('gui port cleanup:', err.message || err);
376
+ }
377
+ }
378
+
339
379
  async function serveWeb(port) {
340
380
  const htmlTemplate = fs.readFileSync(path.join(__dirname, '..', 'index.html'), 'utf-8');
341
381
  const project = guiRegistry.projectLabel();
@@ -352,6 +392,10 @@ async function serveWeb(port) {
352
392
  return;
353
393
  }
354
394
 
395
+ if (req.method === 'GET' && sendVendorFile(res, requestPath)) {
396
+ return;
397
+ }
398
+
355
399
  if (requestPath === '/api/board') {
356
400
  await kanban.migrateEpicGroups();
357
401
  const includeArchived = url.searchParams.get('include_archived') === 'true';
@@ -579,8 +623,8 @@ async function serveWeb(port) {
579
623
  async function cleanupGuiPortFile() {
580
624
  try {
581
625
  await guiRegistry.clearGuiPortFile({ pid: process.pid });
582
- } catch {
583
- // best-effort cleanup
626
+ } catch (err) {
627
+ logGuiPortCleanupError(err);
584
628
  }
585
629
  }
586
630
 
@@ -705,6 +749,9 @@ async function main() {
705
749
  }
706
750
 
707
751
  await cliMcpInit({ useNpx, onlyClaude, onlyOpenCode, force });
752
+ } else if (cmd === 'mcp') {
753
+ const mcpServer = require('../mcp-server.js');
754
+ await mcpServer.main();
708
755
  } else if (cmd === 'migrate') {
709
756
  const dryRun = args.includes('--dry-run');
710
757
  await cliMigrate(dryRun);
@@ -789,18 +836,6 @@ async function main() {
789
836
  await cliAdd(args[1], column, epicGroup);
790
837
  } else if (cmd === 'plan' && args[1] && args[2] === '--json') {
791
838
  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
- }
804
839
  } else {
805
840
  console.error('Unknown command:', cmd);
806
841
  process.exit(1);
package/fenced-text.js ADDED
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Split plain text into text / mermaid fence segments.
3
+ * Dual export: CommonJS (Node tests) + browser global FencedText.
4
+ */
5
+ (function (root, factory) {
6
+ if (typeof module === 'object' && module.exports) {
7
+ module.exports = factory();
8
+ } else {
9
+ root.FencedText = factory();
10
+ }
11
+ })(typeof self !== 'undefined' ? self : this, function () {
12
+ 'use strict';
13
+
14
+ // ```mermaid ... ``` (language case-insensitive). CRLF ok. Other fences stay plain text.
15
+ var FENCE_RE = /^```([^\r\n`]*)\r?\n([\s\S]*?)^```[ \t]*\r?$/gm;
16
+
17
+ function parseFencedText(text) {
18
+ var source = text == null ? '' : String(text);
19
+ if (!source) {
20
+ return [];
21
+ }
22
+
23
+ var segments = [];
24
+ var lastIndex = 0;
25
+ var match;
26
+ FENCE_RE.lastIndex = 0;
27
+
28
+ while ((match = FENCE_RE.exec(source)) !== null) {
29
+ if (match.index > lastIndex) {
30
+ segments.push({ type: 'text', value: source.slice(lastIndex, match.index) });
31
+ }
32
+
33
+ var lang = String(match[1] || '').trim().toLowerCase();
34
+ // Strip optional info string after language (e.g. mermaid title="x")
35
+ if (lang.indexOf(' ') !== -1) {
36
+ lang = lang.split(/\s+/)[0];
37
+ }
38
+ var body = match[2];
39
+ if (lang === 'mermaid') {
40
+ segments.push({ type: 'mermaid', value: body.replace(/\s+$/, '') });
41
+ } else {
42
+ segments.push({ type: 'text', value: match[0] });
43
+ }
44
+
45
+ lastIndex = match.index + match[0].length;
46
+ }
47
+
48
+ if (lastIndex < source.length) {
49
+ segments.push({ type: 'text', value: source.slice(lastIndex) });
50
+ }
51
+
52
+ if (segments.length === 0) {
53
+ segments.push({ type: 'text', value: source });
54
+ }
55
+
56
+ return segments;
57
+ }
58
+
59
+ return {
60
+ parseFencedText: parseFencedText
61
+ };
62
+ });
package/index.html CHANGED
@@ -614,6 +614,45 @@ header h1 { font-size: 14px; font-weight: 700; letter-spacing: -0.2px; }
614
614
  color: var(--text);
615
615
  white-space: pre-wrap;
616
616
  }
617
+ .detail-text .md-text {
618
+ white-space: pre-wrap;
619
+ }
620
+ .md-mermaid {
621
+ margin: 8px 0;
622
+ padding: 10px;
623
+ background: #f8fafc;
624
+ border: 1px solid var(--border);
625
+ border-radius: var(--radius);
626
+ overflow-x: auto;
627
+ text-align: center;
628
+ }
629
+ .md-mermaid svg { max-width: 100%; height: auto; }
630
+ .md-mermaid-error {
631
+ margin: 8px 0;
632
+ padding: 8px 10px;
633
+ background: #fef2f2;
634
+ border: 1px solid #fecaca;
635
+ border-radius: var(--radius);
636
+ text-align: left;
637
+ }
638
+ .md-mermaid-error .md-err-label {
639
+ font-size: 10px;
640
+ font-weight: 700;
641
+ text-transform: uppercase;
642
+ letter-spacing: 0.5px;
643
+ color: #b91c1c;
644
+ margin-bottom: 6px;
645
+ }
646
+ .md-mermaid-error pre {
647
+ margin: 0;
648
+ font-size: 11px;
649
+ line-height: 1.4;
650
+ white-space: pre-wrap;
651
+ word-break: break-word;
652
+ color: var(--text);
653
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
654
+ }
655
+ .sl-epic-text .md-mermaid { margin: 6px 0 0; }
617
656
  .detail-list {
618
657
  margin: 0;
619
658
  padding-left: 18px;
@@ -777,6 +816,8 @@ header h1 { font-size: 14px; font-weight: 700; letter-spacing: -0.2px; }
777
816
 
778
817
  <div class="toast" id="toast"></div>
779
818
 
819
+ <script src="/vendor/fenced-text.js"></script>
820
+ <script src="/vendor/mermaid.min.js"></script>
780
821
  <script>
781
822
  const COL_DEFS = [
782
823
  { id: "icebox", label: "🧊 Icebox", icon: "🧊" },
@@ -877,6 +918,7 @@ function render() {
877
918
  const sl = document.getElementById("swimlanes");
878
919
  sl.innerHTML = "";
879
920
  groupTasks(allTasks).forEach(g => sl.appendChild(renderSwimlane(g)));
921
+ queueMermaidRun();
880
922
  }
881
923
 
882
924
  function groupTasks(tasks) {
@@ -924,11 +966,14 @@ function renderEpicDetailView(entity) {
924
966
 
925
967
  const hero = el("div", "sl-epic-hero");
926
968
  const goals = el("div", "sl-epic-goals");
927
- goals.innerHTML = `<div class="sl-epic-label">Goals</div>${
928
- entity.goals
929
- ? `<div class="sl-epic-text">${escHtml(entity.goals)}</div>`
930
- : `<div class="sl-epic-text empty">No goals set</div>`
931
- }`;
969
+ goals.appendChild(Object.assign(el("div", "sl-epic-label"), { textContent: "Goals" }));
970
+ if (entity.goals) {
971
+ const goalsText = el("div", "sl-epic-text");
972
+ fillRichText(goalsText, entity.goals);
973
+ goals.appendChild(goalsText);
974
+ } else {
975
+ goals.appendChild(Object.assign(el("div", "sl-epic-text empty"), { textContent: "No goals set" }));
976
+ }
932
977
  hero.appendChild(goals);
933
978
 
934
979
  const rollup = el("div", "sl-epic-rollup");
@@ -945,13 +990,35 @@ function renderEpicDetailView(entity) {
945
990
  const body = el("div", "sl-epic-body");
946
991
 
947
992
  const descCard = el("div", "sl-epic-card");
948
- descCard.innerHTML = `<div class="sl-epic-label">Description</div>${
949
- entity.description
950
- ? `<div class="sl-epic-text">${escHtml(entity.description)}</div>`
951
- : `<div class="sl-epic-text empty">No description yet — click Edit epic to add context for agents.</div>`
952
- }`;
993
+ descCard.appendChild(Object.assign(el("div", "sl-epic-label"), { textContent: "What" }));
994
+ if (entity.description) {
995
+ const descText = el("div", "sl-epic-text");
996
+ fillRichText(descText, entity.description);
997
+ descCard.appendChild(descText);
998
+ } else {
999
+ descCard.appendChild(Object.assign(el("div", "sl-epic-text empty"), {
1000
+ textContent: "No description yet — click Edit epic to add what this initiative does."
1001
+ }));
1002
+ }
953
1003
  body.appendChild(descCard);
954
1004
 
1005
+ if (entity.rationale) {
1006
+ const rationaleCard = el("div", "sl-epic-card");
1007
+ rationaleCard.appendChild(Object.assign(el("div", "sl-epic-label"), { textContent: "Rationale" }));
1008
+ const rationaleText = el("div", "sl-epic-text");
1009
+ fillRichText(rationaleText, entity.rationale);
1010
+ rationaleCard.appendChild(rationaleText);
1011
+ body.appendChild(rationaleCard);
1012
+ }
1013
+ if (entity.outcome) {
1014
+ const outcomeCard = el("div", "sl-epic-card");
1015
+ outcomeCard.appendChild(Object.assign(el("div", "sl-epic-label"), { textContent: "Outcome" }));
1016
+ const outcomeText = el("div", "sl-epic-text");
1017
+ fillRichText(outcomeText, entity.outcome);
1018
+ outcomeCard.appendChild(outcomeText);
1019
+ body.appendChild(outcomeCard);
1020
+ }
1021
+
955
1022
  const scopeCard = el("div", "sl-epic-card scope-pair");
956
1023
  scopeCard.innerHTML = `
957
1024
  <div>
@@ -968,7 +1035,10 @@ function renderEpicDetailView(entity) {
968
1035
 
969
1036
  if (entity.notes) {
970
1037
  const notes = el("div", "sl-epic-card sl-epic-notes");
971
- notes.innerHTML = `<div class="sl-epic-label">Notes</div><div class="sl-epic-text">${escHtml(entity.notes)}</div>`;
1038
+ notes.appendChild(Object.assign(el("div", "sl-epic-label"), { textContent: "Notes" }));
1039
+ const notesText = el("div", "sl-epic-text");
1040
+ fillRichText(notesText, entity.notes);
1041
+ notes.appendChild(notesText);
972
1042
  panel.appendChild(notes);
973
1043
  }
974
1044
 
@@ -980,6 +1050,8 @@ function renderEpicDetailEdit(entity) {
980
1050
  const dirty = dirtyEpicEdits[entity.id] || {
981
1051
  title: entity.title || "",
982
1052
  description: entity.description || "",
1053
+ rationale: entity.rationale || "",
1054
+ outcome: entity.outcome || "",
983
1055
  goals: entity.goals || "",
984
1056
  in_scope: (entity.in_scope || []).join("\n"),
985
1057
  out_of_scope: (entity.out_of_scope || []).join("\n"),
@@ -1010,15 +1082,33 @@ function renderEpicDetailEdit(entity) {
1010
1082
  grid.appendChild(goalsWrap);
1011
1083
 
1012
1084
  const descWrap = el("div", "span2");
1013
- descWrap.innerHTML = `<label>Description</label>`;
1085
+ descWrap.innerHTML = `<label>What</label>`;
1014
1086
  const descTa = document.createElement("textarea");
1015
1087
  descTa.value = dirty.description;
1016
- descTa.placeholder = "Context for agents…";
1088
+ descTa.placeholder = "What this initiative does…";
1017
1089
  descTa.style.minHeight = "72px";
1018
1090
  descTa.oninput = () => { dirty.description = descTa.value; dirtyEpicEdits[entity.id] = dirty; };
1019
1091
  descWrap.appendChild(descTa);
1020
1092
  grid.appendChild(descWrap);
1021
1093
 
1094
+ const rationaleWrap = el("div", "span2");
1095
+ rationaleWrap.innerHTML = `<label>Rationale</label>`;
1096
+ const rationaleTa = document.createElement("textarea");
1097
+ rationaleTa.value = dirty.rationale;
1098
+ rationaleTa.placeholder = "Why this approach, not alternatives…";
1099
+ rationaleTa.oninput = () => { dirty.rationale = rationaleTa.value; dirtyEpicEdits[entity.id] = dirty; };
1100
+ rationaleWrap.appendChild(rationaleTa);
1101
+ grid.appendChild(rationaleWrap);
1102
+
1103
+ const outcomeWrap = el("div", "span2");
1104
+ outcomeWrap.innerHTML = `<label>Outcome</label>`;
1105
+ const outcomeTa = document.createElement("textarea");
1106
+ outcomeTa.value = dirty.outcome;
1107
+ outcomeTa.placeholder = "Intended effect / goal…";
1108
+ outcomeTa.oninput = () => { dirty.outcome = outcomeTa.value; dirtyEpicEdits[entity.id] = dirty; };
1109
+ outcomeWrap.appendChild(outcomeTa);
1110
+ grid.appendChild(outcomeWrap);
1111
+
1022
1112
  const inWrap = el("div", "");
1023
1113
  inWrap.innerHTML = `<label>In scope (one per line)</label>`;
1024
1114
  const inTa = document.createElement("textarea");
@@ -1074,6 +1164,8 @@ async function saveEpicEdit(epicId) {
1074
1164
  body: JSON.stringify({
1075
1165
  title: dirty.title.trim(),
1076
1166
  description: dirty.description,
1167
+ rationale: dirty.rationale,
1168
+ outcome: dirty.outcome,
1077
1169
  goals: dirty.goals,
1078
1170
  in_scope: splitLines(dirty.in_scope),
1079
1171
  out_of_scope: splitLines(dirty.out_of_scope),
@@ -1371,6 +1463,8 @@ function renderEditMode(card, epic, colId, shortId) {
1371
1463
  const dirty = dirtyEdits[epic.id] || {
1372
1464
  title: displayTitle,
1373
1465
  description: epic.description || "",
1466
+ rationale: epic.rationale || "",
1467
+ outcome: epic.outcome || "",
1374
1468
  specs: epic.specs || "",
1375
1469
  in_scope: (epic.in_scope || []).join("\n"),
1376
1470
  out_of_scope: (epic.out_of_scope || []).join("\n"),
@@ -1395,14 +1489,22 @@ function renderEditMode(card, epic, colId, shortId) {
1395
1489
  card.appendChild(titleInput);
1396
1490
 
1397
1491
  const meta = el("div", "edit-meta");
1398
- meta.appendChild(renderEditBlock("Description", dirty.description, value => {
1492
+ meta.appendChild(renderEditBlock("What", dirty.description, value => {
1399
1493
  dirty.description = value;
1400
1494
  dirtyEdits[epic.id] = dirty;
1401
- }, "Context and implementation plan..."));
1495
+ }, "What we are doing..."));
1496
+ meta.appendChild(renderEditBlock("Rationale", dirty.rationale, value => {
1497
+ dirty.rationale = value;
1498
+ dirtyEdits[epic.id] = dirty;
1499
+ }, "Why this approach, not alternatives..."));
1500
+ meta.appendChild(renderEditBlock("Outcome", dirty.outcome, value => {
1501
+ dirty.outcome = value;
1502
+ dirtyEdits[epic.id] = dirty;
1503
+ }, "Intended effect / goal..."));
1402
1504
  meta.appendChild(renderEditBlock("Specs", dirty.specs, value => {
1403
1505
  dirty.specs = value;
1404
1506
  dirtyEdits[epic.id] = dirty;
1405
- }, "APIs, constraints, edge cases..."));
1507
+ }, "APIs, constraints, edge cases (how)..."));
1406
1508
  meta.appendChild(renderEditBlock("In Scope", dirty.in_scope, value => {
1407
1509
  dirty.in_scope = value;
1408
1510
  dirtyEdits[epic.id] = dirty;
@@ -1531,6 +1633,8 @@ async function saveEdit(epicId) {
1531
1633
  const origTitle = epic.title.replace(/^[\w.-]+:\s*/, "");
1532
1634
  if (dirty.title !== origTitle) body.title = dirty.title;
1533
1635
  body.description = dirty.description;
1636
+ body.rationale = dirty.rationale;
1637
+ body.outcome = dirty.outcome;
1534
1638
  body.specs = dirty.specs;
1535
1639
  body.in_scope = splitLines(dirty.in_scope);
1536
1640
  body.out_of_scope = splitLines(dirty.out_of_scope);
@@ -1728,7 +1832,13 @@ function splitLines(value) {
1728
1832
  }
1729
1833
  function appendDetailSections(card, epic) {
1730
1834
  if (epic.description) {
1731
- card.appendChild(renderDetailSection("Description", epic.description));
1835
+ card.appendChild(renderDetailSection("What", epic.description));
1836
+ }
1837
+ if (epic.rationale) {
1838
+ card.appendChild(renderDetailSection("Rationale", epic.rationale));
1839
+ }
1840
+ if (epic.outcome) {
1841
+ card.appendChild(renderDetailSection("Outcome", epic.outcome));
1732
1842
  }
1733
1843
  if (epic.specs) {
1734
1844
  card.appendChild(renderDetailSection("Specs", epic.specs));
@@ -1754,11 +1864,130 @@ function renderDetailSection(label, text) {
1754
1864
  const labelEl = el("div", "detail-label");
1755
1865
  labelEl.textContent = label;
1756
1866
  const textEl = el("div", "detail-text");
1757
- textEl.textContent = text;
1867
+ fillRichText(textEl, text);
1758
1868
  wrap.appendChild(labelEl);
1759
1869
  wrap.appendChild(textEl);
1760
1870
  return wrap;
1761
1871
  }
1872
+
1873
+ // ── Mermaid in detail fields (view mode only) ─────────────────────────────────
1874
+
1875
+ let mermaidReady = false;
1876
+ let mermaidRunTimer = null;
1877
+ let mermaidSeq = 0;
1878
+
1879
+ function parseFencedSegments(text) {
1880
+ if (window.FencedText && typeof window.FencedText.parseFencedText === "function") {
1881
+ return window.FencedText.parseFencedText(text);
1882
+ }
1883
+ return text ? [{ type: "text", value: String(text) }] : [];
1884
+ }
1885
+
1886
+ function fillRichText(container, text) {
1887
+ container.textContent = "";
1888
+ const segments = parseFencedSegments(text);
1889
+ if (segments.length === 0) return;
1890
+
1891
+ segments.forEach(seg => {
1892
+ if (seg.type === "mermaid") {
1893
+ const box = el("div", "md-mermaid");
1894
+ box.dataset.mermaidSource = seg.value;
1895
+ const pre = document.createElement("pre");
1896
+ pre.className = "mermaid";
1897
+ pre.textContent = seg.value;
1898
+ box.appendChild(pre);
1899
+ container.appendChild(box);
1900
+ } else if (seg.value) {
1901
+ const span = el("div", "md-text");
1902
+ span.textContent = seg.value;
1903
+ container.appendChild(span);
1904
+ }
1905
+ });
1906
+ }
1907
+
1908
+ function showMermaidFallback(box, source, err) {
1909
+ box.className = "md-mermaid-error";
1910
+ box.textContent = "";
1911
+ const label = el("div", "md-err-label");
1912
+ label.textContent = err && err.message
1913
+ ? `Mermaid error: ${err.message}`
1914
+ : "Mermaid render failed";
1915
+ const pre = document.createElement("pre");
1916
+ pre.textContent = source || box.dataset.mermaidSource || "";
1917
+ box.appendChild(label);
1918
+ box.appendChild(pre);
1919
+ }
1920
+
1921
+ function ensureMermaidInit() {
1922
+ if (mermaidReady) return true;
1923
+ if (typeof mermaid === "undefined" || !mermaid) return false;
1924
+ try {
1925
+ mermaid.initialize({
1926
+ startOnLoad: false,
1927
+ securityLevel: "strict",
1928
+ theme: "neutral"
1929
+ });
1930
+ mermaidReady = true;
1931
+ return true;
1932
+ } catch (err) {
1933
+ console.error("mermaid.initialize failed", err);
1934
+ return false;
1935
+ }
1936
+ }
1937
+
1938
+ async function runMermaidIn(root) {
1939
+ const scope = root || document;
1940
+ const nodes = [...scope.querySelectorAll("pre.mermaid")].filter(
1941
+ n => !n.dataset.processed && n.textContent.trim()
1942
+ );
1943
+ if (nodes.length === 0) return;
1944
+
1945
+ if (!ensureMermaidInit()) {
1946
+ nodes.forEach(pre => {
1947
+ const box = pre.closest(".md-mermaid") || pre.parentElement;
1948
+ if (box) showMermaidFallback(box, pre.textContent, new Error("mermaid library not loaded"));
1949
+ });
1950
+ return;
1951
+ }
1952
+
1953
+ // Prefer per-node render so one bad diagram never blocks siblings.
1954
+ for (const pre of nodes) {
1955
+ const source = pre.textContent;
1956
+ const box = pre.closest(".md-mermaid") || pre.parentElement;
1957
+ const id = `mmd-${Date.now()}-${++mermaidSeq}`;
1958
+ try {
1959
+ const out = await mermaid.render(id, source);
1960
+ // Board re-render may detach this node while await was in flight.
1961
+ if (!pre.isConnected) continue;
1962
+ const svg = typeof out === "string" ? out : (out && out.svg);
1963
+ if (!svg) throw new Error("empty mermaid output");
1964
+ if (box && box.isConnected) {
1965
+ box.innerHTML = svg;
1966
+ box.dataset.processed = "1";
1967
+ } else if (pre.isConnected) {
1968
+ pre.outerHTML = svg;
1969
+ }
1970
+ } catch (err) {
1971
+ // Remove orphaned error SVG mermaid may leave in <body>
1972
+ const stray = document.getElementById(id);
1973
+ if (stray) stray.remove();
1974
+ const strayD = document.getElementById("d" + id);
1975
+ if (strayD) strayD.remove();
1976
+ if (box) showMermaidFallback(box, source, err);
1977
+ else {
1978
+ pre.dataset.processed = "error";
1979
+ pre.title = (err && err.message) || "mermaid error";
1980
+ }
1981
+ }
1982
+ }
1983
+ }
1984
+
1985
+ function queueMermaidRun(root) {
1986
+ clearTimeout(mermaidRunTimer);
1987
+ mermaidRunTimer = setTimeout(() => {
1988
+ runMermaidIn(root).catch(err => console.error("mermaid run failed", err));
1989
+ }, 0);
1990
+ }
1762
1991
  function renderDetailListSection(label, items, extraClass) {
1763
1992
  const wrap = el("div", extraClass ? `detail-section ${extraClass}` : "detail-section");
1764
1993
  const labelEl = el("div", "detail-label");
package/kanban.js CHANGED
@@ -41,8 +41,7 @@ const VIEW_FIELDS = {
41
41
  'out_of_scope',
42
42
  'acceptance_criteria',
43
43
  'test_cases',
44
- 'subtasks',
45
- 'comments'
44
+ 'subtasks'
46
45
  ],
47
46
  full: [
48
47
  'task_number',
@@ -59,8 +58,7 @@ const VIEW_FIELDS = {
59
58
  'acceptance_criteria',
60
59
  'test_cases',
61
60
  'subtasks',
62
- 'notes',
63
- 'comments'
61
+ 'notes'
64
62
  ]
65
63
  };
66
64
 
@@ -255,28 +253,6 @@ function normalizeSubtasks(value) {
255
253
  })).filter((subtask) => subtask.text);
256
254
  }
257
255
 
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
-
280
256
  function normalizeEvidence(value) {
281
257
  if (!Array.isArray(value)) return [];
282
258
  return value.map((item) => ({
@@ -316,7 +292,6 @@ function normalizeTask(task) {
316
292
  test_cases: normalizeStringArray(task.test_cases),
317
293
  subtasks: normalizeSubtasks(task.subtasks),
318
294
  notes: normalizeString(task.notes),
319
- comments: normalizeComments(task.comments),
320
295
  plan: normalizePlan(task.plan),
321
296
  evidence: normalizeEvidence(task.evidence),
322
297
  task_number: extractTaskNumber(id)
@@ -342,7 +317,6 @@ function serializeTask(task) {
342
317
  test_cases: normalized.test_cases,
343
318
  subtasks: normalized.subtasks,
344
319
  notes: normalized.notes,
345
- comments: normalized.comments,
346
320
  plan: normalized.plan,
347
321
  evidence: normalized.evidence,
348
322
  task_number: normalized.task_number
@@ -1341,7 +1315,6 @@ async function doCreate(title, column = 'planned', epicRef = '—', extra = {})
1341
1315
  test_cases: extra.test_cases,
1342
1316
  subtasks: extra.subtasks,
1343
1317
  notes: extra.notes,
1344
- comments: extra.comments,
1345
1318
  plan: extra.plan,
1346
1319
  evidence: extra.evidence
1347
1320
  });
@@ -1488,19 +1461,6 @@ async function updateTaskRecord(taskId, patch) {
1488
1461
  next.subtasks = patch.subtasks;
1489
1462
  }
1490
1463
  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
- }
1504
1464
  if (patch.plan !== undefined) next.plan = patch.plan;
1505
1465
  if (patch.evidence !== undefined) {
1506
1466
  if (!Array.isArray(patch.evidence)) {
@@ -1524,44 +1484,6 @@ async function updateTask(taskId, patch) {
1524
1484
  return withBoardLock(() => updateTaskRecord(taskId, patch));
1525
1485
  }
1526
1486
 
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
-
1565
1487
  async function doMove(epicId, target) {
1566
1488
  try {
1567
1489
  await updateTask(epicId, { column: target });
@@ -1627,7 +1549,6 @@ module.exports = {
1627
1549
  shapeTask,
1628
1550
  shapeEpic,
1629
1551
  updateTask,
1630
- addComment,
1631
1552
  migrateAll,
1632
1553
  migrateEpicGroups,
1633
1554
  doMove,
package/mcp-server.js CHANGED
@@ -76,101 +76,6 @@ 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
-
174
79
  function serializeResult(result) {
175
80
  if (typeof result === 'string') return result;
176
81
 
@@ -465,8 +370,23 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
465
370
  properties: {
466
371
  action: {
467
372
  type: 'string',
468
- enum: MANAGE_ACTIONS,
469
- description: 'create|move|update|delete daily; epic_*; plan_*; comment_add'
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'
470
390
  },
471
391
  title: {
472
392
  type: 'string',
@@ -536,17 +456,9 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
536
456
  type: 'string',
537
457
  description: 'Freeform notes'
538
458
  },
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
- },
547
459
  task_id: {
548
460
  type: 'string',
549
- description: "Required for move/update/delete/comment_add/plan_* except plan_create. '014' or '14'."
461
+ description: "Required for move/update/delete/plan_* except plan_create. '014' or '14'."
550
462
  },
551
463
  column: {
552
464
  type: 'string',
@@ -608,7 +520,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
608
520
  properties: {
609
521
  action: {
610
522
  type: 'string',
611
- enum: GUI_ACTIONS,
523
+ enum: ['start', 'stop', 'status'],
612
524
  description: 'start | stop (owned only) | status'
613
525
  },
614
526
  port: {
@@ -714,7 +626,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
714
626
  }
715
627
 
716
628
  case 'kanban_manage': {
717
- const action = requireToolAction(args, MANAGE_ACTIONS, 'kanban_manage');
629
+ const action = args.action;
718
630
  const returnShape = normalizeReturnShape(args.return);
719
631
 
720
632
  switch (action) {
@@ -912,38 +824,18 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
912
824
  result = await plan.status(args.task_id);
913
825
  break;
914
826
 
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
-
939
827
  default:
940
- throw unknownToolAction(action, args, MANAGE_ACTIONS, 'kanban_manage');
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
+ );
941
833
  }
942
834
  break;
943
835
  }
944
836
 
945
837
  case 'kanban_gui': {
946
- const action = requireToolAction(args, GUI_ACTIONS, 'kanban_gui');
838
+ const action = args.action;
947
839
 
948
840
  switch (action) {
949
841
  case 'start': {
@@ -959,7 +851,11 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
959
851
  break;
960
852
  }
961
853
  default:
962
- throw unknownToolAction(action, args, GUI_ACTIONS, 'kanban_gui');
854
+ throw invalidRequest(
855
+ `Unknown action: ${action}`,
856
+ 'Use one of: start, stop, status',
857
+ { action }
858
+ );
963
859
  }
964
860
  break;
965
861
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kanbango",
3
- "version": "3.3.0",
3
+ "version": "3.4.1",
4
4
  "description": "JSON-first local Kanban board with web GUI, CLI, and MCP server",
5
5
  "main": "index.js",
6
6
  "bin": {
@@ -36,6 +36,7 @@
36
36
  "node": ">=16.0.0"
37
37
  },
38
38
  "dependencies": {
39
- "@modelcontextprotocol/sdk": "^1.0.4"
39
+ "@modelcontextprotocol/sdk": "^1.0.4",
40
+ "mermaid": "^11.17.0"
40
41
  }
41
42
  }
package/tests/run.js CHANGED
@@ -15,10 +15,11 @@ 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');
19
18
  runNode(path.join('tests', 'read-views.test.js'), [], 'Read views test');
20
19
  runNode(path.join('tests', 'mcp-server.test.js'), [], 'MCP server test');
21
20
  runNode(path.join('tests', 'gui-port.test.js'), [], 'GUI port test');
21
+ runNode(path.join('tests', 'fenced-text.test.js'), [], 'Fenced text test');
22
+ runNode(path.join('tests', 'kanban-mermaid-vendor.test.js'), [], 'Kanban mermaid vendor route test');
22
23
  runNode(path.join('tests', 'plan-workflow.test.js'), [], 'Plan workflow test');
23
24
  runNode(path.join('tests', 'agent-playbook.test.js'), [], 'Agent playbook test');
24
25
  runNode(path.join('tests', 'epics.test.js'), [], 'Epics test');
@@ -1,121 +0,0 @@
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