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 +3 -0
- package/.ai/retro/last-run.json +1 -1
- package/.ait-quality.yml +25 -0
- package/CHANGELOG.md +24 -0
- package/README.md +1 -0
- package/bin/kanban.js +49 -2
- package/fenced-text.js +62 -0
- package/index.html +247 -18
- package/kanban.js +240 -107
- package/package.json +3 -2
- package/tests/run.js +3 -0
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}
|
package/.ai/retro/last-run.json
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"ts":"2026-
|
|
1
|
+
{"ts":"2026-08-19T20:04:53.425Z","sessionID":"ses_feb8269efffe9L33YfwuSnVUVa","added":["260819-ztl"],"bumped":[]}
|
package/.ait-quality.yml
ADDED
|
@@ -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
|
-
|
|
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.
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
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.
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
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.
|
|
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>
|
|
1085
|
+
descWrap.innerHTML = `<label>What</label>`;
|
|
1014
1086
|
const descTa = document.createElement("textarea");
|
|
1015
1087
|
descTa.value = dirty.description;
|
|
1016
|
-
descTa.placeholder = "
|
|
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("
|
|
1492
|
+
meta.appendChild(renderEditBlock("What", dirty.description, value => {
|
|
1399
1493
|
dirty.description = value;
|
|
1400
1494
|
dirtyEdits[epic.id] = dirty;
|
|
1401
|
-
}, "
|
|
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("
|
|
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
|
|
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(
|
|
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) =>
|
|
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
|
-
|
|
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 =
|
|
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
|
|
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
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
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
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
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.
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
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
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
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.
|
|
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
|
-
|
|
930
|
+
const saved = await writeEpic(next);
|
|
854
931
|
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
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
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
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
|
|
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, {
|
|
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.
|
|
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');
|