kanbango 3.2.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
@@ -3,3 +3,6 @@
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}
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-07-31T13:58:00.629Z","sessionID":"ses_04792cf77ffec14LhSNoSJReg9","added":["260731-o6g"],"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,6 +7,30 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
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
16
+
17
+ ### Added
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)
25
+
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**.
33
+
10
34
  ## [3.2.0] - 2026-07-31
11
35
 
12
36
  ### Added
package/README.md CHANGED
@@ -169,6 +169,7 @@ 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
174
  | `kanban plan <action> --json '{...}'` | Accepted-plan workflow (create/advance/evidence/done/status) |
174
175
 
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);
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
@@ -121,6 +121,43 @@ function createKanbanError(code, message, hint, details = {}, retryable = false,
121
121
  return error;
122
122
  }
123
123
 
124
+ // Serialize board mutations so concurrent create/move/update cannot race on ids or paths.
125
+ let mutationTail = Promise.resolve();
126
+
127
+ function withBoardLock(fn) {
128
+ const run = mutationTail.then(() => fn());
129
+ mutationTail = run.then(() => undefined, () => undefined);
130
+ return run;
131
+ }
132
+
133
+ function isTaskOrEpicDataFile(file) {
134
+ if (!file || file.startsWith('.')) return false;
135
+ return file.endsWith('.json') || file.endsWith('.md');
136
+ }
137
+
138
+ async function writeFileAtomic(filePath, payload, { exclusive = false } = {}) {
139
+ const dir = path.dirname(filePath);
140
+ const base = path.basename(filePath);
141
+ const tempPath = path.join(
142
+ dir,
143
+ `.${base}.${process.pid}.${Date.now()}.${Math.random().toString(16).slice(2)}.tmp`
144
+ );
145
+
146
+ await fs.writeFile(tempPath, payload, 'utf-8');
147
+ try {
148
+ if (exclusive) {
149
+ // Atomic create-if-absent: never exposes an empty final path to readers.
150
+ await fs.link(tempPath, filePath);
151
+ await fs.unlink(tempPath).catch(() => undefined);
152
+ } else {
153
+ await fs.rename(tempPath, filePath);
154
+ }
155
+ } catch (error) {
156
+ await fs.unlink(tempPath).catch(() => undefined);
157
+ throw error;
158
+ }
159
+ }
160
+
124
161
  function todayIso() {
125
162
  return new Date().toISOString().split('T')[0];
126
163
  }
@@ -536,9 +573,24 @@ async function parseMarkdownTask(filePath, column) {
536
573
  }
537
574
 
538
575
  async function parseJsonTask(filePath, column) {
576
+ let raw;
577
+ try {
578
+ raw = await fs.readFile(filePath, 'utf-8');
579
+ } catch (error) {
580
+ if (error.code === 'ENOENT') throw error;
581
+ throw createKanbanError(
582
+ 'PARSE_ERROR',
583
+ `Task file ${path.basename(filePath)} could not be read`,
584
+ 'Fix permissions or restore the file from version control',
585
+ { file: filePath, reason: error.message },
586
+ false,
587
+ 500
588
+ );
589
+ }
590
+
539
591
  let data;
540
592
  try {
541
- data = JSON.parse(await fs.readFile(filePath, 'utf-8'));
593
+ data = JSON.parse(raw);
542
594
  } catch (error) {
543
595
  throw createKanbanError(
544
596
  'PARSE_ERROR',
@@ -572,7 +624,7 @@ async function allEpics() {
572
624
  try {
573
625
  const files = await fs.readdir(colDir);
574
626
  const taskFiles = files
575
- .filter((file) => file.endsWith('.json') || file.endsWith('.md'))
627
+ .filter((file) => isTaskOrEpicDataFile(file))
576
628
  .sort((left, right) => {
577
629
  const leftBase = path.basename(left, path.extname(left));
578
630
  const rightBase = path.basename(right, path.extname(right));
@@ -589,6 +641,8 @@ async function allEpics() {
589
641
  try {
590
642
  epics.push(await parseEpic(path.join(colDir, file), col));
591
643
  } catch (error) {
644
+ // File may vanish between readdir and read under concurrent delete.
645
+ if (error.code === 'ENOENT') continue;
592
646
  console.error(` parse error ${file}: ${error.message}`);
593
647
  }
594
648
  }
@@ -614,6 +668,7 @@ async function nextEpicNumber() {
614
668
  try {
615
669
  const files = await fs.readdir(EPICS_DIR);
616
670
  for (const file of files) {
671
+ if (!isTaskOrEpicDataFile(file)) continue;
617
672
  const match = file.match(/^E0*(\d+)\.json$/i);
618
673
  if (match) ids.push(parseInt(match[1], 10));
619
674
  }
@@ -662,7 +717,7 @@ async function listEpicEntities() {
662
717
  const epics = [];
663
718
  try {
664
719
  const files = (await fs.readdir(EPICS_DIR))
665
- .filter((file) => file.endsWith('.json'))
720
+ .filter((file) => isTaskOrEpicDataFile(file) && file.endsWith('.json'))
666
721
  .sort((left, right) => left.localeCompare(right));
667
722
  for (const file of files) {
668
723
  try {
@@ -677,7 +732,7 @@ async function listEpicEntities() {
677
732
  return epics;
678
733
  }
679
734
 
680
- async function writeEpic(epic) {
735
+ async function writeEpic(epic, { exclusive = false } = {}) {
681
736
  const normalized = normalizeEpic(epic);
682
737
  if (!normalizeEpicId(normalized.id)) {
683
738
  throw createKanbanError(
@@ -691,7 +746,8 @@ async function writeEpic(epic) {
691
746
  }
692
747
  await ensureBacklogDir();
693
748
  const filePath = epicFilePath(normalized.id);
694
- await fs.writeFile(filePath, JSON.stringify(serializeEpic(normalized), null, 2) + '\n', 'utf-8');
749
+ const payload = JSON.stringify(serializeEpic(normalized), null, 2) + '\n';
750
+ await writeFileAtomic(filePath, payload, { exclusive });
695
751
  return parseJsonEpic(filePath);
696
752
  }
697
753
 
@@ -760,7 +816,9 @@ async function resolveEpicRef(ref, options = {}) {
760
816
  }
761
817
 
762
818
  if (options.createIfMissing) {
763
- const created = await doCreateEpic(normalizeString(ref), {});
819
+ const created = options.skipLock
820
+ ? await createEpicRecord(normalizeString(ref), {})
821
+ : await doCreateEpic(normalizeString(ref), {});
764
822
  return { epic_id: created.id, epic_group: created.title };
765
823
  }
766
824
 
@@ -774,7 +832,7 @@ async function resolveEpicRef(ref, options = {}) {
774
832
  );
775
833
  }
776
834
 
777
- async function doCreateEpic(title, extra = {}) {
835
+ async function createEpicRecord(title, extra = {}) {
778
836
  if (!normalizeString(title)) {
779
837
  throw createKanbanError(
780
838
  'MISSING_REQUIRED_FIELD',
@@ -786,82 +844,102 @@ async function doCreateEpic(title, extra = {}) {
786
844
  );
787
845
  }
788
846
 
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);
847
+ for (let attempt = 0; attempt < 32; attempt++) {
848
+ const nextId = await nextEpicNumber();
849
+ const epic = normalizeEpic({
850
+ id: `E${String(nextId).padStart(3, '0')}`,
851
+ title,
852
+ created: todayIso(),
853
+ description: extra.description,
854
+ goals: extra.goals,
855
+ in_scope: extra.in_scope,
856
+ out_of_scope: extra.out_of_scope,
857
+ notes: extra.notes
858
+ });
859
+ try {
860
+ return await writeEpic(epic, { exclusive: true });
861
+ } catch (error) {
862
+ if (error.code !== 'EEXIST') throw error;
863
+ }
864
+ }
865
+ throw createKanbanError(
866
+ 'CREATE_CONFLICT',
867
+ 'Could not allocate a unique epic id',
868
+ 'Retry the create operation',
869
+ { title },
870
+ true,
871
+ 409
872
+ );
873
+ }
874
+
875
+ async function doCreateEpic(title, extra = {}) {
876
+ return withBoardLock(() => createEpicRecord(title, extra));
801
877
  }
802
878
 
803
879
  async function updateEpicEntity(epicId, patch) {
804
880
  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
- );
881
+ return withBoardLock(async () => {
882
+ const current = await getEpicEntity(epicId);
883
+ const next = { ...current };
884
+
885
+ if (patch.title !== undefined) {
886
+ const title = normalizeString(patch.title);
887
+ if (!title) {
888
+ throw createKanbanError(
889
+ 'VALIDATION_ERROR',
890
+ 'title must be a non-empty string',
891
+ 'Send a non-empty title or omit the field',
892
+ { field: 'title' },
893
+ false,
894
+ 400
895
+ );
896
+ }
897
+ next.title = title;
819
898
  }
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
- );
899
+ if (patch.description !== undefined) next.description = normalizeString(patch.description);
900
+ if (patch.goals !== undefined) next.goals = normalizeString(patch.goals);
901
+ if (patch.in_scope !== undefined) {
902
+ if (!Array.isArray(patch.in_scope)) {
903
+ throw createKanbanError(
904
+ 'VALIDATION_ERROR',
905
+ 'in_scope must be an array of strings',
906
+ 'Send in_scope as an array',
907
+ { field: 'in_scope' },
908
+ false,
909
+ 400
910
+ );
911
+ }
912
+ next.in_scope = normalizeStringArray(patch.in_scope);
834
913
  }
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
- );
914
+ if (patch.out_of_scope !== undefined) {
915
+ if (!Array.isArray(patch.out_of_scope)) {
916
+ throw createKanbanError(
917
+ 'VALIDATION_ERROR',
918
+ 'out_of_scope must be an array of strings',
919
+ 'Send out_of_scope as an array',
920
+ { field: 'out_of_scope' },
921
+ false,
922
+ 400
923
+ );
924
+ }
925
+ next.out_of_scope = normalizeStringArray(patch.out_of_scope);
847
926
  }
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);
927
+ if (patch.notes !== undefined) next.notes = normalizeString(patch.notes);
928
+ if (patch.archived !== undefined) next.archived = Boolean(patch.archived);
852
929
 
853
- const saved = await writeEpic(next);
930
+ const saved = await writeEpic(next);
854
931
 
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 });
932
+ if (patch.title !== undefined && saved.title !== current.title) {
933
+ const tasks = await allTasks();
934
+ for (const task of tasks) {
935
+ if (task.epic_id === saved.id && task.epic_group !== saved.title) {
936
+ await updateTaskRecord(task.id, { epic_group: saved.title, _skipEpicResolve: true });
937
+ }
860
938
  }
861
939
  }
862
- }
863
940
 
864
- return saved;
941
+ return saved;
942
+ });
865
943
  }
866
944
 
867
945
  async function archiveEpic(epicId) {
@@ -872,7 +950,7 @@ async function unarchiveEpic(epicId) {
872
950
  return updateEpicEntity(epicId, { archived: false });
873
951
  }
874
952
 
875
- async function deleteTask(taskId) {
953
+ async function deleteTaskRecord(taskId) {
876
954
  const resolvedId = await resolveTaskId(taskId);
877
955
  const filePath = await findFile(resolvedId);
878
956
  if (!filePath) {
@@ -891,6 +969,7 @@ async function deleteTask(taskId) {
891
969
  await fs.unlink(filePath).catch((error) => {
892
970
  if (error.code !== 'ENOENT') throw error;
893
971
  });
972
+ await removeOtherTaskCopies(task.id, path.join(BACKLOG, '__none__', `${task.id}.json`));
894
973
 
895
974
  return {
896
975
  ok: true,
@@ -901,14 +980,19 @@ async function deleteTask(taskId) {
901
980
  };
902
981
  }
903
982
 
983
+ async function deleteTask(taskId) {
984
+ return withBoardLock(() => deleteTaskRecord(taskId));
985
+ }
986
+
904
987
  async function deleteEpic(epicId) {
988
+ return withBoardLock(async () => {
905
989
  const epic = await getEpicEntity(epicId);
906
990
  const tasks = await allTasks();
907
991
  const children = tasks.filter((task) => task.epic_id === epic.id);
908
992
  const deletedTasks = [];
909
993
 
910
994
  for (const child of children) {
911
- const result = await deleteTask(child.id);
995
+ const result = await deleteTaskRecord(child.id);
912
996
  deletedTasks.push({
913
997
  task_id: result.task_id,
914
998
  title: result.title,
@@ -928,6 +1012,7 @@ async function deleteEpic(epicId) {
928
1012
  deleted_tasks: deletedTasks,
929
1013
  deleted_task_count: deletedTasks.length
930
1014
  };
1015
+ });
931
1016
  }
932
1017
 
933
1018
  async function migrateEpicGroups(options = {}) {
@@ -998,7 +1083,7 @@ async function findFile(epicId) {
998
1083
  try {
999
1084
  const files = await fs.readdir(colDir);
1000
1085
  const candidates = files
1001
- .filter((file) => (file.endsWith('.json') || file.endsWith('.md'))
1086
+ .filter((file) => isTaskOrEpicDataFile(file)
1002
1087
  && path.basename(file, path.extname(file)) === epicId)
1003
1088
  .sort((left, _right) => (left.endsWith('.json') ? -1 : 1));
1004
1089
  if (candidates[0]) {
@@ -1057,12 +1142,34 @@ async function resolveTaskId(input) {
1057
1142
  return String(input);
1058
1143
  }
1059
1144
 
1060
- async function writeTask(task, previousFilePath = null) {
1145
+ async function removeOtherTaskCopies(taskId, keepPath) {
1146
+ const keep = path.resolve(keepPath);
1147
+ for (const col of COLS) {
1148
+ const colDir = path.join(BACKLOG, col);
1149
+ try {
1150
+ const files = await fs.readdir(colDir);
1151
+ for (const file of files) {
1152
+ if (!isTaskOrEpicDataFile(file)) continue;
1153
+ if (path.basename(file, path.extname(file)) !== taskId) continue;
1154
+ const candidate = path.join(colDir, file);
1155
+ if (path.resolve(candidate) === keep) continue;
1156
+ await fs.unlink(candidate).catch((error) => {
1157
+ if (error.code !== 'ENOENT') throw error;
1158
+ });
1159
+ }
1160
+ } catch (error) {
1161
+ if (error.code !== 'ENOENT') throw error;
1162
+ }
1163
+ }
1164
+ }
1165
+
1166
+ async function writeTask(task, previousFilePath = null, { exclusive = false } = {}) {
1061
1167
  const normalized = normalizeTask(task);
1062
1168
  await ensureBacklogDir();
1063
1169
 
1064
1170
  const nextFilePath = path.join(BACKLOG, normalized.column, `${normalized.id}.json`);
1065
- await fs.writeFile(nextFilePath, JSON.stringify(serializeTask(normalized), null, 2) + '\n', 'utf-8');
1171
+ const payload = JSON.stringify(serializeTask(normalized), null, 2) + '\n';
1172
+ await writeFileAtomic(nextFilePath, payload, { exclusive });
1066
1173
 
1067
1174
  if (previousFilePath && path.resolve(previousFilePath) !== path.resolve(nextFilePath)) {
1068
1175
  await fs.unlink(previousFilePath).catch((error) => {
@@ -1070,6 +1177,8 @@ async function writeTask(task, previousFilePath = null) {
1070
1177
  });
1071
1178
  }
1072
1179
 
1180
+ await removeOtherTaskCopies(normalized.id, nextFilePath);
1181
+
1073
1182
  return parseJsonTask(nextFilePath, normalized.column);
1074
1183
  }
1075
1184
 
@@ -1089,7 +1198,7 @@ async function migrateAll(options = {}) {
1089
1198
  }
1090
1199
 
1091
1200
  const mdFiles = files
1092
- .filter((file) => file.endsWith('.md'))
1201
+ .filter((file) => isTaskOrEpicDataFile(file) && file.endsWith('.md'))
1093
1202
  .map((file) => ({
1094
1203
  name: file,
1095
1204
  taskId: path.basename(file, '.md')
@@ -1129,6 +1238,7 @@ async function nextTaskNumber() {
1129
1238
  try {
1130
1239
  const files = await fs.readdir(colDir);
1131
1240
  for (const file of files) {
1241
+ if (!isTaskOrEpicDataFile(file)) continue;
1132
1242
  const match = file.match(/^(?:[A-Z]+-)?(\d+)/);
1133
1243
  if (match) ids.push(parseInt(match[1], 10));
1134
1244
  }
@@ -1180,39 +1290,52 @@ async function doCreate(title, column = 'planned', epicRef = '—', extra = {})
1180
1290
 
1181
1291
  validateColumn(column, 'col');
1182
1292
 
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
- }
1293
+ return withBoardLock(async () => {
1294
+ let epicLink = { epic_id: null, epic_group: '—' };
1295
+ if (!isBlankEpicRef(epicRef)) {
1296
+ epicLink = await resolveEpicRef(epicRef, { createIfMissing: true, skipLock: true });
1297
+ } else if (extra.epic_id) {
1298
+ epicLink = await resolveEpicRef(extra.epic_id, { createIfMissing: false, skipLock: true });
1299
+ }
1189
1300
 
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
1301
+ for (let attempt = 0; attempt < 32; attempt++) {
1302
+ const nextId = await nextTaskNumber();
1303
+ const task = normalizeTask({
1304
+ id: String(nextId).padStart(3, '0'),
1305
+ title,
1306
+ column,
1307
+ epic_id: epicLink.epic_id,
1308
+ epic_group: epicLink.epic_group,
1309
+ created: todayIso(),
1310
+ description: extra.description,
1311
+ specs: extra.specs,
1312
+ in_scope: extra.in_scope,
1313
+ out_of_scope: extra.out_of_scope,
1314
+ acceptance_criteria: extra.acceptance_criteria,
1315
+ test_cases: extra.test_cases,
1316
+ subtasks: extra.subtasks,
1317
+ notes: extra.notes,
1318
+ plan: extra.plan,
1319
+ evidence: extra.evidence
1320
+ });
1321
+ try {
1322
+ return await writeTask(task, null, { exclusive: true });
1323
+ } catch (error) {
1324
+ if (error.code !== 'EEXIST') throw error;
1325
+ }
1326
+ }
1327
+ throw createKanbanError(
1328
+ 'CREATE_CONFLICT',
1329
+ 'Could not allocate a unique task id',
1330
+ 'Retry the create operation',
1331
+ { title },
1332
+ true,
1333
+ 409
1334
+ );
1208
1335
  });
1209
-
1210
- return writeTask(task);
1211
1336
  }
1212
1337
 
1213
- async function updateTask(taskId, patch) {
1214
- validatePatch(patch);
1215
-
1338
+ async function updateTaskRecord(taskId, patch) {
1216
1339
  const resolvedId = await resolveTaskId(taskId);
1217
1340
  const previousFilePath = await findFile(resolvedId);
1218
1341
  if (!previousFilePath) {
@@ -1256,7 +1379,12 @@ async function updateTask(taskId, patch) {
1256
1379
  next.epic_id = null;
1257
1380
  next.epic_group = '—';
1258
1381
  } else {
1259
- const link = await resolveEpicRef(ref, { createIfMissing: Boolean(patch.epic_group !== undefined && patch.epic_id === undefined && patch.epic === undefined) });
1382
+ const link = await resolveEpicRef(ref, {
1383
+ createIfMissing: Boolean(
1384
+ patch.epic_group !== undefined && patch.epic_id === undefined && patch.epic === undefined
1385
+ ),
1386
+ skipLock: true
1387
+ });
1260
1388
  next.epic_id = link.epic_id;
1261
1389
  next.epic_group = link.epic_group;
1262
1390
  }
@@ -1351,6 +1479,11 @@ async function updateTask(taskId, patch) {
1351
1479
  return writeTask(next, previousFilePath);
1352
1480
  }
1353
1481
 
1482
+ async function updateTask(taskId, patch) {
1483
+ validatePatch(patch);
1484
+ return withBoardLock(() => updateTaskRecord(taskId, patch));
1485
+ }
1486
+
1354
1487
  async function doMove(epicId, target) {
1355
1488
  try {
1356
1489
  await updateTask(epicId, { column: target });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kanbango",
3
- "version": "3.2.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
@@ -18,7 +18,10 @@ runNode(path.join('tests', 'update-tasks.test.js'), [], 'Update tasks test');
18
18
  runNode(path.join('tests', 'read-views.test.js'), [], 'Read views test');
19
19
  runNode(path.join('tests', 'mcp-server.test.js'), [], 'MCP server test');
20
20
  runNode(path.join('tests', 'gui-port.test.js'), [], 'GUI port test');
21
+ runNode(path.join('tests', 'fenced-text.test.js'), [], 'Fenced text test');
22
+ runNode(path.join('tests', 'kanban-mermaid-vendor.test.js'), [], 'Kanban mermaid vendor route test');
21
23
  runNode(path.join('tests', 'plan-workflow.test.js'), [], 'Plan workflow test');
22
24
  runNode(path.join('tests', 'agent-playbook.test.js'), [], 'Agent playbook test');
23
25
  runNode(path.join('tests', 'epics.test.js'), [], 'Epics test');
24
26
  runNode(path.join('tests', 'delete-archive.test.js'), [], 'Delete/archive test');
27
+ runNode(path.join('tests', 'race-conditions.test.js'), [], 'Race conditions test');